Skip to content

Commit e735425

Browse files
feat(security): read one user's effective permission sets with their origins (#31336)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XFFCc5xzKLhfXUYbb1wJzNC)_ Fixes #31215 Clause-②: yes Metadata-protocol: none ## 维护者速读 - **改了什么**:新增只读接口 `GET /api/v1/security/users/:id/effective-access`,列出某个用户在当前组织里实际生效的权限集,每个权限集标明全部来源:默认绑定、所任岗位(带岗位名)、直接分配,或由身份派生(配置的平台所有者、AI 席位开关)。接口实现的是 #31256 已合并的 spec 契约 `ISecurityService.describeEffectiveAccess`,本 PR 不改 spec。 - **为什么改**:用户页「权限集」页签只能列出直接分配的行,通过默认绑定和岗位拿到的权限都不显示,管理员看到 0 条,实际却有不少权限。本接口补上数据,页面展示另有 ui 卡负责。 - **风险与回滚**:纯新增只读接口。谁能看谁与 explain 用同一道闸门(本 PR 把这道闸门抽成一个函数,两处共用,explain 行为不变)。查询不存在的用户与「无权查看该用户」返回完全相同的 403,对所有调用方都如此(含全权管理员);这一点与 explain 不同,explain 对已放行的调用方返回 404。core 解析器加了一个可选参数 `withPermissionOrigins`,不传时返回内容和读库次数与原来逐字节相同,有测试钉住。回滚即撤销本 PR。 - **先看**:`packages/plugins/plugin-security/src/security-plugin-effective-access.ts`(闸门、未知用户拒绝、来源合并);`packages/core/src/security/resolve-authz-context.ts`(在各派生点记录来源,不传参数时不变);`packages/rest/src/security-routes.ts`(新路由的拒绝顺序)。 - **两分钟验证**:`pnpm --filter @objectstack/plugin-security exec vitest run src/effective-access.test.ts`,预期 12 个用例全过,包括「三种来源各自正确」「只有直接分配的对照用户只列一条」「两条路径到达的权限集只列一次、带两个来源」「未知用户与无权用户的拒绝完全相同」。 - **需要你做的**:无,席位落地。 ## What changes - **`@objectstack/core`**: `resolveUserAuthzGrants` takes `withPermissionOrigins` and then returns `permissionOrigins`, a map from each name in `permissions` to the spec's `EffectivePermissionSetOrigin[]`. Each origin is recorded at the site that derives the name: the grant-row loop records `direct`, the position-binding loop records `position` (or `default` for the `everyone` anchor), the configured-owner branch records `derived: platform_admin_config`, and the `ai_access` branch records `derived: ai_access`. The `ai_access` read uses the memoised `sys_user` row. Origins are ordered by channel (direct, position, default, derived). Only names the envelope holds keep one. Asking for origins forces a fresh resolution, like explain's ruled cache bypass, because an origin map can only come from a resolution that ran. Without the option, the envelope and the reads are byte-identical; a pin covers this. - **`@objectstack/plugin-security`**: - `mayDescribeAnotherUser` (`security-plugin-explain-access.ts`) is explain's caller gate, moved into one function. Explain calls it in place of its inline copy; its refusal text and details are unchanged. - `describeEffectiveAccess` (`security-plugin-effective-access.ts`, bound on the class and on the registered service literal): 1. Reading your own access needs no gate. Reading anyone else's goes through `mayDescribeAnotherUser`. 2. The user must then exist (`readSysUserRow`). An unknown or empty id gets the same `PermissionDeniedError` as a user you may not manage: one constructor, no id in it. 3. Grants are resolved in the caller's organization, vetted with `vetOrganizationClaim` exactly as explain vets it, with origins on. 4. The set list follows the resolver's order. The human baseline (`humanBaselineFor`) is appended last as `default`, and a set already listed keeps its place. 5. A name the resolution lists without an origin is a fault, never an invented origin. - **`@objectstack/rest`**: adds `GET {basePath}/security/users/:id/effective-access` in `security-routes.ts`, beside explain, through the family's one emitter. - Refusal order: 401 for no user, 400 `VALIDATION_ERROR` for any query parameter (closed set `EFFECTIVE_ACCESS_QUERY_PARAMS` is empty), 501 when the service lacks the member, 403 `PERMISSION_DENIED`, classified refusals as explain classifies them, 500 `INTERNAL_ERROR`. - The answer is the service's object, bare, like `my-delegable-scope`. - Route-ledger row: `sdk`, `client: 'security.describeEffectiveAccess'`. The registrar's docblock names the route. - **`@objectstack/client`**: `security.describeEffectiveAccess(userId)` returns `EffectiveAccess`, pinned both ways in `return-type-precision.test.ts`. - **Changeset**: `.changeset/31215-effective-access-read.md`, patch for all four packages, carrying both declaration lines (kind `none`, so `patch`). No file in `packages/spec` is touched. ## Why The user page's permission-set tab lists `sys_user_permission_set` rows only, so sets that arrive through the `everyone` default binding or through a position never show. Seat decision 6711854159 chose route A: the spec half first (#31238, PR #31256, merged `8b16445f`), then this server half. ## Tests ### New pins - `packages/plugins/plugin-security/src/effective-access.test.ts` (12) runs the real body through `resolveUserAuthzGrants` against a fake engine and catalog: - three origins: `forecasting` direct, `crm_sales_user` from position `sales_rep`, `member_default` / `app_default` default, in resolver order; - a CONTROL user with only a direct set (no default binding) lists exactly one; - `crm_sales_user` reached by a direct row and two positions is listed once with all three origins; - a baseline set the `everyone` anchor also binds is listed once; - `ai_seat` is `derived: ai_access`, and the configured owner's `admin_full_access` is `derived: platform_admin_config` with `platform_admin` first in `positions`; - an external member's `external_default` carries `position: org_external` plus `default`, and `everyone` is not held; - reading your own access needs no administrative capability, and a delegated administrator is admitted; - refusals: 403 for a non-admin; unknown equals forbidden (`code`, `status`, `message` and `details` all equal); a `manage_users` caller asking about an unknown id gets the same refusal, not a 404; an empty id is refused the same way. - `packages/core/src/security/resolve-authz-context.direct-permissions.test.ts` (+3, new top-level describe): - origins in channel order for a set reached through both a grant row and `everyone`; - a switched-off set keeps no origin; - without the option, the envelope and the recorded reads equal a resolution that did ask, minus the map. - `packages/rest/src/security-routes.test.ts` (+7, new top-level describe): registration; a bare relay with `(id, caller)`; anonymous 401 with the service never called; 403 with a byte-identical body for two ids and no id in it; a query parameter refused 400 naming it, with the service never called; 501 without the member or without a provider; 500 `INTERNAL_ERROR`. - `packages/client/src/return-type-precision.test.ts`: `toEqualTypeOf` plus a `@ts-expect-error` wrong-shape pin. ### Ablations (each restored, `git diff HEAD` empty after) Every mutation went through `scripts/ablation-replace.mjs`, whose anchor must hit. The plugin-security and core tests import the mutated module from `src`, so no rebuild is needed. 1. Unknown user answered as explain answers it: `throw new ExplainUserNotFoundError(target)` in place of the shared refusal. Mutation landed (anchor 1 to 0). Red: ``` × a manage_users caller asking about an id no user has is refused that same way — not explain's 404 AssertionError: expected a refusal: expected ExplainUserNotFoundError: [Security] Unkn… to be an instance of PermissionDeniedError Tests 1 failed | 11 passed (12) ``` 2. Gate removed (`if (false)` in place of the `mayDescribeAnotherUser` check). Red: the four refusal cases (`Tests 4 failed | 8 passed (12)`). 3. Core `direct` origin record deleted. Red: ``` × a set reached through a grant row and the `everyone` anchor carries both, in channel order; each other name its one Tests 1 failed | 6 passed (7) ``` ### Package suites and type checks (head 9dbf86c, after merging origin/main; 94ee3ea only adds the gate fixes) - `pnpm --filter @objectstack/plugin-security test`: Test Files 249 passed (249), Tests 5863 passed, 45 skipped, after the merge and rebuild. - `pnpm --filter @objectstack/core test`: 136 files, 3146 tests passed. `@objectstack/rest`: 394 files, 7496 passed, 332 skipped. `@objectstack/client`: 55 files, 712 passed. All three ran on the pre-merge head `24d01fe2`; the merge brought no file of these four packages, `packages/spec` included. - `pnpm --filter PKG typecheck` exits 0 for all four packages (pre-merge head `24d01fe2`). - `eslint --no-inline-config` over the changed files exits 0. - At `94ee3ead`: `effective-access.test.ts` 12/12 and `security-routes.test.ts` 32/32 rerun green. ### Derived gates (head 94ee3ea) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 116 commands for this diff. Each ran with its exit code captured before any pipe, and the record carries `:: exit N` on every line. `--ran` reconciles: 116 derived, 114 run, 2 NOT-MEASURED (derived from a recorded exit 3), 0 UNRUN. - Three gates were red on the first pass and are fixed in `94ee3ead`: - `check:engine-double-contract`: the effective-access test's fake `findOne` now opens with `assertEngineFindOnePredicate`, and `--write` added the pinned-ledger row. - `check:system-context-census`: the shared gate's `isSystem` read moved into `mayDescribeAnotherUser`, so row 9 of `content/docs/permissions/system-context.mdx` now names it beside `explainAccessForCaller` (and the effective-access read), and `--write` regenerated the register. - `check:test-source-alias`: `security-routes.test.ts` imports `@objectstack/objectql` at module top, so the lazy load is paid during collection. - Two first-pass exit-3 refusals were cleared by meeting their prerequisite: `check-plugin-teardown-shape.mjs --self-test` (fetched its pinned fixture commit) and `spec check:skill-examples` (client-react dist present). Both exit 0. - NOT MEASURED, exit 3 (prerequisite: a full workspace build): `check:dual-build-cjs-loads` (no dist for 37 packages outside this closure) and `check:i18n` (the workspace CLI is not built). This diff adds no translation key and no new entry point. ## Acceptance notes - **The departure from explain is deliberate, and the contract names it.** An admitted caller asking explain about an id with no user row gets `404`; this read refuses it with the same `403` as a forbidden id, for every caller, `manage_users` included (claim scope line and seat note). - **The shared gate is a move.** Explain's body now calls `mayDescribeAnotherUser`, with the same checks in the same order and the same fail-closed catches. Its own `PermissionDeniedError` text and details are unchanged. - **Organization:** the read resolves in the caller's active organization and keeps it only while the described user can be resolved there (`vetOrganizationClaim`, as explain). A `manage_users` caller asking about a user outside that organization gets an answer resolved with no organization (`organizationId: null`), which is what explain explains for the same pair. - **The UI consumer card** (the user page's effective-access widget) is the card's named consumer. I did not search the tracker for it. ## Verification Run `pnpm --filter @objectstack/plugin-security exec vitest run src/effective-access.test.ts` (12/12) and `pnpm --filter @objectstack/rest exec vitest run src/security-routes.test.ts` (the effective-access describe, 7/7). --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kUXrK37qc2vkfEHQ4)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4363945 commit e735425

16 files changed

Lines changed: 811 additions & 42 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/core': patch
3+
'@objectstack/plugin-security': patch
4+
'@objectstack/rest': patch
5+
'@objectstack/client': patch
6+
---
7+
8+
feat(security): read one user's effective permission sets, each with where it comes from
9+
10+
Clause-②: yes
11+
Metadata-protocol: none
12+
13+
A user page could list only the permission sets stored as that user's own grant rows. Sets that arrive through the `everyone` default binding or through a held position were never rows, so a user who holds plenty of access through defaults and positions showed none. Now one read lists them all, each with its origins.
14+
15+
- **`GET /api/v1/security/users/:id/effective-access`** (`@objectstack/rest`, beside `security/explain`) answers the spec's `EffectiveAccess`: the user, the organization it was resolved in, the positions held there, and the permission sets in force, each listed once with every origin it arrives through (`direct`, `position` with the position's name, `default`, or `derived` with its standing). It takes no query parameter: one is refused `400 VALIDATION_ERROR` naming it. A deployment whose security service does not serve the read answers `501`.
16+
- **Who may read whom is explain's caller gate**, now one function both reads call (`mayDescribeAnotherUser` in `@objectstack/plugin-security`): your own access always; someone else's with `manage_users`, as the organization administrator of an organization they belong to, or with a delegated `adminScope` covering them. An id no user has is refused with the same `403 PERMISSION_DENIED`, word for word, as a user you may not manage, for every caller. This is where the read differs from `security/explain`, which answers an admitted caller `404` for an unknown id.
17+
- **`ISecurityService.describeEffectiveAccess`** is served by `@objectstack/plugin-security`. The origins come from the resolver itself: `resolveUserAuthzGrants` (`@objectstack/core`) takes a new `withPermissionOrigins` option and returns `permissionOrigins`, recorded where each name is derived. Asking for it is a fresh resolution, like explain's. A resolution that does not ask returns the same envelope as before, from the same reads.
18+
- **`client.security.describeEffectiveAccess(userId)`** (`@objectstack/client`) returns `EffectiveAccess`.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ bugs — it is the protection or the side effect that silently does not happen.
111111
| 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `registerSecurityService` |
112112
| 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `isObjectSchemaMaskExempt` |
113113
| 8b | Authoring-trail reads admitted | rest, runtime | Get: a metadata item's version history, audit rows and stored-version diff (`GET /meta/:type/:name/history`, `/audit`, `/diff`) without `studio.access` or `manage_metadata`, and a package's commit timeline (`GET /packages/:id/commits`) without `studio.access`. Note: narrower than the schema-mask exemption the draft switches ask (`isObjectSchemaMaskExempt`): neither door admits `setup.access`, which organization admins hold | `mayReadAuthoringTrail`, `requireCommitTrailReadCapability` |
114-
| 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `explainAccessForCaller` |
114+
| 9 | `explain()` and the effective-access read (`describeEffectiveAccess`) may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check — both reads ask the one caller gate | `explainAccessForCaller`, `mayDescribeAnotherUser` |
115115
| 9b | The master-detail write check the `security` service serves (`checkControlledByParentWrite`, ADR-0055) answers `allow` | plugin-security | Get: `allow` for any record, with no master judged — the write path's own first exit, so a gate composing it admits exactly what the record's own system-context update would. Lose: nothing a caller reaches today: its two consumers, the `sys_attachment` and `sys_comment` parent gates, return before they ask for a system session | `checkControlledByParentWrite` |
116116
| 10 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `shouldDenyAnonymous` |
117117
| 12 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `packages/plugins/plugin-auth/src/auth-plugin.ts#start` |
@@ -592,7 +592,8 @@ and CI refuses one that differs from what the tree produces.
592592
| `packages/plugins/plugin-security/src/position-catalog-refusal.ts#assertPositionNamesCatalogRow` | 1 | 23b |
593593
| `packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts#registerAuthzMiddleware` | 1 | 1, 2, 30 |
594594
| `packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts#checkControlledByParentWrite` | 1 | 9b |
595-
| `packages/plugins/plugin-security/src/security-plugin-explain-access.ts#explainAccessForCaller` | 2 | 9 |
595+
| `packages/plugins/plugin-security/src/security-plugin-explain-access.ts#explainAccessForCaller` | 1 | 9 |
596+
| `packages/plugins/plugin-security/src/security-plugin-explain-access.ts#mayDescribeAnotherUser` | 1 | 9 |
596597
| `packages/plugins/plugin-security/src/security-plugin-field-readers.ts#resolveProjectionFieldMask` | 1 | 4 |
597598
| `packages/plugins/plugin-security/src/security-plugin-object-admission.ts#canExport` | 1 | 5 |
598599
| `packages/plugins/plugin-security/src/security-plugin-object-admission.ts#canReadObject` | 1 | 6 |

‎packages/client/src/index.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ import type {
176176
AudienceBindingSuggestionSync,
177177
AutomationResult,
178178
DelegableScope,
179+
EffectiveAccess,
179180
FlowRuntimeState,
180181
ImportObjectResult,
181182
ObjectDraft,
@@ -6626,6 +6627,24 @@ export class ObjectStackClient {
66266627
return this.unwrapResponse<DelegableScope>(res);
66276628
},
66286629

6630+
/**
6631+
* ADR-0090 D6/D12 — one user's effective permission sets, each with
6632+
* every origin it arrives through: a default binding, a held position,
6633+
* a direct grant, or a name derived from the user's standing. Resolved
6634+
* in the caller's organization by the resolver enforcement uses.
6635+
*
6636+
* Gated as `explain` is: reading someone else needs `manage_users`,
6637+
* organization administration covering them, or a delegated
6638+
* `adminScope` covering them. An unknown id is refused with the same
6639+
* `403` as one the caller may not manage.
6640+
*/
6641+
describeEffectiveAccess: async (userId: string): Promise<EffectiveAccess> => {
6642+
const res = await this.fetch(
6643+
`${this.baseUrl}/api/v1/security/users/${encodeURIComponent(userId)}/effective-access`,
6644+
);
6645+
return this.unwrapResponse<EffectiveAccess>(res);
6646+
},
6647+
66296648
suggestedBindings: {
66306649
/** List suggestions, optionally by `status` / `packageId` (reconciles first). */
66316650
list: async (opts?: { status?: string; packageId?: string }): Promise<{ suggestions: AudienceBindingSuggestion[]; synced: AudienceBindingSuggestionSync }> => {

‎packages/client/src/return-type-precision.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import type {
8484
AnalyticsResult,
8585
AutomationResult,
8686
DelegableScope,
87+
EffectiveAccess,
8788
FlowRuntimeState,
8889
FlowUnboundCause,
8990
ImportObjectResult,
@@ -139,6 +140,8 @@ export async function returnTypePrecisionPins(): Promise<void> {
139140
// ── shape class 1: the contract type, served bare ────────────────────
140141
// `res.json(await svc.describeDelegableScope(context))` — no envelope.
141142
expectTypeOf(await client.security.describeDelegableScope()).toEqualTypeOf<DelegableScope>();
143+
// `res.json(await svc.describeEffectiveAccess(id, context))` — no envelope.
144+
expectTypeOf(await client.security.describeEffectiveAccess('u1')).toEqualTypeOf<EffectiveAccess>();
142145

143146
// ── shape class 2: contract type inside a route-built ENVELOPE ───────
144147
// The census's highest-risk band: `sendOk(res, { tables })` means the
@@ -235,6 +238,9 @@ export async function returnTypePrecisionPins(): Promise<void> {
235238
// @ts-expect-error a DelegableScope is not a string
236239
const wrongScope: string = await client.security.describeDelegableScope();
237240

241+
// @ts-expect-error an EffectiveAccess is not a bare list of permission sets
242+
const wrongAccess: EffectiveAccess['permissionSets'] = await client.security.describeEffectiveAccess('u1');
243+
238244
// @ts-expect-error the route answers `{ tables }`, not a bare array
239245
const wrongTables: RemoteTable[] = await client.datasources.external.listTables('ds');
240246

@@ -254,6 +260,7 @@ export async function returnTypePrecisionPins(): Promise<void> {
254260
const wrongClone: CloneDataResult = await client.data.create('lead', {});
255261

256262
void wrongScope;
263+
void wrongAccess;
257264
void wrongTables;
258265
void wrongFlow;
259266
void wrongSearch;

‎packages/core/src/security/resolve-authz-context.direct-permissions.test.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,12 @@ const GRANTS = [
5656
{ permission_set: 'elsewhere', organization_id: ORG_B },
5757
];
5858

59-
async function resolve(tenantId: string | undefined, ledger: unknown[] = []) {
59+
async function resolve(tenantId: string | undefined, ledger: unknown[] = [], extra: { withPermissionOrigins?: boolean } = {}) {
60+
const { out } = await resolveRecorded(tenantId, ledger, extra);
61+
return out;
62+
}
63+
64+
async function resolveRecorded(tenantId: string | undefined, ledger: unknown[] = [], extra: { withPermissionOrigins?: boolean } = {}) {
6065
const ql = bindStaticSecurityCatalog(makeRecordingQl({
6166
sys_user: [{ id: 'u1', email: 'u1@example.com' }],
6267
sys_member: [{ user_id: 'u1', organization_id: ORG_A, role: 'member' }, { user_id: 'u1', organization_id: ORG_B, role: 'member' }],
@@ -67,7 +72,8 @@ async function resolve(tenantId: string | undefined, ledger: unknown[] = []) {
6772
sys_permission_set: [],
6873
sys_metadata_activation: ledger,
6974
}), definitions);
70-
return resolveUserAuthzGrants(ql, 'u1', tenantId ? { tenantId } : {});
75+
const out = await resolveUserAuthzGrants(ql, 'u1', { ...(tenantId ? { tenantId } : {}), ...extra });
76+
return { out, calls: (ql as any).calls as Array<{ object: string }> };
7177
}
7278

7379
describe('[#25799] directPermissions is the resolver\'s own direct channel', () => {
@@ -97,3 +103,28 @@ describe('[#25799] directPermissions is the resolver\'s own direct channel', ()
97103
expect(out.directPermissions).toEqual(['both_channels']);
98104
});
99105
});
106+
107+
describe('[ADR-0090 D6] withPermissionOrigins records each name\'s channels, and changes nothing else', () => {
108+
it('a set reached through a grant row and the `everyone` anchor carries both, in channel order; each other name its one', async () => {
109+
const out = await resolve(ORG_A, [], { withPermissionOrigins: true });
110+
expect(out.permissionOrigins).toEqual({
111+
tools: [{ kind: 'direct' }],
112+
both_channels: [{ kind: 'direct' }, { kind: 'default' }],
113+
definition_only: [{ kind: 'default' }],
114+
});
115+
});
116+
117+
it('a switched-off set keeps no origin, since the envelope does not hold it', async () => {
118+
const out = await resolve(ORG_A, [{ metadata_type: 'permission', name: 'tools', package_id: null, active: false }], { withPermissionOrigins: true });
119+
expect(out.permissionOrigins).not.toHaveProperty('tools');
120+
});
121+
122+
it('the envelope and the reads are byte-identical to a resolution that did not ask', async () => {
123+
const plain = await resolveRecorded(ORG_A);
124+
const asked = await resolveRecorded(ORG_A, [], { withPermissionOrigins: true });
125+
expect(plain.out).not.toHaveProperty('permissionOrigins');
126+
const { permissionOrigins: _origins, ...rest } = asked.out;
127+
expect(rest).toEqual(plain.out);
128+
expect(asked.calls.map((c) => c.object)).toEqual(plain.calls.map((c) => c.object));
129+
});
130+
});

‎packages/core/src/security/resolve-authz-context.ts‎

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ import { SystemObjectName } from '@objectstack/spec/system';
6565
import type { AuthzPosture, TenancyPosture } from '@objectstack/spec/security';
6666
import { postureEnforcesWall } from '@objectstack/spec/security';
6767
import { isValueDomainMember } from '@objectstack/spec/shared';
68-
import type { AuthSessionApi } from '@objectstack/spec/contracts';
68+
import type { AuthSessionApi, EffectivePermissionSetOrigin } from '@objectstack/spec/contracts';
6969

7070
import { ApiKeyLifecycleRefusalError, resolveApiKeyAdmission } from './api-key.js';
7171
import type { ApiKeyLifecyclePolicySource, ApiKeyRefusalReason } from './api-key.js';
@@ -879,6 +879,22 @@ export interface UserAuthzGrants {
879879
audience?: 'external';
880880
/** The user's unique email (`sys_user`), for `current_user.email` owner RLS. */
881881
email?: string;
882+
/**
883+
* [ADR-0090 D6] Where each name in `permissions` came from, keyed by name —
884+
* present only when the caller asked for it
885+
* ({@link ResolveUserAuthzGrantsOptions.withPermissionOrigins}). Recorded at
886+
* the derivation sites themselves, so it cannot disagree with them: `direct`
887+
* where an applying grant row of the user's own names an active set (the
888+
* `directPermissions` channel), `position` / `default` where a held
889+
* position's definition in force names it (`default` for the `everyone`
890+
* audience anchor, whose bindings are the default ones), and `derived` where
891+
* the configured platform administrators (§6b-config) or the `ai_access` flag
892+
* (§7) confer the name. Every name in `permissions` the resolution derived has
893+
* at least one origin, each listed once, in the order the envelope lists its
894+
* channels — `direct`, then `position` (in held-position order), `default`,
895+
* `derived`; a `seedPermissions` name the caller supplied has none.
896+
*/
897+
permissionOrigins?: Record<string, EffectivePermissionSetOrigin[]>;
882898
}
883899

884900
export interface ResolveUserAuthzGrantsOptions {
@@ -920,6 +936,16 @@ export interface ResolveUserAuthzGrantsOptions {
920936
* would then trust.
921937
*/
922938
bypassGrantsCache?: boolean;
939+
/**
940+
* [ADR-0090 D6] Record where each permission-set name came from, in
941+
* {@link UserAuthzGrants.permissionOrigins}. For a surface that DESCRIBES a
942+
* user's access (`ISecurityService.describeEffectiveAccess`), never for
943+
* enforcement: the envelope is otherwise byte-identical to a resolution
944+
* without it. It implies {@link bypassGrantsCache}, for the explainer's ruled
945+
* reason: a description is what an administrator reads to verify a change
946+
* took effect, and an origin map is recorded only by a resolution that ran.
947+
*/
948+
withPermissionOrigins?: boolean;
923949
}
924950

925951
/**
@@ -1839,12 +1865,29 @@ export async function resolveUserAuthzGrants(
18391865
// read HERE, before any read of this resolution is issued. Outside that
18401866
// scope (every direct caller of this function) it is `undefined` and nothing
18411867
// changes.
1842-
const requestMemo = openRequestGrantsMemo(ql, userId, opts, readWriteEpoch(ql));
1868+
// [ADR-0090 D6] An origin map is recorded only by a resolution that runs, so
1869+
// asking for one is a fresh resolution (see `withPermissionOrigins`).
1870+
const cacheOpts = opts.withPermissionOrigins ? { ...opts, bypassGrantsCache: true } : opts;
1871+
const requestMemo = openRequestGrantsMemo(ql, userId, cacheOpts, readWriteEpoch(ql));
18431872
if (requestMemo?.hit) return requestMemo.hit;
18441873

1845-
const grantsCache = openUserGrantsCache(ql, userId, opts);
1874+
const grantsCache = openUserGrantsCache(ql, userId, cacheOpts);
18461875
if (grantsCache?.hit) return grantsCache.hit;
18471876

1877+
// [ADR-0090 D6] The origin record, when asked for: each origin once per name.
1878+
// Ordered by channel when the envelope is returned (see `permissionOrigins`).
1879+
const origins = opts.withPermissionOrigins ? new Map<string, EffectivePermissionSetOrigin[]>() : undefined;
1880+
const recordOrigin = (name: string, origin: EffectivePermissionSetOrigin): void => {
1881+
if (!origins) return;
1882+
const list = origins.get(name) ?? [];
1883+
const same = (o: EffectivePermissionSetOrigin): boolean =>
1884+
o.kind === origin.kind
1885+
&& (o.kind !== 'position' || (origin.kind === 'position' && o.position === origin.position))
1886+
&& (o.kind !== 'derived' || (origin.kind === 'derived' && o.source === origin.source));
1887+
if (!list.some(same)) list.push(origin);
1888+
origins.set(name, list);
1889+
};
1890+
18481891
const { tenantId } = opts;
18491892
const grants: UserAuthzGrants = {
18501893
positions: [],
@@ -2138,9 +2181,13 @@ export async function resolveUserAuthzGrants(
21382181
if (disabled.positions.size > 0) {
21392182
grants.positions = grants.positions.filter((n) => !disabled.positions.has(n));
21402183
}
2141-
for (const setNames of setsInForceByPosition(grants.positions, namedByPosition, disabled).values()) {
2184+
for (const [positionName, setNames] of setsInForceByPosition(grants.positions, namedByPosition, disabled)) {
21422185
for (const setName of setNames) {
21432186
if (!boundSetNames.includes(setName)) boundSetNames.push(setName);
2187+
// [ADR-0090 D5] The `everyone` anchor's bindings are the default ones.
2188+
recordOrigin(setName, positionName === 'everyone'
2189+
? { kind: 'default' }
2190+
: { kind: 'position', position: positionName });
21442191
}
21452192
}
21462193

@@ -2168,6 +2215,7 @@ export async function resolveUserAuthzGrants(
21682215
// [#25799] The direct channel, as the explainer reads it: a name this
21692216
// resolution confers through the user's own grant row.
21702217
if (active && !grants.directPermissions.includes(name)) grants.directPermissions.push(name);
2218+
if (active) recordOrigin(name, { kind: 'direct' });
21712219
// platform_admin (ADR-0068 D2) is DERIVED from the anchoring admin_full_access
21722220
// USER grant — the single source of truth (no trusted stored boolean). The
21732221
// set is the catalog's one `admin_full_access` (ADR-0131 D4); a grant the
@@ -2235,6 +2283,7 @@ export async function resolveUserAuthzGrants(
22352283
// envelope a grant-derived one carries.
22362284
hasPlatformAdminGrant = true;
22372285
if (!grants.permissions.includes(ADMIN_FULL_ACCESS)) grants.permissions.push(ADMIN_FULL_ACCESS);
2286+
recordOrigin(ADMIN_FULL_ACCESS, { kind: 'derived', source: 'platform_admin_config' });
22382287
for (const p of ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions ?? []) {
22392288
if (!grants.systemPermissions.includes(p)) grants.systemPermissions.push(p);
22402289
}
@@ -2282,9 +2331,28 @@ export async function resolveUserAuthzGrants(
22822331

22832332
// 7. [cloud ADR-0024 §7] Env-side AI seat: synthesize the `ai_seat` capability from the
22842333
// boolean sys_user.ai_access (sqlite returns 1/0; memory returns boolean).
2285-
if (!grants.permissions.includes('ai_seat')) {
2334+
// An origin record reads the flag even when another channel already named
2335+
// `ai_seat`, so a name reached both ways carries both origins; the row is
2336+
// the memoised one, and the envelope is unchanged.
2337+
if (!grants.permissions.includes('ai_seat') || origins) {
22862338
const aiAccess = ((await getUserRow()) as { ai_access?: unknown } | undefined)?.ai_access;
2287-
if (aiAccess === true || aiAccess === 1 || aiAccess === '1') grants.permissions.push('ai_seat');
2339+
if (aiAccess === true || aiAccess === 1 || aiAccess === '1') {
2340+
if (!grants.permissions.includes('ai_seat')) grants.permissions.push('ai_seat');
2341+
recordOrigin('ai_seat', { kind: 'derived', source: 'ai_access' });
2342+
}
2343+
}
2344+
2345+
// [ADR-0090 D6] Only names the envelope holds keep an origin: a bound name
2346+
// the catalog holds no set for, or a switched-off set, confers nothing.
2347+
if (origins) {
2348+
const channelRank: Record<EffectivePermissionSetOrigin['kind'], number> = { direct: 0, position: 1, default: 2, derived: 3 };
2349+
const permissionOrigins: Record<string, EffectivePermissionSetOrigin[]> = {};
2350+
for (const name of grants.permissions) {
2351+
const list = origins.get(name);
2352+
// A stable sort: positions keep the held-position order they were reached in.
2353+
if (list) permissionOrigins[name] = [...list].sort((a, b) => channelRank[a.kind] - channelRank[b.kind]);
2354+
}
2355+
grants.permissionOrigins = permissionOrigins;
22882356
}
22892357

22902358
// [#11971] Store the fresh envelope (miss path only; the argument list is

0 commit comments

Comments
 (0)