diff --git a/.changeset/18535-anchor-declared-capabilities-consumers.md b/.changeset/18535-anchor-declared-capabilities-consumers.md new file mode 100644 index 00000000000..b89091e26a4 --- /dev/null +++ b/.changeset/18535-anchor-declared-capabilities-consumers.md @@ -0,0 +1,21 @@ +--- +'@objectstack/plugin-security': minor +--- + +The `everyone`-anchor doors now pass the stack's declared capabilities, so an app capability token a stack DECLARES no longer makes its `isDefault` set unbindable (#18535). + +ADR-0090 D5 rules the `everyone`-anchor offending list as 「平台系统权限;带 package provenance 的应用声明 capability 令牌不计」, and PR #17811 landed the predicate that implements it: `describeHighPrivilegeBits(def, context?)` excuses a `systemPermissions` name when the caller says this stack declared it. No consumer in this package passed a context, so all three doors kept judging an app's own gate exactly like `manage_users` — declared ≠ enforced on a contract both the ADR and the spec had already ruled, and an app that declared a capability its navigation gates on could not ship the "every employee holds this" set those gates need. + +All three now read one source — the stack's `capabilities:` declarations, through `readDeclaredCapabilityContext` (registry first, metadata service as the fallback, exactly as the `sys_capability` seeder reads them): + +- **the boot binding** (`bindBaselineToEveryone`) — the ADR-0090 D5 bind of the configured baseline set(s) to this organization's `everyone` anchor; +- **the engine write gate** on a `sys_position_permission_set` insert/update, read at most once per pass and only once an anchor row is in play; +- **`confirmAudienceBindingSuggestion`**'s early refusal, which is the friendly rendition of that same gate — one source is what keeps it from answering "confirmed" and then having its own insert refused under it. + +**Why the declarations and not the `sys_capability` rows.** The predicate's docblock names the rows at boot, but the boot binding runs BEFORE `bootstrapDeclaredCapabilities` seeds them (the bind must follow `bootstrapBuiltinRoles`, which seeds the anchor, and precede the suggestion reconciliation), so the rows are empty there on a first boot. Reading them would refuse every declared token one layer in. + +**Two things do not move.** The platform floor is absolute — declaring a capability named `manage_users` launders nothing, because the predicate applies `PLATFORM_CAPABILITY_NAMES` itself — and an UNDECLARED name still refuses at every door, as does every unreadable or empty declaration list (「omission refuses」). The `guest` tier is untouched: the predicate drops the context for it by contract. + +**What changes for a consumer:** a permission set whose `systemPermissions` names only capabilities the stack declares, marked `isDefault: true`, now binds to `everyone` at boot instead of logging `refusing to bind fallback set to everyone`. If you were relying on that refusal to keep such a set unbound, remove the token from the set or stop declaring the capability. + +Clause-②: yes (widening) diff --git a/.changeset/18535-lint-anchor-declared-capabilities.md b/.changeset/18535-lint-anchor-declared-capabilities.md new file mode 100644 index 00000000000..5f5ee93e966 --- /dev/null +++ b/.changeset/18535-lint-anchor-declared-capabilities.md @@ -0,0 +1,17 @@ +--- +'@objectstack/lint': minor +--- + +`security-anchor-high-privilege` now reads the stack's own `capabilities:` declarations, so a declared app capability token on an `isDefault` set lints clean (#18535). + +The rule holds an `isDefault: true` set to the `everyone`-anchor tier at authoring time, and ADR-0090 D5 puts 「带 package provenance 的应用声明 capability 令牌」 outside that tier's offending list. The rule called `describeAnchorForbiddenBits(ps, 'everyone')` with no `AnchorBindingContext`, so it reported an error for a set the runtime — once it reads the same declarations — binds without complaint. A lint that refuses what the runtime accepts is the drift ADR-0049 says not to ship, in the direction that is hardest to notice: the author never gets to the runtime. + +`validateSecurityPosture` now builds the context from `stack.capabilities` and passes it at that one call site. Nothing else about the rule moves: + +- an **undeclared** `systemPermissions` token still errors — membership in the declaration list is what excuses a token, not the presence of a `capabilities:` collection; +- a **platform** capability still errors even when the stack declares a capability of that name: the platform floor lives inside the predicate, shared with the runtime gate; +- a stack that declares nothing gets the pre-#17811 verdict verbatim. + +**What changes for a consumer:** `os validate` (and any other caller of this rule) stops reporting `security-anchor-high-privilege` on an `isDefault` set whose `systemPermissions` names only capabilities the same stack declares. A stack that was editing its set to silence this rule can declare the capability instead — which is what the ADR asks for, since the declaration is what the runtime reads at boot. + +Clause-②: yes (widening) diff --git a/packages/lint/src/validate-security-posture.test.ts b/packages/lint/src/validate-security-posture.test.ts index dada2360ed1..5027dc279c4 100644 --- a/packages/lint/src/validate-security-posture.test.ts +++ b/packages/lint/src/validate-security-posture.test.ts @@ -447,6 +447,67 @@ describe('validateSecurityPosture (ADR-0090 D7)', () => { ).toEqual([]); }); + // ── [#18535] …and the ADR-0090 D5 half of that rule: 「平台系统权限;带 + // package provenance 的应用声明 capability 令牌不计」. The predicate has taken + // an `AnchorBindingContext` since PR #17811; this rule passes the stack's own + // `capabilities:` declarations into it, which is what makes an app's + // "every employee holds this" set authorable at all. Three cases, because a + // single one of them is satisfied by both a correct rule and a rule that + // stopped judging `systemPermissions` altogether. + it('accepts an isDefault set whose systemPermissions token THIS stack declares (ADR-0090 D5)', () => { + expect( + rulesOf({ + capabilities: [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }], + permissions: [ + { + name: 'app_default', + isDefault: true, + systemPermissions: ['crm.export_pipeline'], + objects: { invoice: { allowRead: true } }, + }, + ], + }), + ).toEqual([]); + }); + + it('still errors on an UNDECLARED systemPermissions token — the control for the case above', () => { + const findings = validateSecurityPosture({ + // A real declaration list, naming a DIFFERENT capability: this pins that + // membership is what excuses a token, never the mere presence of a + // `capabilities:` collection on the stack. + capabilities: [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }], + permissions: [ + { + name: 'app_default', + isDefault: true, + systemPermissions: ['crm.settle_ledger'], + objects: { invoice: { allowRead: true } }, + }, + ], + }).filter((f) => f.rule === SECURITY_ANCHOR_HIGH_PRIVILEGE); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('system permissions'); + }); + + it('still errors on a PLATFORM capability even when the stack declares a capability of that name', () => { + // The platform floor, applied inside the predicate: declaring + // `manage_users` must not launder it past the anchor gate. Keeping this + // case beside the two above is what stops the lint and the runtime gate + // from drifting — both read the same `PLATFORM_CAPABILITY_NAMES`. + const findings = validateSecurityPosture({ + capabilities: [{ name: 'manage_users', label: 'Not Yours' }], + permissions: [ + { + name: 'app_default', + isDefault: true, + systemPermissions: ['manage_users'], + objects: { invoice: { allowRead: true } }, + }, + ], + }).filter((f) => f.rule === SECURITY_ANCHOR_HIGH_PRIVILEGE); + expect(findings).toHaveLength(1); + }); + // ── Rule: security-role-word (ADR-0090 D3) ────────────────────────── // [#8310] Its own function (and registry entry) since the rest of the block // crossed the runtime publish surface — same file, same rule id, same @@ -1189,7 +1250,11 @@ const NOT_SCHEMA_RECEIVERS: Record = { const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: string; keys: () => string[] }> = [ { receiver: 'stack', - expected: ['apps', 'books', 'data', 'objects', 'permissions', 'positions'], + // [#18535] `capabilities` joined the list when the ADR-0090 D5 anchor rule + // started passing the stack's own capability declarations to the predicate + // as `AnchorBindingContext.declaredCapabilities` — a declared token is the + // app's own gate, not a platform system permission. + expected: ['apps', 'books', 'capabilities', 'data', 'objects', 'permissions', 'positions'], declaredBy: 'ObjectStackSchema', keys: () => Object.keys(ObjectStackSchema.shape), }, @@ -1261,6 +1326,16 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s declaredBy: 'ObjectStackSchema.data[]', keys: () => shapeKeysOf(ObjectStackSchema.shape.data), }, + // [#18535] The ADR-0066 D1 capability declarations the anchor rule reads: it + // wants their NAMES and hands the declarations themselves to the predicate, + // which reads `name` and ignores every other field — so nothing is + // transcribed here and this stays a one-key surface. + { + receiver: 'cap', + expected: ['name'], + declaredBy: 'ObjectStackSchema.capabilities[]', + keys: () => shapeKeysOf(ObjectStackSchema.shape.capabilities), + }, ]; /** The `.shape` object itself (not just its keys) of a wrapped collection. */ @@ -1304,6 +1379,7 @@ describe('validateSecurityPosture — reads only keys the spec declares (meta-te 'declared', // #16108: one object's sorted field-name list — `.length` / `.slice` / `.join`. 'entries', // #7503: the rule's own field list — `.find`, a JS method. 'matched', // #14747: one tier's candidate list — `.length` / `.map`, JS methods. + 'declaredCapabilities', // #18535: the stack's own capability list — `.length`, a JS property. ]); expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]); }); diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index 9e89bb5f80c..613f259d3f8 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -119,7 +119,7 @@ */ import { referenceCarrierOf } from '@objectstack/spec/data'; -import { describeAnchorForbiddenBits } from '@objectstack/spec/security'; +import { describeAnchorForbiddenBits, type AnchorBindingContext } from '@objectstack/spec/security'; import { indexObjectGraph, recordsOf, type ObjectGraph } from './object-graph.js'; export const SECURITY_OWD_UNSET = 'security-owd-unset'; @@ -427,6 +427,20 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } const objects = recordsOf(stack.objects); const permissionSets = recordsOf(stack.permissions); + // [#18535, ADR-0090 D5 / ADR-0066 D1] The stack's own capability + // declarations, handed to the anchor predicate as + // `AnchorBindingContext.declaredCapabilities` — the authoring-time half of + // the source the runtime reads at boot. A `systemPermissions` token this + // stack DECLARES is the app's own gate and does not make an `isDefault` set + // unbindable (the runtime agrees, so the lint and the gate stay one rule); + // an UNDECLARED token still offends, and the platform floor is applied by + // the predicate itself, so declaring `manage_users` excuses nothing. + // No declarations ⇒ `undefined` ⇒ the pre-#17811 verdict verbatim. + const declaredCapabilities = recordsOf(stack.capabilities).filter( + (cap) => typeof cap.name === 'string' && cap.name.length > 0, + ); + const anchorContext: AnchorBindingContext | undefined = + declaredCapabilities.length > 0 ? { declaredCapabilities } : undefined; // ── D1/D4/D11: per-object OWD posture ──────────────────────────────── for (let i = 0; i < objects.length; i++) { @@ -778,7 +792,7 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } // this rule to key off, so that binding is outside what a package-time // linter can see and is judged by the bind-time gate alone (#16110). if (ps.isDefault === true) { - const offending = describeAnchorForbiddenBits(ps, 'everyone'); + const offending = describeAnchorForbiddenBits(ps, 'everyone', anchorContext); if (offending) { findings.push({ severity: 'error', diff --git a/packages/plugins/plugin-security/src/declared-capability-context.ts b/packages/plugins/plugin-security/src/declared-capability-context.ts new file mode 100644 index 00000000000..62bc826c151 --- /dev/null +++ b/packages/plugins/plugin-security/src/declared-capability-context.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18535, ADR-0090 D5 / ADR-0066 D1] The {@link AnchorBindingContext} the + * audience-anchor predicates need, read from the stack's `capabilities:` + * declarations. + * + * `describeHighPrivilegeBits` / `describeAnchorForbiddenBits` + * (`@objectstack/spec/security`) are pure and synchronous: they read one + * permission-set definition and cannot discover which capability names THIS + * stack declared. That fact belongs to the caller, and until this module + * existed no runtime caller passed it — so a `systemPermissions` token an app + * had DECLARED was judged exactly like `manage_users`, and the app's own + * `isDefault` set was refused at the `everyone` anchor. ADR-0090 D5 rules the + * opposite: 「平台系统权限;带 package provenance 的应用声明 capability 令牌不计」. + * + * ## Why the METADATA declarations and not the `sys_capability` rows + * + * The predicate's docblock names two sources — the `sys_capability` rows + * carrying `managed_by:'package'` at boot, the stack's own `capabilities` + * array at authoring time. At the boot moment the anchor binding runs, the + * rows DO NOT EXIST YET: `runBootstrap` binds the baseline to `everyone` + * before it calls `bootstrapDeclaredCapabilities`, and that order is fixed by + * two other constraints (the binding must follow `bootstrapBuiltinRoles`, + * which seeds the anchor, and precede the suggestion reconciliation). Reading + * the rows there would read an empty table on a first boot and refuse every + * declared token — the defect this module removes, reintroduced one layer in. + * + * So all three runtime consumers read the DECLARATIONS, through the same + * two-step the seeder itself reads them by (registry first, metadata service + * as the fallback). One source for the three verdicts is not a convenience: + * `confirmAudienceBindingSuggestion` is the friendly early rendition of the + * gate the engine middleware re-enforces on the insert, so a second source + * there would let a confirm pass its own check and then be refused by the + * write it performs. + * + * ⛔ Never derive this list from the set under test — the predicate's own + * docblock says why: a "declared" list read off `systemPermissions` excuses + * every token by construction and turns the gate off. And nothing here filters + * by platform-ness: {@link describeHighPrivilegeBits} applies the platform + * floor itself, so a capability declared under a curated platform name is + * still high-privilege however it reaches this list. + * + * Fails CLOSED at every step: an unreadable registry, an unreadable metadata + * service, a declaration with no `name` and an empty stack all yield + * `undefined`, which is the pre-#17811 verdict verbatim (「omission refuses」). + */ + +import type { AnchorBindingContext } from '@objectstack/spec/security'; +import { readDeclared } from './bootstrap-declared-permissions.js'; + +/** + * Read this stack's declared authorization capabilities as an + * {@link AnchorBindingContext}, or `undefined` when it declares none. + * + * The declarations are handed over as they are — the predicate reads `name` + * off each entry and ignores every other field, so nothing is transcribed and + * a shape change in `CapabilityDeclarationSchema` cannot desynchronize a copy. + * + * @param ql The ObjectQL engine handle (its registry is the primary source). + * @param metadataService The metadata service, read only when the registry + * lists nothing — the same fallback `bootstrapDeclaredCapabilities` uses. + */ +export async function readDeclaredCapabilityContext( + ql: any, + metadataService?: any, +): Promise { + let caps: any[] = readDeclared(ql, 'capability'); + if (caps.length === 0) { + try { + const listed = metadataService?.list?.('capability'); + caps = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []); + } catch { caps = []; } + } + if (!Array.isArray(caps)) return undefined; + const declared = caps.filter( + (c) => c && typeof c === 'object' && typeof (c as { name?: unknown }).name === 'string' + && (c as { name: string }).name.length > 0, + ); + return declared.length > 0 ? { declaredCapabilities: declared } : undefined; +} diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index db0f55dca63..7fc8e5228ca 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -4254,3 +4254,181 @@ describe('managed-object write denies wiring (#3325)', () => { expect(admin.objects.sys_fake_identity).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// [#18535] ADR-0090 D5 — 「平台系统权限;带 package provenance 的应用声明 +// capability 令牌不计」 at the two runtime doors this plugin owns. +// +// PR #17811 landed the predicate half: `describeHighPrivilegeBits(def, +// context?)` excuses a `systemPermissions` name when the caller says this stack +// DECLARED it. No consumer passed a context, so an app's own "every employee +// holds this" set stayed unbindable to `everyone` — declared ≠ enforced, on a +// contract both the ADR and the spec had already ruled. +// +// Each door gets THREE cases, never one: "the declared token binds" alone is +// equally satisfied by a door that stopped judging `systemPermissions` +// altogether, so the undeclared-token control and the platform-floor control +// are what make the accepting case mean anything. +// --------------------------------------------------------------------------- +describe('audience-anchor bindings read the stack\'s declared capabilities (#18535)', () => { + const APP_DEFAULT = (token: string): PermissionSet => ({ + name: 'app_member_default', + label: 'App Member Default', + isDefault: true, + systemPermissions: [token], + objects: { app_announcement: { allowRead: true } }, + }) as any; + + /** + * Boot the plugin far enough to run `runBootstrap` to completion, with the + * declarations served off the METADATA SERVICE — the seeder's own fallback + * source, and the one that answers at this moment in the boot: the anchor + * binding runs BEFORE `bootstrapDeclaredCapabilities` writes any + * `sys_capability` row. (The registry door is pinned on the other consumer, + * `suggested-audience-bindings.test.ts`.) + */ + async function boot(token: string, declaredCapabilities: any[]) { + const tables: Record = { + sys_position: [{ id: 'pos_everyone', name: 'everyone' }], + sys_permission_set: [{ id: 'ps_app', name: 'app_member_default' }], + sys_position_permission_set: [], + }; + const inserts: Array<{ object: string; data: any }> = []; + const ql: any = { + registerMiddleware: () => {}, + find: async (object: string, opts?: any) => { + const where = opts?.where ?? {}; + const matched = (tables[object] ?? []).filter((r: any) => + // The matcher REFUSES what it does not implement rather than + // answering as if it had: a `$or` key compared as a field name + // matches no row and reports that as "no rows" + // (`check:where-matcher` — refusal is the conforming cheap answer for + // a double that only ever sees scalar equality). + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) { + throw new Error(`test double: unsupported WHERE combinator '${k}' — implement it or narrow the fixture`); + } + return r[k] === v; + }), + ); + // The caller's bound, applied BY PRESENCE and after the filter — the + // plugin reads `limit: 5` here to tell an organization-less row from + // this organization's own (`check:objectql-double-limit`). + return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; + }, + findOne: async (object: string, query?: EngineFindOneQueryInput) => { + assertEngineFindOnePredicate(object, query); + return null; + }, + insert: async (object: string, d: any) => { + inserts.push({ object, data: d }); + (tables[object] ??= []).push(d); + return { id: d?.id ?? 'x' }; + }, + // The PRODUCER's own dispatch predicate, never a hand-mirrored guard. + update: async (_o: string, d: any, o?: any) => { + assertEngineUpdateDispatch(d, o); + return true; + }, + getSchema: () => undefined, + }; + const metadata = { + get: async () => null, + list: async (type: string) => (type === 'capability' ? declaredCapabilities : []), + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: ql, + metadata, + }; + const hook = vi.fn(); + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + hook, + }; + const plugin = new SecurityPlugin({ defaultPermissionSets: [APP_DEFAULT(token)] }); + await plugin.init(ctx); + await plugin.start(ctx); + for (const [, cb] of hook.mock.calls.filter((c: any[]) => c[0] === 'kernel:ready')) await cb(); + + // Positive control: the pass really completed. Without it every assertion + // below would be satisfied by a boot that fell over before the binding. + expect( + ctx.logger.info.mock.calls.filter((c: any[]) => String(c[0]).includes('platform bootstrap complete')), + ).toHaveLength(1); + + const refusals = ctx.logger.warn.mock.calls.filter((c: any[]) => + String(c[0]).includes('refusing to bind fallback set to everyone'), + ); + const bindings = inserts.filter((i) => i.object === 'sys_position_permission_set'); + return { plugin, ql, ctx, bindings, refusals }; + } + + it('binds the isDefault set when the stack declares its systemPermissions token', async () => { + const { bindings, refusals } = await boot('crm.export_pipeline', [ + { name: 'crm.export_pipeline', label: 'Export Pipeline' }, + ]); + expect(refusals).toHaveLength(0); + expect(bindings).toHaveLength(1); + expect(bindings[0].data).toMatchObject({ position_id: 'pos_everyone', permission_set_id: 'ps_app' }); + }); + + it('still refuses an UNDECLARED token — the control for the case above', async () => { + // Declarations exist and name a DIFFERENT capability: it is MEMBERSHIP that + // excuses a token, not the presence of a declaration list. + const { bindings, refusals } = await boot('crm.settle_ledger', [ + { name: 'crm.export_pipeline', label: 'Export Pipeline' }, + ]); + expect(bindings).toHaveLength(0); + expect(refusals).toHaveLength(1); + expect(refusals[0][1]).toMatchObject({ set: 'app_member_default', offending: 'system permissions' }); + }); + + it('still refuses a PLATFORM capability the stack declares a same-named capability for', async () => { + const { bindings, refusals } = await boot('manage_users', [{ name: 'manage_users', label: 'Not Yours' }]); + expect(bindings).toHaveLength(0); + expect(refusals).toHaveLength(1); + }); + + // ── The engine-side write gate (`sys_position_permission_set` insert) ── + // + // Same three cases at the door an ADMIN comes through. It must agree with the + // boot binding above and with `confirmAudienceBindingSuggestion`'s early + // rendition — all three now read one source, so a set that binds at boot + // cannot be refused when an admin re-binds it by hand. + const anchorInsert = { + object: 'sys_position_permission_set', + operation: 'insert', + data: { position_id: 'pos_everyone', permission_set_id: 'ps_app' }, + }; + + it('write gate: admits a binding whose token the stack declares', async () => { + const { plugin } = await boot('crm.export_pipeline', [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }]); + await expect((plugin as any).assertAudienceAnchorBindingGate(anchorInsert)).resolves.toBeUndefined(); + }); + + it('write gate: still refuses an undeclared token, with the ADR-0112 envelope', async () => { + const { plugin } = await boot('crm.settle_ledger', [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }]); + const err = await (plugin as any) + .assertAudienceAnchorBindingGate(anchorInsert) + .then(() => null, (e: any) => e); + expect(err).toBeTruthy(); + expect(err.code).toBe('PERMISSION_DENIED'); + expect(err.statusCode).toBe(403); + expect(err.message).toContain('system permissions'); + }); + + it('write gate: still refuses a platform capability of that name', async () => { + const { plugin } = await boot('manage_users', [{ name: 'manage_users', label: 'Not Yours' }]); + const err = await (plugin as any) + .assertAudienceAnchorBindingGate(anchorInsert) + .then(() => null, (e: any) => e); + expect(err?.code).toBe('PERMISSION_DENIED'); + expect(err?.statusCode).toBe(403); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 70f04ed0f93..3a8fd52073f 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -3,6 +3,7 @@ import { Plugin, PluginContext, POSTURE_LADDER, isRowActive } from '@objectstack/core'; import type { PermissionSet, RowLevelSecurityPolicy, TenantLayer0Verdict } from '@objectstack/spec/security'; import { describeHighPrivilegeBits, describeAnchorForbiddenBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; +import type { AnchorBindingContext } from '@objectstack/spec/security'; import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai'; // [#8220] The read-scope provenance mark: this middleware is one of the two // merge boundaries that stamp it (see the RLS injection below). @@ -66,6 +67,7 @@ import { import { bootstrapSystemCapabilities } from './bootstrap-system-capabilities.js'; import { normalizeManagedByVocab } from './normalize-managed-by.js'; import { bootstrapDeclaredCapabilities } from './bootstrap-declared-capabilities.js'; +import { readDeclaredCapabilityContext } from './declared-capability-context.js'; import { RLSCompiler, RLS_DENY_FILTER, policyDeclaresClause } from './rls-compiler.js'; import { computeTenantLayer0Verdict, @@ -3580,9 +3582,17 @@ export class SecurityPlugin implements Plugin { // another's catalog. const bindBaselineToEveryone = async (organizationId?: string): Promise => { try { + // [#18535, ADR-0090 D5] The stack's declared `capabilities:` — the half + // of the anchor question the predicate cannot discover for itself. Read + // ONCE per pass, from the DECLARATIONS rather than from `sys_capability`: + // this binding runs before `bootstrapDeclaredCapabilities` seeds those + // rows (see `declared-capability-context.ts` for why that order is + // fixed), so the rows are empty here on a first boot. Unreadable or + // absent ⇒ `undefined` ⇒ the pre-#17811 verdict, which refuses. + const anchorContext = await readDeclaredCapabilityContext(ql, this.metadata); for (const baselineName of this.baselinePermissionSets) { const boot = this.bootstrapPermissionSets.find((p) => p.name === baselineName); - const offending = boot ? describeHighPrivilegeBits(boot) : null; + const offending = boot ? describeHighPrivilegeBits(boot, anchorContext) : null; if (offending) { ctx.logger.warn('[security] refusing to bind fallback set to everyone — high-privilege bits', { set: baselineName, offending, @@ -5445,6 +5455,22 @@ export class SecurityPlugin implements Plugin { if (rows.length === 0) return; const ql = this.ql; + // [#18535, ADR-0090 D5] The stack's declared `capabilities:`, read at most + // ONCE per gate pass and only once an anchor row is actually in play — the + // common write on this table names an ordinary position and must not pay + // for a read it does not use. Same source as the boot binding and as + // `confirmAudienceBindingSuggestion`, which is this gate's friendly early + // rendition: a second source there would let a confirm pass its own check + // and then be refused by the insert it performs. + let anchorContext: AnchorBindingContext | undefined; + let anchorContextLoaded = false; + const declaredCapabilityContext = async (): Promise => { + if (!anchorContextLoaded) { + anchorContext = await readDeclaredCapabilityContext(ql, this.metadata); + anchorContextLoaded = true; + } + return anchorContext; + }; for (const row of rows) { const positionId = (row as any)?.position_id; if (!positionId || !ql?.find) continue; @@ -5472,7 +5498,14 @@ export class SecurityPlugin implements Plugin { // [ADR-0090 D9] Anchor-tier predicate: `guest` faces the strictest tier // (additionally no edit bit — read-only by default, create is the single // case-by-case write); `everyone` uses the high-privilege predicate. - const offending = describeAnchorForbiddenBits(boot ?? setDef, positionName as 'everyone' | 'guest'); + // [#18535] `guest` faces the strictest tier and the predicate DROPS the + // context for it (D5's app-token excusal is the `everyone` tier's alone), + // so the same call serves both anchors. + const offending = describeAnchorForbiddenBits( + boot ?? setDef, + positionName as 'everyone' | 'guest', + await declaredCapabilityContext(), + ); if (offending) { throw new PermissionDeniedError( `[Security] Access denied: permission set '${setName || setId}' cannot be bound to the '${positionName}' audience anchor — it carries ${offending} (ADR-0090 D5/D9). ` + diff --git a/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts b/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts index e2b043c2278..97a940728fe 100644 --- a/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts +++ b/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts @@ -17,7 +17,7 @@ import { /** In-memory ObjectQL stub (same shape as audience-anchors.test.ts) with an * installed-package registry and insert-call recording so tests can assert * WHICH context a write carried. */ -function makeQl(packages: any[] = []) { +function makeQl(packages: any[] = [], declaredCapabilities: any[] = []) { const tables: Record = { sys_position: [{ id: 'pos_everyone', name: 'everyone' }, { id: 'pos_guest', name: 'guest' }], sys_permission_set: [], @@ -30,7 +30,10 @@ function makeQl(packages: any[] = []) { insertCalls, registry: { getAllPackages: () => packages, - listItems: (_type: string) => [], + // [#18535] The ADR-0066 D1 capability declarations, served off the SAME + // registry door the seeder reads them by (`readDeclared`) — so a pin + // written here exercises the shipped read, not a transcription of it. + listItems: (type: string) => (type === 'capability' ? declaredCapabilities : []), }, async find(object: string, opts: any) { const where = opts?.where ?? {}; @@ -317,6 +320,73 @@ describe('confirmAudienceBindingSuggestion', () => { expect(ql.tables.sys_audience_binding_suggestion[0].status).toBe('pending'); }); + // ── [#18535] ADR-0090 D5: 「平台系统权限;带 package provenance 的应用声明 + // capability 令牌不计」 ───────────────────────────────────────────────── + // + // The confirm path's early gate is the FRIENDLY RENDITION of the engine + // middleware that re-enforces the same predicate on the insert below it, so + // it must ask the identical question: it now passes the stack's declared + // capabilities as `AnchorBindingContext.declaredCapabilities`. Three cases, + // because "the declared token binds" alone is equally satisfied by a gate + // that stopped judging `systemPermissions` at all. + const tokenPackage = (token: string) => ({ + enabled: true, + manifest: { + id: 'com.example.crm', + permissions: [ + { + name: 'crm_member_default', + isDefault: true, + systemPermissions: [token], + objects: { crm_account: { allowRead: true } }, + }, + ], + }, + }); + + it('binds an isDefault set whose systemPermissions token THIS stack declares', async () => { + const ql = makeQl([tokenPackage('crm.export_pipeline')], [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }]); + const deps = makeDeps(ql); + const { suggestions } = await listAudienceBindingSuggestions(deps, ADMIN_CTX, {}); + + const { bindingCreated, suggestion } = await confirmAudienceBindingSuggestion(deps, ADMIN_CTX, suggestions[0].id); + + expect(bindingCreated).toBe(true); + expect(suggestion.status).toBe('confirmed'); + // The row itself, not just the return flag: the anchor really carries it. + const binding = ql.tables.sys_position_permission_set.find((r: any) => r.position_id === 'pos_everyone'); + expect(binding).toBeTruthy(); + // …and the set that got bound is the one carrying the token — a pin that + // would still pass if the token had been dropped on the way in is no pin. + const setRow = ql.tables.sys_permission_set.find((r: any) => r.id === binding.permission_set_id); + expect(JSON.parse(setRow.system_permissions)).toEqual(['crm.export_pipeline']); + }); + + it('still refuses an UNDECLARED systemPermissions token — the control for the case above', async () => { + // A real declaration list naming a DIFFERENT capability: membership is what + // excuses a token, never the presence of declarations on the stack. + const ql = makeQl([tokenPackage('crm.settle_ledger')], [{ name: 'crm.export_pipeline', label: 'Export Pipeline' }]); + const deps = makeDeps(ql); + const { suggestions } = await listAudienceBindingSuggestions(deps, ADMIN_CTX, {}); + + await expect(confirmAudienceBindingSuggestion(deps, ADMIN_CTX, suggestions[0].id)) + .rejects.toThrow(/cannot be bound to the 'everyone' audience anchor/); + expect(ql.tables.sys_position_permission_set).toHaveLength(0); + expect(ql.tables.sys_audience_binding_suggestion[0].status).toBe('pending'); + }); + + it('still refuses a PLATFORM capability even when the stack declares a capability of that name', async () => { + // The platform floor, inside the predicate: declaring `manage_users` must + // not launder it past the anchor gate. + const ql = makeQl([tokenPackage('manage_users')], [{ name: 'manage_users', label: 'Not Yours' }]); + const deps = makeDeps(ql); + const { suggestions } = await listAudienceBindingSuggestions(deps, ADMIN_CTX, {}); + + await expect(confirmAudienceBindingSuggestion(deps, ADMIN_CTX, suggestions[0].id)) + .rejects.toThrow(/cannot be bound to the 'everyone' audience anchor/); + expect(ql.tables.sys_position_permission_set).toHaveLength(0); + }); + it('refuses when the set name is owned by a different package (ADR-0086 D4)', async () => { const ql = makeQl([CRM_PACKAGE]); const deps = makeDeps(ql); diff --git a/packages/plugins/plugin-security/src/suggested-audience-bindings.ts b/packages/plugins/plugin-security/src/suggested-audience-bindings.ts index 2e6a770cd56..30c2ad7ce70 100644 --- a/packages/plugins/plugin-security/src/suggested-audience-bindings.ts +++ b/packages/plugins/plugin-security/src/suggested-audience-bindings.ts @@ -75,6 +75,7 @@ import { describeAnchorForbiddenBits, postureEnforcesWall } from '@objectstack/s import { EVERYONE_POSITION, AUDIENCE_ANCHOR_POSITIONS } from '@objectstack/spec'; import { PermissionDeniedError } from './errors.js'; import { readDeclared, upsertPackagePermissionSet } from './bootstrap-declared-permissions.js'; +import { readDeclaredCapabilityContext } from './declared-capability-context.js'; import { isTenantAdmin } from './delegated-admin-gate.js'; /** @@ -958,7 +959,17 @@ export async function confirmAudienceBindingSuggestion( // Early, friendly rendition of the anchor gate so the caller gets the // decision without a write attempt; the engine middleware re-enforces it // unconditionally on the insert below. - const offending = describeAnchorForbiddenBits(setRow, row.anchor as 'everyone' | 'guest'); + // [#18535, ADR-0090 D5] With the stack's declared `capabilities:` — the same + // source the boot binding and the engine-side gate read. This check is the + // FRIENDLY EARLY RENDITION of the gate the insert below re-enforces + // unconditionally, so the two must ask the identical question: a context here + // that the middleware does not also see would answer "confirmed" and then + // have the write refused under it. + const offending = describeAnchorForbiddenBits( + setRow, + row.anchor as 'everyone' | 'guest', + await readDeclaredCapabilityContext(ql, deps.metadata), + ); if (offending) { throw new PermissionDeniedError( `[Security] Access denied: permission set '${row.permission_set_name}' cannot be bound to the ` + diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index a0f21d79569..987921a5a69 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2999,12 +2999,12 @@ { "file": "packages/plugins/plugin-security/src/security-plugin.test.ts", "verb": "findOne", - "pinned": 5 + "pinned": 6 }, { "file": "packages/plugins/plugin-security/src/security-plugin.test.ts", "verb": "update", - "pinned": 1 + "pinned": 2 }, { "file": "packages/plugins/plugin-security/src/seed-write-refusal.test.ts",