Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/21079-zero-set-deny-baseline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@objectstack/plugin-security': minor
'@objectstack/spec': minor
---

fix(plugin-security,spec)!: a non-system caller that carries a principal and resolves no permission set gets the deny baseline at object admission and at the row scope, and the security contract says so (#21079)

Clause-②: yes (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) No authorable key, export, published type, stored shape or wire shape is removed or renamed, so objectstack migrate meta has nothing to rewrite. What moves is which callers object admission and the row scope accept: a caller that carries a principal and resolves no permission set is now refused, and the remedy is a grant decision (a permission set the deployment declares and assigns), not a rewrite of anything an author already wrote. -->

**BREAKING for callers who resolve no permission set.** Shipped as `minor` under the launch-window convention.

**What changed.** ADR-0056 D2 gives an unauthenticated principal the deny baseline, not "no checks", and ADR-0090 D9 gives a guest the `guest` position and nothing else. A non-system caller that carries a principal (a position, a named permission set or a user id) but resolves no permission set was instead admitted to every object no set grants, for reads and writes, and read with the record-sharing predicate as its only row scope. Now an empty set list grants nothing:

- **Object admission refuses it.** Every engine operation (find, findOne, count, aggregate, insert, update, delete) is refused with `403 PERMISSION_DENIED`, the same refusal any caller gets for an object its sets do not grant. `ISecurityService.canReadObject` and `canExport`, and the write preview's admission, answer `false` for it.
- **Its row scope is the deny filter.** `ISecurityService.getReadFilter` answers the filter that matches zero rows for it, as it already did on a resolution failure.
- **The second principal of a delegated request is held to the same answer.** An agent acting on behalf of a delegator who resolves no permission set was already refused by the engine; `canReadObject`, `canExport` and the write preview now refuse it too.

The field answers for this caller (`getReadableFields`, `getQueryableFields`, `getWritableFields`, `getMetadataReadableFields`) are unchanged: they are field-level answers, and the contract now says that object admission is not part of them. The `ISecurityService` docblocks in `@objectstack/spec/contracts` that stated the old zero-set admission (`canReadObject`, `canExport`, the metadata-plane field projection) and the deny cases of `getReadFilter` narrow to match. No method, parameter or return type changes.

**Who this reaches.**

- An unauthenticated request carried as the guest envelope, on a deployment that grants anonymous callers no permission set.
- A context that names only permission sets the deployment does not register.
- A signed-in user on an embedder that switches the baseline off (`fallbackPermissionSet: null`) and grants that user nothing.

A context that carries no principal at all (no position, no named set, no user id) is handed through as before; ADR-0096 stages it separately. A caller who resolves at least one permission set is decided by its sets, as before, and so is a system context. The public form submit is unaffected: its declaration-derived grant admits the create and its read-back ahead of object admission. Signed-in users of a stock `objectstack serve` deployment are unaffected: it applies the member baseline to every one of them, so none resolves an empty list.

**Migration.** A caller that resolves no permission set is refused object admission and reads nothing. An app-declared anonymous endpoint (`authRequired: false`) can no longer read or write objects until the `guest` anchor's bindings are resolved for anonymous callers (#21158). An embedder that sets `fallbackPermissionSet: null` must grant its signed-in users a set explicitly.

**For implementers of `ISecurityService`.** Answer `canReadObject`, `canExport` and the object-admission half of a write `false`, and `getReadFilter` with your deny filter, for a non-system caller that carries a principal and resolves no permission set; admit only the principal-less context.
22 changes: 15 additions & 7 deletions content/docs/ui/forms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,19 @@ export default defineView({

## 2. (Optional) Create the `guest_portal` permission set

Authorization no longer depends on this set — the declaration-derived
`publicFormGrant` (see the note above) is what permits the insert. You only need
a `guest_portal` permission set if you rely on the legacy back-compat path (e.g.
an older runtime, or object hooks that branch on the `guest_portal` permission).
When present it is still attached to the anonymous context, so keep it
INSERT-only on the target object — the guest-safe shape (ADR-0090 D9).
The submit's authorization no longer depends on this set — the
declaration-derived `publicFormGrant` (see the note above) is what permits the
insert. For the submit you only need a `guest_portal` permission set if you rely
on the legacy back-compat path (e.g. an older runtime, or object hooks that
branch on the `guest_portal` permission). A form that declares a public picker
(see *the public picker* below) is different: the picker's search runs under
this set and nothing else, so without one that grants read on the picker's
target object the picker answers `403 PERMISSION_DENIED` — the ADR-0056 D2 deny
baseline refuses a caller that resolves no permission set. When present the set
is attached to the anonymous context, so keep it INSERT-only on the form's
target object — the guest-safe shape (ADR-0090 D9) — and remember that any read
you grant a picker's target object is readable by every anonymous visitor
through that picker.

{/* os:check */}
```ts
Expand Down Expand Up @@ -284,14 +291,15 @@ Errors:
|---|---|---|
| `400 INVALID_REQUEST` | missing / blank slug or field |
| `403 LOOKUP_NOT_PUBLIC` | the field has no `publicPicker` block — the deliberate loud default (#3022); also any server-managed anchor (`owner_id`, `organization_id`, …), which never gets a picker even if one is declared |
| `403 PERMISSION_DENIED` | the deployment registers no `guest_portal` set that grants read on the picker's target object, so the search context resolves no permission set and is refused at object admission — the ADR-0056 D2 deny baseline. The picker's declaration bounds what is searched and projected; it never grants the read itself |
| `404 FORM_NOT_FOUND` | slug not registered on any `sharing.allowAnonymous: true` view |
| `500 LOOKUP_TARGET_MISSING` | the referenced object could not be resolved from either `publicPicker.object` or the field definition — the field names no target object at all (or its object metadata is unreachable). Until #7486 this also fired for a perfectly well-formed field, because the fallback read only the legacy spellings and not the canonical `reference`; declaring `object` was the workaround and is no longer needed. |
| `500 INTERNAL_ERROR` | the field def **declares** a target this route cannot READ — a stored `reference` holding an object or an array rather than the object name `FieldSchema` declares. ⚠️ Deliberately **not** `LOOKUP_TARGET_MISSING`: "nothing names the target" and "the named target is unreadable" want different fixes from whoever owns the metadata, so they get different answers. The unreadable carrier is named in full in the server log (it is withheld from the response body, as every fault's text is); the picker's search never runs. |

### Auth model

- None of the three routes calls `enforceAuth`, so they work under the always-on anonymous-deny default (there is no `requireAuth` knob to configure since v17).
- The execution context the **submit** route hands to ObjectQL is `{ publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true }` with no `userId`. The Security plugin honors `publicFormGrant` first — a create + read-back grant scoped to exactly the declared object — so authorization holds even without a `guest_portal` profile. `permissions: ['guest_portal']` is retained for back-compat. The **lookup** route's search context is `{ permissions: ['guest_portal'], anonymous: true }` — no `publicFormGrant` (it reads the picker's target object, not the form's), which is why its result set is bounded by the picker declaration instead.
- The execution context the **submit** route hands to ObjectQL is `{ publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true }` with no `userId`. The Security plugin honors `publicFormGrant` first — a create + read-back grant scoped to exactly the declared object — so authorization holds even without a `guest_portal` profile. `permissions: ['guest_portal']` is retained for back-compat. The **lookup** route's search context is `{ permissions: ['guest_portal'], anonymous: true }` — no `publicFormGrant` (it reads the picker's target object, not the form's), so the picker declaration bounds what the search matches and projects, and the deployment's `guest_portal` set is what authorizes it: with no such set granting read on the target object, the search context resolves no permission set and is refused at object admission (`403 PERMISSION_DENIED`, the ADR-0056 D2 deny baseline).
- No CSRF or auth header is needed; embed the form on any domain.

## 5. Embedding from a front-end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -671,10 +671,19 @@ describe('the arms the CRUD grant alone does not cover', () => {
await expect(plugin.canWriteObject('invoice', 'insert', ORGLESS_CTX, PLAIN_PAYLOAD)).resolves.toBe(false);
});

it('ADMITS the org-bound twin who resolves no permission set — arm 4 still admits', async () => {
const { plugin } = await boot([], undefined, { orgScoping: true, noBaseline: true });
it('[#21079] DENIES the org-bound twin who resolves no permission set — arm 4 is the deny baseline, wall or no wall', async () => {
const { plugin, middleware } = await boot([], undefined, { orgScoping: true, noBaseline: true });
await expect((plugin as any).resolvePermissionSetsForContext(WRITER_CTX)).resolves.toEqual([]);
await expect(plugin.canWriteObject('invoice', 'insert', WRITER_CTX, PLAIN_PAYLOAD)).resolves.toBe(true);
await expect(plugin.canWriteObject('invoice', 'insert', WRITER_CTX, PLAIN_PAYLOAD)).resolves.toBe(false);
// …which is the middleware's own answer: refused at its CRUD gate, ahead of the wall.
const refusal = await middleware(
{ object: 'invoice', operation: 'insert', context: { ...WRITER_CTX }, options: {}, ast: { where: {} }, data: PLAIN_PAYLOAD },
async () => {},
).then(
() => null,
(e: { code?: unknown; status?: unknown; statusCode?: unknown }) => ({ code: e.code, status: e.status ?? e.statusCode }),
);
expect(refusal).toEqual({ code: 'PERMISSION_DENIED', status: 403 });
});

it('ADMITS the same caller with an active organization — so the arm is not a blanket deny', async () => {
Expand Down
19 changes: 15 additions & 4 deletions packages/plugins/plugin-security/src/get-writable-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,24 @@ describe('getWritableFields — the answers the contract names', () => {
expect(await plugin.getWritableFields('invoice', { isSystem: true })).toEqual(FIELDS);
});

it('no permission sets resolved: the full field set minus the capability-gated field, which the write gate refuses', async () => {
it('no permission sets resolved: the full field set minus the capability-gated field — a field answer, while [#21079] the write itself is refused at object admission', async () => {
const { plugin, middleware } = await boot([], { noBaseline: true });
// [#21063] The caller holds no capability, so `margin`'s
// `requiredPermissions` refuses it on write; nothing else is refused.
// `requiredPermissions` is the one field the field layer refuses it.
expect(await plugin.getWritableFields('invoice', WRITER_CTX)).toEqual(FIELDS.filter((f) => f !== 'margin'));
expect(await middlewareAdmits(middleware, 'update', WRITER_CTX, { margin: PAYLOAD_VALUE.margin })).toBe(false);
expect(await middlewareAdmits(middleware, 'update', WRITER_CTX, { secret: PAYLOAD_VALUE.secret })).toBe(true);
// [#21079] The answer is field-level only, as the contract states: this
// caller carries a principal and resolves no set, so the ADR-0056 D2 deny
// baseline refuses every write at the CRUD gate, whatever field it names.
for (const field of ['margin', 'secret']) {
const refusal = await middleware(
{ object: 'invoice', operation: 'update', context: { ...WRITER_CTX }, options: {}, ast: { where: {} }, data: { [field]: PAYLOAD_VALUE[field] } },
async () => {},
).then(
() => null,
(e: { code?: unknown; status?: unknown; statusCode?: unknown }) => ({ code: e.code, status: e.status ?? e.statusCode }),
);
expect(refusal, `update naming ${field}`).toEqual({ code: 'PERMISSION_DENIED', status: 403 });
}
});

it('an unresolvable object is no answer (undefined), not an empty one', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ async function boot(kind: 'secret' | 'public', posture?: 'single' | 'group' | 'i
});

const stored = async (object: string, name: string) => (await table(object).where({ name }).select('id')).length;
return { engine, readsOfLine, userColumnsRead, stored, table };
return { engine, readsOfLine, userColumnsRead, stored, table, plugin };
}

/** Everything the caller sees of one write and one preview naming `line`. */
Expand Down Expand Up @@ -365,14 +365,25 @@ describe('#18682 — a caller with no userId that is not system is bound like a
}
});

it('a user only org Y holds, under no wall: every door refuses as not readable, whatever its value', async () => {
it('a user only org Y holds, under no wall: every door refuses, whatever its value — the write as not readable, the preview at its write gate', async () => {
const banned = await observe('secret', 'u_y', PUBLIC_FORM('qa_review'), 'single', 'qa_review');
const clear = await observe('public', 'u_y', PUBLIC_FORM('qa_review'), 'single', 'qa_review');

expect(clear.seen).toEqual(banned.seen);
expect(banned.seen.refusal).toMatchObject({ code: 'VALIDATION_FAILED', message: expect.stringContaining("could not read 'sys_user'") });
expect(banned.seen.committed).toBe(0);
expect(banned.seen.preview).toEqual({ valid: false, errors: [expect.stringContaining("could not read 'sys_user'")] });
// [#21079] The preview asks the engine's write gate before it reads anything
// related. This grant context names a set the deployment does not register,
// so it resolves no permission set, and the ADR-0056 D2 deny baseline
// refuses it there (the grant is not one of `canWriteObject`'s arms — its
// docblock says why). The preview therefore refuses with the related user
// never read, instead of reading it and failing the read.
const h = await boot('public', 'single');
expect(
await h.plugin.canWriteObject('qa_review', 'insert', PUBLIC_FORM('qa_review'), { name: 'probe', reviewer: 'u_y' }),
).toBe(false);
expect(banned.seen.preview.valid).toBe(false);
expect(banned.seen.preview.errors).toHaveLength(1);
expect(banned.userColumnsRead).not.toContain('banned');
});

Expand Down
Loading
Loading