Skip to content

feat(security): read one user's effective permission sets with their origins - #31336

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-rc3s3i
Nov 8, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-rc3s3i

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #31215

Clause-②: yes
Metadata-protocol: none

维护者速读

  • 改了什么:新增只读接口 GET /api/v1/security/users/:id/effective-access,列出某个用户在当前组织里实际生效的权限集,每个权限集标明全部来源:默认绑定、所任岗位(带岗位名)、直接分配,或由身份派生(配置的平台所有者、AI 席位开关)。接口实现的是 feat(spec): declare the optional ISecurityService.describeEffectiveAccess and its EffectiveAccess answer types #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)
  1. Gate removed (if (false) in place of the mayDescribeAnotherUser check). Red: the four refusal cases (Tests 4 failed | 8 passed (12)).

  2. 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

…th their origins

The resolver records each permission-set name's origin when asked
(withPermissionOrigins), and the security service's describeEffectiveAccess
reads it behind explain's caller gate, now one shared function.

Claude-Session: https://claude.ai/code/session_01FEkQ5kUXrK37qc2vkfEHQ4
Co-authored-by: Claude <noreply@anthropic.com>
…ide explain

The door relays the security service's describeEffectiveAccess through the
explain family's one emitter with an empty closed query set, ledgered as sdk;
the client method returns the spec's EffectiveAccess.

Claude-Session: https://claude.ai/code/session_01FEkQ5kUXrK37qc2vkfEHQ4
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5kUXrK37qc2vkfEHQ4
Co-authored-by: Claude <noreply@anthropic.com>
The effective-access test's fake engine routes findOne through
assertEngineFindOnePredicate and the pinned ledger records it; the REST
door's test pays the objectql load at collection; the system-context page
names mayDescribeAnotherUser on the row that owns the explain gate, now
that both reads ask it.

Claude-Session: https://claude.ai/code/session_01FEkQ5kUXrK37qc2vkfEHQ4
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/l label Nov 8, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 8, 2026
@github-actions

github-actions Bot commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/client, @objectstack/core, @objectstack/plugin-security, @objectstack/rest, touching 58 documentable anchor(s).

42 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json f5e23e2f7c570d14da0f012e0857f4be7c6636d2.

⛔ 14 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: PERMISSION_DENIED (literal, 51 pages)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 64 of 227 client-bound route-ledger rows — the other 163 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 163: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 70 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f5e23e2f7c570d14da0f012e0857f4be7c6636d2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3fb8dfc78b220a7f8ead8d0d9b503436203ce7a0 — the merge of head 94ee3ead4260b2f9e77423a52a5d4ef64797d719 into base f5e23e2f7c570d14da0f012e0857f4be7c6636d2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3fb8dfc78b220a7f8ead8d0d9b503436203ce7a0 && git checkout 3fb8dfc78b220a7f8ead8d0d9b503436203ce7a0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f5e23e2f7c570d14da0f012e0857f4be7c6636d2 94ee3ead4260b2f9e77423a52a5d4ef64797d719 && git checkout -B drift-repro f5e23e2f7c570d14da0f012e0857f4be7c6636d2 && git merge --no-ff 94ee3ead4260b2f9e77423a52a5d4ef64797d719

node scripts/docs-audit/affected-docs.mjs --json f5e23e2f7c570d14da0f012e0857f4be7c6636d2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f5e23e2f7c570d14da0f012e0857f4be7c6636d2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ

Contract-review-claim: session_01FEkQ5o8PE7ZtVjcor51wBZ · head 94ee3ea · 2026-11-08T02:42Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 94ee3ead4260b2f9e77423a52a5d4ef64797d719
Local-runs: none

Inputs read: card #31215 (body; comments 6711625441, 6711801309, 6711840578, 6711854159, 6715940083, 6716292410), PR #31336 (body, 16-file list, git diff origin/main...94ee3ead), the head's check-runs, and the published contract on main (packages/spec/src/contracts/security-service.ts: describeEffectiveAccess?, EffectiveAccess, EffectivePermissionSet, EffectivePermissionSetOrigin, landed by #31256). Code outside the diff (resolve-user-grants-cache.ts, request-grants-memo.ts, builtin-positions.ts, external-baseline.ts, explain-engine.ts#readSysUserRow, query-allowlist.ts, the sibling my-delegable-scope door) was read to judge the diff, never reviewed.

① Derived judgments

The four things the contract docblock binds the implementer to — each judged one by one:

  1. Resolution order — right. The contract orders permissionSets as grant-row sets in grant order, then position-bound sets in held-position order, then the derived names (admin_full_access, then ai_seat), then the human baseline, a set already listed keeping its first place. security-plugin-effective-access.ts walks grants.permissions as the resolver built it (take() folds grant rows before boundSetNames; §6b-config pushes ADMIN_FULL_ACCESS; §7 pushes ai_seat) and appends humanBaselineFor(...) last as default; byName dedupes so a later arrival only adds an origin. positions is [...grants.positions] — the resolver's own list, with platform_admin unshifted first (6c), everyone pushed only for a non-external member, and switched-off positions already filtered. Pinned by the first, third, fifth and sixth cases of effective-access.test.ts.
  2. Unknown-user refusal — right. One constructor, effectiveAccessRefusal(), with no id and no details, is thrown for a caller the gate refuses, for an id readSysUserRow finds no row for, and for an empty id; the existence read runs only after the gate, so a refused caller learns nothing about which ids exist; a manage_users caller asking about an unknown id gets that same refusal, not explain's ExplainUserNotFoundError 404 (ablation 1 in the PR body turned exactly that case red). The REST door maps it to one 403 PERMISSION_DENIED whose body is pinned byte-identical across a known and an unknown id and contains no id.
  3. external_default placement — right. The resolver records { kind: 'position', position: 'org_external' } from setsInForceByPosition because builtin-positions.ts on main registers org_external with permissionSets: [external_default] into the catalog that positionBoundSetNames reads, and humanBaselineFor answers [external_default] for an external holder, which the service appends as default. everyone is never a position origin: its bindings are recorded as default at the one derivation site. The sixth test pins both origins on one entry and everyone absent from positions.
  4. What positions lists — right. Exactly the resolver's list, in force, derived platform_admin leading, membership projections and own rows next, everyone last for a non-external member; the fifth test pins platform_admin first and the sixth pins ['org_external'] alone.

Accept-set and public-surface changes the diff implies:

  • New REST door GET {basePath}/security/users/:id/effective-access (security-routes.ts, through the family's one respondError emitter) — right. Refusal order 401 (no userId, service never called) → 400 VALIDATION_ERROR for any query parameter, from the empty closed set EFFECTIVE_ACCESS_QUERY_PARAMS via unknownQueryParamRefusalMessage (the helper whose docblock names own-envelope doors as its callers; the handler reads no other key of req.query, so the set measured from its read points is empty, AGENTS.md route rule 5) → 501 NOT_IMPLEMENTED when !svc || typeof svc.describeEffectiveAccess !== 'function', the same shape and wording pattern as my-delegable-scope → bare relay of the service's object → 403 matched by code/name as explain matches it → classifiedRefusalAnswer → 500. Ledger row disposition: 'sdk', client: 'security.describeEffectiveAccess', family security-explain; the client-side ledger coverage test resolves that path to a real function. The registrar docblock in rest-server.ts names the route. Seven route tests pin registration, bare relay with (id, caller), 401, byte-identical 403, 400 naming the parameter, 501 for both a member-less service and no provider, and 500.
  • Client method security.describeEffectiveAccess(userId) — right. Returns EffectiveAccess through unwrapResponse, encodeURIComponent(userId) in the path; return-type-precision.test.ts pins toEqualTypeOf against EffectiveAccess and a @ts-expect-error wrong-shape assignment. exported-any-returns.json needs no entry (annotated return).
  • New service method SecurityPlugin#describeEffectiveAccess — right. Bound on the class and on the registered literal, which is typed against ISecurityService, so the optional member's signature is compiler-checked; EffectiveAccessHost types every member it reads as the class member. Self-read needs no gate (the contract's "or the caller's own"); every other id goes through mayDescribeAnotherUser. Organization: the caller's tenantId, re-resolved under vetOrganizationClaim exactly as explain does (buildContextForUser twice there, resolveIn twice here), answered as organizationId ?? null. A name in grants.permissions without an origin throws a fault rather than inventing one — correct, since the service passes no seedPermissions, the one channel the docblock exempts. registered-security-service-members.pin.test.ts is unaffected: the member is declared on the interface.
  • resolveUserAuthzGrants in @objectstack/core — right; no existing caller's answer moves. withPermissionOrigins builds cacheOpts = { ...opts, bypassGrantsCache: true }; both openRequestGrantsMemo and openUserGrantsCache return undefined on bypassGrantsCache before any read, so an origin-bearing envelope is neither served from nor committed to either cache — a later caller without the option can never receive permissionOrigins. Without the option: origins is undefined, recordOrigin is a no-op, the position loop iterates the same Map (entries in place of values), and the ai_seat branch condition !includes('ai_seat') || origins reduces to the old one. Every grants.permissions push site has an origin record: grant rows (direct, when active), position-bound (position or default for everyone), §6b-config (derived: platform_admin_config), §7 (derived: ai_seat); the final pass keeps origins only for names the envelope holds, so a disabled or body-less set keeps none. Pinned by the three new core cases, including byte-identical envelope and identical read sequence with and without the option.
  • Refactor of security-plugin-explain-access.ts — right; one gate, one spelling. mayDescribeAnotherUser carries the old inline block verbatim in order: isSystem short-circuit, resolvePermissionSetsForContext with its .catch answering an empty list, manage_users, organizationAdminCoversUser with its .catch answering false, then scopesCoverUser with its .catch answering false. Explain calls it in place of the inline copy and throws the same PermissionDeniedError text with the same { object, operation, targetUserId } details, then its own readSysUserRow → 404 unchanged. Explain's answer moves for no caller. The system-context census (content/docs/permissions/system-context.mdx row 9 and register) is regenerated to name both functions, with explainAccessForCaller down from 2 reads to 1.
  • Card acceptance — met: three origins listed with the right kind (test 1), CONTROL direct-only user lists exactly one (test 2), a set reached by a direct row and two positions listed once with all three origins (test 3), anonymous 401 at the door, non-admin 403, unknown user byte-identical to forbidden for a member caller and for a manage_users caller, empty id likewise.
  • Security — the read lists nothing beyond what the caller gate allows: the gate precedes the existence read and the resolution, the refusal names no id, the self-read path still requires a row, and the organization the sets are resolved in is vetted as explain vets it.

Findings: no must-fix. Should-fix: none. Nit (no effect on correctness or contract): security-explain-envelope.test.ts (#8073, "the whole explain family reduces to ONE skeleton") is not extended to the new door; the new route tests cover every arm through the same emitter, so the skeleton holds by construction.

② Semver level

.changeset/31215-effective-access-read.md declares patch for @objectstack/core, @objectstack/plugin-security, @objectstack/rest, @objectstack/client — exactly the four published packages (all 18.3.0, none private) whose files the diff touches; the other changed paths (content/docs/..., scripts/engine-double-contract.pinned.json, the changeset itself) publish nothing. Metadata-protocol: none, so the level is patch by AGENTS.md checklist 3 ("none takes patch"); Clause-②: yes is line-initial in both the PR body and the changeset body, with no arm, which is the right declaration for a widening of the served HTTP surface that narrows nothing. packages/spec is untouched, so no gen:api-surface artifact is owed. Check-run Check Changeset: success. Right.

③ Boundary flags

Report 6716292410: open_questions is empty — nothing to answer. Flags and deviations, each answered:

  • NOT MEASURED check:dual-build-cjs-loads — covered by the head's Build Core job (ci.yml, step "Every published require entry point actually loads", blocking). At render time that job was still in_progress; its conclusion is the gate verdict and the seat reads it before landing.
  • NOT MEASURED check:i18n — covered by the head's Type Check · consumer gates job (lint.yml, step "Check generated translation bundles are in sync with the schema"). The job concluded success and the step itself concluded success (read off the job's steps, since the step is continue-on-error: true and the job's conclusion alone would not carry it). The diff adds no translation key and no bundle entry point.
  • Deviation: eslint over changed files, not pnpm lint per package — the head's Lint · ESLint and the scoped lint jobs are the verdict; the ones concluded are green.
  • Deviation: fix commit made with --no-verify — the pre-push hooks ran on push (report), and every hook-mirrored gate also runs in CI on the head; nothing in the diff is a hook-only check.
  • Out of scope (dev): the UI consumer card was not searched for — escalated to the seat: triage 6715940083 keeps pm:blocking because console: show a user's effective permission sets and where each comes from on the user page #31216 waits on this card, which is the consumer the card body names ("filed alongside"); the seat confirms that is the widget card.
  • PR state — the report says pr_create opened feat(security): read one user's effective permission sets with their origins #31336 as a draft; the seat marks it ready before the queue.
  • Check-runs on 94ee3ead (62 at last read): 27 success, 8 skipped (opt-in and path-filtered jobs), 0 failure/cancelled/timed_out; 27 still in_progress at render (Test Core 1–8, Type Check · workspace, Build Core, Type Check UI, Test UI 1–16). Green so far include Check Changeset, Governed Surface Queue Guard, PR-body guards, Check PR Size, Spec property liveness, Type Check · source gates, Type Check · consumer gates, Lint · PM dispatch-gates, Lint · ESLint, Lint · repo & tooling gates, Lint · CI, release & engine gates, Lint · docs & code guards, Lint · scoped · query-options erasure, Lint · scoped · tooling self-tests, Dogfood Regression Gate. This record judges the diff against the contract; a red among the still-running jobs is a gate FAIL on its own terms that this record does not override.

Implemented-by: claude/project-thread-rc3s3i
Reviewed-by: session_01FEkQ5o8PE7ZtVjcor51wBZ

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ

席位意见:接受。新增按用户读取「实际权限集及来源」的接口(默认绑定、岗位、直接分配),实现 spec #31238 已声明的 ISecurityService.describeEffectiveAccess?;门禁与 explain 共用同一个调用方判定,未知用户与无权管理的用户一律同样的 403。契约复审 PASS,无必改、无应改项。

席位核对 — ACCEPT · domain:server#5 · Head-sha 94ee3ead4260b2f9e77423a52a5d4ef64797d719 · 2026-11-08T03:09Z

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  • Shape: 16 files (+811 / −42), read from the diff: the plugin-security service method and its shared caller gate (mayDescribeAnotherUser, moved verbatim out of explain), the @objectstack/rest door beside explain with its ledger row (sdk), the @objectstack/client method typed to the spec types, an opt-in origin read in resolveUserAuthzGrants (inert without the option), tests, the system-context doc row, one changeset (patch: core, plugin-security, rest, client). ⛔ No packages/spec edit: the contract landed first in PR feat(spec): declare the optional ISecurityService.describeEffectiveAccess and its EffectiveAccess answer types #31256 (spec: declare ISecurityService.describeEffectiveAccess? and the EffectiveAccess answer types (spec half of #31215) #31238), per the seat decision 6711854159.
  • Declarations: Clause-②: yes (a new served read widens the published HTTP surface; triage 6711625441: protocol addition) — the review of record on the declaration face is 6716390936 (PASS on this head, TIER_DEFAULT, reviewer other than the author; every one of the spec docblock's four bindings judged right one by one; card acceptance pinned, unknown user byte-identical to forbidden). Metadata-protocol: none.
  • Gates: head check-runs 57 success, 8 skipped, none red or pending (Build Core covers the dev's NOT MEASURED check:dual-build-cjs-loads; Type Check · consumer gates covers check:i18n). check-expected-skips: OK (exit 0). check-governed-merges: NOT governed. Mergeable state clean.
  • Consumer: the ui card console: show a user's effective permission sets and where each comes from on the user page #31216 (effective-access widget) waits on this read; it is that lane's.
  • Landing: ungoverned, tier S. Ready + auto-merge in one relay dispatch; Fixes #31215 closes the card on merge.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 8, 2026 03:10
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 8, 2026 03:10
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 8, 2026
Merged via the queue into main with commit e735425 Nov 8, 2026
66 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-rc3s3i branch November 8, 2026 03:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security: a per-user effective-access read that says where each permission set comes from (default binding, position, direct)

1 participant