Repository navigation
fix(core): write results respect operation-level query access #1770
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@opensaas/stack-core': patch | ||
| --- | ||
|
|
||
| Security fix: a `create`/`update`/`delete` result now respects operation-level `query` access. A session that cannot query the written row gets only the list's system fields back (the write still persists); `sudo()` is unchanged. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
103 changes: 103 additions & 0 deletions
103
packages/core/src/context/write-result-query-access.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| import { afterAll, beforeAll, describe, expect, test } from 'vitest' | ||
| import type { OpenSaasConfig } from '../config/types.js' | ||
| import { text } from '../fields/index.js' | ||
| import { createTestContext, type TestContext } from '../testing/context.js' | ||
|
|
||
| const BOOT = 120_000 | ||
|
|
||
| const config: OpenSaasConfig = { | ||
| db: { provider: 'postgresql' }, | ||
| lists: { | ||
| Ledger: { | ||
| fields: { note: text(), ownerId: text() }, | ||
| access: { | ||
| operation: { | ||
| query: () => false, | ||
| create: () => true, | ||
| update: () => true, | ||
| delete: () => true, | ||
| }, | ||
| }, | ||
| }, | ||
| Doc: { | ||
| fields: { note: text(), ownerId: text() }, | ||
| access: { | ||
| operation: { | ||
| query: ({ session }) => ({ ownerId: { equals: String(session?.userId) } }), | ||
| create: () => true, | ||
| update: () => true, | ||
| delete: () => true, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| describe('a write result respects operation-level query access', () => { | ||
| let harness: TestContext | ||
|
|
||
| beforeAll(async () => { | ||
| harness = await createTestContext(config, { userId: 'u1' }) | ||
| }, BOOT) | ||
|
|
||
| afterAll(async () => { | ||
| await harness?.close() | ||
| }) | ||
|
|
||
| test( | ||
| 'create on a list the session cannot query returns only the id, and persists', | ||
| async () => { | ||
| const row = await harness.context.db.Ledger.create({ data: { note: 'n', ownerId: 'u1' } }) | ||
| expect(row).not.toBeNull() | ||
| expect(Object.keys(row ?? {})).toEqual(['id']) | ||
| const stored = await harness.context.sudo().db.Ledger.all() | ||
| expect(stored).toHaveLength(1) | ||
| }, | ||
| BOOT, | ||
| ) | ||
|
|
||
| test( | ||
| 'filter rule: own row returns the full row, someone else’s returns system fields only', | ||
| async () => { | ||
| const own = await harness.context.db.Doc.create({ data: { note: 'a', ownerId: 'u1' } }) | ||
| expect(own).toMatchObject({ note: 'a', ownerId: 'u1' }) | ||
| const other = await harness.context.db.Doc.create({ data: { note: 'b', ownerId: 'u2' } }) | ||
| expect(Object.keys(other ?? {})).toEqual(['id']) | ||
| }, | ||
| BOOT, | ||
| ) | ||
|
|
||
| test( | ||
| 'update that moves a row out of scope returns system fields only', | ||
| async () => { | ||
| const own = await harness.context.db.Doc.create({ data: { note: 'c', ownerId: 'u1' } }) | ||
| const moved = await harness.context.db.Doc.update({ | ||
| where: { id: String(own?.id) }, | ||
| data: { ownerId: 'u2' }, | ||
| }) | ||
| expect(Object.keys(moved ?? {})).toEqual(['id']) | ||
| }, | ||
| BOOT, | ||
| ) | ||
|
|
||
| test( | ||
| 'delete of a row the session could not query returns system fields only', | ||
| async () => { | ||
| const row = await harness.context.sudo().db.Doc.create({ data: { note: 'd', ownerId: 'u2' } }) | ||
| const deleted = await harness.context.db.Doc.delete({ where: { id: String(row?.id) } }) | ||
| expect(Object.keys(deleted ?? {})).toEqual(['id']) | ||
| }, | ||
| BOOT, | ||
| ) | ||
|
|
||
| test( | ||
| 'sudo returns the full row', | ||
| async () => { | ||
| const row = await harness.context | ||
| .sudo() | ||
| .db.Ledger.create({ data: { note: 'z', ownerId: 'u9' } }) | ||
| expect(row).toMatchObject({ note: 'z' }) | ||
| }, | ||
| BOOT, | ||
| ) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Under PostgreSQL's default Read Committed isolation, this queryability read and the subsequent delete are separate statements without a row lock. If the row initially matches a query filter such as
ownerId = session.userId, a concurrent transaction can change both the owner and sensitive fields before the delete acquires its lock;delete()then returns the newly modified row, but the stalequeryable = truecauses all of those fields to be exposed. Lock the target across the check and delete, or otherwise derive visibility atomically from the row actually deleted.AGENTS.md reference: AGENTS.md:L123-L127
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The race is real but narrow, and I'm leaving it as is. A session reaches it only if it holds delete access to the row, the row matches its
queryfilter at the check, and another transaction moves it out of scope before the delete locks it. In that casedelete()returns the row as the other transaction left it. Before this PR a delete returned the full row unconditionally, so this PR only narrows what a caller can see, andupdateandcreateare checked after the write under the transaction's own row lock. Closing the gap for delete would mean taking aforUpdatelock on the target before the check, in every delete. I'd rather not add that without the maintainer asking for it.Generated by Claude Code