Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions packages/plugins/plugin-security/src/security-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> = { 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();
});
});
54 changes: 49 additions & 5 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,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<string, { isPrivate: boolean; tenancyDisabled: boolean; requiredPermissions: string[] }>();
private readonly objectSecurityMetaCache = new Map<string, { isPrivate: boolean; tenancyDisabled: boolean; requiredPermissions: string[]; fieldRequiredPermissions: Record<string, string[]> }>();
private dbLoader?: (names: string[]) => Promise<PermissionSet[]>;
private logger: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void } = {};

Expand Down Expand Up @@ -381,7 +381,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<string, string[]> };

// 1.5. [ADR-0066 D3] requiredPermissions AND-gate — a capability
// prerequisite checked BEFORE the CRUD grant (ADR §Precedence): a
Expand Down Expand Up @@ -540,10 +540,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,
Expand Down Expand Up @@ -688,7 +690,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);
}
Expand Down Expand Up @@ -1232,25 +1236,65 @@ 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<string, string[]> }> {
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<string, string[]> = {};
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:
(obj as any)?.tenancy?.enabled === false || (obj as any)?.systemFields?.tenant === false,
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<string, { readable: boolean; editable: boolean }>,
fieldRequiredPermissions: Record<string, string[]>,
permissionSets: PermissionSet[],
): Record<string, { readable: boolean; editable: boolean }> {
const entries = Object.entries(fieldRequiredPermissions ?? {});
if (entries.length === 0) return baseFieldPerms;
const held = this.permissionEvaluator.getSystemPermissions(permissionSets);
const merged: Record<string, { readable: boolean; editable: boolean }> = { ...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.
Expand Down
5 changes: 5 additions & 0 deletions packages/spec/liveness/field.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 16 additions & 0 deletions packages/spec/src/data/field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
8 changes: 8 additions & 0 deletions packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down