Skip to content

Commit 6392a35

Browse files
os-zhuangclaude
andcommitted
feat(authz): ADR-0066 D3 field-level requiredPermissions (+ D5 confirmation)
- 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 <noreply@anthropic.com>
1 parent 4035b31 commit 6392a35

5 files changed

Lines changed: 152 additions & 5 deletions

File tree

packages/plugins/plugin-security/src/security-plugin.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1556,3 +1556,77 @@ describe('SecurityPlugin – metadata-change cache invalidation', () => {
15561556
expect((plugin as any).cbpRelCache.size).toBe(0);
15571557
});
15581558
});
1559+
1560+
1561+
// ---------------------------------------------------------------------------
1562+
// ADR-0066 D3 — field-level requiredPermissions (read-mask + write-deny)
1563+
// ---------------------------------------------------------------------------
1564+
describe('SecurityPlugin — ADR-0066 D3 field-level requiredPermissions', () => {
1565+
const fieldsSchema = {
1566+
fields: {
1567+
id: { name: 'id' },
1568+
name: { name: 'name' },
1569+
salary: { name: 'salary', requiredPermissions: ['view_salary'] },
1570+
},
1571+
};
1572+
const setNoCap: PermissionSet = {
1573+
name: 'fld_member', isProfile: true,
1574+
objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
1575+
} as any;
1576+
const setWithCap: PermissionSet = {
1577+
name: 'fld_cap', isProfile: true,
1578+
objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
1579+
systemPermissions: ['view_salary'],
1580+
} as any;
1581+
1582+
const harnessFor = (sets: PermissionSet[], fallback: string) => {
1583+
let middleware: any;
1584+
const schema = { name: 'task', ...fieldsSchema };
1585+
const ql: any = {
1586+
registerMiddleware: (mw: any) => { if (!middleware) middleware = mw; },
1587+
getSchema: () => schema,
1588+
findOne: async () => null,
1589+
find: async () => [],
1590+
};
1591+
const metadata = { get: async () => schema, list: async () => sets };
1592+
const services: Record<string, any> = { manifest: { register: vi.fn() }, objectql: ql, metadata };
1593+
const ctx: any = {
1594+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
1595+
registerService: vi.fn(),
1596+
getService: (n: string) => { if (!(n in services)) throw new Error(`service not registered: ${n}`); return services[n]; },
1597+
};
1598+
const plugin = new SecurityPlugin({ fallbackPermissionSet: fallback });
1599+
return { plugin, ctx, run: async (opCtx: any) => { await middleware(opCtx, async () => {}); return opCtx; } };
1600+
};
1601+
1602+
it('masks a capability-gated field on read when the caller lacks the capability', async () => {
1603+
const h = harnessFor([setNoCap], 'fld_member');
1604+
await h.plugin.init(h.ctx); await h.plugin.start(h.ctx);
1605+
const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, result: [{ id: 'r1', name: 'A', salary: 100 }], context: { userId: 'u1', roles: [], permissions: [] } };
1606+
await h.run(opCtx);
1607+
expect(opCtx.result[0].name).toBe('A');
1608+
expect(opCtx.result[0].salary).toBeUndefined();
1609+
});
1610+
1611+
it('does NOT mask when the caller holds the capability', async () => {
1612+
const h = harnessFor([setWithCap], 'fld_cap');
1613+
await h.plugin.init(h.ctx); await h.plugin.start(h.ctx);
1614+
const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, result: [{ id: 'r1', name: 'A', salary: 100 }], context: { userId: 'u1', roles: [], permissions: ['fld_cap'] } };
1615+
await h.run(opCtx);
1616+
expect(opCtx.result[0].salary).toBe(100);
1617+
});
1618+
1619+
it('denies a write to a capability-gated field when the caller lacks the capability', async () => {
1620+
const h = harnessFor([setNoCap], 'fld_member');
1621+
await h.plugin.init(h.ctx); await h.plugin.start(h.ctx);
1622+
const opCtx: any = { object: 'task', operation: 'insert', data: { name: 'A', salary: 200 }, context: { userId: 'u1', roles: [], permissions: [] } };
1623+
await expect(h.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' });
1624+
});
1625+
1626+
it('allows the write when the caller holds the capability', async () => {
1627+
const h = harnessFor([setWithCap], 'fld_cap');
1628+
await h.plugin.init(h.ctx); await h.plugin.start(h.ctx);
1629+
const opCtx: any = { object: 'task', operation: 'insert', data: { name: 'A', salary: 200 }, context: { userId: 'u1', roles: [], permissions: ['fld_cap'] } };
1630+
await expect(h.run(opCtx)).resolves.toBeDefined();
1631+
});
1632+
});

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export class SecurityPlugin implements Plugin {
118118
* `requiredPermissions` capability contract. Populated lazily from the schema;
119119
* cleared on metadata change alongside the other schema-derived caches.
120120
*/
121-
private readonly objectSecurityMetaCache = new Map<string, { isPrivate: boolean; tenancyDisabled: boolean; requiredPermissions: string[] }>();
121+
private readonly objectSecurityMetaCache = new Map<string, { isPrivate: boolean; tenancyDisabled: boolean; requiredPermissions: string[]; fieldRequiredPermissions: Record<string, string[]> }>();
122122
private dbLoader?: (names: string[]) => Promise<PermissionSet[]>;
123123
private logger: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void } = {};
124124

@@ -379,7 +379,7 @@ export class SecurityPlugin implements Plugin {
379379
const secMeta =
380380
permissionSets.length > 0
381381
? await this.getObjectSecurityMeta(opCtx.object)
382-
: { isPrivate: false, tenancyDisabled: false, requiredPermissions: [] as string[] };
382+
: { isPrivate: false, tenancyDisabled: false, requiredPermissions: [] as string[], fieldRequiredPermissions: {} as Record<string, string[]> };
383383

384384
// 1.5. [ADR-0066 D3] requiredPermissions AND-gate — a capability
385385
// prerequisite checked BEFORE the CRUD grant (ADR §Precedence): a
@@ -538,10 +538,12 @@ export class SecurityPlugin implements Plugin {
538538
opCtx.data &&
539539
permissionSets.length > 0
540540
) {
541-
const fieldPerms = this.permissionEvaluator.getFieldPermissions(
541+
let fieldPerms = this.permissionEvaluator.getFieldPermissions(
542542
opCtx.object,
543543
permissionSets,
544544
);
545+
// [ADR-0066 D3] AND-gate field-level requiredPermissions into the map.
546+
fieldPerms = this.foldFieldRequiredPermissions(fieldPerms, secMeta.fieldRequiredPermissions, permissionSets);
545547
if (Object.keys(fieldPerms).length > 0) {
546548
const forbidden = this.fieldMasker.detectForbiddenWrites(
547549
opCtx.data,
@@ -686,7 +688,9 @@ export class SecurityPlugin implements Plugin {
686688

687689
// 4. Field-level security: mask restricted fields in read results
688690
if (opCtx.result && ['find', 'findOne'].includes(opCtx.operation)) {
689-
const fieldPerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, permissionSets);
691+
let fieldPerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, permissionSets);
692+
// [ADR-0066 D3] AND-gate field-level requiredPermissions into the mask.
693+
fieldPerms = this.foldFieldRequiredPermissions(fieldPerms, secMeta.fieldRequiredPermissions, permissionSets);
690694
if (Object.keys(fieldPerms).length > 0) {
691695
opCtx.result = this.fieldMasker.maskResults(opCtx.result, fieldPerms, opCtx.object);
692696
}
@@ -1223,25 +1227,65 @@ export class SecurityPlugin implements Plugin {
12231227
*/
12241228
private async getObjectSecurityMeta(
12251229
object: string,
1226-
): Promise<{ isPrivate: boolean; tenancyDisabled: boolean; requiredPermissions: string[] }> {
1230+
): Promise<{ isPrivate: boolean; tenancyDisabled: boolean; requiredPermissions: string[]; fieldRequiredPermissions: Record<string, string[]> }> {
12271231
const cached = this.objectSecurityMetaCache.get(object);
12281232
if (cached) return cached;
12291233
let obj: any = typeof this.ql?.getSchema === 'function' ? this.ql.getSchema(object) : null;
12301234
if (!obj) {
12311235
try { obj = await this.metadata?.get?.('object', object); } catch { obj = null; }
12321236
}
1237+
// [ADR-0066 D3] Per-field capability requirements: { fieldName -> capability[] }.
1238+
const fieldRequiredPermissions: Record<string, string[]> = {};
1239+
const fields: any = (obj as any)?.fields;
1240+
if (Array.isArray(fields)) {
1241+
for (const f of fields) {
1242+
if (f?.name && Array.isArray(f.requiredPermissions) && f.requiredPermissions.length > 0) {
1243+
fieldRequiredPermissions[String(f.name)] = f.requiredPermissions.map(String);
1244+
}
1245+
}
1246+
} else if (fields && typeof fields === 'object') {
1247+
for (const [fname, fdef] of Object.entries(fields)) {
1248+
const rp = (fdef as any)?.requiredPermissions;
1249+
if (Array.isArray(rp) && rp.length > 0) fieldRequiredPermissions[fname] = rp.map(String);
1250+
}
1251+
}
12331252
const meta = {
12341253
isPrivate: (obj as any)?.access?.default === 'private',
12351254
tenancyDisabled:
12361255
(obj as any)?.tenancy?.enabled === false || (obj as any)?.systemFields?.tenant === false,
12371256
requiredPermissions: Array.isArray((obj as any)?.requiredPermissions)
12381257
? (obj as any).requiredPermissions.map(String)
12391258
: [],
1259+
fieldRequiredPermissions,
12401260
};
12411261
if (obj) this.objectSecurityMetaCache.set(object, meta);
12421262
return meta;
12431263
}
12441264

1265+
/**
1266+
* [ADR-0066 D3] Fold per-field `requiredPermissions` into a FieldPermission map.
1267+
* A field whose declared capabilities are NOT all held by the caller is forced
1268+
* non-readable + non-editable (AND-gate, strictest-wins over permission-set
1269+
* field grants) so the existing FieldMasker masks it on read and denies it on
1270+
* write. Returns the base map unchanged when no field declares requirements.
1271+
*/
1272+
private foldFieldRequiredPermissions(
1273+
baseFieldPerms: Record<string, { readable: boolean; editable: boolean }>,
1274+
fieldRequiredPermissions: Record<string, string[]>,
1275+
permissionSets: PermissionSet[],
1276+
): Record<string, { readable: boolean; editable: boolean }> {
1277+
const entries = Object.entries(fieldRequiredPermissions ?? {});
1278+
if (entries.length === 0) return baseFieldPerms;
1279+
const held = this.permissionEvaluator.getSystemPermissions(permissionSets);
1280+
const merged: Record<string, { readable: boolean; editable: boolean }> = { ...baseFieldPerms };
1281+
for (const [field, caps] of entries) {
1282+
if (caps.length > 0 && !caps.every((c) => held.has(c))) {
1283+
merged[field] = { readable: false, editable: false };
1284+
}
1285+
}
1286+
return merged;
1287+
}
1288+
12451289
/**
12461290
* Resolve the column-name set for an object (lowercased). Returns
12471291
* `null` if the schema can't be loaded — caller should fail-closed.

packages/spec/liveness/field.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@
8888
"status": "live",
8989
"note": "renderer."
9090
},
91+
"requiredPermissions": {
92+
"status": "live",
93+
"evidence": "packages/plugins/plugin-security/src/security-plugin.ts",
94+
"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."
95+
},
9196
"system": {
9297
"status": "live",
9398
"evidence": "packages/objectql/src/engine.ts"

packages/spec/src/data/field.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1503,3 +1503,19 @@ describe('FieldSchema - columnName', () => {
15031503
expect(result.columnName).toBe('expiresAt');
15041504
});
15051505
});
1506+
1507+
1508+
describe('ADR-0066 D3 — field-level requiredPermissions', () => {
1509+
it('FieldSchema accepts requiredPermissions', () => {
1510+
const f = FieldSchema.parse({ type: 'number', requiredPermissions: ['view_salary'] });
1511+
expect(f.requiredPermissions).toEqual(['view_salary']);
1512+
});
1513+
it('Field.text passes requiredPermissions through the builder', () => {
1514+
const f: any = Field.text({ label: 'SSN', requiredPermissions: ['view_pii'] });
1515+
expect(f.requiredPermissions).toEqual(['view_pii']);
1516+
});
1517+
it('requiredPermissions is optional (absent ⇒ undefined)', () => {
1518+
const f = FieldSchema.parse({ type: 'text' });
1519+
expect(f.requiredPermissions).toBeUndefined();
1520+
});
1521+
});

packages/spec/src/data/field.zod.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,14 @@ export const FieldSchema = lazySchema(() => z.object({
554554
/** Security & Visibility */
555555
hidden: z.boolean().default(false).describe('Hidden from default UI'),
556556
readonly: z.boolean().default(false).describe('Read-only in UI'),
557+
558+
/**
559+
* [ADR-0066 D3] Capabilities required to READ/EDIT this field. A field
560+
* declaring `requiredPermissions` is masked on read and denied on write unless
561+
* the caller holds ALL listed capabilities — an AND-gate that is strictest-wins
562+
* over permission-set field grants. Enforced by plugin-security's FieldMasker.
563+
*/
564+
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).'),
557565
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.'),
558566
sortable: z.boolean().optional().default(true).describe('Whether field is sortable in list views'),
559567
inlineHelpText: z.string().optional().describe('Help text displayed below the field in forms'),

0 commit comments

Comments
 (0)