Skip to content

Commit 57261f3

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(authz): ADR-0066 D3 field-level requiredPermissions (+ D5 confirmation) (#2240)
- 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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 45d3507 commit 57261f3

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
@@ -119,7 +119,7 @@ export class SecurityPlugin implements Plugin {
119119
* `requiredPermissions` capability contract. Populated lazily from the schema;
120120
* cleared on metadata change alongside the other schema-derived caches.
121121
*/
122-
private readonly objectSecurityMetaCache = new Map<string, { isPrivate: boolean; tenancyDisabled: boolean; requiredPermissions: string[] }>();
122+
private readonly objectSecurityMetaCache = new Map<string, { isPrivate: boolean; tenancyDisabled: boolean; requiredPermissions: string[]; fieldRequiredPermissions: Record<string, string[]> }>();
123123
private dbLoader?: (names: string[]) => Promise<PermissionSet[]>;
124124
private logger: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void } = {};
125125

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

386386
// 1.5. [ADR-0066 D3] requiredPermissions AND-gate — a capability
387387
// prerequisite checked BEFORE the CRUD grant (ADR §Precedence): a
@@ -540,10 +540,12 @@ export class SecurityPlugin implements Plugin {
540540
opCtx.data &&
541541
permissionSets.length > 0
542542
) {
543-
const fieldPerms = this.permissionEvaluator.getFieldPermissions(
543+
let fieldPerms = this.permissionEvaluator.getFieldPermissions(
544544
opCtx.object,
545545
permissionSets,
546546
);
547+
// [ADR-0066 D3] AND-gate field-level requiredPermissions into the map.
548+
fieldPerms = this.foldFieldRequiredPermissions(fieldPerms, secMeta.fieldRequiredPermissions, permissionSets);
547549
if (Object.keys(fieldPerms).length > 0) {
548550
const forbidden = this.fieldMasker.detectForbiddenWrites(
549551
opCtx.data,
@@ -688,7 +690,9 @@ export class SecurityPlugin implements Plugin {
688690

689691
// 4. Field-level security: mask restricted fields in read results
690692
if (opCtx.result && ['find', 'findOne'].includes(opCtx.operation)) {
691-
const fieldPerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, permissionSets);
693+
let fieldPerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, permissionSets);
694+
// [ADR-0066 D3] AND-gate field-level requiredPermissions into the mask.
695+
fieldPerms = this.foldFieldRequiredPermissions(fieldPerms, secMeta.fieldRequiredPermissions, permissionSets);
692696
if (Object.keys(fieldPerms).length > 0) {
693697
opCtx.result = this.fieldMasker.maskResults(opCtx.result, fieldPerms, opCtx.object);
694698
}
@@ -1232,25 +1236,65 @@ export class SecurityPlugin implements Plugin {
12321236
*/
12331237
private async getObjectSecurityMeta(
12341238
object: string,
1235-
): Promise<{ isPrivate: boolean; tenancyDisabled: boolean; requiredPermissions: string[] }> {
1239+
): Promise<{ isPrivate: boolean; tenancyDisabled: boolean; requiredPermissions: string[]; fieldRequiredPermissions: Record<string, string[]> }> {
12361240
const cached = this.objectSecurityMetaCache.get(object);
12371241
if (cached) return cached;
12381242
let obj: any = typeof this.ql?.getSchema === 'function' ? this.ql.getSchema(object) : null;
12391243
if (!obj) {
12401244
try { obj = await this.metadata?.get?.('object', object); } catch { obj = null; }
12411245
}
1246+
// [ADR-0066 D3] Per-field capability requirements: { fieldName -> capability[] }.
1247+
const fieldRequiredPermissions: Record<string, string[]> = {};
1248+
const fields: any = (obj as any)?.fields;
1249+
if (Array.isArray(fields)) {
1250+
for (const f of fields) {
1251+
if (f?.name && Array.isArray(f.requiredPermissions) && f.requiredPermissions.length > 0) {
1252+
fieldRequiredPermissions[String(f.name)] = f.requiredPermissions.map(String);
1253+
}
1254+
}
1255+
} else if (fields && typeof fields === 'object') {
1256+
for (const [fname, fdef] of Object.entries(fields)) {
1257+
const rp = (fdef as any)?.requiredPermissions;
1258+
if (Array.isArray(rp) && rp.length > 0) fieldRequiredPermissions[fname] = rp.map(String);
1259+
}
1260+
}
12421261
const meta = {
12431262
isPrivate: (obj as any)?.access?.default === 'private',
12441263
tenancyDisabled:
12451264
(obj as any)?.tenancy?.enabled === false || (obj as any)?.systemFields?.tenant === false,
12461265
requiredPermissions: Array.isArray((obj as any)?.requiredPermissions)
12471266
? (obj as any).requiredPermissions.map(String)
12481267
: [],
1268+
fieldRequiredPermissions,
12491269
};
12501270
if (obj) this.objectSecurityMetaCache.set(object, meta);
12511271
return meta;
12521272
}
12531273

1274+
/**
1275+
* [ADR-0066 D3] Fold per-field `requiredPermissions` into a FieldPermission map.
1276+
* A field whose declared capabilities are NOT all held by the caller is forced
1277+
* non-readable + non-editable (AND-gate, strictest-wins over permission-set
1278+
* field grants) so the existing FieldMasker masks it on read and denies it on
1279+
* write. Returns the base map unchanged when no field declares requirements.
1280+
*/
1281+
private foldFieldRequiredPermissions(
1282+
baseFieldPerms: Record<string, { readable: boolean; editable: boolean }>,
1283+
fieldRequiredPermissions: Record<string, string[]>,
1284+
permissionSets: PermissionSet[],
1285+
): Record<string, { readable: boolean; editable: boolean }> {
1286+
const entries = Object.entries(fieldRequiredPermissions ?? {});
1287+
if (entries.length === 0) return baseFieldPerms;
1288+
const held = this.permissionEvaluator.getSystemPermissions(permissionSets);
1289+
const merged: Record<string, { readable: boolean; editable: boolean }> = { ...baseFieldPerms };
1290+
for (const [field, caps] of entries) {
1291+
if (caps.length > 0 && !caps.every((c) => held.has(c))) {
1292+
merged[field] = { readable: false, editable: false };
1293+
}
1294+
}
1295+
return merged;
1296+
}
1297+
12541298
/**
12551299
* Resolve the column-name set for an object (lowercased). Returns
12561300
* `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)