From 93eaf72054f0e7b0aa3f35b136d7052863d0a49e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 15:52:50 +0000 Subject: [PATCH 1/9] fix(plugin-security)!: an empty permission-set list is the deny baseline at object admission and row scope A non-system caller that carries a principal and resolves no permission set is now refused at object admission (the engine middleware's CRUD gate, and canReadObject / canWriteObject / canExport) and answered the deny sentinel as its row scope (getReadFilter). The principal-less context keeps its hand-off. A delegator who resolves no set is refused by the three probes as the middleware already refused it. The ISecurityService docblocks that stated the zero-set answers are narrowed to match. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../plugin-security/src/security-plugin.ts | 159 +++++++++++------- .../spec/src/contracts/security-service.ts | 46 +++-- 2 files changed, 125 insertions(+), 80 deletions(-) diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 970439f6638..b4f1e1acfd4 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -339,6 +339,15 @@ const EMPTY_REQUIRED_PERMISSIONS: NormalizedRequiredPermissions = Object.freeze( * permission set, holds no capability, and is the one * {@link SecurityPlugin.resolveCallerPosture} gives the object's masking rules * and [#21063] its per-field capability contract. + * + * [#21079] ADR-0056 D2 — THE DENY BASELINE. For such a caller an empty set list + * grants nothing: it is asked the same grant questions as any caller, over the + * empty list, and every one answers no. Object admission refuses it (the + * middleware's step 2 CRUD gate, and `canReadObject` / `canWriteObject` / + * `canExport`), and its row scope is the deny sentinel (`getReadFilter`). This + * predicate is the ONE exception an empty list has, everywhere: the + * principal-less hand-off above, which ADR-0096 stages separately. ⛔ No door, + * probe or layer carries a second spelling of "no sets means no restriction". */ function isPrincipalLessContext(context: any): boolean { const positions = context?.positions ?? []; @@ -2504,8 +2513,16 @@ export class SecurityPlugin implements Plugin { const referentialFieldClearWrite = opCtx.operation === 'update' && opCtx.context?.__referentialFieldClear === true; - // 2. CRUD permission check ([#12597] except the referential FK clear) - if (permissionSets.length > 0 && !referentialFieldClearWrite) { + // 2. CRUD permission check ([#12597] except the referential FK clear). + // + // [#21079] ADR-0056 D2 — the deny baseline. NOT guarded on a resolved + // set: a caller who resolves none is asked the same grant question over + // its empty list, and an empty list grants nothing, so it is refused + // here with the same `PERMISSION_DENIED` any ungranted caller gets. The + // guard this replaced admitted that caller to every object no set + // grants. A principal-less context never reaches this line — it was + // handed through above ({@link isPrincipalLessContext}, ADR-0096). + if (!referentialFieldClearWrite) { const allowed = this.permissionEvaluator.checkObjectPermission( opCtx.operation, opCtx.object, @@ -5327,6 +5344,13 @@ export class SecurityPlugin implements Plugin { * * Fails CLOSED on any resolution failure: a dropped predicate here is the * leak, so an unresolvable layer denies (zero rows) rather than widening. + * + * [#21079] And it answers the deny sentinel, not a composed scope, for a + * non-system caller who carries a principal and resolves NO permission set — + * the ADR-0056 D2 deny baseline, which the middleware enforces by refusing + * that caller at object admission. Only the principal-less context + * ({@link isPrincipalLessContext}, ADR-0096) keeps the sharing predicate + * alone. */ async getReadFilter( object: string, @@ -5334,8 +5358,6 @@ export class SecurityPlugin implements Plugin { ): Promise | undefined> { // System operations bypass scoping (mirrors the middleware's isSystem skip). if (context?.isSystem) return undefined; - const positions = context?.positions ?? []; - const explicit = context?.permissions ?? []; // [#4467] The OWD/sharing predicate is resolved for EVERY non-system caller, // ahead of the RLS branches below, because it is a SEPARATE middleware in // the chain this method mirrors: none of the RLS stand-downs below is a @@ -5362,7 +5384,9 @@ export class SecurityPlugin implements Plugin { // without a `userId` (`computeControlledByParentFilter` returns null), and // this branch is reached only when there is none — so the middleware adds // nothing here either. Agreement holds by both sides standing down. - if (positions.length === 0 && explicit.length === 0 && !context?.userId) { + // [#21079] The same predicate the middleware hands through on — the one + // exception the deny baseline has (ADR-0096); see below. + if (isPrincipalLessContext(context)) { return sharingFilter ?? undefined; } // [#2852] D10 delegator intersection is NOT implemented on this path. @@ -5388,6 +5412,14 @@ export class SecurityPlugin implements Plugin { } try { const permissionSets = await this.resolvePermissionSetsForContext(context); + // [#21079] ADR-0056 D2 — the deny baseline at the row scope. A caller who + // carries a principal (the principal-less hand-off returned above) and + // resolves NO permission set is refused at the middleware's object + // admission, so the scope that agrees with it is zero rows. Composed + // from the layers instead, it was the sharing predicate alone — every + // row of an object whose sharing model is not private. Not a failure, + // so nothing is logged. + if (permissionSets.length === 0) return { ...RLS_DENY_FILTER }; const filter = await this.computeRlsFilter(permissionSets, object, 'find', context); // [#5815] ADR-0055 — the SECOND thing the middleware ANDs into `ast.where` // for a read. Resolved from the sets already resolved above, exactly as @@ -5510,9 +5542,12 @@ export class SecurityPlugin implements Plugin { * is the complement of that gate's own primitive, * `FieldMasker.getNonEditableFields` — so a field is here iff a payload * naming it passes step 2.5. [#21063] A caller who resolves no permission - * set gets the full set minus every capability-gated field: step 2.5 reaches + * set gets the full set minus every capability-gated field: step 2.5 judges * it over the posture's per-field capability contract - * ({@link resolveCallerPosture}), and its evaluator map is empty. + * ({@link resolveCallerPosture}), and its evaluator map is empty. [#21079] + * Like every answer here, that one is field-level only: such a caller who + * carries a principal is refused the write itself at object admission (the + * ADR-0056 D2 deny baseline), ahead of step 2.5. */ async getWritableFields(object: string, context?: any): Promise { const mask = await this.resolveProjectionFieldMask(object, context, { fallbackOnEmptySets: false }); @@ -5685,10 +5720,13 @@ export class SecurityPlugin implements Plugin { * * ⛔ Deliberately NOT carried: the object's capability contract * (`requiredPermissions` on the object). It is an OBJECT-level admission - * gate, and this caller's object admission — the capability and CRUD gates, - * both guarded by a resolved set — is not decided here. Its field answers - * are: the published projections' answer for this caller is the full field - * set minus every field it is not served, as the service contract states. + * gate, and this caller's object admission is not decided here: [#21079] + * the middleware's CRUD gate refuses it over its empty set list (the + * ADR-0056 D2 deny baseline), ahead of every field gate. Its field answers + * are decided here, and are still read: by the public-form grant's result + * masker, which admits ahead of that gate, and by the published projections, + * whose answer for this caller is the full field set minus every field it is + * not served, as the service contract states. */ private async resolveCallerPosture( object: string, @@ -5732,9 +5770,10 @@ export class SecurityPlugin implements Plugin { * one verdict" a property of the code rather than a promise: * * 1. `isSystem` → admit (the middleware's total bypass); - * 2. no permission sets resolved → admit (the middleware guards its whole - * CRUD gate with `if (permissionSets.length > 0)`; reporting a denial the - * data path would not enforce is its own kind of drift); + * 2. [#21079] no permission sets resolved → the ADR-0056 D2 deny baseline: + * DENY, as the middleware's CRUD gate answers an empty list; ADMIT only + * a principal-less context, which the middleware hands through before + * any gate ({@link isPrincipalLessContext}, ADR-0096); * 3. `secMeta.unresolved` → DENY (#3545 — `isPrivate` would default to * `false`, which is exactly what lets a plain `'*'` wildcard reach an * object ADR-0066 D2 says it must not); @@ -5766,10 +5805,11 @@ export class SecurityPlugin implements Plugin { try { const permissionSets = await this.resolvePermissionSetsForContext(context); - // 2. No sets resolved (unauthenticated, or a deployment with no sets) → - // no permission-set restriction applies, exactly as the middleware - // treats it. - if (permissionSets.length === 0) return true; + // 2. [#21079] No sets resolved → the deny baseline (ADR-0056 D2): an + // empty list grants nothing, which is the middleware's step 2 answer + // for a caller who carries a principal. Only the principal-less + // context is admitted, as the middleware hands it through. + if (permissionSets.length === 0) return isPrincipalLessContext(context); const { isPrivate, unresolved, requiredPermissions } = await this.getObjectSecurityMeta(objectName); @@ -5790,11 +5830,14 @@ export class SecurityPlugin implements Plugin { // 4. [ADR-0066 D3/⑤] The capability AND-gate, ahead of the grant, for both // principals — a caller missing any required capability is denied // however permissive their grants are. + // [#21079] A delegator who resolves NO set is the deny baseline too: the + // middleware asks both of its delegator arms over that empty list and + // refuses, so neither arm here stands down on it. const required = requiredCapsForOperation(requiredPermissions, 'find'); if (required.length > 0) { const held = this.permissionEvaluator.getSystemPermissions(permissionSets); if (required.some((cap) => !held.has(cap))) return false; - if (delegatorSets && delegatorSets.length > 0) { + if (delegatorSets) { const delHeld = this.permissionEvaluator.getSystemPermissions(delegatorSets); if (required.some((cap) => !delHeld.has(cap))) return false; } @@ -5808,7 +5851,6 @@ export class SecurityPlugin implements Plugin { // 6. [ADR-0090 D10] The delegator must independently grant the same read. if ( delegatorSets && - delegatorSets.length > 0 && !this.permissionEvaluator.checkObjectPermission('find', objectName, delegatorSets, { isPrivate }) ) { return false; @@ -5861,8 +5903,11 @@ export class SecurityPlugin implements Plugin { * middleware calls, handed `object`, `operation`, `context` and the rows * of `data`. A plain-CRUD holder on an RBAC link table DENIES; a tenant * admin passes to the arms below; - * 4. no permission sets resolved → arm 10 decides (the middleware guards its - * whole CRUD gate with `if (permissionSets.length > 0)`, not that wall); + * 4. [#21079] no permission sets resolved → the ADR-0056 D2 deny baseline: + * DENY, as the middleware's CRUD gate answers an empty list; ADMIT only + * a principal-less context, which the middleware hands through before + * any gate ({@link isPrincipalLessContext}, ADR-0096) and which carries + * no user id for arm 10 to ask about; * 5. `secMeta.unresolved` → DENY (#3545); * 6. ADR-0066 D3/⑤ `requiredPermissions` capability AND-gate for the WRITE * CRUD class, checked BEFORE the grant, for the caller AND (D10) the @@ -5904,9 +5949,8 @@ export class SecurityPlugin implements Plugin { * 10. ADR-0123 D2 `organizationWallRefusal` — the method the middleware's * step 3.7 throws on, asked under that step's guard (a payload is * supplied, the context names a user) at that step's point, after arm - * 9; arm 4 asks it too, as the middleware does with no set resolved. It - * is handed no row — the payload decides only whether it is asked — so - * it refuses a CALLER CLASS, as arms 2 and 3 do. + * 9. It is handed no row — the payload decides only whether it is + * asked — so it refuses a CALLER CLASS, as arms 2 and 3 do. * * `can-write-object-admission.test.ts` pins this method's answer equal to the * registered middleware's on its equivalence block's cases, and pins one @@ -6035,9 +6079,7 @@ export class SecurityPlugin implements Plugin { // 10. [ADR-0123 D2] The no-active-organization write wall — the // middleware's own verdict, `organizationWallRefusal`, under its step // 3.7 guard: a payload is supplied and the context names a user. Its - // point is last, after the field gate; it is spelled here because - // arm 4 returns through it — the middleware asks it with no set - // resolved as well. + // point is last, after the field gate. const organizationWallAdmits = async ( delegatorSets: PermissionSet[] | null, delegatorContext: unknown, @@ -6046,29 +6088,14 @@ export class SecurityPlugin implements Plugin { && (await this.organizationWallRefusal( permissionSets, objectName, operation, context, delegatorSets, delegatorContext, ))); - // 4. No sets resolved → no permission-set GRANT applies (the middleware - // guards its capability and CRUD gates with `if (permissionSets.length > 0)`), - // and arm 10 is not behind that guard. [#21063] Neither is arm 9: a - // caller who carries a principal reaches the middleware's step 2.5 - // over the posture it reads (resolveCallerPosture), whose per-field - // capability contract this caller holds none of. A principal-less - // context is handed through before any gate, so it is asked nothing - // about fields. - if (permissionSets.length === 0) { - if (data && !isPrincipalLessContext(context)) { - const standIn = await this.resolveCallerPosture(objectName, permissionSets); - // [#3545] The field arm reads the per-field capability contract off - // the posture; an unreadable posture leaves it unknown, and reading - // it as "no contract" would admit every capability-gated field. Fail - // closed, as the write projection answers `[]` there. - if (standIn.unresolved) return false; - const forbidden = this.computeForbiddenFieldWrites( - objectName, data as Record | Record[], standIn, permissionSets, null, - ); - if (forbidden.length > 0) return false; - } - return await organizationWallAdmits(null, null); - } + // 4. [#21079] No sets resolved → the deny baseline (ADR-0056 D2): an + // empty list grants no create or edit, which is the middleware's step + // 2 answer for a caller who carries a principal — refused there + // before its field gate or its organization wall is reached, whatever + // the payload. Only the principal-less context is admitted, as the + // middleware hands it through before any gate; it names no user, so + // arm 10 would not be asked about it either. + if (permissionSets.length === 0) return isPrincipalLessContext(context); const { isPrivate, unresolved, requiredPermissions, fieldRequiredPermissions } = await this.getObjectSecurityMeta(objectName); @@ -6089,12 +6116,14 @@ export class SecurityPlugin implements Plugin { } // 6. [ADR-0066 D3/⑤] The capability AND-gate, ahead of the grant, for both - // principals. + // principals. [#21079] A delegator who resolves NO set is the deny + // baseline too: the middleware asks both of its delegator arms over + // that empty list and refuses, so neither arm here stands down on it. const required = requiredCapsForOperation(requiredPermissions, operation); if (required.length > 0) { const held = this.permissionEvaluator.getSystemPermissions(permissionSets); if (required.some((cap) => !held.has(cap))) return false; - if (delegatorSets && delegatorSets.length > 0) { + if (delegatorSets) { const delHeld = this.permissionEvaluator.getSystemPermissions(delegatorSets); if (required.some((cap) => !delHeld.has(cap))) return false; } @@ -6108,7 +6137,6 @@ export class SecurityPlugin implements Plugin { // 8. [ADR-0090 D10] The delegator must independently grant the same write. if ( delegatorSets && - delegatorSets.length > 0 && !this.permissionEvaluator.checkObjectPermission(operation, objectName, delegatorSets, { isPrivate }) ) { return false; @@ -6152,10 +6180,11 @@ export class SecurityPlugin implements Plugin { * `export` branch — so the axis cannot drift from data-plane enforcement. * * Fails CLOSED (an access-narrowing answer): a dangling on-behalf-of delegator - * denies, and callers must treat a throw as a denial. `isSystem` bypasses, and - * so does an empty set resolution — the middleware skips its CRUD gate - * entirely for a caller with no permission sets, and reporting a denial the - * data path would not enforce is its own kind of drift. + * denies, and callers must treat a throw as a denial. `isSystem` bypasses. + * [#21079] An empty set resolution is the ADR-0056 D2 deny baseline — `false`, + * as the middleware's CRUD gate refuses the read an export is — except for a + * principal-less context, which the middleware hands through before any gate + * ({@link isPrincipalLessContext}, ADR-0096). */ async canExport(object: string, context?: any): Promise { const objectName = String(object ?? ''); @@ -6164,10 +6193,11 @@ export class SecurityPlugin implements Plugin { if (context?.isSystem) return true; const permissionSets = await this.resolvePermissionSetsForContext(context); - // No sets resolved (e.g. unauthenticated, or a deployment with no sets) → - // no permission-set restriction applies, exactly as the middleware treats it - // (`if (permissionSets.length > 0)` guards its whole CRUD gate). - if (permissionSets.length === 0) return true; + // [#21079] No sets resolved → the deny baseline (ADR-0056 D2): an empty + // list grants nothing, which is the middleware's step 2 answer for a caller + // who carries a principal. Only the principal-less context is admitted, as + // the middleware hands it through. + if (permissionSets.length === 0) return isPrincipalLessContext(context); const { isPrivate, unresolved } = await this.getObjectSecurityMeta(objectName); // [#3545] Posture unresolvable → deny. `isPrivate` would default to `false`, @@ -6181,14 +6211,15 @@ export class SecurityPlugin implements Plugin { // [ADR-0090 D10] An on-behalf-of caller may never export past what the // DELEGATOR could have exported themselves — intersect the delegator's own // answer. A dangling delegator fails CLOSED, the same stance the CRUD - // middleware and {@link getReadableFields} take. + // middleware and {@link getReadableFields} take. [#21079] So does a + // delegator who resolves NO set: the evaluator answers that empty list's + // export as it answers any list, and the middleware refuses its read. if (context?.onBehalfOf?.userId) { const del = await resolveDelegatorContext(this.ql, context); if (del.kind === 'missing') return false; if (del.kind === 'resolved') { const delegatorSets = await this.resolvePermissionSetsForContext(del.context); if ( - delegatorSets.length > 0 && !this.permissionEvaluator.checkObjectPermission('export', objectName, delegatorSets, { isPrivate }) ) { return false; diff --git a/packages/spec/src/contracts/security-service.ts b/packages/spec/src/contracts/security-service.ts index 256182e7bd6..2ac73d55d14 100644 --- a/packages/spec/src/contracts/security-service.ts +++ b/packages/spec/src/contracts/security-service.ts @@ -258,6 +258,13 @@ export interface ISecurityService { * matches zero rows — never `undefined`. `undefined` means one thing only: * this caller has no row restriction on this object. * + * **The deny baseline (ADR-0056 D2).** A non-system caller that carries a + * principal (a position, a named permission set or a user id) and resolves + * NO permission set gets the same DENY filter: an empty set list grants no + * row, as it grants no object ({@link canReadObject}). Only a context that + * carries no principal at all keeps the scope the other layers compose for + * it (ADR-0096 stages that context separately). + * * **⚠️ Request-scoped: call it per request, and never memoise what it * returns.** The documented use — `engine.find(object, { where: await * security.getReadFilter(object, ctx) })` — puts a PLATFORM-authored @@ -320,21 +327,22 @@ export interface ISecurityService { * the app-declared baseline COMPOSED with the platform `member_default`, * #7555) instead of falling open to the data plane's answer for that caller. * - * **Why the two differ rather than converge.** `getReadableFields` mirrors the - * engine middleware, which skips its permission-set GRANT gates for a caller - * with no permission sets but still applies each field's own declarations to - * it: a `maskingRule` serves the value masked, and a field's + * **Why the two differ rather than converge.** `getReadableFields` is a + * FIELD-level answer and mirrors the engine middleware's field gates, which + * apply each field's own declarations to a caller with no permission sets: a + * `maskingRule` serves the value masked, and a field's * `requiredPermissions`, none of which that caller holds, keeps the field - * from being served. On the DATA plane, reporting a narrowing the enforcement - * path would not apply is its own kind of drift, so the full field set minus + * from being served. Whether that caller may read the OBJECT is not part of + * it — that is {@link canReadObject}'s answer, the deny baseline for a caller + * that carries a principal. So on the DATA plane the full field set minus * the capability-gated fields that caller is not served is the correct, - * drift-free answer there. The metadata plane has no such symmetry to + * drift-free field answer. The metadata plane has no such symmetry to * preserve — the question is disclosure, and D7 rules that a public/guest * deployment's schema exposure must be a deliberate permission-set decision * rather than an accidental everything-default. It still answers as the data * plane does when the fallback set itself resolves to nothing (no - * `member_default` in the deployment at all): that is the "no FLS posture - * here" tier, not a restricted caller. + * `member_default` in the deployment at all): there is then no baseline to + * disclose by, and the field answer is all that is left to give. * * **Fails SOFT, with the same two distinct empty answers as * {@link getReadableFields}:** `undefined` is "no answer — use your own @@ -564,9 +572,11 @@ export interface ISecurityService { * * **Fails CLOSED.** This is an access-narrowing answer: implementations return * `false` (and callers must treat a throw as `false`) rather than degrading to - * "allowed". A system context bypasses and returns `true`; so does a caller - * with no resolved permission sets, mirroring the middleware, whose CRUD gate - * is skipped entirely when set resolution comes back empty. + * "allowed". A system context bypasses and returns `true`. A caller that + * carries a principal and resolves no permission set gets `false` — the + * ADR-0056 D2 deny baseline, mirroring the middleware, whose CRUD gate + * refuses that caller's read; only a context that carries no principal at + * all is admitted, as the middleware hands it through (ADR-0096). */ canExport(object: string, context?: SecurityContext): Promise; @@ -585,7 +595,9 @@ export interface ISecurityService { * deployment. Any door that bypasses the engine middleware MUST ask both. * * The verdict is the middleware's own read gate, arm for arm and in its order: - * the `isSystem` bypass, the "no permission sets resolved" skip, the + * the `isSystem` bypass, the ADR-0056 D2 deny baseline for a caller that + * carries a principal and resolves no permission set (only a context that + * carries no principal at all is handed through), the * fail-closed refusal on an unresolvable object posture, the ADR-0066 D3 * `requiredPermissions` capability AND-gate, the `allowRead` CRUD grant, and * the ADR-0090 D10 delegator intersection for an on-behalf-of caller. It is @@ -600,9 +612,11 @@ export interface ISecurityService { * * **Fails CLOSED.** This is an access-narrowing answer: implementations return * `false` (and callers must treat a throw as `false`) rather than degrading to - * "allowed". A system context bypasses and returns `true`; so does a caller - * with no resolved permission sets, mirroring the middleware, whose CRUD gate - * is skipped entirely when set resolution comes back empty. + * "allowed". A system context bypasses and returns `true`. A caller that + * carries a principal and resolves no permission set gets `false` — the + * ADR-0056 D2 deny baseline, mirroring the middleware, whose CRUD gate + * refuses that caller; only a context that carries no principal at all is + * admitted, as the middleware hands it through (ADR-0096). * * **OPTIONAL, and absence is a defined state — not a bug.** A security service * that predates this method omits it, and a consumer resolving the service as From a62c37f2bde855acf668869bb0d1a898c8508889 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:03:50 +0000 Subject: [PATCH 2/9] test(plugin-security): pin the deny baseline per caller class and re-pin the zero-set field suites A new suite pins, per measured member (the guest envelope, a context naming only an unregistered set, a signed-in user on a baseline-disabled embedder), that every engine operation is refused at object admission before it runs, that the three probes answer no, and that the row scope is the deny sentinel; with a set-holding member, a registered named set and a set-holding delegator as controls, the principal-less hand-off as the boundary, and a zero-set delegator refused by the probes as by the middleware. The 21 cases that read the old zero-set admission now assert the refusal (code and status), the deny sentinel, or the probe's no; every field-level answer they pinned is kept. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../src/can-write-object-admission.test.ts | 15 +- .../src/get-writable-fields.test.ts | 19 +- ...redicate-related-read-tenant-scope.test.ts | 17 +- .../src/zero-set-capability-fold.test.ts | 74 ++--- .../src/zero-set-deny-baseline.test.ts | 270 ++++++++++++++++++ .../src/zero-set-masking.test.ts | 65 +++-- 6 files changed, 382 insertions(+), 78 deletions(-) create mode 100644 packages/plugins/plugin-security/src/zero-set-deny-baseline.test.ts diff --git a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts index 376f77e3850..376769615a6 100644 --- a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts +++ b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts @@ -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 () => { diff --git a/packages/plugins/plugin-security/src/get-writable-fields.test.ts b/packages/plugins/plugin-security/src/get-writable-fields.test.ts index 9af29d3abf5..bf461203ba1 100644 --- a/packages/plugins/plugin-security/src/get-writable-fields.test.ts +++ b/packages/plugins/plugin-security/src/get-writable-fields.test.ts @@ -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 () => { diff --git a/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts b/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts index cd431de3179..e74b2204fa8 100644 --- a/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts +++ b/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts @@ -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`. */ @@ -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'); }); diff --git a/packages/plugins/plugin-security/src/zero-set-capability-fold.test.ts b/packages/plugins/plugin-security/src/zero-set-capability-fold.test.ts index ea615cf3cd0..b3668154f9b 100644 --- a/packages/plugins/plugin-security/src/zero-set-capability-fold.test.ts +++ b/packages/plugins/plugin-security/src/zero-set-capability-fold.test.ts @@ -2,14 +2,17 @@ /** * [#21063] A capability-gated field reaches a caller who resolves NO - * permission set as what it declares: hidden on read, refused on write. + * permission set as what it declares: hidden on read, refused on write — and + * [#21079] that caller, when it carries a principal, is refused the object + * itself (the ADR-0056 D2 deny baseline), ahead of every field gate. * * `requiredPermissions` on a field declares "mask on read, deny on write; * AND-gate" (ADR-0066 D3). A caller who resolves no permission set holds no * capability, so the gate applies to it. The field here declares the gate and * NO masking rule, so "mask on read" is the hidden state: the key is not - * served. The explain engine has always reported that; the record doors now - * agree with it. + * served. The explain engine has always reported that, and the field answers + * agree with it. The record doors refuse the object outright, which explain's + * `object_crud` layer has always reported for this caller too. * * The caller class is pinned three ways, each by what resolution answers for * it rather than by a door (the three `zero-set-masking.test.ts` pins): a @@ -21,16 +24,15 @@ * * For each, ONE derivation answers every reader: * - * - the record door (the middleware's result masker) does not serve the - * field, and explain's `fls` layer reports it masked from responses: the - * two agree; + * - the record door refuses the read at object admission, and explain + * agrees: `object_crud` denies, `allowed` is false, and its `fls` layer + * still reports the field masked from responses; * - `getReadableFields` and `getMetadataReadableFields` leave it out; - * - `getQueryableFields` equals the middleware's predicate and - * aggregate-input guards field for field, position for position, and - * leaves it out; - * - a write naming it is refused by the middleware's field write gate, and - * `getWritableFields` and `canWriteObject` agree with that gate field for - * field. + * - `getQueryableFields` leaves it out, and the middleware refuses every + * query at object admission before its query guards are asked; + * - `getWritableFields` leaves it out, and every write — naming it or not — + * is refused at object admission, with `canWriteObject` agreeing with the + * middleware field for field. * * Controls: a caller holding the capability is served the stored value and may * query and write the field; a caller holding a set without the capability @@ -112,6 +114,7 @@ async function boot(opts: { noBaseline?: boolean } = {}) { const security = registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1] as { resolvePermissionSetsForContext: (context: unknown) => Promise; explain: (request: { object: string; operation: string }, context: unknown) => Promise<{ + allowed: boolean; layers: Array<{ layer: string; verdict: string; detail: string }>; }>; }; @@ -128,6 +131,9 @@ const PROBES: ReadonlyArray Promise) => Promise, opCtx: Record, @@ -191,13 +197,21 @@ describe('[#21063] a caller who resolves no permission set is not served a capab expect(await security.resolvePermissionSetsForContext({ ...c.context })).toEqual([]); }); - it('the record door does not serve the field, and explain agrees: hidden on both', async () => { + it('[#21079] the record door refuses the read at object admission, and explain agrees: the object denied, the field hidden', async () => { const { middleware, security } = await boot({ noBaseline: c.noBaseline }); - const row = await served(middleware, c.context); - const door = { title: row.title, gatedServed: GATED in row }; - const explained = await explainHides(security, c.context, GATED); - expect({ door, explainHides: explained }).toEqual({ - door: { title: ROW.title, gatedServed: false }, + const door = await run(middleware, { + object: 'ledger', operation: 'find', context: { ...c.context }, options: {}, ast: { where: {} }, + }); + const decision = await security.explain({ object: 'ledger', operation: 'read' }, { ...c.context }); + expect({ + door, + explainAllowed: decision.allowed, + explainObjectCrud: decision.layers.find((l) => l.layer === 'object_crud')?.verdict, + explainHides: await explainHides(security, c.context, GATED), + }).toEqual({ + door: REFUSED_AT_ADMISSION, + explainAllowed: false, + explainObjectCrud: 'denies', explainHides: true, }); }); @@ -208,43 +222,33 @@ describe('[#21063] a caller who resolves no permission set is not served a capab expect(await plugin.getMetadataReadableFields('ledger', { ...c.context })).toEqual(WITHOUT_GATED); }); - it('the query projection and the middleware\'s query guards agree, field for field, and leave the field out', async () => { + it('the query projection leaves the field out, and [#21079] the middleware refuses every query at object admission', async () => { const { plugin, middleware } = await boot({ noBaseline: c.noBaseline }); - const queryable = await plugin.getQueryableFields('ledger', { ...c.context }); - expect(queryable).toEqual(WITHOUT_GATED); + expect(await plugin.getQueryableFields('ledger', { ...c.context })).toEqual(WITHOUT_GATED); for (const field of FIELDS) { for (const [position, operation, ast] of PROBES) { const verdict = await run(middleware, { object: 'ledger', operation, context: { ...c.context }, options: {}, ast: ast(field), }); - if (queryable!.includes(field)) { - expect(verdict, `${field} as ${position}`).toEqual({ admitted: true }); - } else { - expect(verdict, `${field} as ${position}`).toMatchObject({ admitted: false, code: 'PERMISSION_DENIED', status: 403 }); - } + expect(verdict, `${field} as ${position}`).toEqual(REFUSED_AT_ADMISSION); } } }); - it('the write gate refuses the field, and the write projection and the write admission agree with it, field for field', async () => { + it('the write projection leaves the field out, and [#21079] every write is refused at object admission, with the write admission agreeing field for field', async () => { const { plugin, middleware } = await boot({ noBaseline: c.noBaseline }); - const writable = await plugin.getWritableFields('ledger', { ...c.context }); - expect(writable).toEqual(WITHOUT_GATED); + expect(await plugin.getWritableFields('ledger', { ...c.context })).toEqual(WITHOUT_GATED); for (const operation of ['insert', 'update'] as const) { for (const field of FIELDS) { const data = { [field]: PAYLOAD_VALUE[field] }; const verdict = await run(middleware, { object: 'ledger', operation, context: { ...c.context }, options: {}, ast: { where: {} }, data, }); - if (writable!.includes(field)) { - expect(verdict, `${operation} naming ${field}`).toEqual({ admitted: true }); - } else { - expect(verdict, `${operation} naming ${field}`).toMatchObject({ admitted: false, code: 'PERMISSION_DENIED', status: 403 }); - } + expect(verdict, `${operation} naming ${field}`).toEqual(REFUSED_AT_ADMISSION); expect( await plugin.canWriteObject('ledger', operation, { ...c.context }, data), `canWriteObject: ${operation} naming ${field}`, - ).toBe(verdict.admitted); + ).toBe(false); } } }); diff --git a/packages/plugins/plugin-security/src/zero-set-deny-baseline.test.ts b/packages/plugins/plugin-security/src/zero-set-deny-baseline.test.ts new file mode 100644 index 00000000000..154df6b8e47 --- /dev/null +++ b/packages/plugins/plugin-security/src/zero-set-deny-baseline.test.ts @@ -0,0 +1,270 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21079] ADR-0056 D2 — an empty permission-set list is the DENY BASELINE. + * + * A non-system caller that carries a principal (a position, a named permission + * set or a user id) and resolves NO permission set used to be admitted to every + * object no set grants, and read with the sharing predicate as its only row + * scope: the middleware's CRUD gate, and the `canReadObject` / `canWriteObject` + * / `canExport` probes beside it, each stood down on an empty list, and + * `getReadFilter` composed a scope that never said "deny". ADR-0056 D2 says an + * unauthenticated principal gets the deny baseline, not "no checks"; ADR-0090 + * D9 says a guest holds the `guest` position and nothing else. + * + * The caller class is pinned per measured member, each by what resolution + * answers for it rather than by a door, and each case first asserts that + * premise (zero sets resolved), so a later change to resolution moves the case + * out of the class loudly instead of silently testing something else: + * + * - the guest envelope (the `guest` audience anchor, which no set is named + * after) on a deployment that registers no guest set; + * - a context that names only a permission set the deployment does not + * register, with no user id (the shape the public-form lookup picker hands + * the engine on a deployment without that set); + * - a signed-in user on an embedder that switches the baseline off + * (`fallbackPermissionSet: null`) and grants that user nothing. + * + * For each, ONE answer at every layer: object admission refuses it — the + * middleware with `PERMISSION_DENIED` / 403 for every engine operation, before + * the operation runs, and the three probes `false` — and its row scope is the + * deny sentinel. + * + * Controls: a signed-in member who resolves a set is admitted to the object the + * set grants (and refused the one it does not), and a deployment that + * registers the named set is decided by that set. The boundary: a + * principal-less context (no position, no named set, no user id) is handed + * through exactly as before (ADR-0096 stages it separately). + * + * And the same answer for the second principal of a delegated request: an + * agent acting for a delegator who resolves no set is refused by the probes as + * the middleware refuses it, with a delegator who resolves a set as the control. + * + * Fixtures are synthetic. Harness mirrors `zero-set-masking.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; +import { RLS_DENY_FILTER } from './rls-compiler.js'; + +const SCHEMAS: Record = { + ledger: { name: 'ledger', fields: { title: { type: 'text', label: 'Title' } } }, + granted: { name: 'granted', fields: { title: { type: 'text', label: 'Title' } } }, +}; + +const CRUD = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowExport: true }; + +/** The member baseline: one object granted, the other not. */ +const MEMBER_SET = { name: 'member_default', label: 'Synthetic member', objects: { granted: CRUD } } as unknown as PermissionSet; +/** The set the picker-shaped context names, registered only on the control deployment. */ +const NAMED_SET = { name: 'synth_named', label: 'Synthetic named set', objects: { ledger: CRUD } } as unknown as PermissionSet; +/** A delegated agent's own ceiling: everything on `ledger`, so the delegator's leg is what decides. */ +const AGENT_SET = { name: 'synth_agent', label: 'Synthetic agent ceiling', objects: { ledger: CRUD } } as unknown as PermissionSet; +/** What the live delegator holds in the delegation control. */ +const DELEGATOR_SET = { name: 'synth_delegator', label: 'Synthetic delegator', objects: { ledger: CRUD } } as unknown as PermissionSet; + +const DELEGATOR_ID = 'u_delegator'; + +type Deployment = { sets: PermissionSet[]; noBaseline?: boolean; delegatorPermissions?: string[] }; + +async function boot(deployment: Deployment) { + const middlewares: Array<(opCtx: any, next: () => Promise) => Promise> = []; + const registered: Record = {}; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: { + registerMiddleware: (mw: any) => middlewares.push(mw), + getSchema: (name: string) => SCHEMAS[name], + // The delegator's user row, and nothing else: the delegator's grants come + // from the explicit list below, or from no list at all. + findOne: vi.fn(async (object: string, query: any) => + (object === 'sys_user' && query?.where?.id === DELEGATOR_ID ? { id: DELEGATOR_ID, email: 'delegator@synth.test' } : null)), + find: vi.fn(async () => []), + }, + metadata: { + get: async (_type: string, name: string) => SCHEMAS[name], + list: async () => deployment.sets, + }, + }; + const ctx: Record = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin( + deployment.noBaseline + ? { defaultPermissionSets: [], fallbackPermissionSet: null } + : { fallbackPermissionSet: 'member_default' }, + ); + await plugin.init(ctx as any); + await plugin.start(ctx as any); + if (middlewares.length === 0) throw new Error('SecurityPlugin registered no middleware'); + const security = registered['security'] as { + canReadObject(object: string, context?: unknown): Promise; + canExport(object: string, context?: unknown): Promise; + getReadFilter(object: string, context?: unknown): Promise | undefined>; + }; + return { plugin, middleware: middlewares[0], security }; +} + +type Verdict = { admitted: true } | { admitted: false; ran: boolean; code?: unknown; status?: unknown }; + +/** Drive the registered middleware; `ran` says whether the operation itself was reached. */ +async function run( + middleware: (opCtx: any, next: () => Promise) => Promise, + object: string, + operation: string, + context: Record, +): Promise { + let ran = false; + const opCtx: Record = { object, operation, context: { ...context }, options: {}, ast: { where: {} } }; + if (operation === 'insert' || operation === 'update') opCtx.data = { title: 'SYNTH' }; + if (operation === 'aggregate') opCtx.ast = { aggregations: [{ function: 'count', alias: 'n' }] }; + try { + await middleware(opCtx, async () => { ran = true; }); + return { admitted: true }; + } catch (e) { + const err = e as { code?: unknown; status?: unknown; statusCode?: unknown }; + return { admitted: false, ran, code: err.code, status: err.status ?? err.statusCode }; + } +} + +const REFUSED_AT_ADMISSION = { admitted: false, ran: false, code: 'PERMISSION_DENIED', status: 403 } as const; +const ENGINE_OPERATIONS = ['find', 'findOne', 'count', 'aggregate', 'insert', 'update', 'delete'] as const; + +/** Every probe this caller class is answered by, on one object. */ +async function probes( + booted: Awaited>, + object: string, + context: Record, +) { + const { plugin, security } = booted; + return { + canReadObject: await security.canReadObject(object, { ...context }), + canExport: await security.canExport(object, { ...context }), + canInsert: await plugin.canWriteObject(object, 'insert', { ...context }), + canInsertPayload: await plugin.canWriteObject(object, 'insert', { ...context }, { title: 'SYNTH' }), + canUpdatePayload: await plugin.canWriteObject(object, 'update', { ...context }, { title: 'SYNTH' }), + }; +} + +const ALL_FALSE = { canReadObject: false, canExport: false, canInsert: false, canInsertPayload: false, canUpdatePayload: false }; +const ALL_TRUE = { canReadObject: true, canExport: true, canInsert: true, canInsertPayload: true, canUpdatePayload: true }; + +/** The measured members of the class, each on the deployment that produces it. */ +const ZERO_SET_CASES: Array<{ label: string; deployment: Deployment; context: Record }> = [ + { + label: 'the guest envelope, on a deployment that registers no guest set', + deployment: { sets: [MEMBER_SET] }, + context: { positions: ['guest'], permissions: [], principalKind: 'guest', isSystem: false }, + }, + { + label: 'a context naming only a set the deployment does not register, with no user id', + deployment: { sets: [MEMBER_SET] }, + context: { permissions: ['synth_named'], anonymous: true }, + }, + { + label: 'a signed-in user granted nothing, on an embedder that switches the baseline off', + deployment: { sets: [], noBaseline: true }, + context: { userId: 'u_synth', tenantId: 'org-1', positions: [], permissions: [] }, + }, +]; + +describe('[#21079] a non-system caller that carries a principal and resolves no permission set gets the deny baseline', () => { + for (const c of ZERO_SET_CASES) { + describe(c.label, () => { + it('premise: resolution answers no permission set for this caller', async () => { + const { plugin } = await boot(c.deployment); + await expect((plugin as any).resolvePermissionSetsForContext({ ...c.context })).resolves.toEqual([]); + }); + + it('the middleware refuses every engine operation at object admission, before it runs', async () => { + const { middleware } = await boot(c.deployment); + for (const operation of ENGINE_OPERATIONS) { + expect(await run(middleware, 'ledger', operation, c.context), operation).toEqual(REFUSED_AT_ADMISSION); + } + }); + + it('the object-admission probes answer no, as the middleware does', async () => { + const booted = await boot(c.deployment); + expect(await probes(booted, 'ledger', c.context)).toEqual(ALL_FALSE); + }); + + it('the row scope is the deny sentinel', async () => { + const { security } = await boot(c.deployment); + expect(await security.getReadFilter('ledger', { ...c.context })).toEqual({ ...RLS_DENY_FILTER }); + }); + }); + } +}); + +describe('[#21079] the controls: a caller who resolves a set is decided by that set', () => { + const MEMBER = { userId: 'u_member', tenantId: 'org-1', positions: [], permissions: [] }; + + it('a signed-in member is admitted to the object its set grants, with no deny scope', async () => { + const booted = await boot({ sets: [MEMBER_SET] }); + await expect((booted.plugin as any).resolvePermissionSetsForContext({ ...MEMBER })).resolves.toHaveLength(1); + for (const operation of ENGINE_OPERATIONS) { + expect(await run(booted.middleware, 'granted', operation, MEMBER), operation).toEqual({ admitted: true }); + } + expect(await probes(booted, 'granted', MEMBER)).toEqual(ALL_TRUE); + expect(await booted.security.getReadFilter('granted', { ...MEMBER })).not.toEqual({ ...RLS_DENY_FILTER }); + }); + + it('…and refused the object its set does not grant, so the control is not "admit everything"', async () => { + const booted = await boot({ sets: [MEMBER_SET] }); + expect(await run(booted.middleware, 'ledger', 'find', MEMBER)).toEqual(REFUSED_AT_ADMISSION); + expect(await probes(booted, 'ledger', MEMBER)).toEqual(ALL_FALSE); + }); + + it('a deployment that registers the named set admits the same context to what that set grants', async () => { + const NAMED = { permissions: ['synth_named'], anonymous: true }; + const booted = await boot({ sets: [MEMBER_SET, NAMED_SET] }); + await expect((booted.plugin as any).resolvePermissionSetsForContext({ ...NAMED })).resolves.toHaveLength(1); + expect(await run(booted.middleware, 'ledger', 'find', NAMED)).toEqual({ admitted: true }); + expect(await booted.security.canReadObject('ledger', { ...NAMED })).toBe(true); + expect(await booted.security.getReadFilter('ledger', { ...NAMED })).not.toEqual({ ...RLS_DENY_FILTER }); + }); +}); + +describe('[#21079] the boundary: a principal-less context is handed through, unchanged (ADR-0096)', () => { + const PRINCIPAL_LESS = { positions: [], permissions: [] }; + + it('the middleware admits it, the probes admit it, and its row scope is not the deny sentinel', async () => { + const booted = await boot({ sets: [MEMBER_SET] }); + for (const operation of ENGINE_OPERATIONS) { + expect(await run(booted.middleware, 'ledger', operation, PRINCIPAL_LESS), operation).toEqual({ admitted: true }); + } + expect(await probes(booted, 'ledger', PRINCIPAL_LESS)).toEqual(ALL_TRUE); + expect(await booted.security.getReadFilter('ledger', { ...PRINCIPAL_LESS })).toBeUndefined(); + }); +}); + +describe('[#21079] the second principal: a delegator who resolves no permission set is the deny baseline too', () => { + const AGENT = { + userId: 'u_agent', tenantId: 'org-1', principalKind: 'agent', positions: [], permissions: ['synth_agent'], + onBehalfOf: { userId: DELEGATOR_ID, principalKind: 'human' }, + }; + + it('on an embedder with no baseline, the delegator resolves nothing: the middleware refuses, and the probes agree', async () => { + const booted = await boot({ sets: [AGENT_SET], noBaseline: true }); + await expect((booted.plugin as any).resolvePermissionSetsForContext({ ...AGENT })).resolves.toHaveLength(1); + expect(await run(booted.middleware, 'ledger', 'find', AGENT)).toEqual(REFUSED_AT_ADMISSION); + expect(await run(booted.middleware, 'ledger', 'insert', AGENT)).toEqual(REFUSED_AT_ADMISSION); + expect(await probes(booted, 'ledger', AGENT)).toEqual(ALL_FALSE); + }); + + it('CONTROL: a delegator who resolves a set granting the object — the middleware admits, and the probes agree', async () => { + // The delegator's baseline is the set that grants `ledger`, so the only + // difference from the case above is what the delegator resolves. + const DELEGATOR_BASELINE = { ...DELEGATOR_SET, name: 'member_default' } as unknown as PermissionSet; + const booted = await boot({ sets: [AGENT_SET, DELEGATOR_BASELINE] }); + expect(await run(booted.middleware, 'ledger', 'find', AGENT)).toEqual({ admitted: true }); + expect(await run(booted.middleware, 'ledger', 'insert', AGENT)).toEqual({ admitted: true }); + expect(await probes(booted, 'ledger', AGENT)).toEqual(ALL_TRUE); + }); +}); diff --git a/packages/plugins/plugin-security/src/zero-set-masking.test.ts b/packages/plugins/plugin-security/src/zero-set-masking.test.ts index 467d0f8ce73..231e818579f 100644 --- a/packages/plugins/plugin-security/src/zero-set-masking.test.ts +++ b/packages/plugins/plugin-security/src/zero-set-masking.test.ts @@ -1,13 +1,15 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#20995] A masking rule reaches a caller who resolves NO permission set. + * [#20995] A masking rule reaches a caller who resolves NO permission set — + * and [#21079] that caller, when it carries a principal, is refused the object + * itself (the ADR-0056 D2 deny baseline). * * `maskingRule` declares itself for "every non-system caller unless the * field's `requiredPermissions` are ALL held", and "masked callers cannot * filter/sort/group/aggregate on the field". A caller who holds no permission - * set holds no capability, so every rule applies to them: the field is served - * masked and is not queryable. + * set holds no capability, so every rule applies to them in the FIELD answers: + * the field is a served column that is not queryable. * * The caller class is pinned three ways, each by what resolution answers for * it rather than by a door: a user id on a deployment with no baseline, a @@ -16,17 +18,20 @@ * premise (zero sets resolved), so a later change to resolution moves the case * out of the class loudly instead of silently testing something else. * - * For each, the published answers and the engine middleware read ONE - * derivation: + * For each: * - * - `getQueryableFields` equals the middleware's predicate and aggregate-input - * guards, field for field and position for position (the equivalence the - * #20935 suite holds for callers who resolve sets), and excludes every - * masked field; - * - result masking serves the masked fields masked, and `getReadableFields` - * keeps them as served columns; - * - a write echoing the masked placeholder is refused, as for any masked - * caller, so serving the mask cannot turn into overwriting the value. + * - `getQueryableFields` still excludes every masked field, and the + * middleware refuses every query at object admission + * (`PERMISSION_DENIED`, 403) before any field guard is asked; + * - the read is refused at object admission and the row scope is the deny + * sentinel, while `getReadableFields` keeps the masked fields as served + * columns (a field answer, not an object one); + * - a write echoing the masked placeholder is refused, now at object + * admission, ahead of the echo gate. + * + * The result masker's zero-set reading is still reached on the data plane + * through the public-form grant, which admits ahead of object admission; that + * door is pinned in `public-form-grant-masking.test.ts`. * * The last block pins the class's boundary: a principal-less context (no * position, no named set, no user id) is handed straight through by the @@ -40,6 +45,7 @@ import type { PermissionSet } from '@objectstack/spec/security'; import type { FieldMaskingRule } from '@objectstack/spec/data'; import { SecurityPlugin } from './security-plugin.js'; import { maskFieldValue } from './field-masker.js'; +import { RLS_DENY_FILTER } from './rls-compiler.js'; const RULE: FieldMaskingRule = { keepHead: 1, keepTail: 1 }; const SCHEMAS: Record = { @@ -135,7 +141,10 @@ const ZERO_SET_CASES: Array<{ label: string; noBaseline?: boolean; context: Reco }, ]; -describe('[#20995] a caller who resolves no permission set is served a masked field masked, and may not query on it', () => { +/** [#21079] The middleware's object-admission refusal: the deny baseline's answer to an empty set list. */ +const REFUSED_AT_ADMISSION = { admitted: false, code: 'PERMISSION_DENIED', status: 403 } as const; + +describe('[#20995] a caller who resolves no permission set: every masking rule applies in its field answers, and [#21079] object admission refuses it', () => { for (const c of ZERO_SET_CASES) { describe(c.label, () => { it('premise: resolution answers no permission set for this caller', async () => { @@ -143,46 +152,36 @@ describe('[#20995] a caller who resolves no permission set is served a masked fi expect(await security.resolvePermissionSetsForContext({ ...c.context })).toEqual([]); }); - it('the query projection and the middleware\'s query guards agree, field for field, and exclude every masked field', async () => { + it('the query projection excludes every masked field, and the middleware refuses every query at object admission', async () => { const { plugin, middleware } = await boot({ noBaseline: c.noBaseline }); - const queryable = await plugin.getQueryableFields('ledger', { ...c.context }); - expect(queryable).toEqual(FIELDS.filter((f) => !MASKED.includes(f))); + expect(await plugin.getQueryableFields('ledger', { ...c.context })).toEqual(FIELDS.filter((f) => !MASKED.includes(f))); for (const field of FIELDS) { for (const [position, operation, ast] of PROBES) { const verdict = await run(middleware, { object: 'ledger', operation, context: { ...c.context }, options: {}, ast: ast(field), }); - if (queryable!.includes(field)) { - expect(verdict, `${field} as ${position}`).toEqual({ admitted: true }); - } else { - expect(verdict, `${field} as ${position}`).toMatchObject({ admitted: false, code: 'PERMISSION_DENIED', status: 403 }); - } + expect(verdict, `${field} as ${position}`).toEqual(REFUSED_AT_ADMISSION); } } }); - it('result masking serves the masked fields masked, and the read projection keeps them as served columns', async () => { + it('the read is refused at object admission and its row scope is the deny sentinel; the read projection keeps the masked fields as served columns', async () => { const { plugin, middleware } = await boot({ noBaseline: c.noBaseline }); const opCtx: Record = { - object: 'ledger', operation: 'find', context: { ...c.context }, options: {}, ast: { where: {} }, result: [{ ...ROW }], + object: 'ledger', operation: 'find', context: { ...c.context }, options: {}, ast: { where: {} }, }; - expect(await run(middleware, opCtx)).toEqual({ admitted: true }); - const served = opCtx.result[0]; - expect(served.title).toBe(ROW.title); - for (const f of MASKED) { - expect(served[f], f).toBe(maskFieldValue((ROW as Record)[f], RULE)); - expect(served[f], f).not.toBe((ROW as Record)[f]); - } + expect(await run(middleware, opCtx)).toEqual(REFUSED_AT_ADMISSION); + expect(await plugin.getReadFilter('ledger', { ...c.context })).toEqual({ ...RLS_DENY_FILTER }); expect(await plugin.getReadableFields('ledger', { ...c.context })).toEqual(FIELDS); }); - it('a write echoing the masked placeholder is refused, so the served mask never overwrites the value', async () => { + it('a write echoing the masked placeholder is refused — at object admission, ahead of the echo gate', async () => { const { middleware } = await boot({ noBaseline: c.noBaseline }); const verdict = await run(middleware, { object: 'ledger', operation: 'update', context: { ...c.context }, options: {}, data: { id: 'r1', always_masked: maskFieldValue(ROW.always_masked, RULE) }, }); - expect(verdict).toMatchObject({ admitted: false, code: 'VALIDATION_ERROR', status: 400 }); + expect(verdict).toEqual(REFUSED_AT_ADMISSION); }); }); } From 246155747d646d64370274c5e984b9fa6bd16003 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:04:42 +0000 Subject: [PATCH 3/9] refactor(plugin-security): keep the delegator change to the load-bearing grant arms The capability arms' delegator stand-down is dominated by the grant arm that now asks the delegator's empty list, so it stays as it was; only the grant arms of canReadObject and canWriteObject (and canExport's) carry the deny baseline for the second principal. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../plugin-security/src/security-plugin.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index b4f1e1acfd4..a4a95715daf 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -5830,14 +5830,11 @@ export class SecurityPlugin implements Plugin { // 4. [ADR-0066 D3/⑤] The capability AND-gate, ahead of the grant, for both // principals — a caller missing any required capability is denied // however permissive their grants are. - // [#21079] A delegator who resolves NO set is the deny baseline too: the - // middleware asks both of its delegator arms over that empty list and - // refuses, so neither arm here stands down on it. const required = requiredCapsForOperation(requiredPermissions, 'find'); if (required.length > 0) { const held = this.permissionEvaluator.getSystemPermissions(permissionSets); if (required.some((cap) => !held.has(cap))) return false; - if (delegatorSets) { + if (delegatorSets && delegatorSets.length > 0) { const delHeld = this.permissionEvaluator.getSystemPermissions(delegatorSets); if (required.some((cap) => !delHeld.has(cap))) return false; } @@ -5849,6 +5846,9 @@ export class SecurityPlugin implements Plugin { } // 6. [ADR-0090 D10] The delegator must independently grant the same read. + // [#21079] Asked over the delegator's list whatever its length, as the + // middleware asks it: a delegator who resolves NO set grants nothing, + // the deny baseline for the second principal too. if ( delegatorSets && !this.permissionEvaluator.checkObjectPermission('find', objectName, delegatorSets, { isPrivate }) @@ -6116,14 +6116,12 @@ export class SecurityPlugin implements Plugin { } // 6. [ADR-0066 D3/⑤] The capability AND-gate, ahead of the grant, for both - // principals. [#21079] A delegator who resolves NO set is the deny - // baseline too: the middleware asks both of its delegator arms over - // that empty list and refuses, so neither arm here stands down on it. + // principals. const required = requiredCapsForOperation(requiredPermissions, operation); if (required.length > 0) { const held = this.permissionEvaluator.getSystemPermissions(permissionSets); if (required.some((cap) => !held.has(cap))) return false; - if (delegatorSets) { + if (delegatorSets && delegatorSets.length > 0) { const delHeld = this.permissionEvaluator.getSystemPermissions(delegatorSets); if (required.some((cap) => !delHeld.has(cap))) return false; } @@ -6135,6 +6133,9 @@ export class SecurityPlugin implements Plugin { } // 8. [ADR-0090 D10] The delegator must independently grant the same write. + // [#21079] Asked over the delegator's list whatever its length, as the + // middleware asks it: a delegator who resolves NO set grants nothing, + // the deny baseline for the second principal too. if ( delegatorSets && !this.permissionEvaluator.checkObjectPermission(operation, objectName, delegatorSets, { isPrivate }) From a12139a9832f3df81b8ca8db3c96b01fd4712847 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:16:41 +0000 Subject: [PATCH 4/9] test(dogfood): re-pin the anonymous endpoint and the zero-set doors to the deny baseline An explicit authRequired:false endpoint still passes authentication on the fixture, and its object read is now refused at admission (403 PERMISSION_DENIED) on a deployment that grants anonymous callers nothing; the declared cache directive stays off that refusal. The picker door and the baseline-disabled record door refuse at admission, the record door for the read and for a query naming an unmasked field alike. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- ...eclarative-endpoint-policy.dogfood.test.ts | 24 +++--- .../test/zero-set-masking.dogfood.test.ts | 73 ++++++++++--------- 2 files changed, 52 insertions(+), 45 deletions(-) diff --git a/packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts b/packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts index 268ca519adf..fdb9d5bafc2 100644 --- a/packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts +++ b/packages/qa/dogfood/test/declarative-endpoint-policy.dogfood.test.ts @@ -16,8 +16,12 @@ // 1. an OMITTED `authRequired` really denies anonymous — the upgrade guide // tells every maintainer that "an omission is safe", and this is what // makes that sentence testable rather than reassuring; -// 2. an EXPLICIT `authRequired: false` really serves anonymous — otherwise -// D6's obligation would be protecting nothing; +// 2. an EXPLICIT `authRequired: false` really lets anonymous through the +// authentication gate — otherwise D6's obligation would be protecting +// nothing. [#21079] What it reaches behind that gate is what the +// permission model grants anonymous callers, and this fixture grants them +// nothing: the ADR-0056 D2 deny baseline refuses the endpoint's object +// read at object admission (403, not the 401 an omitted key answers); // 3. the armed budget really meters, on the wire: the (N+1)-th anonymous // request inside the window gets 429 **with a `Retry-After` header**. A // 429 that loses that header has told the client nothing it can act on, @@ -83,17 +87,19 @@ describe('[#5112] authRequired — the default is the protection', () => { }); }); -describe('[#5112] ADR-0121 D6 — anonymous is served, and metered', () => { - it('an EXPLICIT authRequired:false serves an anonymous caller', async () => { +describe('[#5112] ADR-0121 D6 — anonymous passes authentication, and is metered', () => { + it('[#21079] an EXPLICIT authRequired:false lets an anonymous caller through authentication, and the deny baseline refuses its object read', async () => { const res = await stack.api(PUBLIC_FEED, { method: 'GET' }); - expect(res.status, await res.clone().text()).toBe(200); - const body = (await res.json()) as { success?: boolean }; - expect(body.success).toBe(true); + expect(res.status, await res.clone().text()).toBe(403); + const body = (await res.json()) as { success?: boolean; error?: { code?: unknown } }; + expect(body.success).toBe(false); + expect(body.error?.code).toBe('PERMISSION_DENIED'); }); - it('carries the declared cacheTtlSeconds on the anonymous success', async () => { + it('[#21079] never puts the declared cacheTtlSeconds on the anonymous refusal — the directive is success-only', async () => { const res = await stack.api(PUBLIC_FEED, { method: 'GET' }); - expect(res.headers.get('cache-control')).toMatch(/max-age=15/); + expect(res.status, await res.clone().text()).toBe(403); + expect(res.headers.get('cache-control') ?? '').not.toMatch(/max-age=15/); }); it('answers 429 WITH Retry-After once the armed budget is exhausted', async () => { diff --git a/packages/qa/dogfood/test/zero-set-masking.dogfood.test.ts b/packages/qa/dogfood/test/zero-set-masking.dogfood.test.ts index 52f02bf4620..cf5368a75a3 100644 --- a/packages/qa/dogfood/test/zero-set-masking.dogfood.test.ts +++ b/packages/qa/dogfood/test/zero-set-masking.dogfood.test.ts @@ -1,23 +1,26 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// [#20995] A field whose masking rule applies is served MASKED to a caller who -// resolves no permission set, on a real boot. +// [#20995] A field whose masking rule applies used to be served MASKED to a +// caller who resolves no permission set, on a real boot — and [#21079] that +// caller, when it carries a principal, is now refused the object itself: the +// ADR-0056 D2 deny baseline, so no row, masked or stored, leaves the door. // -// `maskingRule` declares itself for "every non-system caller unless the -// field's `requiredPermissions` are ALL held". A caller who resolves no -// permission set holds nothing, so the rule applies to it. Two doors that -// produce that caller on a real composition are driven here: +// Two doors that produce that caller on a real composition are driven here: // -// - the public form's lookup picker, which serves the referenced object's -// declared display fields to a visitor with no session, on a deployment -// that registers no profile for public forms; and +// - the public form's lookup picker, which hands the engine a context naming +// the public-form profile, to a visitor with no session, on a deployment +// that registers no such profile; and // - the record door, for a signed-in user on a deployment whose baseline is // switched off (`fallbackPermissionSet: null`) and who holds no grant. // // What is asserted, by class: the scene is real (a system read carries the -// stored value); the field is served masked, never stored; a field with no -// rule beside it is served as stored (the door really served the row); and, -// on the record door, a predicate on the masked field is refused. +// stored value), and the door refuses at object admission (403, +// `PERMISSION_DENIED`) — the record door for the read and for a query alike. +// The masker's zero-set reading is still reached on a real boot through the +// public form submit's echo, pinned by `public-form-read-back-masking`. +// +// The picker-door case is shared with the card retiring the picker (#21180), +// which deletes that door; whichever of the two lands second adapts this file. // // `bootStack` with the real `SecurityPlugin`, `ObjectQL`, SQL driver, REST and // auth layers. `@objectstack/plugin-security` resolves to its BUILT output @@ -108,26 +111,23 @@ async function seedContact(stack: Awaited>): Promis return id; } -function expectMasked(value: unknown): void { - expect(typeof value, 'the masked field is served, as a string').toBe('string'); - expect(value).not.toBe(STORED); - expect(String(value)).toContain('*'); - expect(String(value)).toHaveLength(STORED.length); +/** [#21079] The door's answer to the deny baseline: refused at object admission, nothing served. */ +async function expectRefusedAtAdmission(res: Response, what: string): Promise { + const text = await res.clone().text(); + expect(res.status, `${what}: ${text}`).toBe(403); + const refusal = (await res.json()) as { code?: unknown; error?: { code?: unknown } }; + expect(refusal.code ?? refusal.error?.code, what).toBe('PERMISSION_DENIED'); + expect(text, `${what}: no stored value leaves the door`).not.toContain(STORED); } -describe('[#20995] a masked field is served masked to a caller who resolves no permission set', () => { +describe('[#20995] a caller who resolves no permission set, on a real boot: [#21079] refused at object admission', () => { it( - 'on the public form lookup door, to a visitor with no session', + 'on the public form lookup door, to a visitor with no session, on a deployment without the public-form profile', async () => { const stack = await bootStack(zsmaskStack as unknown as Stack); try { await seedContact(stack); - const res = await stack.api('/forms/zsmask-intake/lookup/contact'); - expect(res.status).toBe(200); - const body = (await res.json()) as { data: Array> }; - expect(body.data).toHaveLength(1); - expect(body.data[0].name, 'the door served the row').toBe(NAME); - expectMasked(body.data[0][KEY]); + await expectRefusedAtAdmission(await stack.api('/forms/zsmask-intake/lookup/contact'), 'the picker'); } finally { await stack.stop(); } @@ -145,17 +145,18 @@ describe('[#20995] a masked field is served masked to a caller who resolves no p const id = await seedContact(stack); const token = await stack.signUp('zsmask-nobody@verify.test'); - const res = await stack.apiAs(token, 'GET', `/data/${CONTACT}/${id}`); - expect(res.status).toBe(200); - const body = (await res.json()) as { record?: Record }; - const record = body.record ?? (body as Record); - expect(record.name, 'the door served the row').toBe(NAME); - expectMasked(record[KEY]); - - const probe = await stack.apiAs(token, 'POST', `/data/${CONTACT}/query`, { where: { [KEY]: STORED } }); - expect(probe.status).toBe(403); - const refusal = (await probe.json()) as { code?: unknown; error?: { code?: unknown } }; - expect(refusal.code ?? refusal.error?.code).toBe('PERMISSION_DENIED'); + await expectRefusedAtAdmission(await stack.apiAs(token, 'GET', `/data/${CONTACT}/${id}`), 'the record read'); + // A query refused for naming the masked field would be the field + // guard's answer; the one naming an unmasked field shows the object + // itself is what is refused. + await expectRefusedAtAdmission( + await stack.apiAs(token, 'POST', `/data/${CONTACT}/query`, { where: { [KEY]: STORED } }), + 'a query naming the masked field', + ); + await expectRefusedAtAdmission( + await stack.apiAs(token, 'POST', `/data/${CONTACT}/query`, { where: { name: NAME } }), + 'a query naming an unmasked field', + ); } finally { await stack.stop(); } From a0366185e286cc4b0346727615b6241598a0c929 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:27:40 +0000 Subject: [PATCH 5/9] chore(changeset): the deny baseline for an empty permission-set list, BREAKING, with the ruling's migration text Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .changeset/21079-zero-set-deny-baseline.md | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .changeset/21079-zero-set-deny-baseline.md diff --git a/.changeset/21079-zero-set-deny-baseline.md b/.changeset/21079-zero-set-deny-baseline.md new file mode 100644 index 00000000000..40b531c5ee2 --- /dev/null +++ b/.changeset/21079-zero-set-deny-baseline.md @@ -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) + + + +**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. From 3e49b1afa0d6e3cca060ff60a1018449c8c8595a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:55:15 +0000 Subject: [PATCH 6/9] test(plugin-security): the deny-baseline suite's fake engine opens findOne with the engine's own predicate check The engine-double contract gate counts the fake; its findOne now routes through assertEngineFindOnePredicate as ObjectQL.findOne does, and the unused find stub is gone. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../plugin-security/src/zero-set-deny-baseline.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-security/src/zero-set-deny-baseline.test.ts b/packages/plugins/plugin-security/src/zero-set-deny-baseline.test.ts index 154df6b8e47..547f6342aa2 100644 --- a/packages/plugins/plugin-security/src/zero-set-deny-baseline.test.ts +++ b/packages/plugins/plugin-security/src/zero-set-deny-baseline.test.ts @@ -45,6 +45,7 @@ import { describe, it, expect, vi } from 'vitest'; import type { PermissionSet } from '@objectstack/spec/security'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; import { SecurityPlugin } from './security-plugin.js'; import { RLS_DENY_FILTER } from './rls-compiler.js'; @@ -77,10 +78,11 @@ async function boot(deployment: Deployment) { registerMiddleware: (mw: any) => middlewares.push(mw), getSchema: (name: string) => SCHEMAS[name], // The delegator's user row, and nothing else: the delegator's grants come - // from the explicit list below, or from no list at all. - findOne: vi.fn(async (object: string, query: any) => - (object === 'sys_user' && query?.where?.id === DELEGATOR_ID ? { id: DELEGATOR_ID, email: 'delegator@synth.test' } : null)), - find: vi.fn(async () => []), + // from the deployment's sets, or from no set at all. + findOne: vi.fn(async (object: string, query: any) => { + assertEngineFindOnePredicate(object, query); + return object === 'sys_user' && query?.where?.id === DELEGATOR_ID ? { id: DELEGATOR_ID, email: 'delegator@synth.test' } : null; + }), }, metadata: { get: async (_type: string, name: string) => SCHEMAS[name], From 4d2b19108e0a91b1c55aba39b356a43316443ca4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:51:01 +0000 Subject: [PATCH 7/9] test(rest,dogfood): re-pin the public picker on a deployment without the guest set to the deny baseline On a deployment that registers no guest set the picker's context resolves no permission set, so the engine refuses it at object admission: every listing, search and masked-only picker answers 403 PERMISSION_DENIED and serves nothing, for a visitor and a signed-in caller alike. The key the picker composes is unchanged and still never names the masked field; the guest-set and lifted-rule classes keep their pins. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- ...ublic-picker-queryable-key.dogfood.test.ts | 67 +++++++-------- ...c-form-lookup-picker-queryable-key.test.ts | 85 +++++++++++++++++-- 2 files changed, 111 insertions(+), 41 deletions(-) diff --git a/packages/qa/dogfood/test/public-picker-queryable-key.dogfood.test.ts b/packages/qa/dogfood/test/public-picker-queryable-key.dogfood.test.ts index 99e296c85e6..2d6df88d19c 100644 --- a/packages/qa/dogfood/test/public-picker-queryable-key.dogfood.test.ts +++ b/packages/qa/dogfood/test/public-picker-queryable-key.dogfood.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // [#21062] A public lookup picker whose FIRST display field declares a masking -// rule serves its rows, on a real boot, to a caller the rule applies to. +// rule served its rows, on a real boot, to a caller the rule applies to. // // The picker searches and sorts by one key. It is the first display field the // caller may query on, by the security service's published answer @@ -10,13 +10,21 @@ // searched or sorted on, so a picker keyed on it answered `403` to every such // caller. // -// What is asserted, by class, for a visitor with no session on a deployment -// that registers no profile for public forms: the scene is real (a system read -// carries the stored values); the rows are sorted on the next queryable display -// field, and the search matches it; the masked field is still served, masked; -// a signed-in caller reaching the same door is served the same (the door builds -// its own context); and a picker whose only display field is masked answers -// the engine's refusal, `403 PERMISSION_DENIED`. +// [#21079] On a deployment that registers no profile for public forms, the +// picker's context resolves NO permission set, and an empty set list is the +// ADR-0056 D2 deny baseline: the engine refuses that caller at object +// admission, before any field guard. So on this fixture every picker answers +// `403 PERMISSION_DENIED` and serves nothing — the key it composes is unchanged +// (the unit pin `public-form-lookup-picker-queryable-key.test.ts` holds that), +// but no row leaves the door. The picker is retired on its own card; until +// then this is what it answers here. +// +// What is asserted, by class, for a visitor with no session on that +// deployment: the scene is real (a system read carries the stored values); the +// listing and the search are both refused at object admission, with no stored +// value in the answer; a signed-in caller reaching the same door is refused the +// same (the door builds its own context); and a picker whose only display field +// is masked is refused with the same code and status. // // `bootStack` with the real `SecurityPlugin`, `ObjectQL`, SQL driver, REST and // auth layers. `@objectstack/rest` resolves to its BUILT output here (no source @@ -38,7 +46,6 @@ const SEED = [ { name: 'Alpha Synthetic', [KEY]: 'SYNTH3CCC' }, { name: 'Charlie Synthetic', [KEY]: 'SYNTH2BBB' }, ]; -const BY_NAME = ['Alpha Synthetic', 'Bravo Synthetic', 'Charlie Synthetic']; const PqkeyContact = ObjectSchema.create({ name: CONTACT, @@ -103,19 +110,19 @@ const pqkeyStack = defineStack({ type Stack = Parameters[0]; type Row = Record; -function expectServedMasked(rows: Row[]): void { - const stored = new Map(SEED.map((r) => [r.name, r[KEY]])); - for (const row of rows) { - const value = row[KEY]; - expect(typeof value, 'the masked field is served, as a string').toBe('string'); - expect(value).not.toBe(stored.get(String(row.name))); - expect(String(value)).toContain('*'); - } +/** [#21079] The deny baseline's answer at the door: refused at object admission, nothing served. */ +async function expectRefusedAtAdmission(res: Response, what: string): Promise { + const text = await res.clone().text(); + expect(res.status, `${what}: ${text}`).toBe(403); + const refusal = (await res.json()) as { code?: unknown; error?: { code?: unknown }; data?: unknown }; + expect(refusal.code ?? refusal.error?.code, what).toBe('PERMISSION_DENIED'); + expect(refusal.data, `${what}: no row is served`).toBeUndefined(); + for (const row of SEED) expect(text, `${what}: no stored value leaves the door`).not.toContain(String(row[KEY])); } -describe('[#21062] a public picker whose first display field is masked serves rows sorted on the next queryable one', () => { +describe('[#21062] a public picker whose first display field is masked: [#21079] refused at object admission on a deployment without the public-form profile', () => { it( - 'to a visitor with no session and to a signed-in caller; a picker with no queryable display field is refused', + 'to a visitor with no session and to a signed-in caller; a picker with no queryable display field is refused the same', async () => { const stack = await bootStack(pqkeyStack as unknown as Stack); try { @@ -130,24 +137,14 @@ describe('[#21062] a public picker whose first display field is masked serves ro ['a visitor with no session', (path: string) => stack.api(path)], ['a signed-in caller', (path: string) => stack.apiAs(token, 'GET', path)], ] as const) { - const listed = await call('/forms/pqkey-intake/lookup/contact'); - expect(listed.status, who).toBe(200); - const rows = ((await listed.json()) as { data: Row[] }).data; - expect(rows.map((r) => r.name), `${who}: sorted on the next queryable display field`).toEqual(BY_NAME); - expectServedMasked(rows); - - const searched = await call('/forms/pqkey-intake/lookup/contact?q=Charlie'); - expect(searched.status, who).toBe(200); - const hits = ((await searched.json()) as { data: Row[] }).data; - expect(hits.map((r) => r.name), `${who}: the search matches the next queryable display field`) - .toEqual(['Charlie Synthetic']); - expectServedMasked(hits); + await expectRefusedAtAdmission(await call('/forms/pqkey-intake/lookup/contact'), `${who}: the listing`); + await expectRefusedAtAdmission(await call('/forms/pqkey-intake/lookup/contact?q=Charlie'), `${who}: the search`); } - const refused = await stack.api('/forms/pqkey-intake/lookup/contact_masked_only'); - expect(refused.status).toBe(403); - const refusal = (await refused.json()) as { code?: unknown; error?: { code?: unknown } }; - expect(refusal.code ?? refusal.error?.code).toBe('PERMISSION_DENIED'); + await expectRefusedAtAdmission( + await stack.api('/forms/pqkey-intake/lookup/contact_masked_only'), + 'a picker with no queryable display field', + ); } finally { await stack.stop(); } diff --git a/packages/rest/src/public-form-lookup-picker-queryable-key.test.ts b/packages/rest/src/public-form-lookup-picker-queryable-key.test.ts index 88f2fbe4be1..d17fbac2e3f 100644 --- a/packages/rest/src/public-form-lookup-picker-queryable-key.test.ts +++ b/packages/rest/src/public-form-lookup-picker-queryable-key.test.ts @@ -17,11 +17,14 @@ * What is pinned, by caller class (the picker's own context — the route builds * it, the session is not read): * - * - masked classes — the deployment registers no `guest_portal` set (the - * context resolves no set), or registers one that does not hold the - * capability the rule names: rows are sorted, and searched, on the next - * queryable display field; the masked field is still served, masked; and no - * request the engine receives names the masked field; + * - masked class — the deployment registers a `guest_portal` set that does not + * hold the capability the rule names: rows are sorted, and searched, on the + * next queryable display field; the masked field is still served, masked; + * and no request the engine receives names the masked field; + * - [#21079] the deployment registers no `guest_portal` set, so the context + * resolves no set: the ADR-0056 D2 deny baseline refuses every picker at + * object admission (`403 PERMISSION_DENIED`), while the key it composes is + * unchanged and still never names the masked field; * - a picker with no queryable display field answers the engine's own refusal * for those fields, and the engine is never asked; * - controls — a picker with no masked display field, and a caller the rule is @@ -286,8 +289,78 @@ describe('[#21062] the fixture is authorable', () => { }); }); +/** + * [#21079] The deployment registers no guest set, so the picker's context + * resolves NO permission set — and an empty set list is the ADR-0056 D2 deny + * baseline: the engine refuses that caller at object admission, before any + * field guard, whatever the query names. The field answers are unchanged (the + * masked field is readable and not queryable, and the key the picker composes + * is still the next queryable display field), but nothing is served: every + * picker on this deployment answers `403 PERMISSION_DENIED`. The picker itself + * is retired on its own card; until then this is what it answers here. + */ +describe('[#21062] a picker whose first display field is masked for its caller — the deployment registers no guest set (the context resolves none): [#21079] refused at object admission', () => { + let h: Harness; + beforeAll(async () => { h = await boot([MEMBER_SET]); }, 60_000); + afterAll(async () => { try { await h?.engine.destroy(); } catch { /* noop */ } }); + + /** The deny baseline's answer at the door: refused at object admission, nothing served. */ + function expectRefusedAtAdmission(res: { status: number; body: any }): void { + expect({ status: res.status, code: res.body?.code }, JSON.stringify(res.body)).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect(res.body?.data, 'no row is served').toBeUndefined(); + for (const stored of Object.values(STORED_BY_ID)) expect(JSON.stringify(res.body)).not.toContain(stored); + } + + it('the premise: the security service answers the masked field readable and not queryable', async () => { + const context = { permissions: ['guest_portal'], anonymous: true }; + expect(await h.security.getReadableFields(CONTACT, context)).toContain(MASKED); + expect(await h.security.getQueryableFields(CONTACT, context)).not.toContain(MASKED); + expect(await engineRefusal(h.engine, [MASKED])).toMatchObject({ status: 403, code: 'PERMISSION_DENIED' }); + }); + + it('[#21079] the picker whose first display field is masked is refused at object admission', async () => { + expectRefusedAtAdmission(await h.lookup('c_first')); + }); + + it('[#21079] its search is refused at object admission too', async () => { + expectRefusedAtAdmission(await h.lookup('c_first', 'Brav')); + }); + + it('never uses the masked field as a key', async () => { + const before = h.finds().length; + await h.lookup('c_first'); + await h.lookup('c_first', 'Brav'); + const asked = h.finds().slice(before); + expect(asked).toHaveLength(2); + for (const query of asked) { + expect(queriedFields(query)).not.toContain(MASKED); + expect(query.orderBy).toEqual([{ field: 'name', order: 'asc' }]); + } + }); + + it('[#21079] a picker with no queryable display field answers 403 PERMISSION_DENIED, as the engine does, and the engine is never asked', async () => { + const reference = await engineRefusal(h.engine, PICKERS.c_only.displayFields); + expect(reference).toMatchObject({ status: 403, code: 'PERMISSION_DENIED' }); + const before = h.finds().length; + for (const q of [undefined, 'A']) { + const res = await h.lookup('c_only', q); + expectRefusedAtAdmission(res); + expect({ status: res.status, code: res.body?.code }).toEqual({ status: reference!.status, code: reference!.code }); + } + expect(h.finds().length - before, 'the engine was asked').toBe(0); + }); + + it('[#21079] control: a picker with no masked display field still keys on its first display field, and is refused at object admission too', async () => { + const before = h.finds().length; + expectRefusedAtAdmission(await h.lookup('c_control')); + expectRefusedAtAdmission(await h.lookup('c_control', 'Charl')); + const asked = h.finds().slice(before); + expect(asked).toHaveLength(2); + for (const query of asked) expect(query.orderBy).toEqual([{ field: 'name', order: 'asc' }]); + }); +}); + for (const [label, sets] of [ - ['the deployment registers no guest set (the context resolves none)', [MEMBER_SET]], ['the guest set does not hold the capability the rule names', [MEMBER_SET, GUEST_SET]], ] as const) { describe(`[#21062] a picker whose first display field is masked for its caller — ${label}`, () => { From 335cae74b4e1f53cd03d7231499aec44928e8f28 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:53:38 +0000 Subject: [PATCH 8/9] test(rest): the masked-only picker pin proves the engine's refusal is object admission, not the field guard Under the field guard alone the masked-only picker was refused with the same code and status, so the re-pinned case stayed green with the deny baseline put back. It now also asks the engine for a query keyed on a field the caller may query on, which only object admission refuses. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../rest/src/public-form-lookup-picker-queryable-key.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/rest/src/public-form-lookup-picker-queryable-key.test.ts b/packages/rest/src/public-form-lookup-picker-queryable-key.test.ts index d17fbac2e3f..d338b514c2e 100644 --- a/packages/rest/src/public-form-lookup-picker-queryable-key.test.ts +++ b/packages/rest/src/public-form-lookup-picker-queryable-key.test.ts @@ -341,6 +341,11 @@ describe('[#21062] a picker whose first display field is masked for its caller it('[#21079] a picker with no queryable display field answers 403 PERMISSION_DENIED, as the engine does, and the engine is never asked', async () => { const reference = await engineRefusal(h.engine, PICKERS.c_only.displayFields); expect(reference).toMatchObject({ status: 403, code: 'PERMISSION_DENIED' }); + // The engine's refusal for this caller is object admission, not the + // field guard: a query keyed on a field it MAY query on is refused the + // same. (Under the field guard alone that query was served.) + expect(await engineRefusal(h.engine, PICKERS.c_control.displayFields.slice(0, 1))) + .toMatchObject({ status: 403, code: 'PERMISSION_DENIED' }); const before = h.finds().length; for (const q of [undefined, 'A']) { const res = await h.lookup('c_only', q); From 03ae8604a894db1b7a3f36d34d9ad03904e30609 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:57:46 +0000 Subject: [PATCH 9/9] docs(forms): the public picker's search is authorized by the guest set, and answers 403 PERMISSION_DENIED without one Three passages said the picker's results were bounded by its declaration alone and that the guest set was only a back-compat leftover. Under the deny baseline a search context that resolves no permission set is refused at object admission, so the picker needs a guest set that grants read on its target object; the errors table gains that answer. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- content/docs/ui/forms.mdx | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx index fb7ac67bdf1..0a4555eba04 100644 --- a/content/docs/ui/forms.mdx +++ b/content/docs/ui/forms.mdx @@ -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 @@ -284,6 +291,7 @@ 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. | @@ -291,7 +299,7 @@ Errors: ### 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