You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit e735425
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
| 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`|
113
113
| 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`|
115
115
| 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`|
116
116
| 10 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId`|`shouldDenyAnonymous`|
0 commit comments