From 6392a35498183817e18b5dc5cecb93a6408fd730 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:40:13 +0800 Subject: [PATCH] feat(authz): ADR-0066 D3 field-level requiredPermissions (+ D5 confirmation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spec: FieldSchema.requiredPermissions: string[] (passes through Field.* builders). - plugin-security: a field whose requiredPermissions aren't all held by the caller's systemPermissions is masked on read and denied on write (AND-gate, strictest-wins over permission-set field grants). getObjectSecurityMeta now reads per-field requiredPermissions; foldFieldRequiredPermissions folds unmet ones into the FieldMasker map as {readable:false,editable:false}, reusing maskResults + detectForbiddenWrites — no masker signature change. - liveness: classify field.requiredPermissions (live). api-surface unchanged. - Tests: spec field (+3) + middleware read-mask/write-deny (+4). D5 (package-seeded admin-maintainable per-object secure defaults) needs no new code: stack.permissions already seeds sys_permission_set (incl. per-object + per-field grants), admin-editable in Setup — it composes with D2 (private) + D1 (capability seeding). cloud sys_license is the worked example. Delegated admin (#9) remains future per the ADR. Co-Authored-By: Claude Opus 4.8 --- .../src/security-plugin.test.ts | 74 +++++++++++++++++++ .../plugin-security/src/security-plugin.ts | 54 ++++++++++++-- packages/spec/liveness/field.json | 5 ++ packages/spec/src/data/field.test.ts | 16 ++++ packages/spec/src/data/field.zod.ts | 8 ++ 5 files changed, 152 insertions(+), 5 deletions(-) diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index d0091fda7b..07e1719821 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -1556,3 +1556,77 @@ describe('SecurityPlugin – metadata-change cache invalidation', () => { expect((plugin as any).cbpRelCache.size).toBe(0); }); }); + + +// --------------------------------------------------------------------------- +// ADR-0066 D3 — field-level requiredPermissions (read-mask + write-deny) +// --------------------------------------------------------------------------- +describe('SecurityPlugin — ADR-0066 D3 field-level requiredPermissions', () => { + const fieldsSchema = { + fields: { + id: { name: 'id' }, + name: { name: 'name' }, + salary: { name: 'salary', requiredPermissions: ['view_salary'] }, + }, + }; + const setNoCap: PermissionSet = { + name: 'fld_member', isProfile: true, + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + } as any; + const setWithCap: PermissionSet = { + name: 'fld_cap', isProfile: true, + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + systemPermissions: ['view_salary'], + } as any; + + const harnessFor = (sets: PermissionSet[], fallback: string) => { + let middleware: any; + const schema = { name: 'task', ...fieldsSchema }; + const ql: any = { + registerMiddleware: (mw: any) => { if (!middleware) middleware = mw; }, + getSchema: () => schema, + findOne: async () => null, + find: async () => [], + }; + const metadata = { get: async () => schema, list: async () => sets }; + const services: Record = { manifest: { register: vi.fn() }, objectql: ql, metadata }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: vi.fn(), + getService: (n: string) => { if (!(n in services)) throw new Error(`service not registered: ${n}`); return services[n]; }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: fallback }); + return { plugin, ctx, run: async (opCtx: any) => { await middleware(opCtx, async () => {}); return opCtx; } }; + }; + + it('masks a capability-gated field on read when the caller lacks the capability', async () => { + const h = harnessFor([setNoCap], 'fld_member'); + await h.plugin.init(h.ctx); await h.plugin.start(h.ctx); + const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, result: [{ id: 'r1', name: 'A', salary: 100 }], context: { userId: 'u1', roles: [], permissions: [] } }; + await h.run(opCtx); + expect(opCtx.result[0].name).toBe('A'); + expect(opCtx.result[0].salary).toBeUndefined(); + }); + + it('does NOT mask when the caller holds the capability', async () => { + const h = harnessFor([setWithCap], 'fld_cap'); + await h.plugin.init(h.ctx); await h.plugin.start(h.ctx); + const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, result: [{ id: 'r1', name: 'A', salary: 100 }], context: { userId: 'u1', roles: [], permissions: ['fld_cap'] } }; + await h.run(opCtx); + expect(opCtx.result[0].salary).toBe(100); + }); + + it('denies a write to a capability-gated field when the caller lacks the capability', async () => { + const h = harnessFor([setNoCap], 'fld_member'); + await h.plugin.init(h.ctx); await h.plugin.start(h.ctx); + const opCtx: any = { object: 'task', operation: 'insert', data: { name: 'A', salary: 200 }, context: { userId: 'u1', roles: [], permissions: [] } }; + await expect(h.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); + }); + + it('allows the write when the caller holds the capability', async () => { + const h = harnessFor([setWithCap], 'fld_cap'); + await h.plugin.init(h.ctx); await h.plugin.start(h.ctx); + const opCtx: any = { object: 'task', operation: 'insert', data: { name: 'A', salary: 200 }, context: { userId: 'u1', roles: [], permissions: ['fld_cap'] } }; + await expect(h.run(opCtx)).resolves.toBeDefined(); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 0d19c91fac..e7f46ff269 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -118,7 +118,7 @@ export class SecurityPlugin implements Plugin { * `requiredPermissions` capability contract. Populated lazily from the schema; * cleared on metadata change alongside the other schema-derived caches. */ - private readonly objectSecurityMetaCache = new Map(); + private readonly objectSecurityMetaCache = new Map }>(); private dbLoader?: (names: string[]) => Promise; private logger: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void } = {}; @@ -379,7 +379,7 @@ export class SecurityPlugin implements Plugin { const secMeta = permissionSets.length > 0 ? await this.getObjectSecurityMeta(opCtx.object) - : { isPrivate: false, tenancyDisabled: false, requiredPermissions: [] as string[] }; + : { isPrivate: false, tenancyDisabled: false, requiredPermissions: [] as string[], fieldRequiredPermissions: {} as Record }; // 1.5. [ADR-0066 D3] requiredPermissions AND-gate — a capability // prerequisite checked BEFORE the CRUD grant (ADR §Precedence): a @@ -538,10 +538,12 @@ export class SecurityPlugin implements Plugin { opCtx.data && permissionSets.length > 0 ) { - const fieldPerms = this.permissionEvaluator.getFieldPermissions( + let fieldPerms = this.permissionEvaluator.getFieldPermissions( opCtx.object, permissionSets, ); + // [ADR-0066 D3] AND-gate field-level requiredPermissions into the map. + fieldPerms = this.foldFieldRequiredPermissions(fieldPerms, secMeta.fieldRequiredPermissions, permissionSets); if (Object.keys(fieldPerms).length > 0) { const forbidden = this.fieldMasker.detectForbiddenWrites( opCtx.data, @@ -686,7 +688,9 @@ export class SecurityPlugin implements Plugin { // 4. Field-level security: mask restricted fields in read results if (opCtx.result && ['find', 'findOne'].includes(opCtx.operation)) { - const fieldPerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, permissionSets); + let fieldPerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, permissionSets); + // [ADR-0066 D3] AND-gate field-level requiredPermissions into the mask. + fieldPerms = this.foldFieldRequiredPermissions(fieldPerms, secMeta.fieldRequiredPermissions, permissionSets); if (Object.keys(fieldPerms).length > 0) { opCtx.result = this.fieldMasker.maskResults(opCtx.result, fieldPerms, opCtx.object); } @@ -1223,13 +1227,28 @@ export class SecurityPlugin implements Plugin { */ private async getObjectSecurityMeta( object: string, - ): Promise<{ isPrivate: boolean; tenancyDisabled: boolean; requiredPermissions: string[] }> { + ): Promise<{ isPrivate: boolean; tenancyDisabled: boolean; requiredPermissions: string[]; fieldRequiredPermissions: Record }> { const cached = this.objectSecurityMetaCache.get(object); if (cached) return cached; let obj: any = typeof this.ql?.getSchema === 'function' ? this.ql.getSchema(object) : null; if (!obj) { try { obj = await this.metadata?.get?.('object', object); } catch { obj = null; } } + // [ADR-0066 D3] Per-field capability requirements: { fieldName -> capability[] }. + const fieldRequiredPermissions: Record = {}; + const fields: any = (obj as any)?.fields; + if (Array.isArray(fields)) { + for (const f of fields) { + if (f?.name && Array.isArray(f.requiredPermissions) && f.requiredPermissions.length > 0) { + fieldRequiredPermissions[String(f.name)] = f.requiredPermissions.map(String); + } + } + } else if (fields && typeof fields === 'object') { + for (const [fname, fdef] of Object.entries(fields)) { + const rp = (fdef as any)?.requiredPermissions; + if (Array.isArray(rp) && rp.length > 0) fieldRequiredPermissions[fname] = rp.map(String); + } + } const meta = { isPrivate: (obj as any)?.access?.default === 'private', tenancyDisabled: @@ -1237,11 +1256,36 @@ export class SecurityPlugin implements Plugin { requiredPermissions: Array.isArray((obj as any)?.requiredPermissions) ? (obj as any).requiredPermissions.map(String) : [], + fieldRequiredPermissions, }; if (obj) this.objectSecurityMetaCache.set(object, meta); return meta; } + /** + * [ADR-0066 D3] Fold per-field `requiredPermissions` into a FieldPermission map. + * A field whose declared capabilities are NOT all held by the caller is forced + * non-readable + non-editable (AND-gate, strictest-wins over permission-set + * field grants) so the existing FieldMasker masks it on read and denies it on + * write. Returns the base map unchanged when no field declares requirements. + */ + private foldFieldRequiredPermissions( + baseFieldPerms: Record, + fieldRequiredPermissions: Record, + permissionSets: PermissionSet[], + ): Record { + const entries = Object.entries(fieldRequiredPermissions ?? {}); + if (entries.length === 0) return baseFieldPerms; + const held = this.permissionEvaluator.getSystemPermissions(permissionSets); + const merged: Record = { ...baseFieldPerms }; + for (const [field, caps] of entries) { + if (caps.length > 0 && !caps.every((c) => held.has(c))) { + merged[field] = { readable: false, editable: false }; + } + } + return merged; + } + /** * Resolve the column-name set for an object (lowercased). Returns * `null` if the schema can't be loaded — caller should fail-closed. diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 9e98a62273..f48bf6ff2a 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -88,6 +88,11 @@ "status": "live", "note": "renderer." }, + "requiredPermissions": { + "status": "live", + "evidence": "packages/plugins/plugin-security/src/security-plugin.ts", + "note": "ADR-0066 D3 field-level capability gate. getObjectSecurityMeta reads field.requiredPermissions; foldFieldRequiredPermissions forces non-readable+non-editable when the caller's systemPermissions don't cover them, so FieldMasker masks on read + detectForbiddenWrites denies on write (AND-gate). Unit-proven in packages/plugins/plugin-security/src/security-plugin.test.ts." + }, "system": { "status": "live", "evidence": "packages/objectql/src/engine.ts" diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index 68bc6e5906..725efd47d7 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -1503,3 +1503,19 @@ describe('FieldSchema - columnName', () => { expect(result.columnName).toBe('expiresAt'); }); }); + + +describe('ADR-0066 D3 — field-level requiredPermissions', () => { + it('FieldSchema accepts requiredPermissions', () => { + const f = FieldSchema.parse({ type: 'number', requiredPermissions: ['view_salary'] }); + expect(f.requiredPermissions).toEqual(['view_salary']); + }); + it('Field.text passes requiredPermissions through the builder', () => { + const f: any = Field.text({ label: 'SSN', requiredPermissions: ['view_pii'] }); + expect(f.requiredPermissions).toEqual(['view_pii']); + }); + it('requiredPermissions is optional (absent ⇒ undefined)', () => { + const f = FieldSchema.parse({ type: 'text' }); + expect(f.requiredPermissions).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 7e24d58ce4..18c4b36894 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -554,6 +554,14 @@ export const FieldSchema = lazySchema(() => z.object({ /** Security & Visibility */ hidden: z.boolean().default(false).describe('Hidden from default UI'), readonly: z.boolean().default(false).describe('Read-only in UI'), + + /** + * [ADR-0066 D3] Capabilities required to READ/EDIT this field. A field + * declaring `requiredPermissions` is masked on read and denied on write unless + * the caller holds ALL listed capabilities — an AND-gate that is strictest-wins + * over permission-set field grants. Enforced by plugin-security's FieldMasker. + */ + requiredPermissions: z.array(z.string()).optional().describe('[ADR-0066 D3] Capabilities required to read/edit this field (mask on read, deny on write; AND-gate).'), system: z.boolean().optional().describe('Auto-injected system/audit field (e.g. created_at, updated_by, organization_id). Tools that surface system fields separately from author-declared business fields should branch on this flag.'), sortable: z.boolean().optional().default(true).describe('Whether field is sortable in list views'), inlineHelpText: z.string().optional().describe('Help text displayed below the field in forms'),