From 88cfaf9c7969399e9f326eaf32b8c642eb16dbb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 06:21:01 +0000 Subject: [PATCH 1/6] fix(core): materialise plain '*' coverage in the effective object-permission map buildEffectiveObjectPermissions merged each set's explicit entries and kept '*' as a key of its own, so an object covered only by a plain wildcard (no super-user bit) had no entry, and current_user.can() read it as no grant where PermissionEvaluator.checkObjectPermission allows. A new pass puts each set's plain '*' grants on the registered public objects that set does not name, after the super-user seed and before the fold. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../security/effective-object-permissions.ts | 138 ++++++++++++++++-- 1 file changed, 127 insertions(+), 11 deletions(-) diff --git a/packages/core/src/security/effective-object-permissions.ts b/packages/core/src/security/effective-object-permissions.ts index 5732cd3c7ae..d0701c03453 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,103 @@ 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; + access?: { default?: unknown } | null; +} + +/** 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 (schema.access?.default === 'private') 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 +285,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 +388,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 +411,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 +451,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 From 3fdfdebc3b8408733d45e70d0210671ebd4051ae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 06:27:43 +0000 Subject: [PATCH 2/6] test: pin plain-wildcard coverage and can()/checkObjectPermission parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core: the coverage pass's rules (registered public objects only, a set's explicit entry excludes its own wildcard, another set's wildcard widens a present entry, true bits only, no no-verb entries, super-user wildcards left to the seed and fold, seed-before-cover ordering). plugin-security: a table-driven parity pin — shipped and authored plain wildcard subjects x registered objects x every can() verb, the real can() over the member's map against PermissionEvaluator.checkObjectPermission; the member and route byte-equality pins now exercise the new pass. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../effective-object-permissions.test.ts | 126 ++++++++++++ ...t-user-endpoints-effective-objects.test.ts | 30 ++- .../get-effective-object-permissions.test.ts | 182 +++++++++++++++++- 3 files changed, 325 insertions(+), 13 deletions(-) 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/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); + }); +}); From 03c9590eac9f8714e4bb34d5e2b437bdfa1dd73b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 06:32:53 +0000 Subject: [PATCH 3/6] docs: changeset and checklist step for plain-wildcard coverage The changeset tells /auth/me/permissions and getEffectiveObjectPermissions readers what the objects slot gains. The access-security parity item gains the wall-less org admin persona, its step and acceptance clause, the producer's source anchor and the unit parity table in automated.ref. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../20083-effective-map-plain-wildcard.md | 23 +++++++++++++++++ .../areas/access-security.json | 25 ++++++++++++++++--- 2 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 .changeset/20083-effective-map-plain-wildcard.md diff --git a/.changeset/20083-effective-map-plain-wildcard.md b/.changeset/20083-effective-map-plain-wildcard.md new file mode 100644 index 00000000000..0eeb42eb99f --- /dev/null +++ b/.changeset/20083-effective-map-plain-wildcard.md @@ -0,0 +1,23 @@ +--- +'@objectstack/core': patch +--- + +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 a direct caller of `buildEffectiveObjectPermissions` 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..49e762f7cd5 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_project is PRESENT with allowEdit:true although no set W holds names it (the organization_admin_no_bypass '*' covers it); 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_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_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 (the covered public object present and writable, the private sys_secret absent and refused), the source anchor for the producer, and the unit parity table to automated.ref", + "ref": "#20083" } ] }, From e7c221164b990025a05d3486e672b4e25f06bc35 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 06:45:17 +0000 Subject: [PATCH 4/6] fix(core): type the posture slice's access as unknown, grade the changeset minor The coverage step reads access.default off each allSchemas entry. Typing the member unknown keeps every call that compiled before compiling; the element type still gains an optional member, a type widening, so the changeset is graded minor. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .changeset/20083-effective-map-plain-wildcard.md | 4 ++-- .../src/security/effective-object-permissions.ts | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.changeset/20083-effective-map-plain-wildcard.md b/.changeset/20083-effective-map-plain-wildcard.md index 0eeb42eb99f..f36971de73f 100644 --- a/.changeset/20083-effective-map-plain-wildcard.md +++ b/.changeset/20083-effective-map-plain-wildcard.md @@ -1,5 +1,5 @@ --- -'@objectstack/core': patch +'@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) @@ -16,7 +16,7 @@ The population it hit: `organization_admin_no_bypass`, which a deployment withou - 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 a direct caller of `buildEffectiveObjectPermissions` 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. +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. diff --git a/packages/core/src/security/effective-object-permissions.ts b/packages/core/src/security/effective-object-permissions.ts index d0701c03453..f8e0efad472 100644 --- a/packages/core/src/security/effective-object-permissions.ts +++ b/packages/core/src/security/effective-object-permissions.ts @@ -184,7 +184,18 @@ export interface ApiExposureSchemaLike { */ export interface ObjectAccessPostureLike { name?: string; - access?: { default?: unknown } | null; + /** + * 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. */ @@ -251,7 +262,7 @@ function materializePlainWildcardCoverage( for (const schema of allSchemas) { const name = schema?.name; if (!name || name === '*') continue; - if (schema.access?.default === 'private') continue; + if (isPrivatePosture(schema)) continue; const had = Object.prototype.hasOwnProperty.call(objects, name); const acc: Record = had ? objects[name] : {}; let touched = false; From 403f6537996daea775f396b36a798bb4eb3507b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 07:02:08 +0000 Subject: [PATCH 5/6] docs(qa): the wall-less org admin step names the measured showcase shape Measured on a booted showcase: showcase_semantic_zoo is named by no set and is absent from the wall-less org admin's map before this change, while showcase_project is named read-only by showcase_member_default and widened by the organization_admin_no_bypass wildcard. The step and clause now say so. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- docs/qa/platform-checklist/areas/access-security.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 49e762f7cd5..574bff06530 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -2550,7 +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_project is PRESENT with allowEdit:true although no set W holds names it (the organization_admin_no_bypass '*' covers it); 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)", + "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", @@ -2594,9 +2594,9 @@ "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_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", + "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_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", + "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" } ], @@ -2637,7 +2637,7 @@ { "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 (the covered public object present and writable, the private sys_secret absent and refused), the source anchor for the producer, and the unit parity table to automated.ref", + "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" } ] From 228724b3f35bddf84f42190db316624e264cdad4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 07:49:17 +0000 Subject: [PATCH 6/6] docs(changeset): the can() write-path note says the plain-wildcard gap is closed in this release Deliberate correction of the pending 18783-server-can-option-visibility changeset: its "Known gap, not changed here" paragraph described the plain-wildcard coverage this branch adds, and read false once it lands in the same release. Only that paragraph is rewritten; it names this branch's changeset and states the remaining super-user divergence without claiming a fix. Every other sentence of the file is byte-identical. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .changeset/18783-server-can-option-visibility.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.**