diff --git a/.changeset/baseline-composes-with-platform.md b/.changeset/baseline-composes-with-platform.md new file mode 100644 index 0000000000..1a1c17c492 --- /dev/null +++ b/.changeset/baseline-composes-with-platform.md @@ -0,0 +1,44 @@ +--- +'@objectstack/plugin-security': patch +'@objectstack/plugin-hono-server': patch +'@objectstack/spec': patch +--- + +fix(security): an app-declared permission baseline COMPOSES with the platform `member_default` instead of replacing it (#7555) + +A permission set marked `isDefault: true` used to become the deployment's ONLY +baseline: `SecurityPlugin`'s `fallbackPermissionSet` held a single name, and an +app's declared set went into it, so every member of that app silently lost the +platform floor. Measured on the showcase (#7555): a fresh member is served all +10 built-in Account nav entries and 7/7 of the objects behind them answer 403, +because `showcase_member_default` names no `sys_*` object and `member_default` +was no longer in force for anyone in that app. + +That is the ADR-0090 D5 fallback cliff in its second spelling — D5 rules the +baseline additive without exception ("The fallback cliff is abolished. … +`everyone` is additive like any other position: baseline ∪ explicit, always") +and narrows `isDefault` to a package-authored *suggestion*, "never a runtime +fallback". + +The human baseline is now the list of names it always was: the declared set +**plus** the platform `member_default`, deduped. Both are pushed into the +per-request resolution, both back the post-resolution fallback and the ADR-0106 +D7 metadata-plane resolution, and both are bound to the `everyone` audience +anchor at boot so `security/explain` and the Setup UI report the default a +request actually applies. The composed list is published as a new +`security.baselinePermissionSets` service, which `/auth/me/permissions` and +`/me/apps` read so the capability and tab surface cannot disagree with the data +plane; `security.fallbackPermissionSet` is unchanged and still means "the single +name this deployment declared". + +Deliberately unchanged: + +- **Agent principals** keep exactly their ADR-0090 D10 restricted ceiling — the + composed human baseline is unreachable from `principalKind: 'agent'`. +- **`fallbackPermissionSet: null`** still disables the baseline entirely; the + composition never re-adds one. +- **`member_default`'s own grant rows**, the D5/D9 high-privilege anchor-binding + gate, and #5491's narrowing of the platform baseline to explicit-allow. + +An app that declares no `isDefault` set resolves `['member_default']` and is +byte-for-byte unaffected. diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts index 799351ae44..0d6fff4c9f 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -312,6 +312,36 @@ function isWriteOptedIn(v: boolean | { enabled?: boolean } | undefined | null): return v === true || (typeof v === 'object' && v !== null && v.enabled === true); } +/** + * [#7555, ADR-0090 D5] The baseline permission-set NAMES this deployment + * applies to a human principal — read from SecurityPlugin, never re-derived. + * + * The plugin registers `security.baselinePermissionSets` (the app-declared + * baseline COMPOSED with the platform `member_default`); this file's two + * resolutions must ask for that list rather than the single + * `security.fallbackPermissionSet` name, or an app that declares an `isDefault` + * set gets the pre-#7555 DISPLACEMENT here — its members' capability and tab + * surface computed from the app set alone, disagreeing with the data plane one + * function call away. + * + * The `security.fallbackPermissionSet` read is kept as the fallback for a + * SecurityPlugin too old to register the list, and the bare `member_default` + * default for a stack with no SecurityPlugin at all — both pre-existing + * behaviours, unchanged. + */ +function baselinePermissionSetNames(ctx: { getService: (name: string) => T | undefined }): string[] { + const composed = (() => { + try { return ctx.getService('security.baselinePermissionSets'); } + catch { return undefined; } + })(); + if (Array.isArray(composed)) return composed; + const declared: string | null = (() => { + try { return ctx.getService('security.fallbackPermissionSet') ?? 'member_default'; } + catch { return 'member_default'; } + })(); + return declared ? [declared] : []; +} + /** * Buckets whose user-context generic writes are guarded fail-closed at the * engine: `better-auth` by plugin-auth's identity write guard (ADR-0092 D2), @@ -665,10 +695,7 @@ export function registerCurrentUserEndpoints( try { return ctx.getService('security.bootstrapPermissionSets') ?? []; } catch { return []; } })(); - const fallbackName: string | null = (() => { - try { return ctx.getService('security.fallbackPermissionSet') ?? 'member_default'; } - catch { return 'member_default'; } - })(); + const fallbackNames: string[] = baselinePermissionSetNames(ctx); // DB loader: surfaces user-defined permission sets // (created via the admin UI as `sys_permission_set` // rows) that aren't in metadata or bootstrap. @@ -737,9 +764,9 @@ export function registerCurrentUserEndpoints( let resolved: ResolvedPermissionSetLike[] = await evaluator .resolvePermissionSets(requested, metadata, bootstrap, dbLoader) .catch(() => []); - if (resolved.length === 0 && fallbackName) { + if (resolved.length === 0 && fallbackNames.length > 0) { resolved = await evaluator - .resolvePermissionSets([fallbackName], metadata, bootstrap, dbLoader) + .resolvePermissionSets(fallbackNames, metadata, bootstrap, dbLoader) .catch(() => []); } // Most-permissive merge of `objects` and `fields` across @@ -915,10 +942,7 @@ export function registerCurrentUserEndpoints( try { return ctx.getService('security.bootstrapPermissionSets') ?? []; } catch { return []; } })(); - const fallbackName: string | null = (() => { - try { return ctx.getService('security.fallbackPermissionSet') ?? 'member_default'; } - catch { return 'member_default'; } - })(); + const fallbackNames: string[] = baselinePermissionSetNames(ctx); const requested = [ ...((execCtx as any).positions ?? []), ...((execCtx as any).permissions ?? []), @@ -951,9 +975,9 @@ export function registerCurrentUserEndpoints( let resolved: ResolvedPermissionSetLike[] = await evaluator .resolvePermissionSets(requested, metadata, bootstrap, dbLoader) .catch(() => []); - if (resolved.length === 0 && fallbackName) { + if (resolved.length === 0 && fallbackNames.length > 0) { resolved = await evaluator - .resolvePermissionSets([fallbackName], metadata, bootstrap, dbLoader) + .resolvePermissionSets(fallbackNames, metadata, bootstrap, dbLoader) .catch(() => []); } const tabRank: Record = { hidden: 0, default_off: 1, default_on: 2, visible: 3 }; diff --git a/packages/plugins/plugin-security/src/app-default-permission-set.ts b/packages/plugins/plugin-security/src/app-default-permission-set.ts index f2a6161f40..1dd5cde900 100644 --- a/packages/plugins/plugin-security/src/app-default-permission-set.ts +++ b/packages/plugins/plugin-security/src/app-default-permission-set.ts @@ -1,17 +1,74 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +/** + * [ADR-0090 D5, #7555] The PLATFORM's own human baseline permission set. + * + * Every authenticated human principal resolves this set in addition to whatever + * else they hold. It is the platform floor: read on the better-auth identity + * tables and self-service on the caller's own preference rows — the grants that + * keep `/auth/me`, the org switcher and the built-in Account app working for a + * member who holds no application profile at all. A platform app's platform + * object belongs here (maintainer ruling, 2026-08-11). + */ +export const PLATFORM_BASELINE_PERMISSION_SET = 'member_default'; + +/** + * [#7555] The human baseline as the LIST of set names it actually is: the + * app/deployment-declared baseline COMPOSED WITH the platform baseline — never + * one displacing the other. + * + * ADR-0090 D5 rules the baseline additive without exception ("The fallback + * cliff is abolished. … `everyone` is additive like any other position: + * baseline ∪ explicit, always"), and narrows `isDefault` to "a package-authored + * *suggestion* consumed once at install time … never a runtime fallback". The + * interim wiring below (`appSecurityPluginOptions`) nevertheless funnels an + * app's `isDefault` set into a SINGLE `fallbackPermissionSet` name, so + * declaring one silently REPLACED `member_default` for every member of that + * app. That is the D5 cliff in its other spelling — an app-authoring decision + * costing members the entire platform floor — and #7555 measured what it does: + * on the showcase, all 10 built-in Account nav entries are served and 7/7 of + * the objects behind them answer 403, because the declared set names no `sys_*` + * object and `member_default` was no longer in force. + * + * The composition is safe by construction in one direction only, which is the + * direction that matters: the evaluator merges sets most-permissively, so + * adding the platform baseline back can only ADD grants for human principals. + * It is deliberately NOT a way to widen anything else — + * + * • `null` still means "no baseline at all" and composes to `[]`. That is the + * one escape hatch, and it is all-or-nothing on purpose: a deployment that + * wants a floor NARROWER than the platform's states it by unbinding + * `member_default` from the `everyone` anchor (the D5 end state), not by + * naming a different set here. + * • AGENT principals never reach this list at all — ADR-0090 D10 gives them a + * restricted CEILING, not a human floor, and `resolvePermissionSetsForContext` + * branches before it is consulted. + * + * Order is app-set-first, platform-second, and load-bearing only for the + * explain surface's reading order; the merge itself is order-independent. + */ +export function composeHumanBaselinePermissionSets( + configured: string | null | undefined, +): string[] { + if (!configured) return []; + return configured === PLATFORM_BASELINE_PERMISSION_SET + ? [PLATFORM_BASELINE_PERMISSION_SET] + : [configured, PLATFORM_BASELINE_PERMISSION_SET]; +} + /** * ADR-0090 D5 (interim wiring, supersedes ADR-0056 D7) — resolve the * app-declared default permission-set NAME from a stack's `permissions[]`. * * A permission set marked `isDefault` declares the app's suggested default * access posture. Until the built-in `everyone` position lands (ADR-0090 P2), - * the CLI keeps using this name as the runtime fallback for users with no - * explicit grants; P2 replaces the fallback mechanism with an install-time - * suggestion bound to `everyone`. + * the CLI keeps using this name as the app's runtime baseline — composed with + * the platform baseline, never replacing it (see + * {@link composeHumanBaselinePermissionSets}, #7555); P2 replaces the mechanism + * with an install-time suggestion bound to `everyone`. * * Returns the first `isDefault` set's `name`, or `undefined` when none is - * declared (callers then keep the built-in `member_default` fallback). + * declared (callers then run on the platform baseline alone). */ export function appDefaultPermissionSetName(permissions: unknown): string | undefined { if (!Array.isArray(permissions)) return undefined; diff --git a/packages/plugins/plugin-security/src/baseline-composition.test.ts b/packages/plugins/plugin-security/src/baseline-composition.test.ts new file mode 100644 index 0000000000..8c8d90c504 --- /dev/null +++ b/packages/plugins/plugin-security/src/baseline-composition.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#7555] The human baseline COMPOSES; it does not displace. +// +// An app that marks one of its permission sets `isDefault` used to REPLACE the +// platform baseline for every member of that app: `fallbackPermissionSet` was a +// single name, and the app's name went in it. ADR-0090 D5 rules the opposite +// ("The fallback cliff is abolished. … `everyone` is additive like any other +// position: baseline ∪ explicit, always"), and the QA run behind #7555 measured +// what the displacement costs: on the showcase, all 10 built-in Account nav +// entries are served to a fresh member and 7/7 of the objects behind them +// answer 403, because `showcase_member_default` names no `sys_*` object and +// `member_default` was no longer in force. +// +// The cases below are red in BOTH directions on purpose: +// • the app half goes red if the declared baseline stops being in force; +// • the platform half goes red if the composition regresses to displacement; +// • the `null` half goes red if composition turned the baseline into an +// unconditional fall-open; +// • the agent half goes red if the composed HUMAN baseline ever becomes +// reachable from an agent principal (ADR-0090 D10). + +import { describe, it, expect, vi } from 'vitest'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai'; +import { SecurityPlugin } from './security-plugin.js'; +import { securityDefaultPermissionSets } from './manifest.js'; +import { + composeHumanBaselinePermissionSets, + PLATFORM_BASELINE_PERMISSION_SET, +} from './app-default-permission-set.js'; + +// --------------------------------------------------------------------------- +// The pure decision, on its own. +// --------------------------------------------------------------------------- +describe('composeHumanBaselinePermissionSets (#7555)', () => { + it('composes an app-declared baseline WITH the platform one, app first', () => { + expect(composeHumanBaselinePermissionSets('app_member_default')).toEqual([ + 'app_member_default', + 'member_default', + ]); + }); + + it('does not duplicate the platform baseline when that IS the configured name', () => { + expect(composeHumanBaselinePermissionSets(PLATFORM_BASELINE_PERMISSION_SET)).toEqual([ + 'member_default', + ]); + }); + + it('`null` still means NO baseline at all — composition never re-adds one', () => { + // The one escape hatch, and deliberately all-or-nothing: a deployment that + // wants a floor narrower than the platform's unbinds `member_default` from + // the `everyone` anchor, it does not name a different set to displace it. + expect(composeHumanBaselinePermissionSets(null)).toEqual([]); + expect(composeHumanBaselinePermissionSets(undefined)).toEqual([]); + expect(composeHumanBaselinePermissionSets('')).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// …and the same decision as the runtime enforces it. +// --------------------------------------------------------------------------- + +/** + * The app's declared default: read on ONE app object, and — exactly like the + * showcase's own `showcase_member_default` — no `sys_*` object whatsoever. + * That absence is the whole point: under displacement it is the entire access a + * fresh member has. + */ +const appDefault: PermissionSet = { + name: 'app_member_default', + label: 'App Member Default', + isDefault: true, + objects: { app_announcement: { allowRead: true } }, +} as any; + +/** + * `sys_user_preference` is the discriminator, for the reason the showcase + * dogfood pins already record: the platform baseline grants it and nothing else + * in the default set list does, so reading it answers "is `member_default` in + * force" and nothing else. Asserted rather than assumed — if the platform + * baseline ever stops naming it, this file must say so out loud instead of + * quietly measuring nothing (the #6964 vacuity, one file over). + */ +const PLATFORM_ONLY_OBJECT = 'sys_user_preference'; + +it('guard: the platform baseline is the only default set granting the discriminator object', () => { + const granting = securityDefaultPermissionSets + .filter((p) => (p.objects as any)?.[PLATFORM_ONLY_OBJECT]?.allowRead === true) + .map((p) => p.name); + expect(granting).toEqual([PLATFORM_BASELINE_PERMISSION_SET]); +}); + +const makeHarness = (permissionSets: PermissionSet[]) => { + const fields: Record = {}; + for (const f of ['id', 'organization_id', 'owner_id', 'user_id', 'created_by', 'name']) { + fields[f] = { name: f }; + } + const schema: any = { name: 'probe', fields }; + let middleware: any; + const ql = { + registerMiddleware: (mw: any) => { if (!middleware) middleware = mw; }, + getSchema: () => schema, + findOne: vi.fn(async () => null), + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: ql, + metadata: { get: async () => schema, list: async () => permissionSets }, + }; + const registered = new Map(); + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: (name: string, value: unknown) => registered.set(name, value), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + return { + ctx, + registered, + read: async (object: string, context: any) => { + const opCtx: any = { object, operation: 'find', options: {}, context }; + await middleware(opCtx, async () => {}); + return opCtx; + }, + }; +}; + +const bootWith = async (options: ConstructorParameters[0]) => { + const harness = makeHarness([appDefault]); + const plugin = new SecurityPlugin(options); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + return harness; +}; + +const MEMBER = { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }; + +describe('an app-declared baseline composes with the platform one (#7555)', () => { + it('the app half: the declared baseline is in force', async () => { + const h = await bootWith({ fallbackPermissionSet: 'app_member_default' }); + await expect(h.read('app_announcement', MEMBER)).resolves.toBeDefined(); + }); + + it('the platform half: `member_default` is STILL in force alongside it', async () => { + // Pre-#7555 this rejected — the single `fallbackPermissionSet` name held + // the app's set, so the platform baseline resolved for nobody in that app + // and every built-in Account destination 403'd for its members. + const h = await bootWith({ fallbackPermissionSet: 'app_member_default' }); + await expect(h.read(PLATFORM_ONLY_OBJECT, MEMBER)).resolves.toBeDefined(); + }); + + it('composition never re-adds a baseline the deployment DISABLED (`null`)', async () => { + // Asserted on the resolved baseline rather than through the middleware, and + // the difference is not stylistic: with zero permission sets the middleware + // SKIPS its whole CRUD gate by design ("no permission-set restriction + // applies"), so a `.rejects` case here would be red for a reason that has + // nothing to do with composition — and green whether or not `member_default` + // had been quietly composed back in. The baseline list is the thing under + // test, so the baseline list is what this reads. + const h = await bootWith({ fallbackPermissionSet: null }); + expect(h.registered.get('security.baselinePermissionSets')).toEqual([]); + expect(h.registered.get('security.fallbackPermissionSet')).toBeNull(); + }); + + it('and an undeclared app is unchanged — the platform baseline, alone', async () => { + const h = await bootWith(undefined); + await expect(h.read(PLATFORM_ONLY_OBJECT, MEMBER)).resolves.toBeDefined(); + await expect(h.read('app_announcement', MEMBER)).rejects.toMatchObject({ + name: 'PermissionDeniedError', + }); + }); + + it('publishes the composed list as `security.baselinePermissionSets`', async () => { + const h = await bootWith({ fallbackPermissionSet: 'app_member_default' }); + expect(h.registered.get('security.baselinePermissionSets')).toEqual([ + 'app_member_default', + 'member_default', + ]); + // …while `security.fallbackPermissionSet` keeps meaning "the single name + // this deployment DECLARED". Consumers on the old contract are unmoved. + expect(h.registered.get('security.fallbackPermissionSet')).toBe('app_member_default'); + }); +}); + +describe('ADR-0090 D10 — the composed human baseline is unreachable from an agent', () => { + const AGENT = { + userId: 'u1', + tenantId: 'org-1', + positions: [], + permissions: [], + principalKind: 'agent', + }; + + it('an agent gets the restricted CEILING, never the human floor', async () => { + const h = await bootWith({ fallbackPermissionSet: 'app_member_default' }); + // Both objects deny: the agent resolves `mcp_agent_restricted` (no object + // access) and neither the app baseline nor `member_default` is composed in. + // A composed human baseline leaking here would silently widen a read-only + // agent past the scope its delegating user consented to. + await expect(h.read(PLATFORM_ONLY_OBJECT, AGENT)).rejects.toMatchObject({ + name: 'PermissionDeniedError', + }); + await expect(h.read('app_announcement', AGENT)).rejects.toMatchObject({ + name: 'PermissionDeniedError', + }); + }); + + it('the restricted set the agent DOES get is the spec constant, still shipped', () => { + // Guards the case above against passing for the wrong reason: "everything + // denies" would also be true if the restricted set had simply vanished from + // the default list, which is a different bug wearing the same green. + expect(securityDefaultPermissionSets.map((p) => p.name)).toContain( + MCP_AGENT_PERMISSION_SET_RESTRICTED, + ); + }); +}); diff --git a/packages/plugins/plugin-security/src/explain-engine.test.ts b/packages/plugins/plugin-security/src/explain-engine.test.ts index f903676b65..306b7436cb 100644 --- a/packages/plugins/plugin-security/src/explain-engine.test.ts +++ b/packages/plugins/plugin-security/src/explain-engine.test.ts @@ -36,7 +36,7 @@ function makeDeps(overrides: Partial & { sets?: any[]; schema }, computeRlsFilter: async () => overrides.rls !== undefined ? overrides.rls : null, getFieldMask: () => ({}), - fallbackPermissionSet: 'member_default', + baselinePermissionSets: ['member_default'], ...overrides, }; } diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index ccb99012fb..8ee6880d86 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -174,8 +174,17 @@ export interface ExplainEngineDeps { object: string, fieldRequiredPermissions: Record, ) => Record; - /** Configured additive baseline set name (default member_default), for attribution. */ - fallbackPermissionSet: string | null; + /** + * Configured additive baseline set NAMES (default `['member_default']`), for + * attribution. + * + * [#7555] A list, not a name: the baseline is the app-declared set COMPOSED + * with the platform's `member_default`, so a report that attributed only one + * of them would label the other "resolved" — the vaguest bucket `viaOf` has, + * on the grant most likely to be the answer to "why can this member read + * anything at all". + */ + baselinePermissionSets: string[]; // ── [C2 / ADR-0095] Record-grained deps. All OPTIONAL: absent → the engine // stays object-level and byte-compatible. Present → the sharing / rls / owd / @@ -866,7 +875,7 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput } const positions: string[] = context?.positions ?? []; const viaOf = (name: string): string => { - if (name === deps.fallbackPermissionSet) return 'additive baseline (ADR-0090 D5)'; + if (deps.baselinePermissionSets.includes(name)) return 'additive baseline (ADR-0090 D5)'; if (positions.includes(name)) return `position:${name}`; if ((context?.permissions ?? []).includes(name)) return 'direct grant'; return 'resolved'; diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 8d6efd3e17..3d146d1077 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -64,7 +64,12 @@ export { objectPostureGate, registerObjectPostureGate } from './object-posture-g export type { ObjectPostureGateContext } from './object-posture-gate.js'; export { claimSeedOwnership } from './claim-seed-ownership.js'; export { normalizeManagedByVocab } from './normalize-managed-by.js'; -export { appDefaultPermissionSetName, appSecurityPluginOptions } from './app-default-permission-set.js'; +export { + appDefaultPermissionSetName, + appSecurityPluginOptions, + composeHumanBaselinePermissionSets, + PLATFORM_BASELINE_PERMISSION_SET, +} from './app-default-permission-set.js'; export { DelegatedAdminGate, isTenantAdmin } from './delegated-admin-gate.js'; export { assertEngineOwnedWriteAllowed, ENGINE_OWNED_BUCKETS } from './system-write-guard.js'; export type { EngineOwnedSchemaLike } from './system-write-guard.js'; diff --git a/packages/plugins/plugin-security/src/metadata-unresolvable-posture.test.ts b/packages/plugins/plugin-security/src/metadata-unresolvable-posture.test.ts index e915fac793..400b7c2d64 100644 --- a/packages/plugins/plugin-security/src/metadata-unresolvable-posture.test.ts +++ b/packages/plugins/plugin-security/src/metadata-unresolvable-posture.test.ts @@ -195,7 +195,7 @@ describe('[#3545] unresolvable object metadata — security posture fails closed }, computeRlsFilter: async () => null, getFieldMask: () => ({}), - fallbackPermissionSet: 'member_default', + baselinePermissionSets: ['member_default'], }) as any; const ctx = { userId: 'u1', positions: ['everyone'], permissions: [] }; diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 2f3fab9f75..147a3bd7bd 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -9,6 +9,7 @@ import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai'; // for one defect class is what that module exists to prevent. import { renderOperationMessage } from '@objectstack/spec/system'; import { PermissionEvaluator, crudBucketForOperation } from './permission-evaluator.js'; +import { composeHumanBaselinePermissionSets, PLATFORM_BASELINE_PERMISSION_SET } from './app-default-permission-set.js'; import { DelegatedAdminGate } from './delegated-admin-gate.js'; import { INVITATION_PLACEMENT_SERVICE, @@ -320,11 +321,18 @@ export interface SecurityPluginOptions { */ defaultPermissionSets?: PermissionSet[]; /** - * Permission set name applied as an implicit baseline whenever an - * authenticated request has no resolved permission sets (and no positions - * that map to one). This guarantees baseline tenant/owner RLS for - * every logged-in user even before an admin assigns explicit - * profiles. Set to `null` to disable. + * Permission set name applied as an implicit ADDITIVE baseline on every + * authenticated human request (ADR-0090 D5: `baseline ∪ explicit, always`). + * This guarantees baseline tenant/owner RLS for every logged-in user even + * before an admin assigns explicit profiles. + * + * [#7555] Naming a set here ADDS it to the baseline; it does not REPLACE the + * platform baseline (`member_default`), which composes in alongside it — see + * {@link composeHumanBaselinePermissionSets}. An app declaring `isDefault` + * therefore keeps the built-in Account destinations working for its members + * instead of silently costing them the whole platform floor. + * + * Set to `null` to disable the baseline entirely — the platform one included. * * @default 'member_default' */ @@ -424,7 +432,7 @@ export class SecurityPlugin implements Plugin { * Services init() registers on every path (ADR-0116, #4131) — lets the * kernel name this plugin when a consumer requires one before it inits. */ - providesServices = ['security.permissions', 'security.rls', 'security.fieldMasker', 'security.bootstrapPermissionSets', 'security.fallbackPermissionSet']; + providesServices = ['security.permissions', 'security.rls', 'security.fieldMasker', 'security.bootstrapPermissionSets', 'security.fallbackPermissionSet', 'security.baselinePermissionSets']; type = 'standard'; version = '1.0.0'; dependencies = ['com.objectstack.engine.objectql']; @@ -434,6 +442,17 @@ export class SecurityPlugin implements Plugin { private fieldMasker = new FieldMasker(); private readonly bootstrapPermissionSets: PermissionSet[]; private readonly fallbackPermissionSet: string | null; + /** + * [#7555, ADR-0090 D5] The HUMAN baseline, as the list of set names it is: + * {@link fallbackPermissionSet} COMPOSED WITH the platform baseline + * (`member_default`), deduped — `[]` when the baseline is disabled (`null`). + * + * `fallbackPermissionSet` stays the single name the app/deployment DECLARED + * (that is what the `security.fallbackPermissionSet` service means, and what + * consumers key attribution on); this is what actually RESOLVES per request. + * Agents never see it — ADR-0090 D10 gives them a restricted ceiling instead. + */ + private readonly baselinePermissionSets: string[]; /** * Runtime probe — set in `start()` from * `ctx.getService('org-scoping')`. When `false`, the PLATFORM's own @@ -522,8 +541,11 @@ export class SecurityPlugin implements Plugin { // ADR-0056 D7: an app may declare its default profile via `isDefault: true` // on a permission set; it becomes the fallback for users with no explicit // grants. Falls back to the built-in `member_default` when none is declared. - ? (this.bootstrapPermissionSets.find((p) => (p as { isDefault?: boolean }).isDefault)?.name ?? 'member_default') + ? (this.bootstrapPermissionSets.find((p) => (p as { isDefault?: boolean }).isDefault)?.name ?? PLATFORM_BASELINE_PERMISSION_SET) : options.fallbackPermissionSet; + // [#7555] …and the baseline that actually resolves is that name COMPOSED + // with the platform's own, never one displacing the other (ADR-0090 D5). + this.baselinePermissionSets = composeHumanBaselinePermissionSets(this.fallbackPermissionSet); } async init(ctx: PluginContext): Promise { @@ -541,6 +563,14 @@ export class SecurityPlugin implements Plugin { // the platform-objects package directly. ctx.registerService('security.bootstrapPermissionSets', this.bootstrapPermissionSets); ctx.registerService('security.fallbackPermissionSet', this.fallbackPermissionSet); + // [#7555] The baseline as it actually RESOLVES — the declared name composed + // with the platform's own (ADR-0090 D5). `security.fallbackPermissionSet` + // above keeps meaning "the single name this deployment declared", so + // existing consumers keep their `string | null` contract; a consumer that + // wants the effective baseline (`/auth/me/permissions`, REST) reads THIS + // one and falls back to `[fallbackPermissionSet]` on a stack too old to + // register it. + ctx.registerService('security.baselinePermissionSets', this.baselinePermissionSets); ctx.getService<{ register(m: any): void }>('manifest').register({ ...securityPluginManifestHeader, @@ -2275,8 +2305,8 @@ export class SecurityPlugin implements Plugin { } catch (e) { ctx.logger.warn('[security] built-in role seeding failed', { error: (e as Error).message }); } - // [ADR-0090 D5] Bind the configured baseline set to the `everyone` - // audience anchor (idempotent). This makes the CLI/dev fallback + // [ADR-0090 D5] Bind the configured baseline set(s) to the `everyone` + // audience anchor (idempotent). This makes the CLI/dev baseline // (`fallbackPermissionSet` — the app's `isDefault` set) visible as an // ordinary position binding: same table, same audit path, same explain // surface as any admin-authored default grant. The binding is validated @@ -2285,31 +2315,40 @@ export class SecurityPlugin implements Plugin { // `bootstrapBuiltinRoles` (which seeds the `everyone` anchor) and before // `syncAudienceBindingSuggestions` (so the app's own fallback set is // already bound and never generates a redundant pending suggestion). + // + // [#7555] Every name in the COMPOSED baseline is bound, not just the + // declared one — the join table takes many rows per position, and the + // explain surface's whole job is to answer "what does a new member get" + // truthfully. Binding only the app's set while `member_default` also + // resolves at request time would make `security/explain` report a + // narrower default than the runtime actually applies. The refusal is + // PER SET: a high-privilege name is skipped loudly and the rest still + // bind (the D5/D9 anchor gate is untouched, and each set faces it). try { - if (this.fallbackPermissionSet) { - const boot = this.bootstrapPermissionSets.find((p) => p.name === this.fallbackPermissionSet); + for (const baselineName of this.baselinePermissionSets) { + const boot = this.bootstrapPermissionSets.find((p) => p.name === baselineName); const offending = boot ? describeHighPrivilegeBits(boot) : null; if (offending) { ctx.logger.warn('[security] refusing to bind fallback set to everyone — high-privilege bits', { - set: this.fallbackPermissionSet, offending, + set: baselineName, offending, }); - } else { - const everyoneRows = await ql.find('sys_position', { where: { name: 'everyone' }, limit: 1, context: { isSystem: true } }); - const everyone: any = Array.isArray(everyoneRows) && everyoneRows[0] ? everyoneRows[0] : null; - const setRows = await ql.find('sys_permission_set', { where: { name: this.fallbackPermissionSet }, limit: 1, context: { isSystem: true } }); - const set: any = Array.isArray(setRows) && setRows[0] ? setRows[0] : null; - if (everyone?.id && set?.id) { - const existing = await ql.find('sys_position_permission_set', { - where: { position_id: everyone.id, permission_set_id: set.id }, limit: 1, context: { isSystem: true }, - }); - if (!(Array.isArray(existing) && existing[0])) { - await ql.insert('sys_position_permission_set', { - id: `pps_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, - position_id: everyone.id, - permission_set_id: set.id, - }, { context: { isSystem: true } }); - ctx.logger.info('[security] baseline set bound to everyone anchor (ADR-0090 D5)', { set: this.fallbackPermissionSet }); - } + continue; + } + const everyoneRows = await ql.find('sys_position', { where: { name: 'everyone' }, limit: 1, context: { isSystem: true } }); + const everyone: any = Array.isArray(everyoneRows) && everyoneRows[0] ? everyoneRows[0] : null; + const setRows = await ql.find('sys_permission_set', { where: { name: baselineName }, limit: 1, context: { isSystem: true } }); + const set: any = Array.isArray(setRows) && setRows[0] ? setRows[0] : null; + if (everyone?.id && set?.id) { + const existing = await ql.find('sys_position_permission_set', { + where: { position_id: everyone.id, permission_set_id: set.id }, limit: 1, context: { isSystem: true }, + }); + if (!(Array.isArray(existing) && existing[0])) { + await ql.insert('sys_position_permission_set', { + id: `pps_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, + position_id: everyone.id, + permission_set_id: set.id, + }, { context: { isSystem: true } }); + ctx.logger.info('[security] baseline set bound to everyone anchor (ADR-0090 D5)', { set: baselineName }); } } } @@ -2574,7 +2613,7 @@ export class SecurityPlugin implements Plugin { fp = this.foldFieldRequiredPermissions(fp, fieldRequired, sets as any); return fp as any; }, - fallbackPermissionSet: this.fallbackPermissionSet, + baselinePermissionSets: this.baselinePermissionSets, // ── record-grained deps (only consulted when recordId is present) ── computeLayeredRlsFilter: (sets, o, engineOp, c) => this.computeLayeredRlsFilter(sets as any, o, engineOp, c), fetchRecord: async (o: string, rid: string) => { @@ -3268,15 +3307,27 @@ export class SecurityPlugin implements Plugin { // user's own baseline still applies on the OTHER side of the intersection // (resolved from `onBehalfOf` via a context without this flag). const isAgent = context?.principalKind === 'agent'; - const baseline = isAgent ? MCP_AGENT_PERMISSION_SET_RESTRICTED : this.fallbackPermissionSet; - // [ADR-0090 D5] Baseline is ADDITIVE, always (for humans): the configured - // baseline set applies to every authenticated request IN ADDITION to - // whatever else resolved. The former "only when the user has nothing else" - // conditional was the fallback CLIFF — receiving your first explicit grant - // silently cost you the entire baseline. Agents skip this additive step - // (their ceiling is closed, not floored) — see above. - if (!isAgent && context?.userId && baseline && !requested.includes(baseline)) { - requested.push(baseline); + // [#7555] The human side is a LIST, and the agent side deliberately is not: + // the agent's ceiling is EXACTLY the restricted set, and composing the + // platform baseline into it is precisely the widening D10 forbids. The two + // branches produce arrays only so the code below reads once. + const baseline: string[] = isAgent + ? [MCP_AGENT_PERMISSION_SET_RESTRICTED] + : this.baselinePermissionSets; + // [ADR-0090 D5] Baseline is ADDITIVE, always (for humans): the baseline + // set(s) apply to every authenticated request IN ADDITION to whatever else + // resolved. The former "only when the user has nothing else" conditional + // was the fallback CLIFF — receiving your first explicit grant silently + // cost you the entire baseline. Agents skip this additive step (their + // ceiling is closed, not floored) — see above. + // [#7555] The same cliff had a second spelling the list closes: an app + // declaring an `isDefault` set REPLACED `member_default` here, so every + // member of that app lost the platform floor (and with it every built-in + // Account destination) the moment the app declared a posture of its own. + if (!isAgent && context?.userId) { + for (const name of baseline) { + if (!requested.includes(name)) requested.push(name); + } } let permissionSets = await this.permissionEvaluator.resolvePermissionSets( requested, @@ -3293,10 +3344,10 @@ export class SecurityPlugin implements Plugin { if ( permissionSets.length === 0 && context?.userId && - baseline + baseline.length > 0 ) { permissionSets = await this.permissionEvaluator.resolvePermissionSets( - [baseline], + baseline, this.metadata, this.bootstrapPermissionSets, this.dbLoader, @@ -3307,9 +3358,9 @@ export class SecurityPlugin implements Plugin { } /** - * [ADR-0106 D7] Resolve the configured fallback permission set on its own — - * the second step `/auth/me/permissions` takes when a caller's own names - * resolve to nothing (`resolved.length === 0 && fallbackName`). + * [ADR-0106 D7] Resolve the configured baseline permission set(s) on their + * own — the second step `/auth/me/permissions` takes when a caller's own + * names resolve to nothing (`resolved.length === 0 && fallbackName`). * * Distinct from the post-resolution fallback inside * {@link resolvePermissionSetsForContext}, which is gated on `context.userId` @@ -3318,13 +3369,25 @@ export class SecurityPlugin implements Plugin { * principal at all, so that a guest-facing deployment's metadata exposure is * a permission-set decision rather than an accidental everything-default. * - * Returns `[]` when no fallback set is configured or it does not resolve. + * [#7555] Reads the COMPOSED baseline, deliberately — D7 warrants it. This + * method exists to answer "what does this deployment's baseline disclose", + * and its whole justification is being *the same* resolution the data plane + * performs ("the same two-step `/auth/me/permissions` performs", above); one + * plane composing while the other displaced would put the two planes in + * disagreement about what the baseline even is, which is the drift D7 is + * written to avoid. On every deployment that declares no app baseline the + * list is exactly `['member_default']` and nothing changes; on one that does, + * exposure becomes platform ∪ app — both deliberate permission-set decisions, + * which is the bar D7 sets, rather than an app declaration silently NARROWING + * a guest's schema view relative to a deployment that declared nothing. + * + * Returns `[]` when no baseline is configured or none of it resolves. */ private async resolveFallbackPermissionSets(): Promise { - const fallback = this.fallbackPermissionSet; - if (!fallback) return []; + const fallback = this.baselinePermissionSets; + if (fallback.length === 0) return []; return this.permissionEvaluator.resolvePermissionSets( - [fallback], + fallback, this.metadata, this.bootstrapPermissionSets, this.dbLoader, diff --git a/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts b/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts index 608ffb9edd..7bc12c6930 100644 --- a/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts @@ -24,10 +24,17 @@ // 403 there too — it held identically either way, so it could not tell "the // declared default is in force" from "no default is in force at all", which is // the one thing this file exists to tell. The surviving discriminator runs the -// other way round: name an object ONLY the built-in baseline grants. The same -// run settles the risk that would have killed that idea — a NAMED fallback set -// REPLACES `member_default` rather than merging additively on top of it, so +// other way round: name an object ONLY the built-in baseline grants, so // `sys_user_preference` is 200 if and only if the built-in baseline governs. +// +// [#7555] The `sys_user_preference=403` in rows 2 and 3 above was the DEFECT, +// not the contract. Those rows measured a named fallback set DISPLACING +// `member_default`, and #7555 measured what that costs a real member: all 10 +// built-in Account nav entries served, 7/7 of the objects behind them 403. The +// baseline now COMPOSES (ADR-0090 D5, "baseline ∪ explicit, always"), so rows 2 +// and 3 read `sys_user_preference=200` and the file's pair is red in both +// directions still — announcement for the declared half, preference for the +// platform half. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; @@ -41,9 +48,12 @@ const stackPerms = ((showcaseStack as { permissions?: unknown[] }).permissions ? const appDefault = appDefaultPermissionSetName(stackPerms); const declaredDefault = stackPerms.find((p) => p?.name === appDefault) as unknown; +const SYS = { isSystem: true } as const; + describe('showcase: app-declared default profile, CLI-wired (ADR-0056 D7)', () => { let stack: VerifyStack; let memberToken: string; + let ql: any; beforeAll(async () => { // The full CLI boot loads stack permission sets into the metadata service, so @@ -59,6 +69,7 @@ describe('showcase: app-declared default profile, CLI-wired (ADR-0056 D7)', () = }); await stack.signIn(); memberToken = await stack.signUp('d7-showcase-member@verify.test'); + ql = await stack.kernel.getServiceAsync('objectql'); }, 60_000); afterAll(async () => { await stack?.stop(); }); @@ -72,14 +83,46 @@ describe('showcase: app-declared default profile, CLI-wired (ADR-0056 D7)', () = expect(r.status, 'declared default grants announcement read').toBe(200); }); - it('and NOT by the built-in member_default baseline (its own explicit grant is absent)', async () => { + it('AND by the built-in member_default baseline — the two COMPOSE (#7555)', async () => { // `sys_user_preference` is granted by `member_default` and by nothing else // here (`default-permission-sets.ts`: allowRead/allowCreate/allowEdit, with a // `sys_user_preference_self` RLS carve-out), and `showcase_member_default` // names no `sys_*` object at all. So it is 200 exactly when the built-in // baseline governs — which is the discrimination `showcase_contact` lost when // #5491 removed the wildcard that used to make a denial informative. + // + // [#7555] The DIRECTION flipped, the discriminator did not. This case used + // to demand `not.toBe(200)`, pinning "a NAMED fallback set REPLACES + // `member_default`" — the exact defect #7555 reports: every member of an app + // that declares a default lost the whole platform floor, so all 10 built-in + // Account nav entries were served and 7/7 of the objects behind them 403'd. + // ADR-0090 D5 rules the baseline additive ("baseline ∪ explicit, always"), + // so the platform set must still govern alongside the declared one, and this + // object is still the one that says whether it does. const r = await stack.apiAs(memberToken, 'GET', '/data/sys_user_preference'); - expect(r.status, 'the built-in baseline is REPLACED by the declared default, not merged with it').not.toBe(200); + expect(r.status, 'the platform baseline COMPOSES with the declared default, it is not replaced by it').toBe(200); + }); + + it('and the explain surface tells the same story: BOTH sets are bound to the everyone anchor (#7555)', async () => { + // The runtime composing while the anchor carried only the app's set would + // make `security/explain` — and the Setup UI reading the same join table — + // report a narrower default than every request actually applies. The join + // table takes many rows per position; the boot binding writes one per + // baseline name. + const everyone = await ql.findOne('sys_position', { where: { name: 'everyone' }, context: SYS }); + expect(everyone?.id, 'everyone anchor seeded').toBeTruthy(); + const bound: string[] = []; + for (const name of ['showcase_member_default', 'member_default']) { + const set = await ql.findOne('sys_permission_set', { where: { name }, context: SYS }); + if (!set?.id) continue; + const row = await ql.findOne('sys_position_permission_set', { + where: { position_id: everyone.id, permission_set_id: set.id }, context: SYS, + }); + if (row) bound.push(name); + } + expect(bound, 'everyone ← app baseline AND platform baseline').toEqual([ + 'showcase_member_default', + 'member_default', + ]); }); }); diff --git a/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts b/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts index dbdeadb940..b0da95f108 100644 --- a/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts @@ -22,13 +22,18 @@ // // Row 1 is the world the old case claimed to exclude, and `private_note` is 403 // there too — it held identically either way. The surviving discriminator runs -// the other way round: name an object ONLY the built-in baseline grants. The -// same run settles the risk that would have killed that idea — a NAMED fallback -// set REPLACES `member_default` rather than merging additively on top of it, so +// the other way round: name an object ONLY the built-in baseline grants, so // `sys_user_preference` is 200 if and only if the built-in baseline governs. // +// [#7555] The `sys_user_preference=403` in rows 2 and 3 was the DEFECT, not the +// contract: it measured a named fallback set DISPLACING `member_default`, which +// ADR-0090 D5 forbids ("baseline ∪ explicit, always") and which cost every +// member of a baseline-declaring app the whole platform floor. Rows 2 and 3 now +// read `sys_user_preference=200`. +// // The pair below is therefore red in both directions: the first case goes red if -// the declared default is not in force, the second if the built-in one still is. +// the declared default is not in force, the second if it DISPLACES the built-in +// one instead of composing with it. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; @@ -68,13 +73,20 @@ describe('showcase: app-declared default profile (ADR-0056 D7)', () => { expect(r.status, 'default profile grants announcement read').toBe(200); }); - it('and NOT by the built-in member_default baseline (its own explicit grant is absent)', async () => { + it('AND by the built-in member_default baseline — the two COMPOSE (#7555)', async () => { // `sys_user_preference` is granted by `member_default` and by nothing else // here (`default-permission-sets.ts`: allowRead/allowCreate/allowEdit, with a // `sys_user_preference_self` RLS carve-out). So it is 200 exactly when the // built-in baseline governs — the counterfactual `showcase_private_note` used // to carry before #5491 removed the wildcard that made it discriminating. + // + // [#7555] The DIRECTION flipped, the discriminator did not. `not.toBe(200)` + // pinned the displacement — an app declaring `isDefault` silently cost its + // members the entire platform floor, which is why the QA run behind #7555 + // found all 10 built-in Account nav entries served and 7/7 of the objects + // behind them answering 403. ADR-0090 D5 rules the baseline additive + // ("baseline ∪ explicit, always"), so the platform set still governs. const r = await stack.apiAs(memberToken, 'GET', '/data/sys_user_preference'); - expect(r.status, 'the built-in baseline is REPLACED by the declared default, not merged with it').not.toBe(200); + expect(r.status, 'the platform baseline COMPOSES with the declared default, it is not replaced by it').toBe(200); }); }); diff --git a/packages/spec/src/contracts/security-service.ts b/packages/spec/src/contracts/security-service.ts index 79615cd57d..4efebae6ee 100644 --- a/packages/spec/src/contracts/security-service.ts +++ b/packages/spec/src/contracts/security-service.ts @@ -239,9 +239,10 @@ export interface ISecurityService { * served, as opposed to which columns of a row it may read. * * Identical to {@link getReadableFields} in every respect but one — a caller - * that resolves to **zero** permission sets goes through the same fallback-set - * resolution `/auth/me/permissions` uses (`security.fallbackPermissionSet`, - * default `member_default`) instead of falling open to the full field set. + * that resolves to **zero** permission sets goes through the same baseline + * resolution `/auth/me/permissions` uses (`security.baselinePermissionSets`: + * the app-declared baseline COMPOSED with the platform `member_default`, + * #7555) instead of falling open to the full field set. * * **Why the two differ rather than converge.** `getReadableFields` mirrors the * engine middleware, which skips its whole field gate for a caller with no