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
24 changes: 24 additions & 0 deletions .changeset/explain-rest-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@objectstack/rest": minor
"@objectstack/plugin-security": minor
---

ADR-0090 D6 — the explain engine gets its REST face (#2696).

**`@objectstack/rest`**: new `GET/POST /api/v1/security/explain`
(`object`/`operation`/`userId`, validated against the spec's
`ExplainRequestSchema`) delegating to the `security` service's
`explain(request, callerContext)` — the same code paths the enforcement
middleware runs, so the returned `ExplainDecision` is explained by
construction. The route is authenticated-only (401 even on
`requireAuth=false` deployments), returns 501 when no security service
exposes `explain`, and maps the service's `PermissionDeniedError` to 403.
Registered on scoped (`/environments/:environmentId`) and unscoped base
paths; the env kernel's own `security` service is preferred, with a new
host-kernel `securityServiceProvider` fallback wired by the REST plugin.

**`@objectstack/plugin-security`**: `explainAccessForCaller` now honors
delegated administration (D12) — explaining ANOTHER user is authorized by
`manage_users` **or** a delegated `adminScope` whose business-unit subtree
covers the target user (new `DelegatedAdminGate.scopesCoverUser`, fail-closed
on unresolvable scopes/memberships). Self-explain still needs neither.
13 changes: 11 additions & 2 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,17 @@ Each layer carries a verdict (`grants` / `denies` / `narrows` / `widens` /
`neutral` / `not_applicable`), a human explanation, and **contributor
attribution** — which permission set granted, reached via which position /
additive baseline / direct grant. For reads, the decision includes the
composed row filter as the machine artifact. Explaining another user requires
the `manage_users` capability.
composed row filter as the machine artifact.

The same report is reachable over REST as `GET/POST /api/v1/security/explain`
(`object`, `operation`, optional `userId`; validated against
`ExplainRequestSchema`). The endpoint is authenticated-only and delegates to
the service, so both surfaces share one authorization rule: explaining
**another** user requires the `manage_users` capability **or** a delegated
`adminScope` whose business-unit subtree covers that user (ADR-0090 D12) —
an administrator who can rewire a user's grants may read why they resolve as
they do. Studio's Access pillar ships a "why can this user access?" panel on
top of this endpoint.

## Governance: how "declared = enforced" is kept true

Expand Down
22 changes: 22 additions & 0 deletions packages/plugins/plugin-security/src/delegated-admin-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,28 @@ export class DelegatedAdminGate {
}
}

/**
* [ADR-0090 D6 × D12] Does the actor (via their resolved sets) hold a
* delegated `adminScope` whose BU subtree covers `targetUserId`? Used by
* the explain API: an administrator who can already manage a user's
* assignments inside their delegation boundary may also read WHY that
* user's access resolves the way it does — without tenant-level
* `manage_users`. Any scope kind qualifies (assignments, bindings,
* authoring): all of them administer capability inside the subtree, and
* the report is read-only. Fail-closed: unresolvable scopes/memberships
* cover nothing.
*/
async scopesCoverUser(sets: PermissionSet[], targetUserId: string): Promise<boolean> {
const held = await this.resolveHeldScopes(sets);
if (held.length === 0) return false;
const userBUs = await this.businessUnitsOfUser(targetUserId);
if (userBUs.size === 0) return false;
for (const s of held) {
for (const bu of userBUs) if (s.subtree.has(bu)) return true;
}
return false;
}

// ── sys_user_position: user ↔ position assignments ──────────────────

private async assertAssignmentWrite(opCtx: any, ctx: any, held: HeldScope[]): Promise<void> {
Expand Down
119 changes: 119 additions & 0 deletions packages/plugins/plugin-security/src/security-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2074,3 +2074,122 @@ describe('SecurityPlugin — ADR-0066 D3 field-level requiredPermissions', () =>
await expect(h.run(opCtx)).resolves.toBeDefined();
});
});

// ---------------------------------------------------------------------------
// ADR-0090 D6 × D12 — explainAccessForCaller authorization: manage_users OR a
// delegated adminScope whose BU subtree covers the target user.
// ---------------------------------------------------------------------------
describe('explainAccessForCaller (ADR-0090 D6/D12)', () => {
const EAST_SCOPE = {
businessUnit: 'east',
includeSubtree: true,
manageAssignments: true,
assignablePermissionSets: ['sales_user'],
};

const memberDefault: PermissionSet = {
name: 'member_default', label: 'Member',
objects: { task: { allowRead: true } },
} as any;
const subAdmin: PermissionSet = {
name: 'sub_admin', label: 'East delegate',
objects: {}, adminScope: EAST_SCOPE,
} as any;
const hrAdmin: PermissionSet = {
name: 'hr_admin', label: 'HR admin',
objects: {}, systemPermissions: ['manage_users'],
} as any;

/** Middleware harness + in-memory tables for BU subtree / membership reads. */
const makeExplainHarness = () => {
const tables: Record<string, any[]> = {
sys_business_unit: [
{ id: 'bu_hq', name: 'hq', parent_business_unit_id: null },
{ id: 'bu_east', name: 'east', parent_business_unit_id: 'bu_hq' },
{ id: 'bu_west', name: 'west', parent_business_unit_id: 'bu_hq' },
],
sys_business_unit_member: [
{ id: 'm1', business_unit_id: 'bu_east', user_id: 'u_east_1' },
{ id: 'm2', business_unit_id: 'bu_west', user_id: 'u_west_1' },
],
sys_user: [{ id: 'u_delegate' }, { id: 'u_east_1' }, { id: 'u_west_1' }, { id: 'u_hr' }],
sys_user_position: [],
sys_user_permission_set: [],
sys_permission_set: [],
sys_position: [],
};
const matches = (row: any, where: any): boolean =>
Object.entries(where ?? {}).every(([k, v]) => {
if (v && typeof v === 'object' && Array.isArray((v as any).$in)) return (v as any).$in.includes(row[k]);
return row[k] === v;
});
const baseSchema: any = { name: 'task', fields: { id: { name: 'id' }, owner_id: { name: 'owner_id' }, name: { name: 'name' } } };
const ql = {
registerMiddleware: vi.fn(),
getSchema: () => baseSchema,
async find(object: string, opts: any) {
const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where));
return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows;
},
async findOne(object: string, opts: any) {
const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where));
return rows[0] ?? null;
},
};
const services: Record<string, any> = {
manifest: { register: vi.fn() },
objectql: ql,
metadata: { get: async () => baseSchema, list: async () => [memberDefault, subAdmin, hrAdmin] },
};
const ctx: any = {
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
registerService: vi.fn(),
getService: (name: string) => {
if (!(name in services)) throw new Error(`service not registered: ${name}`);
return services[name];
},
};
return { ctx, tables };
};

const boot = async () => {
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
const h = makeExplainHarness();
await plugin.init(h.ctx);
await plugin.start(h.ctx);
return { plugin, h };
};

it('a delegated admin may explain a user INSIDE their scope subtree without manage_users', async () => {
const { plugin } = await boot();
const caller = { userId: 'u_delegate', positions: [], permissions: ['sub_admin'] };
const d = await plugin.explainAccessForCaller({ object: 'task', operation: 'read', userId: 'u_east_1' }, caller);
expect(d.principal.userId).toBe('u_east_1');
expect(d.object).toBe('task');
});

it('the same delegate is DENIED for a user OUTSIDE the subtree (fail closed)', async () => {
const { plugin } = await boot();
const caller = { userId: 'u_delegate', positions: [], permissions: ['sub_admin'] };
await expect(
plugin.explainAccessForCaller({ object: 'task', operation: 'read', userId: 'u_west_1' }, caller),
).rejects.toMatchObject({ name: 'PermissionDeniedError' });
});

it('manage_users still explains anyone (tenant-level path unchanged)', async () => {
const { plugin } = await boot();
const caller = { userId: 'u_hr', positions: [], permissions: ['hr_admin'] };
const d = await plugin.explainAccessForCaller({ object: 'task', operation: 'read', userId: 'u_west_1' }, caller);
expect(d.principal.userId).toBe('u_west_1');
});

it('no manage_users and no covering scope → denied; self-explain needs neither', async () => {
const { plugin } = await boot();
const plain = { userId: 'u_east_1', positions: [], permissions: [] };
await expect(
plugin.explainAccessForCaller({ object: 'task', operation: 'read', userId: 'u_west_1' }, plain),
).rejects.toMatchObject({ name: 'PermissionDeniedError' });
const self = await plugin.explainAccessForCaller({ object: 'task', operation: 'read', userId: 'u_east_1' }, plain);
expect(self.principal.userId).toBe('u_east_1');
});
});
27 changes: 20 additions & 7 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1162,9 +1162,12 @@ export class SecurityPlugin implements Plugin {
*/
/**
* [ADR-0090 D6] Explain access for a caller. `request.userId` (explaining
* someone else) requires the caller to hold `manage_users` or be system —
* an access report is itself sensitive data. The evaluation delegates to
* {@link explainAccess} with the SAME internals the middleware uses.
* someone else) requires the caller to hold `manage_users`, be system, or —
* [D12] — hold a delegated `adminScope` whose BU subtree covers the target
* user (an access report is itself sensitive data, but an admin who can
* already rewire a user's grants may read why they resolve as they do).
* The evaluation delegates to {@link explainAccess} with the SAME internals
* the middleware uses.
*/
async explainAccessForCaller(
request: { object: string; operation: string; userId?: string },
Expand All @@ -1181,10 +1184,20 @@ export class SecurityPlugin implements Plugin {
const callerSets = await this.resolvePermissionSetsForContext(callerContext).catch(() => []);
const held = this.permissionEvaluator.getSystemPermissions(callerSets);
if (!held.has('manage_users')) {
throw new PermissionDeniedError(
`[Security] Access denied: explaining another user's access requires the 'manage_users' capability (ADR-0090 D6).`,
{ object, operation, targetUserId: request.userId },
);
// [ADR-0090 D12] Delegated administrators may explain principals
// inside their delegation boundary (fail-closed on any error).
const delegated = this.delegatedAdminGate
? await this.delegatedAdminGate
.scopesCoverUser(callerSets, request.userId)
.catch(() => false)
: false;
if (!delegated) {
throw new PermissionDeniedError(
`[Security] Access denied: explaining another user's access requires the 'manage_users' ` +
`capability or a delegated adminScope covering that user (ADR-0090 D6/D12).`,
{ object, operation, targetUserId: request.userId },
);
}
}
}
targetContext = await buildContextForUser(this.ql, request.userId);
Expand Down
4 changes: 3 additions & 1 deletion packages/rest/src/rest-api-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,9 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
};

// Security service resolver — used by the ADR-0090 D5/D9
// /security/suggested-bindings routes (plugin-security).
// /security/suggested-bindings routes and the D6 /security/explain
// route (plugin-security). Returns undefined when plugin-security
// is not mounted so the routes fail cleanly (501).
const securityServiceProvider = async (_environmentId?: string): Promise<any | undefined> => {
try {
return ctx.getService<any>('security');
Expand Down
113 changes: 111 additions & 2 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -970,8 +970,9 @@ export class RestServer {
private i18nServiceProvider?: (environmentId?: string) => Promise<any | undefined>;
private analyticsServiceProvider?: (environmentId?: string) => Promise<any | undefined>;
private settingsServiceProvider?: (environmentId?: string) => Promise<any | undefined>;
/** [ADR-0090 D5/D9] `security` service resolver — used by the
* /security/suggested-bindings routes (plugin-security). */
/** [ADR-0090] `security` service resolver — used by the
* /security/suggested-bindings (D5/D9) and /security/explain (D6)
* routes (plugin-security). */
private securityServiceProvider?: (environmentId?: string) => Promise<any | undefined>;
/** Sync probe: is a kernel service registered? Single-env path for nav
* capability gates (ADR-0057 D10) — resolveExecCtx sets no kernel in
Expand Down Expand Up @@ -1887,6 +1888,7 @@ export class RestServer {
this.registerApprovalsEndpoints(bp);
this.registerAnalyticsEndpoints(bp);
this.registerSecurityEndpoints(bp);
this.registerSecurityExplainEndpoints(bp);
// Data-action routes (e.g. GET /data/:object/export, POST
// /data/:object/import) use static-literal action segments that
// MUST be registered BEFORE the greedy GET /data/:object/:id
Expand Down Expand Up @@ -4863,6 +4865,113 @@ export class RestServer {
});
}

/**
* [ADR-0090 D6] Access-explanation endpoint — the REST face of the
* explain engine (framework#2696).
*
* GET {basePath}/security/explain?object=…&operation=…&userId=…
* POST {basePath}/security/explain body: { object, operation, userId? }
*
* Delegates to the security service's `explain(request, callerContext)`
* (`SecurityPlugin.explainAccessForCaller`) — the same code paths the
* enforcement middleware runs, so the report is explained by
* construction. Caller authorization lives in the SERVICE, not here:
* explaining ANOTHER user requires `manage_users` or a delegated
* `adminScope` covering that user (D12); the service's
* `PermissionDeniedError` maps to 403. The route itself only insists on
* an authenticated caller (an access report is sensitive even about
* oneself, and the anonymous `guest` posture is not this endpoint's
* business) and returns 501 when no security service exposing `explain`
* is mounted (a deployment without `@objectstack/plugin-security`).
*/
private registerSecurityExplainEndpoints(basePath: string): void {
const isScoped = basePath.includes('/environments/:environmentId');
// Resolve the ENVIRONMENT's security service first (its resolver /
// evaluator / RLS compiler are bound to the env kernel's own data
// engine); the host provider is the single-kernel fallback.
const resolveService = async (environmentId?: string, req?: any) => {
try {
const envId = await this.resolveRequestEnvironmentId(environmentId, req);
if (envId && envId !== 'platform' && this.kernelManager) {
const kernel = await this.kernelManager.getOrCreate(envId);
const svc = await kernel.getServiceAsync<any>('security').catch(() => undefined);
if (svc) return svc;
}
} catch { /* fall back to the host service */ }
if (!this.securityServiceProvider) return undefined;
try { return await this.securityServiceProvider(environmentId); }
catch { return undefined; }
};

const handler = async (req: any, res: any) => {
try {
const environmentId = isScoped ? req.params?.environmentId : undefined;
const context = await this.resolveExecCtx(environmentId, req);
if (this.enforceAuth(req, res, context)) return;
if (!context?.userId) {
// Even on requireAuth=false deployments the explain surface
// stays authenticated-only — it is an admin diagnosis tool.
return res.status(401).json({
code: 'UNAUTHORIZED',
message: 'The access-explanation endpoint requires an authenticated caller.',
});
}

const svc = await resolveService(environmentId, req);
if (!svc || typeof svc.explain !== 'function') {
return res.status(501).json({
code: 'NOT_IMPLEMENTED',
message: 'Access explanation is not available on this deployment (no security service with explain).',
});
}

// GET reads the request from the query string, POST from the
// body — one contract (ExplainRequestSchema), two transports.
const src = req.method === 'GET' ? (req.query ?? {}) : (req.body ?? {});
const { ExplainRequestSchema } = await import('@objectstack/spec/security');
const parsed = (ExplainRequestSchema as any).safeParse({
object: src.object,
operation: src.operation ?? 'read',
...(src.userId != null && src.userId !== '' ? { userId: src.userId } : {}),
});
if (!parsed.success) {
return res.status(400).json({
code: 'VALIDATION_FAILED',
message: 'Invalid explain request — expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string }.',
detail: String(parsed.error?.message ?? '').slice(0, 1000),
});
}

const decision = await svc.explain(parsed.data, context);
res.json(decision);
} catch (error: any) {
const msg = String(error?.message ?? error ?? '');
if (
error?.code === 'PERMISSION_DENIED' ||
error?.name === 'PermissionDeniedError' ||
msg.startsWith('[Security] Access denied')
) {
return res.status(403).json({ code: 'PERMISSION_DENIED', message: msg.slice(0, 1000) });
}
logError('[REST] Security explain error:', error);
res.status(500).json({ code: 'EXPLAIN_FAILED', error: msg.slice(0, 500) });
}
};

this.routeManager.register({
method: 'GET',
path: `${basePath}/security/explain`,
handler,
metadata: { summary: 'Explain why a principal can (or cannot) perform an operation on an object (ADR-0090 D6)', tags: ['security'] },
});
this.routeManager.register({
method: 'POST',
path: `${basePath}/security/explain`,
handler,
metadata: { summary: 'Explain why a principal can (or cannot) perform an operation on an object (ADR-0090 D6)', tags: ['security'] },
});
}

private registerSharingEndpoints(basePath: string): void {
const { crud } = this.config;
const dataPath = `${basePath}${crud.dataPrefix}`;
Expand Down
Loading