diff --git a/.changeset/18783-server-can-option-visibility.md b/.changeset/18783-server-can-option-visibility.md index 2de3df5c867..f357292a9fe 100644 --- a/.changeset/18783-server-can-option-visibility.md +++ b/.changeset/18783-server-can-option-visibility.md @@ -34,6 +34,6 @@ stage: Field.select({ - If the security service cannot resolve the map, a write that needs it is refused with the resolution's own error — fail closed. It is never read as "no grants". - With no security plugin, or an engine older than the seam, there is no permission data. The gate stays unevaluable and the value is admitted with the same `warn` as before, which names the missing input. The security plugin logs one `warn` at start when the engine lacks the seam. -**Known gap, not changed here.** `can()` reads only the per-object entries of the map, and `/auth/me/permissions` lists an object for a `'*'` wildcard grant only when that grant carries a super-user bit. So a subject whose access to an object comes only from a plain wildcard — for example `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins — gets `false` from `current_user.can('', …)`, although the data plane admits the write. Before this release such a gate was never enforced for anyone; after it, that population is refused on a `can`-gated option. Any client that answers `can()` from the same `/auth/me/permissions` map gets the same `false`. +**Plain-wildcard coverage, closed in this release.** `can()` reads only the per-object entries of the map. Before #20083, `/auth/me/permissions` listed an object for a `'*'` wildcard grant only when that grant carried a super-user bit, so a subject whose access to an object came only from a plain wildcard — for example `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins — got `false` from `current_user.can()` for that object, although the data plane admits the write, and was refused on a `can`-gated option. That gap is closed in this same release by #20083 (`.changeset/20083-effective-map-plain-wildcard.md`): `buildEffectiveObjectPermissions` now puts each set's plain `'*'` on the registered public objects that set does not name, so that population's map — and any client that answers `can()` from the same `/auth/me/permissions` map — carries an entry for each object the wildcard covers, with the wildcard's grants, narrowed on a guarded managed object by the same managed-write clamp as every other entry. The map still differs from `PermissionEvaluator.checkObjectPermission` for subjects holding a super-user wildcard: an entry the super-user set itself names narrower can read as granted, and an entry reached through a super-user wildcard carries no `transfer`. **No spec key, route or config key is added or removed.** diff --git a/.changeset/20083-effective-map-plain-wildcard.md b/.changeset/20083-effective-map-plain-wildcard.md new file mode 100644 index 00000000000..f36971de73f --- /dev/null +++ b/.changeset/20083-effective-map-plain-wildcard.md @@ -0,0 +1,23 @@ +--- +'@objectstack/core': minor +--- + +fix(core): the effective object-permission map covers what a plain `'*'` grant covers, so `current_user.can()` agrees with the server for a wall-less org admin (#20083) + +`buildEffectiveObjectPermissions` builds the `objects` slot of `GET /auth/me/permissions` (`@objectstack/plugin-hono-server`) and the map `ISecurityService.getEffectiveObjectPermissions` returns (`@objectstack/plugin-security`), which the engine hands to `current_user.can(object, verb)` on the write path. It merged each permission set's EXPLICIT entries and kept `'*'` as a key of its own. The server's check does not stop there: `PermissionEvaluator.checkObjectPermission` resolves each set to its explicit entry for the object when it has one, and otherwise to its `'*'` — for a public object always, for a private one only when the wildcard carries a super-user bit. So an object reached only through a plain wildcard (no `viewAllRecords` / `modifyAllRecords`) had no entry in the map, and `can()` — which reads an absent entry as "no grant" — answered `false` where the server allows. + +The population it hit: `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins. With it and `member_default`, `current_user.can('crm_account', 'edit')` was `false` while the data plane accepted the edit, so a `can()`-gated option was refused on the write path, and a client that answers `can()` from `/auth/me/permissions` got the same `false`. `viewer_readonly` read the same way (`read` on every object it covers). + +**What changes.** A new step in `buildEffectiveObjectPermissions`, after the super-user seed and before the wildcard fold, applies each set's plain `'*'` to the registered objects that set does not name: + +- only registered objects, and only public ones (`access.default` other than `'private'`); +- a set that names the object keeps its explicit entry as its whole answer, as on the server; +- another set's plain wildcard widens an entry that is already present, bit by bit; +- only `true` grant bits are copied; a wildcard's `false` or unset bit adds nothing; +- an object the step would add, but whose entry grants no verb on its own, is left out. + +The step reads `name` and `access.default` off the `allSchemas` entries, so the element type of `allSchemas` on `buildEffectiveObjectPermissions`' schema source gains an optional `access?: unknown` member (the package exports no new name for it). That is a type widening only: every call that compiled before still compiles, and a schema literal carrying `access` now does too. A direct caller passes the registered schemas themselves there, as both in-repo callers do; an entry without `access` reads as public, exactly as the server reads it. + +**What a reader of `/auth/me/permissions` sees.** For a subject holding a plain wildcard, `objects` gains an entry for every registered public object the wildcard covers that had none, annotated with `apiOperations` by the same rule as every other entry. An entry that was already there may gain `true` bits. Nothing is removed. For a subject holding no plain wildcard — `admin_full_access`, a walled `organization_admin`, `member_default` alone — the response is byte-identical to before. The response shape, its keys and the route are unchanged. + +This closes the known gap that the `current_user.can()` write-path entry in this release describes: `organization_admin_no_bypass` now reads `true` from `can()` where the data plane allows. diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index e67718b8289..574bff06530 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -2522,13 +2522,14 @@ "title": "The /auth/me/permissions aggregation (and its /auth/me/localization + /me/apps siblings) mirrors server-side enforcement in both directions — and the trio's anonymous 200 is the deliberate exception to the 401 floor", "since": "v15", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": [ "contributor member C (showcase_contributor — carries the FLS grant on showcase_project budget figures, the field-parity fixture)", "plain member P (member_default only — the withheld-verb contrast)", "admin (the entitled /me/apps contrast)", + "wall-less org admin W (organization_admin_no_bypass + member_default, NO admin_full_access — the plain-wildcard population: its only '*' carries no viewAllRecords/modifyAllRecords bit)", "anonymous (the designed-200 exception)" ], "fixtures": { @@ -2536,10 +2537,12 @@ "requires": [ "showcase_contributor: allowEdit on showcase_project plus FLS budget/spent/budget_remaining readable:true/editable:false (permission-sets.ts) — the same fixture access-security.fls-mask-and-strip drives; this item asserts the AGGREGATION agrees with that enforcement, not the enforcement itself", "member_default WITHOUT allowEdit on showcase_project — compute the ADR-0090 D5 baseline union per access-security.crud-permission-matrix's knownGaps before picking the withheld verb, or a baseline-granted verb will read as an aggregation violation", - "an app whose requiredPermissions a plain member lacks (the setup built-in requires setup capabilities admin_full_access carries) — the /me/apps contrast pair" + "an app whose requiredPermissions a plain member lacks (the setup built-in requires setup capabilities admin_full_access carries) — the /me/apps contrast pair", + "W: a fresh user who owns or administers an organization under the stock wall-less posture, where auto-org-admin-grant assigns organization_admin_no_bypass (not organization_admin); confirm permissionSets in W's own map before scoring — a W that also resolves admin_full_access is the super-user population, not this one" ], "knownGaps": [ - "the SecurityPlugin-absent fail-open branch (current-user-endpoints.ts empty-but-authenticated body; /me/apps failOpen returning every app) has no showcase fixture — a stack without SecurityPlugin is a different boot. Declared boundary; do not score it here" + "the SecurityPlugin-absent fail-open branch (current-user-endpoints.ts empty-but-authenticated body; /me/apps failOpen returning every app) has no showcase fixture — a stack without SecurityPlugin is a different boot. Declared boundary; do not score it here", + "showcase declares no access.default:'private' app object, so W's private-object contrast runs on the platform's sys_secret (private, named by no shipped set) — it proves the plain wildcard's posture boundary, not an app-authored one" ] }, "steps": [ @@ -2547,6 +2550,7 @@ "as C: GET /api/v1/auth/me/permissions — record objects.showcase_project, fields['showcase_project.budget'], permissionSets, systemPermissions", "verb parity, both directions: as C PATCH a showcase_project name (the map says allowEdit) — 2xx; as P read P's own map (allowEdit absent/false on showcase_project) then issue the identical PATCH — 403 PERMISSION_DENIED", "field parity: C's map says budget editable:false — C's budget PATCH is refused/stripped with the stored value unchanged (the fls-mask-and-strip oracle; cross-check only, do not re-score that item here)", + "plain-wildcard parity: as W, GET /api/v1/auth/me/permissions — objects.showcase_semantic_zoo is PRESENT with allowEdit:true although no set W holds names it (the organization_admin_no_bypass '*' covers it), and objects.showcase_project reads allowEdit:true although showcase_member_default names it read-only (another set's plain '*' widens a named entry, because the server resolves each set on its own); create a showcase_project as W, then PATCH its name — 2xx (W's own row, so row-level scope, which this item does not score, cannot decide it); then objects.sys_secret is ABSENT (private: a plain '*' does not reach it) and W's GET /api/v1/data/sys_secret answers 403 PERMISSION_DENIED (its apiMethods offer list, so the refusal is the permission layer's, not the exposure layer's)", "as P then admin: GET /api/v1/me/apps — P's list excludes the capability-gated app and includes showcase_app; admin's includes it; for every returned app verify requiredPermissions ⊆ the caller's merged systemPermissions", "as any member: GET /api/v1/auth/me/localization — the resolved currency/locale/timezone", "anonymous (no Authorization header, fresh client): GET all three endpoints and capture status + body", @@ -2588,6 +2592,12 @@ "oracle": "api", "verify": "member trace carries authenticated:true plus the three keys, with timezone and locale non-null; configure localization.currency and localization.timezone and re-trace — both must move to the configured values (an unmoved trace is the #15387 defect, not a pass)", "evidence": "the trace" + }, + { + "clause": "the plain-wildcard population holds the same parity: for W, whose only '*' carries no super-user bit, the map carries an entry for every registered PUBLIC object that '*' covers and no set of W's names (showcase_semantic_zoo), widens an entry another set names narrower (showcase_project reads allowEdit:true and W's PATCH of its own row is 2xx), and carries NONE for a private object the plain '*' does not reach (sys_secret absent, W's read 403) — an absent entry reads as 'no grant' to current_user.can(), so a missing covered object is an under-claim FAIL against the endpoint, and an entry for the private one an over-claim FAIL", + "oracle": "api", + "verify": "W's /auth/me/permissions objects.showcase_semantic_zoo, objects.showcase_project and objects.sys_secret against W's live PATCH and GET outcomes; permissionSets in the same body must list organization_admin_no_bypass and must NOT list admin_full_access, or the persona is wrong and the clause is not scored", + "evidence": "W's map + the PATCH and GET traces" } ], "negative": [ @@ -2607,10 +2617,11 @@ ], "automated": { "kind": "dogfood", - "ref": "packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts (the /me/apps half: member sees showcase, requiredPermissions gates, anonymous [], tabPermissions hidden drop and more-visible grant wins) + plugin-hono-server unit suites hono-current-user-endpoints.test.ts / current-user-endpoints-additive-baseline.test.ts / current-user-endpoints-position-grants.test.ts / current-user-endpoints-delegated-resolution.test.ts. STILL MANUAL: the live parity cross-check of the returned maps against actual enforcement responses (clauses 1-2) and the self-scoping probe" + "ref": "packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts (the /me/apps half: member sees showcase, requiredPermissions gates, anonymous [], tabPermissions hidden drop and more-visible grant wins) + plugin-hono-server unit suites hono-current-user-endpoints.test.ts / current-user-endpoints-additive-baseline.test.ts / current-user-endpoints-position-grants.test.ts / current-user-endpoints-delegated-resolution.test.ts + plugin-security get-effective-object-permissions.test.ts (the plain-wildcard parity table behind the plain-wildcard clause: shipped and authored plain-'*' subjects x registered objects x every can() verb, the map read through the real can() against PermissionEvaluator.checkObjectPermission — a unit oracle over fixture schemas, not the live stack). STILL MANUAL: the live parity cross-check of the returned maps against actual enforcement responses (clauses 1-2 and the plain-wildcard clause) and the self-scoping probe" }, "source": [ "packages/plugins/plugin-hono-server/src/current-user-endpoints.ts#tabPermissions (/auth/me/permissions aggregation + most-permissive merge), (/auth/me/localization), (/me/apps requiredPermissions/tabPermissions filter), (the /api/v1 prefix)", + "packages/core/src/security/effective-object-permissions.ts#buildEffectiveObjectPermissions (the objects slot: explicit merge, super-user seed, plain-wildcard coverage, fold, managed-write clamp, apiOperations annotation — the one function the route and ISecurityService.getEffectiveObjectPermissions share)", "packages/core/src/security/auth-gate.ts#ALLOW_ROUTES (ALLOW_ROUTES — /me/apps + /me/localization reachable to gated users. Was ALLOW_SUFFIXES, an endsWith test that also exempted any path merely ENDING in those two — /data/xyz/me/apps among them; the allow-list is anchored to a mount base now, so these are EXACT routes at a mount and a record id can no longer spell its way into the exemption)", "#7616 (delegated permission-set resolution — the enforcement path's own answer), #2752 (/me/apps registry sourcing), #3391 (effective apiOperations annotation), #4093 (guarded degraded branch), ADR-0090 D5 (additive baseline)", "cross-ref access-security.anonymous-deny-surfaces — the 401 floor this trio is the declared exception to", @@ -2622,6 +2633,12 @@ "date": "2026-08-30", "change": "new — the raw-mounted current-user trio (/auth/me/permissions, /auth/me/localization, /me/apps) is the console's whole permission layer and appeared in no ledger and no checklist item: aggregation-vs-enforcement parity was untested in either direction, and the deliberate anonymous-200 design (distinct bodies per endpoint: authenticated:false for two, apps:[] for the third — corrected from the sweep register, which implied one shape for all three) was unpinned and at risk of being 'fixed' into a 401", "ref": "#sweep-2026-08-30" + }, + { + "revision": 2, + "date": "2026-09-25", + "change": "the parity steps never reached the population the map got wrong: a wall-less org admin (organization_admin_no_bypass, a plain '*' with no super-user bit) had NO entry for an app object covered only by that wildcard, so current_user.can() answered false where checkObjectPermission allowed. Added persona W, its step and acceptance clause (an object no set names present and writable, a narrower named entry widened, the private sys_secret absent and refused — the shape measured on a booted showcase), the source anchor for the producer, and the unit parity table to automated.ref", + "ref": "#20083" } ] }, diff --git a/packages/core/src/security/effective-object-permissions.test.ts b/packages/core/src/security/effective-object-permissions.test.ts index c4bd07386d4..45ffe2462c5 100644 --- a/packages/core/src/security/effective-object-permissions.test.ts +++ b/packages/core/src/security/effective-object-permissions.test.ts @@ -66,6 +66,27 @@ describe('buildEffectiveObjectPermissions', () => { expect(map).toEqual({ deal: { allowRead: true } }); }); + it('seeds before it covers: an entry the seed placed keeps its place and only gains grants', () => { + const schemas: Record = { + a_open: { name: 'a_open' }, + b_private: { name: 'b_private', access: { default: 'private' } }, + c_open: { name: 'c_open' }, + }; + const map: any = buildEffectiveObjectPermissions( + [ + { objects: { '*': { allowRead: true, viewAllRecords: true } } }, + { objects: { '*': { allowCreate: true, allowRead: true, allowEdit: true } } }, + ], + { allSchemas: () => Object.values(schemas), schemaOf: (n) => schemas[n] }, + ); + // Registration order, not "covered first, seeded after". + expect(Object.keys(map)).toEqual(['*', 'a_open', 'b_private', 'c_open']); + expect(Object.keys(map.a_open)).toEqual(['allowCreate', 'allowRead', 'allowEdit', 'allowDelete', 'apiOperations']); + expect(map.a_open).toMatchObject({ allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false }); + // The private object takes nothing from the plain wildcard; the read bypass still reads it. + expect(map.b_private).toMatchObject({ allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }); + }); + it('with no schema source at all it is the bare merge plus the fold', () => { const map: any = buildEffectiveObjectPermissions([ { objects: { '*': { modifyAllRecords: true }, deal: { allowRead: false } } }, @@ -74,3 +95,108 @@ describe('buildEffectiveObjectPermissions', () => { expect(map.deal.apiOperations).toBeUndefined(); }); }); + +/** + * [#20083] A plain `'*'` — a wildcard carrying neither super-user bit — is + * materialised onto the registered objects it covers, per set, the way + * `PermissionEvaluator.checkObjectPermission` resolves it: a set's explicit + * entry is that set's whole answer for the object; otherwise its wildcard + * applies to a public object and never to a private one. Without it the map + * held no entry for an object reached only through such a wildcard, and + * `current_user.can()` read "no grant" where the server allows. + * + * The enforcement-side half of this parity — every verb, shipped sets, the + * real `can()` against the real evaluator — is pinned table-driven in + * plugin-security's `get-effective-object-permissions.test.ts`, the one + * package that holds both functions. + */ +describe('[#20083] plain wildcard coverage', () => { + const SCHEMAS: Record = { + crm_account: { name: 'crm_account' }, + crm_lead: { name: 'crm_lead', enable: { apiMethods: ['get', 'list'] } }, + crm_secret: { name: 'crm_secret', access: { default: 'private' } }, + crm_note: { name: 'crm_note', access: { default: 'public' } }, + }; + const source = { allSchemas: () => Object.values(SCHEMAS), schemaOf: (n: string) => SCHEMAS[n] }; + const WILD = { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, allowTransfer: false, viewAllRecords: false, modifyAllRecords: false }; + + it('puts a plain wildcard\'s grants on every registered public object no set names', () => { + const map: any = buildEffectiveObjectPermissions([{ objects: { '*': WILD } }], source); + for (const name of ['crm_account', 'crm_lead', 'crm_note']) { + expect(map[name], name).toMatchObject({ allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true }); + } + // Only `true` bits travel: a wildcard's `false` grants nothing and is not copied. + expect(map.crm_account).not.toHaveProperty('allowTransfer'); + expect(map.crm_account).not.toHaveProperty('modifyAllRecords'); + // The later passes still run over the new entries. + expect(map.crm_lead.apiOperations).toEqual(expect.arrayContaining(['get', 'list'])); + expect(map.crm_lead.apiOperations).not.toContain('update'); + }); + + it('never covers a private object', () => { + const map: any = buildEffectiveObjectPermissions([{ objects: { '*': WILD } }], source); + expect(map).not.toHaveProperty('crm_secret'); + }); + + it('never covers an object the registry does not hold', () => { + const map: any = buildEffectiveObjectPermissions([{ objects: { '*': WILD } }], source); + expect(Object.keys(map).sort()).toEqual(['*', 'crm_account', 'crm_lead', 'crm_note']); + }); + + it('a set that names the object contributes its explicit entry, never its own wildcard', () => { + const map: any = buildEffectiveObjectPermissions( + [{ objects: { '*': WILD, crm_account: { allowRead: true, allowEdit: false } } }], + source, + ); + expect(map.crm_account).toMatchObject({ allowRead: true, allowEdit: false }); + expect(map.crm_account).not.toHaveProperty('allowDelete'); + expect(map.crm_note).toMatchObject({ allowEdit: true, allowDelete: true }); + }); + + it('ANOTHER set\'s plain wildcard widens a present entry, bit by bit', () => { + const map: any = buildEffectiveObjectPermissions( + [ + { objects: { crm_account: { allowRead: true, allowEdit: false } } }, + { objects: { '*': { allowRead: true, allowEdit: true, allowExport: true } } }, + ], + source, + ); + expect(map.crm_account).toEqual({ allowRead: true, allowEdit: true, allowExport: true }); + }); + + it('an export-only wildcard lends its bit to a present entry, and adds no entry of its own', () => { + const map: any = buildEffectiveObjectPermissions( + [ + { objects: { crm_account: { allowRead: true } } }, + { objects: { '*': { allowExport: true } } }, + ], + source, + ); + expect(map.crm_account).toEqual({ allowRead: true, allowExport: true }); + // Export is `grant ∧ read`: alone it grants no verb, so nothing is added for it. + expect(Object.keys(map).sort()).toEqual(['*', 'crm_account']); + }); + + it('a wildcard that grants nothing adds nothing', () => { + const map: any = buildEffectiveObjectPermissions([{ objects: { '*': { allowRead: false } } }], source); + expect(map).toEqual({ '*': { allowRead: false } }); + }); + + it('leaves a super-user wildcard to the seed and the fold', () => { + const map: any = buildEffectiveObjectPermissions( + [{ objects: { '*': { ...WILD, viewAllRecords: true, modifyAllRecords: true } } }], + source, + ); + // Seeded all-false, then folded: the super-user entry shape, not the wildcard's own bits. + expect(Object.keys(map.crm_account)).toEqual(['allowCreate', 'allowRead', 'allowEdit', 'allowDelete', 'apiOperations']); + // …and the private object is the seed's, as before. + expect(map.crm_secret).toMatchObject({ allowRead: true, allowEdit: true }); + }); + + it('a throwing registry leaves the merge standing and covers nothing', () => { + const map: any = buildEffectiveObjectPermissions([{ objects: { '*': WILD } }], { + allSchemas: () => { throw new Error('registry down'); }, + }); + expect(map).toEqual({ '*': WILD }); + }); +}); diff --git a/packages/core/src/security/effective-object-permissions.ts b/packages/core/src/security/effective-object-permissions.ts index 5732cd3c7ae..f8e0efad472 100644 --- a/packages/core/src/security/effective-object-permissions.ts +++ b/packages/core/src/security/effective-object-permissions.ts @@ -37,7 +37,7 @@ import { effectiveOperationsArray, type EnableLike, } from '@objectstack/spec/data'; -import type { EffectiveObjectPermission } from '@objectstack/spec/security'; +import { objectPermissionGrants, type EffectiveObjectPermission } from '@objectstack/spec/security'; /** * Does the `'*'` entry carry the super-user READ bypass? @@ -175,6 +175,114 @@ export interface ApiExposureSchemaLike { enable?: EnableLike | null; } +/** + * The posture slice of a registered object schema: whether a plain `'*'` grant + * covers it at all. `access.default === 'private'` keeps a wildcard carrying no + * super-user bypass bit off the object — ADR-0066 D2, read exactly as + * `PermissionEvaluator` reads it (`access?.default === 'private'`; anything else + * is public). + */ +export interface ObjectAccessPostureLike { + name?: string; + /** + * The registered schema's own `access` block, passed through as it is. Typed + * `unknown` so that any schema a caller already hands over still type-checks; + * the one read is `access.default === 'private'`. + */ + access?: unknown; +} + +/** `access.default === 'private'` off a registered schema — the evaluator's own test. */ +function isPrivatePosture(schema: ObjectAccessPostureLike | null | undefined): boolean { + const access = schema?.access; + return typeof access === 'object' && access !== null && (access as { default?: unknown }).default === 'private'; +} + +/** The `allow*` bits {@link objectPermissionGrants} reads, i.e. every bit a `can()` verb resolves to. */ +const GRANT_BITS = ['allowRead', 'allowCreate', 'allowEdit', 'allowDelete', 'allowTransfer', 'allowExport'] as const; +const GRANT_BIT_SET: ReadonlySet = new Set(GRANT_BITS); + +/** Does the entry grant any verb on its own? (`objectPermissionGrants` over every verb target.) */ +function grantsAnyVerb(entry: Record): boolean { + return GRANT_BITS.some((bit) => objectPermissionGrants(entry as EffectiveObjectPermission, bit)); +} + +/** + * [#20083] Materialise every PLAIN `'*'` grant — a wildcard carrying neither + * super-user bypass bit — onto the registered objects it covers, mutating the + * map in place. Runs after the merge and the super-user seed (an entry the + * seed placed only gains grants here, and keeps its place), BEFORE the fold. + * + * The merge above folds each set's EXPLICIT entries and keeps `'*'` as a key of + * its own, but the server does not stop there: `PermissionEvaluator`'s + * `resolveObjectPermission` answers, PER SET, with that set's explicit entry + * for the object when it has one, and otherwise with its `'*'` — for a public + * object always, for a private one only when the wildcard is a super-user + * grant. So a set whose plain wildcard covers an object contributes that + * wildcard to the object, and `checkObjectPermission` allows as soon as ANY + * set's contribution grants. Without this pass the map held no entry for an + * object reached only that way, and `current_user.can()` — which reads an absent + * entry as "no grant" — answered `false` for a wall-less org admin + * (`organization_admin_no_bypass`) on every app object the server lets them + * write; and it held a narrower entry wherever one set named the object and + * another covered it by its wildcard. + * + * Exactly as broad as that resolution, never broader: + * - only REGISTERED objects are covered — the server refuses an object whose + * posture it cannot resolve, whatever the wildcard says; + * - a set that names the object explicitly contributes nothing here: for + * that set the explicit entry is the whole answer, and the merge already + * carries it; + * - a `private` object takes nothing from a plain wildcard; + * - only `true` bits are merged — the grants every `can()` verb reads. A + * wildcard's `false` or unset bit grants nothing, and depth keys + * (`readScope`/`writeScope`) are not grants; + * - an object the pass would ADD but whose entry grants no verb on its own is + * left out: an absent entry and an all-`false` one read the same. + * + * A super-user wildcard is NOT materialised here: {@link seedSuperUserRestrictedObjects} + * and {@link foldWildcardSuperUser} carry it, and this pass leaves their answer + * byte-for-byte as it was for every subject holding no plain wildcard. + */ +function materializePlainWildcardCoverage( + objects: Record, + sets: ReadonlyArray, + allSchemas: readonly (ApiExposureSchemaLike & ObjectAccessPostureLike)[], +): void { + const plainWildcards: Array<{ named: Record; wild: Record }> = []; + for (const ps of sets) { + const named = ps?.objects as Record | null | undefined; + const wild = named?.['*'] as Record | null | undefined; + if (!named || !wild || typeof wild !== 'object') continue; + if (wild.viewAllRecords === true || wild.modifyAllRecords === true) continue; + if (!GRANT_BITS.some((bit) => wild[bit] === true)) continue; + plainWildcards.push({ named, wild }); + } + if (plainWildcards.length === 0) return; + for (const schema of allSchemas) { + const name = schema?.name; + if (!name || name === '*') continue; + if (isPrivatePosture(schema)) continue; + const had = Object.prototype.hasOwnProperty.call(objects, name); + const acc: Record = had ? objects[name] : {}; + let touched = false; + for (const { named, wild } of plainWildcards) { + // `resolveObjectPermission`'s own test: a set's explicit entry, when it + // has one, is that set's whole answer for the object. + if (named[name]) continue; + // The wildcard's own key order, so an entry this pass adds reads like + // every other entry the map carries. + for (const [bit, value] of Object.entries(wild)) { + if (value === true && GRANT_BIT_SET.has(bit) && acc[bit] !== true) { + acc[bit] = true; + touched = true; + } + } + } + if (!had && touched && grantsAnyVerb(acc)) objects[name] = acc; + } +} + /** * [#3391] Seed false-initialized per-object entries for a wildcard SUPER-USER, * for every registered object whose `apiMethods` whitelist tightens exposure. @@ -188,7 +296,9 @@ export interface ApiExposureSchemaLike { * * [#18990] Admitted by {@link wildcardGrantsSuperRead} — the READ bypass, so * BOTH super-user classes are seeded, and a plain wildcard grant carrying - * neither bypass bit still is not. This pass used to be guarded to + * neither bypass bit still is not — [#20083] its coverage is + * {@link materializePlainWildcardCoverage}'s, which puts the wildcard's own + * grants on the objects it covers. This pass used to be guarded to * `modifyAllRecords` alone, on the reading that materializing a `false` entry * for a viewAll-only caller would flip the client's `check('edit')` from * "undefined → default-allow" to "explicit false → deny". It does flip it, and @@ -289,8 +399,13 @@ export function annotateEffectiveApiOperations( * (no seed, no clamp, no `apiOperations`) — it never drops the map. */ export interface EffectiveObjectPermissionsSchemaSource { - /** Every registered object schema — read by the super-user seed. */ - allSchemas?: () => readonly ApiExposureSchemaLike[] | null | undefined; + /** + * Every registered object schema — read by the plain-wildcard coverage + * (`name` and `access.default`, [#20083]) and by the super-user seed (`name` + * and `enable`). Hand over the registered schemas themselves: an entry whose + * `access` is missing reads as public, exactly as it does to the server. + */ + allSchemas?: () => readonly (ApiExposureSchemaLike & ObjectAccessPostureLike)[] | null | undefined; /** One object's schema — read by the managed-write clamp and the `apiOperations` annotation. */ schemaOf?: (objectName: string) => (ManagedSchemaLike & ApiExposureSchemaLike) | null | undefined; /** Where a failed seed / annotation pass is reported. */ @@ -307,16 +422,19 @@ export interface EffectiveObjectPermissionsInputSet { * sets are `sets` — the `objects` slot of `/auth/me/permissions` and the answer * of `ISecurityService.getEffectiveObjectPermissions`, from this ONE function. * - * In order, exactly as the endpoint has always composed it: + * In order: * * 1. the most-permissive merge of every set's explicit `objects` entries — * same semantics as `PermissionEvaluator.getFieldPermissions`, for ALL * objects in one pass (`'*'` is merged as an ordinary key); * 2. {@link seedSuperUserRestrictedObjects} — guarded: a failure is reported * and the map is kept; - * 3. {@link foldWildcardSuperUser}; - * 4. {@link clampManagedObjectWrites}; - * 5. {@link annotateEffectiveApiOperations} — guarded like (2). + * 3. {@link materializePlainWildcardCoverage} — [#20083] each set's plain + * `'*'` onto the registered objects it covers for that set, so the map is + * as broad as `checkObjectPermission` there; guarded like (2); + * 4. {@link foldWildcardSuperUser}; + * 5. {@link clampManagedObjectWrites}; + * 6. {@link annotateEffectiveApiOperations} — guarded like (2). * * Every entry is a FRESH object: nothing in the returned map aliases a * permission set, so a caller may freeze or serialise it freely. @@ -344,18 +462,27 @@ export function buildEffectiveObjectPermissions( const schemaOf = (name: string): (ManagedSchemaLike & ApiExposureSchemaLike) | undefined => { try { return source.schemaOf?.(name) ?? undefined; } catch { return undefined; } }; + const allSchemas = (() => { + try { return source.allSchemas?.() ?? []; } catch { return [] as ApiExposureSchemaLike[]; } + })(); // [#3391] For a wildcard super-user — [#18990] either bypass bit, not // modify-all alone — seed restricting objects absent from the merged map so // fold pulls what it pulls and annotate can attach their effective // apiOperations. Guarded — a failure here must never drop the whole map. try { - const allSchemas = (() => { - try { return source.allSchemas?.() ?? []; } catch { return [] as ApiExposureSchemaLike[]; } - })(); seedSuperUserRestrictedObjects(objects, allSchemas); } catch (e: any) { source.logger?.warn?.('[effective-permissions] apiOperations seed failed', { err: e?.message }); } + // [#20083] A plain `'*'` covers every registered public object its set does + // not name — per set, as the server resolves it. After the seed, so an entry + // the seed already placed keeps its place in the map and only gains grants. + // Guarded like the seed. + try { + materializePlainWildcardCoverage(objects, sets, allSchemas); + } catch (e: any) { + source.logger?.warn?.('[effective-permissions] plain-wildcard coverage failed', { err: e?.message }); + } // Make the per-object map reflect the server's ACTUAL effective enforcement // = permission-set grant ∩ identity write guard (ADR-0057 D10, cited as an // attribution, #9628): (1) fold the `'*'` super-user grant into every object diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-effective-objects.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-effective-objects.test.ts index 66a4a117a01..a400c6a7007 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-effective-objects.test.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-effective-objects.test.ts @@ -10,9 +10,10 @@ // reintroduced here — the second copy that would let the console and the // server's own `current_user.can()` disagree — turns this red. // -// The fixture makes every step of the composition fire (merge, super-user -// seed, fold, managed-write clamp, apiOperations annotation), because an -// equality over a map none of them touched would pin nothing. +// The fixtures make every step of the composition fire (merge, super-user +// seed, plain-wildcard coverage, fold, managed-write clamp, apiOperations +// annotation), because an equality over a map none of them touched would pin +// nothing. import { describe, it, expect } from 'vitest'; import { Hono } from 'hono'; @@ -48,7 +49,7 @@ const ql = { getSchema: (name: string) => SCHEMAS[name], }; -function mount() { +function mount(resolved: unknown[] = RESOLVED) { const services: Record = { auth: { api: { @@ -60,7 +61,7 @@ function mount() { }, objectql: ql, metadata: { list: async () => [] as unknown[] }, - security: { resolvePermissionSetsForContext: async () => RESOLVED }, + security: { resolvePermissionSetsForContext: async () => resolved }, }; const app = new Hono(); registerCurrentUserEndpoints({ @@ -92,6 +93,25 @@ describe('[#18783] /auth/me/permissions `objects` is the one effective-map funct expect(Array.isArray(body.objects.report.apiOperations)).toBe(true); // annotation }); + it('[#20083] a PLAIN wildcard reaches every registered public object it covers — the same bytes', async () => { + // The wall-less org admin's shape: a `'*'` with no super-user bit, and a + // second set naming one object explicitly. The server lets this subject + // write `report` and `deal` through the wildcard, so the map carries them. + const plain = [ + { name: 'ops_plain', objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, fields: {} }, + { name: 'sales', objects: { deal: { allowRead: true, allowEdit: false } }, fields: {} }, + ]; + const body: any = await (await mount(plain).request(`http://localhost${ME_PERMISSIONS}`)).json(); + const expected = buildEffectiveObjectPermissions(plain, { + allSchemas: () => ql.registry.getAllObjects(), + schemaOf: (name) => ql.getSchema(name), + }); + expect(JSON.stringify(body.objects)).toBe(JSON.stringify(expected)); + expect(body.objects.report).toMatchObject({ allowRead: true, allowEdit: true }); // named by no set + expect(body.objects.deal).toMatchObject({ allowRead: true, allowEdit: true }); // another set's wildcard widens it + expect(body.objects.sys_member).toMatchObject({ allowRead: true, allowEdit: false }); // the clamp still has the last word + }); + it('keeps the rest of the envelope on its own merges', async () => { const body: any = await (await mount().request(`http://localhost${ME_PERMISSIONS}`)).json(); expect(body.permissionSets).toEqual(['ops_admin', 'sales']); diff --git a/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts index 33e6cf5ae3f..aef13e0e9dc 100644 --- a/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts +++ b/packages/plugins/plugin-security/src/get-effective-object-permissions.test.ts @@ -17,7 +17,10 @@ * the same resolution and the same engine, the function the endpoint builds * that slot with (the endpoint's own half is pinned in plugin-hono-server's * `current-user-endpoints-effective-objects.test.ts`); - * - the WHOLE map — seeded, folded, clamped and annotated, not a slice; + * - the WHOLE map — seeded, covered (a plain `'*'`, #20083), folded, clamped + * and annotated, not a slice — and, per cell, the map `current_user.can()` + * answers what `PermissionEvaluator.checkObjectPermission` answers (the + * parity table at the foot of this file); * - it THROWS on resolution failure and ⛔ never answers `{}`; * - request-scoped: resolved per ask, never cached across requests. * @@ -27,9 +30,19 @@ import { describe, it, expect, vi } from 'vitest'; import { buildEffectiveObjectPermissions } from '@objectstack/core'; +import { ExpressionEngine, toEvalPermissions } from '@objectstack/formula'; +import { SysAttachment, SysMember, SysSecret, SysUser, SysUserPreference } from '@objectstack/platform-objects'; import type { ISecurityService } from '@objectstack/spec/contracts'; -import type { PermissionSet } from '@objectstack/spec/security'; +import { + OBJECT_PERMISSION_VERB_NAMES, + PermissionSetSchema, + resolveObjectPermissionVerb, + type PermissionSet, +} from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; +import { PermissionEvaluator } from './permission-evaluator.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { SysPermissionSet, SysPosition } from './objects/index.js'; /** The metadata-declared baseline every member resolves additively. */ const MEMBER_DEFAULT: PermissionSet = { @@ -68,6 +81,20 @@ const SALES_ROW = { tab_permissions: JSON.stringify({}), }; +/** + * [#20083] A DB-authored PLAIN wildcard — no bypass bit — the shape of the + * wall-less org admin (`organization_admin_no_bypass`). It names no object, so + * every entry it contributes is the plain-wildcard coverage pass's. + */ +const OPS_PLAIN_ROW = { + name: 'ops_plain', + label: 'Ops (no bypass)', + object_permissions: JSON.stringify({ '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }), + field_permissions: JSON.stringify({}), + system_permissions: JSON.stringify([]), + tab_permissions: JSON.stringify({}), +}; + /** Registered schemas: one plain, one better-auth-managed, one whose `apiMethods` tighten exposure. */ const SCHEMAS: Record = { deal: { name: 'deal', label: 'Deal', fields: { id: { name: 'id' } } }, @@ -88,14 +115,17 @@ function matches(row: Record, where: Record | }); } -function bootPlugin(opts: { dbRows?: Array>; engineSeam?: boolean } = {}) { +function bootPlugin( + opts: { dbRows?: Array>; engineSeam?: boolean; schemas?: Record } = {}, +) { const dbRows = opts.dbRows ?? []; + const schemas = opts.schemas ?? SCHEMAS; const permissionSetReads: string[][] = []; const registered: Array<(context: unknown) => Promise> = []; const ql: any = { registerMiddleware: () => {}, - registry: { getAllObjects: () => Object.values(SCHEMAS) }, - getSchema: (name: string) => SCHEMAS[name] ?? null, + registry: { getAllObjects: () => Object.values(schemas) }, + getSchema: (name: string) => schemas[name] ?? null, find: async (object: string, query: any) => { const tables: Record>> = { sys_permission_set: dbRows }; if (object === 'sys_permission_set') permissionSetReads.push(query?.where?.name?.$in ?? []); @@ -108,7 +138,7 @@ function bootPlugin(opts: { dbRows?: Array>; engineSeam? ql.registerEffectiveObjectPermissionsResolver = (fn: (context: unknown) => Promise) => registered.push(fn); } const metadata: any = { - get: async (_type: string, name: string) => SCHEMAS[name] ?? null, + get: async (_type: string, name: string) => schemas[name] ?? null, list: async () => [MEMBER_DEFAULT], }; const services: Record = { manifest: { register: vi.fn() }, objectql: ql, metadata }; @@ -142,6 +172,7 @@ function endpointObjects(sets: unknown, ql: any) { const ADMIN = { userId: 'u_admin', permissions: ['ops_admin'] } as any; const REP = { userId: 'u_rep', permissions: ['sales'] } as any; +const PLAIN = { userId: 'u_plain', permissions: ['ops_plain'] } as any; describe('[#18783] getEffectiveObjectPermissions — reachable, and the endpoint\'s answer', () => { it('is exposed on the REGISTERED literal, where a cross-package consumer feature-detects it', async () => { @@ -150,12 +181,16 @@ describe('[#18783] getEffectiveObjectPermissions — reachable, and the endpoint }); it('is BYTE-EQUAL to the /auth/me/permissions computation over the same resolution', async () => { - const { svc, ql } = await locate({ dbRows: [OPS_ADMIN_ROW, SALES_ROW] }); - for (const context of [ADMIN, REP, { userId: 'u_member' }]) { + const { svc, ql } = await locate({ dbRows: [OPS_ADMIN_ROW, SALES_ROW, OPS_PLAIN_ROW] }); + for (const context of [ADMIN, REP, PLAIN, { userId: 'u_member' }]) { const sets = await svc.resolvePermissionSetsForContext!(context); const member = await svc.getEffectiveObjectPermissions!(context); expect(JSON.stringify(member), context.userId).toBe(JSON.stringify(endpointObjects(sets, ql))); } + // [#20083] …with the plain-wildcard coverage pass firing for the no-bypass subject, so the + // equality covers it too: `report` is named by no set, and reaches the map through `'*'`. + const plain: any = await svc.getEffectiveObjectPermissions!(PLAIN); + expect(plain.report).toMatchObject({ allowRead: true, allowEdit: true }); }); it('is the WHOLE map — merged, seeded, folded, clamped and annotated, not a slice', async () => { @@ -244,3 +279,134 @@ describe('[#18783] the engine is handed the same producer', () => { expect(lines.some((l: string) => l.includes('registerEffectiveObjectPermissionsResolver'))).toBe(true); }); }); + +/** + * [#20083] PARITY — the map the member answers, read the way `current_user.can()` + * reads it, against the server's own verdict, `PermissionEvaluator.checkObjectPermission`, + * cell by cell: subjects x registered objects x every verb `can()` accepts. + * + * The map used to omit every object a subject reached only through a PLAIN `'*'` + * (one with no super-user bit), so the wall-less org admin's `can('crm_account', + * 'edit')` answered `false` where the server writes; and it kept a narrower entry + * wherever one set named the object and another covered it by its wildcard. Both + * directions are held here, over the shipped sets and the wildcard shapes an + * author can write: + * + * - EXACT, cell for cell — except that + * - on a guarded managed object (`better-auth` / `engine-owned` / `append-only`) + * the create / edit / delete verbs may read NARROWER than the evaluator: that + * is the managed-write clamp, the engine write guard the permission sets do not + * model. There the pin holds the refuse direction only — the map never grants + * what the evaluator refuses. + * + * Subjects whose sets carry a SUPER-USER wildcard are outside this table: their + * entries are the super-user seed's and fold's, which this card leaves as they + * were, and which the plain-wildcard pass does not touch (pinned in core's + * `effective-object-permissions.test.ts`). + */ +describe('[#20083] parity: can() over the member\'s map answers what checkObjectPermission answers', () => { + const REGISTERED: Record = { + // App objects: public, restricted exposure, private posture, API switched off. + crm_account: { name: 'crm_account', label: 'Account', fields: { name: { type: 'text' } } }, + crm_lead: { name: 'crm_lead', label: 'Lead', fields: { name: { type: 'text' } }, enable: { apiMethods: ['get', 'list'] } }, + crm_secret: { name: 'crm_secret', label: 'Secret', fields: { name: { type: 'text' } }, access: { default: 'private' } }, + crm_hidden: { name: 'crm_hidden', label: 'Hidden', fields: { name: { type: 'text' } }, enable: { apiEnabled: false } }, + // Real platform objects, one per posture the shipped sets treat differently. + [SysAttachment.name]: SysAttachment, // public, named by no shipped set + [SysUserPreference.name]: SysUserPreference, // named by member_default, not by the org admin + [SysUser.name]: SysUser, // better-auth, opts `edit` in + [SysMember.name]: SysMember, // better-auth, write-denied blanket + [SysSecret.name]: SysSecret, // private, engine-owned, named by no shipped set + [SysPosition.name]: SysPosition, // the org admin's read-only RBAC rows + [SysPermissionSet.name]: SysPermissionSet, + }; + const shipped = (name: string): PermissionSet => { + const set = defaultPermissionSets.find((ps) => ps.name === name); + if (!set) throw new Error(`no shipped permission set '${name}'`); + return set; + }; + const authored = (name: string, objects: Record): PermissionSet => + PermissionSetSchema.parse({ name, label: name, objects }); + + const SUBJECTS: Record = { + 'wall-less org admin': [shipped('organization_admin_no_bypass'), shipped('member_default')], + 'member': [shipped('member_default')], + 'viewer': [shipped('viewer_readonly'), shipped('member_default')], + 'an explicit entry beside another set\'s plain wildcard': [ + authored('reader', { crm_account: { allowRead: true } }), + authored('wild', { '*': { allowRead: true, allowEdit: true, allowExport: true } }), + ], + 'one set: a plain wildcard AND a narrower explicit entry': [ + authored('same', { '*': { allowRead: true, allowEdit: true, allowDelete: true }, crm_account: { allowRead: true } }), + ], + 'an export-only wildcard beside a reader': [ + authored('reader', { crm_account: { allowRead: true } }), + authored('exporter', { '*': { allowExport: true } }), + ], + 'a wildcard that grants nothing': [authored('none', { '*': {} })], + }; + + const OPERATION: Record = { + allowRead: 'find', allowCreate: 'insert', allowEdit: 'update', allowDelete: 'delete', + allowTransfer: 'transfer', allowExport: 'export', + }; + const GUARDED = new Set(['better-auth', 'engine-owned', 'append-only']); + const CLAMPED = new Set(['allowCreate', 'allowEdit', 'allowDelete']); + const USER = { id: 'u_parity', positions: [] as string[] }; + const evaluator = new PermissionEvaluator(); + + /** The real `can()`: formula's CEL binding over the published map shape. */ + function can(permissions: ReturnType, object: string, verb: string): boolean { + const res = ExpressionEngine.evaluate( + { dialect: 'cel', source: `current_user.can('${object}', '${verb}')` }, + { user: USER, permissions }, + ); + if (!res.ok) throw new Error(`can('${object}', '${verb}') did not evaluate: ${JSON.stringify(res.error)}`); + return res.value === true; + } + + it('covers every verb the vocabulary accepts', () => { + expect([...OBJECT_PERMISSION_VERB_NAMES].sort()).toEqual( + ['create', 'delete', 'edit', 'export', 'import', 'read', 'remove', 'transfer', 'update', 'write'], + ); + }); + + for (const [label, sets] of Object.entries(SUBJECTS)) { + it(`${label}: no cell where the map and the evaluator disagree`, async () => { + const { svc, plugin } = await locate({ schemas: REGISTERED }); + vi.spyOn(plugin as any, 'resolvePermissionSetsForContext').mockResolvedValue(sets); + const permissions = toEvalPermissions(await svc.getEffectiveObjectPermissions!({ userId: USER.id })); + + const wrong: string[] = []; + let cells = 0; + for (const schema of Object.values(REGISTERED)) { + const isPrivate = schema.access?.default === 'private'; + const clamped = GUARDED.has(schema.managedBy); + for (const verb of OBJECT_PERMISSION_VERB_NAMES) { + const target = resolveObjectPermissionVerb(verb)!; + const map = can(permissions, schema.name, verb); + const server = evaluator.checkObjectPermission(OPERATION[target], schema.name, sets, { isPrivate }); + cells += 1; + if (map === server) continue; + // The managed-write clamp may only NARROW, and only on its own verbs. + if (clamped && CLAMPED.has(target) && server && !map) continue; + wrong.push(`${schema.name}.${verb}: can()=${map} checkObjectPermission=${server}`); + } + } + expect(cells).toBe(Object.keys(REGISTERED).length * OBJECT_PERMISSION_VERB_NAMES.length); + expect(wrong).toEqual([]); + }); + } + + it('the wall-less org admin\'s reported case, spelled out: edit on an app object reached only through `*`', async () => { + const sets = SUBJECTS['wall-less org admin']; + const { svc, plugin } = await locate({ schemas: REGISTERED }); + vi.spyOn(plugin as any, 'resolvePermissionSetsForContext').mockResolvedValue(sets); + const permissions = toEvalPermissions(await svc.getEffectiveObjectPermissions!({ userId: USER.id })); + expect(evaluator.checkObjectPermission('update', 'crm_account', sets)).toBe(true); + expect(can(permissions, 'crm_account', 'edit')).toBe(true); + // …and the private object stays out of a plain wildcard's reach on both sides. + expect(evaluator.checkObjectPermission('find', 'crm_secret', sets, { isPrivate: true })).toBe(false); + expect(can(permissions, 'crm_secret', 'read')).toBe(false); + }); +});