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
6 changes: 6 additions & 0 deletions .changeset/quick-hoops-drum.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
"@objectstack/spec": minor
"@objectstack/rest": minor
---

Add a batch form to `security/explain` (#8326): `recordIds: string[]` (max 200, mutually exclusive with `recordId`) on the existing request shape answers the per-record `decision.record.visible` verdict map for one `(object, operation)` pair in one round trip. The response gains an optional `records` array where `records[i]` answers `recordIds[i]` (duplicates answered per position); a missing record fail-closes to `visible: false` with `decidedBy` omitted. Each id is evaluated through the singular pipeline, so the batch answer for a record is identical to the singular answer by construction. Singular and object-level requests and responses stay byte-compatible; a request carrying both spellings, an empty batch, or more than 200 ids is refused with `400 VALIDATION_FAILED`.
6 changes: 4 additions & 2 deletions content/docs/references/security/explain.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,8 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object
| **principal** | `{ userId: string \| null; positions: string[]; permissionSets: string[]; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>; … }` | ✅ | |
| **layers** | `{ layer: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| … +6 more>; kernelTier?: Enum<'layer_0_tenant' \| 'layer_1_business'>; verdict: Enum<'grants' \| 'denies' \| 'narrows' \| 'widens' \| 'neutral' \| 'not_applicable'>; detail: string; … }[]` | ✅ | |
| **readFilter** | `any` | optional | |
| **record** | `{ recordId: string; visible: boolean; decidedBy?: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| … +6 more> }` | optional | Row-level verdict for the specific record; set only for record-grained requests. |
| **record** | `{ recordId: string; visible: boolean; decidedBy?: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| … +6 more> }` | optional | Row-level verdict for the specific record; set only for singular record-grained requests. |
| **records** | `{ recordId: string; visible: boolean; decidedBy?: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| … +6 more> }[]` | optional | Per-record verdicts for a batch request — records[i] answers recordIds[i]; set only when the request carried recordIds. |


---
Expand DownExpand Up@@ -183,7 +184,8 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | |
| **operation** | `Enum<'read' \| 'create' \| 'update' \| 'delete' \| 'transfer' \| 'restore' \| 'purge' \| 'export'>` | ✅ | |
| **recordId** | `string` | optional | Optional id of one concrete record to explain at row granularity; omitted = object-level (pre-C2) request. |
| **recordId** | `string` | optional | Optional id of one concrete record to explain at row granularity; omitted = object-level (pre-C2) request. Mutually exclusive with recordIds. |
| **recordIds** | `string[]` | optional | Batch of record ids (1–200) to answer at row granularity in one round trip; records[i] answers recordIds[i]. Mutually exclusive with recordId. |
| **userId** | `string` | optional | |


Expand Down
51 changes: 47 additions & 4 deletions packages/rest/src/rest-server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9045,7 +9045,14 @@ export class RestServer {
* explain engine (framework#2696).
*
* GET {basePath}/security/explain?object=…&operation=…&userId=…
* POST {basePath}/security/explain body: { object, operation, userId? }
* POST {basePath}/security/explain body: { object, operation, userId?,
* recordId? | recordIds? }
*
* [#8326] `recordIds` (max 200, exclusive with `recordId`) is the batch
* form of the record-grained question: one `(object, operation)` pair
* answered per record in one round trip — `records[i]` answers
* `recordIds[i]`; each entry is the verdict the singular form returns for
* that id (see `ExplainRequestSchema`'s TSDoc for the full contract).
*
* Delegates to the security service's `explain(request, callerContext)`
* (`SecurityPlugin.explainAccessForCaller`) — the same code paths the
Expand DownExpand Up@@ -9150,24 +9157,60 @@ export class RestServer {
// refusal gate is imitating, so there is nothing to add here.
const src = req.method === 'GET' ? (req.query ?? {}) : (req.body ?? {});
const { ExplainRequestSchema } = await import('@objectstack/spec/security');
// [#8326] GET-transport normalization ONLY: a query string
// cannot spell a one-element array (`?recordIds=a` parses to
// the bare string), so a lone string is wrapped on GET. On
// POST the body is JSON and can say what it means — a string
// where the contract says array stays a 400, not a wrap.
const rawRecordIds = req.method === 'GET' && typeof src.recordIds === 'string' && src.recordIds !== ''
? [src.recordIds]
: src.recordIds;
const parsed = (ExplainRequestSchema as any).safeParse({
object: src.object,
operation: src.operation ?? 'read',
...(src.userId != null && src.userId !== '' ? { userId: src.userId } : {}),
// [C2 / ADR-0095] Optional record id — explains ONE concrete
// row at record granularity; omitted stays object-level.
...(src.recordId != null && src.recordId !== '' ? { recordId: src.recordId } : {}),
// [#8326] Optional batch of record ids — the schema owns the
// cap (200), the min (1), and recordId/recordIds mutual
// exclusion, so every refusal is the one 400 below.
...(rawRecordIds != null ? { recordIds: rawRecordIds } : {}),
});
if (!parsed.success) {
return respondError(
res, 400, 'VALIDATION_FAILED',
'Invalid explain request — expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string, recordId?: string }.',
'Invalid explain request — expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string, recordId?: string, recordIds?: string[] (max 200, exclusive with recordId) }.',
String(parsed.error?.message ?? '').slice(0, 1000),
);
}

const decision = await svc.explain(parsed.data, context);
res.json(decision);
const { recordIds, ...singularRequest } = parsed.data as { recordIds?: string[] } & Record<string, unknown>;
if (!recordIds) {
// Singular / object-level — the pre-#8326 path, byte-identical.
const decision = await svc.explain(parsed.data, context);
return res.json(decision);
}

// [#8326] Batch form — transport amortization of the SINGULAR
// evaluation, not a new semantic: the object-level trace is one
// object-level explain, and each per-record verdict is the
// singular record-grained explain for that id, relayed
// verbatim. "Batch answer ≡ N singular answers" is therefore a
// property of the construction, and the agreement test pins it
// from staying that way by accident.
const decision = await svc.explain(singularRequest, context);
const verdictById = new Map<string, unknown>();
for (const id of new Set(recordIds)) {
const single = await svc.explain({ ...singularRequest, recordId: id }, context);
// A service without record-grained support answers no
// record verdict; fail CLOSED (a hidden button beats a
// shown-then-403), with no decidedBy fabricated.
verdictById.set(id, single?.record ?? { recordId: id, visible: false });
}
// Ordering contract: records[i] answers recordIds[i] — same
// order, same length, duplicates answered per position.
res.json({ ...decision, records: recordIds.map((id) => verdictById.get(id)) });
} catch (error: any) {
const msg = String(error?.message ?? error ?? '');
if (
Expand Down
144 changes: 144 additions & 0 deletions packages/rest/src/security-routes.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -153,3 +153,147 @@ describe('GET/POST /security/explain (ADR-0090 D6)', () => {
expect(res.body.error.message).toBe('boom');
});
});

// ── [#8326] batch form — recordIds on the same request shape ─────────────────

/**
* A deterministic fake security service: the record verdict is a pure function
* of the recordId, so "batch answer ≡ N singular answers" is checkable as data
* rather than as mock-call bookkeeping. `r_gone` plays the missing record —
* the engine's fail-closed answer is `visible: false` with no `decidedBy`.
*/
function deterministicExplain() {
return vi.fn(async (request: any) => {
const base = { ...DECISION, object: request.object, operation: request.operation };
if (!request.recordId) return base;
if (request.recordId === 'r_gone') {
return { ...base, record: { recordId: request.recordId, visible: false } };
}
const visible = request.recordId.endsWith('_ok');
return {
...base,
record: { recordId: request.recordId, visible, decidedBy: visible ? 'sharing' : 'rls' },
};
});
}

describe('[#8326] POST/GET /security/explain with recordIds (batch form)', () => {
it('answers records[i] for recordIds[i] — same order, same length, duplicates per position', async () => {
const explain = deterministicExplain();
const { post } = buildServer(async () => ({ explain }), { callerCtx: CALLER });
const res = mockRes();
const recordIds = ['a_ok', 'b_no', 'r_gone', 'a_ok'];
await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'update', recordIds } } as any, res);

expect(res.statusCode).toBe(200);
expect(res.body.records).toEqual([
{ recordId: 'a_ok', visible: true, decidedBy: 'sharing' },
{ recordId: 'b_no', visible: false, decidedBy: 'rls' },
{ recordId: 'r_gone', visible: false }, // missing record: fail closed, no decider
{ recordId: 'a_ok', visible: true, decidedBy: 'sharing' },
]);
// The object-level trace rides along, without a singular `record` verdict.
expect(res.body.allowed).toBe(true);
expect(res.body.record).toBeUndefined();
// Evaluation is the singular pipeline per unique id + one object-level
// pass — the service never sees `recordIds`.
for (const call of explain.mock.calls) expect(call[0].recordIds).toBeUndefined();
expect(explain.mock.calls.map((c: any[]) => c[0].recordId).sort((a: any, b: any) => String(a).localeCompare(String(b))))
.toEqual([undefined, 'a_ok', 'b_no', 'r_gone'].sort((a: any, b: any) => String(a).localeCompare(String(b))));
});

it('AGREEMENT: the batch answer equals N singular answers for the same records', async () => {
const recordIds = ['a_ok', 'b_no', 'r_gone', 'c_ok'];

// N singular round trips through the REAL handler.
const singularVerdicts: unknown[] = [];
for (const recordId of recordIds) {
const { post } = buildServer(async () => ({ explain: deterministicExplain() }), { callerCtx: CALLER });
const res = mockRes();
await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'update', recordId } } as any, res);
expect(res.statusCode).toBe(200);
singularVerdicts.push(res.body.record);
}

// One batch round trip through the REAL handler, same service semantics.
const { post } = buildServer(async () => ({ explain: deterministicExplain() }), { callerCtx: CALLER });
const res = mockRes();
await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'update', recordIds } } as any, res);
expect(res.statusCode).toBe(200);

expect(res.body.records).toEqual(singularVerdicts);
});

it('refuses a batch over the 200-id cap with 400 VALIDATION_FAILED (never truncates)', async () => {
const explain = deterministicExplain();
const { post } = buildServer(async () => ({ explain }), { callerCtx: CALLER });
const res = mockRes();
const recordIds = Array.from({ length: 201 }, (_, i) => `r_${i}_ok`);
await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'update', recordIds } } as any, res);

// The refusal envelope: code AND status (ADR-0112 D5 position).
expect(res.statusCode).toBe(400);
expect(res.body.error.code).toBe('VALIDATION_FAILED');
expect(explain).not.toHaveBeenCalled();
});

it('refuses recordId + recordIds together with 400 (loud, no silent precedence) and an empty batch too', async () => {
const explain = deterministicExplain();
const { post } = buildServer(async () => ({ explain }), { callerCtx: CALLER });

let res = mockRes();
await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'update', recordId: 'a_ok', recordIds: ['a_ok'] } } as any, res);
expect(res.statusCode).toBe(400);
expect(res.body.error.code).toBe('VALIDATION_FAILED');

res = mockRes();
await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'update', recordIds: [] } } as any, res);
expect(res.statusCode).toBe(400);
expect(res.body.error.code).toBe('VALIDATION_FAILED');
expect(explain).not.toHaveBeenCalled();
});

it('POST does NOT wrap a bare-string recordIds — the JSON body can spell an array, so a string is a 400', async () => {
const explain = deterministicExplain();
const { post } = buildServer(async () => ({ explain }), { callerCtx: CALLER });
const res = mockRes();
await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'update', recordIds: 'a_ok' } } as any, res);
expect(res.statusCode).toBe(400);
expect(res.body.error.code).toBe('VALIDATION_FAILED');
expect(explain).not.toHaveBeenCalled();
});

it('GET wraps a single repeated query param (a query string cannot spell a one-element array)', async () => {
const explain = deterministicExplain();
const { get } = buildServer(async () => ({ explain }), { callerCtx: CALLER });
const res = mockRes();
await get!.handler({ method: 'GET', params: {}, headers: {}, query: { object: 'task', operation: 'update', recordIds: 'a_ok' } } as any, res);
expect(res.statusCode).toBe(200);
expect(res.body.records).toEqual([{ recordId: 'a_ok', visible: true, decidedBy: 'sharing' }]);
});

it('a singular request stays byte-compatible: no records[] key appears on the response', async () => {
const explain = deterministicExplain();
const { post } = buildServer(async () => ({ explain }), { callerCtx: CALLER });
const res = mockRes();
await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'update', recordId: 'a_ok' } } as any, res);
expect(res.statusCode).toBe(200);
expect(res.body.record).toEqual({ recordId: 'a_ok', visible: true, decidedBy: 'sharing' });
expect('records' in res.body).toBe(false);
expect(explain).toHaveBeenCalledTimes(1);
expect(explain).toHaveBeenCalledWith({ object: 'task', operation: 'update', recordId: 'a_ok' }, CALLER);
});

it('fail-closes per record when the service predates record-grained explain (no record verdict answered)', async () => {
// A pre-C2 service ignores recordId and returns object-level decisions only.
const explain = vi.fn().mockResolvedValue(DECISION);
const { post } = buildServer(async () => ({ explain }), { callerCtx: CALLER });
const res = mockRes();
await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'update', recordIds: ['a', 'b'] } } as any, res);
expect(res.statusCode).toBe(200);
expect(res.body.records).toEqual([
{ recordId: 'a', visible: false },
{ recordId: 'b', visible: false },
]);
});
});
1 change: 1 addition & 0 deletions packages/spec/api-surface/security.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@
"CriteriaSharingRule (type)",
"CriteriaSharingRuleParsed (type)",
"CriteriaSharingRuleSchema (const)",
"EXPLAIN_BATCH_MAX_RECORD_IDS (const)",
"EffectiveObjectPermission (type)",
"EffectiveObjectPermissionSchema (const)",
"ExplainDecision (type)",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/authorable-surface/security.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,6 +69,7 @@
"security/ExplainDecision:principal",
"security/ExplainDecision:readFilter",
"security/ExplainDecision:record",
"security/ExplainDecision:records",
"security/ExplainLayer:contributors",
"security/ExplainLayer:detail",
"security/ExplainLayer:kernelTier",
Expand All@@ -89,6 +90,7 @@
"security/ExplainRequest:object",
"security/ExplainRequest:operation",
"security/ExplainRequest:recordId",
"security/ExplainRequest:recordIds",
"security/ExplainRequest:userId",
"security/FieldPermission:editable",
"security/FieldPermission:readable",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/security.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@
"CriteriaSharingRule": "src/security/sharing.zod.ts#CriteriaSharingRule (type)",
"CriteriaSharingRuleParsed": "src/security/sharing.zod.ts#CriteriaSharingRuleParsed (type)",
"CriteriaSharingRuleSchema": "src/security/sharing.zod.ts#CriteriaSharingRuleSchema (const)",
"EXPLAIN_BATCH_MAX_RECORD_IDS": "src/security/explain.zod.ts#EXPLAIN_BATCH_MAX_RECORD_IDS (const)",
"EffectiveObjectPermission": "src/security/permission.zod.ts#EffectiveObjectPermission (type)",
"EffectiveObjectPermissionSchema": "src/security/permission.zod.ts#EffectiveObjectPermissionSchema (const)",
"ExplainDecision": "src/security/explain.zod.ts#ExplainDecision (type)",
Expand Down
Loading
Loading