Repository navigation
feat(security): read one user's effective permission sets with their origins - #31336
Conversation
…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>
📓 Docs Drift CheckThis PR changes 4 package(s): 42 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 14 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 70 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
|
domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ Contract-review-claim: session_01FEkQ5o8PE7ZtVjcor51wBZ · head 94ee3ea · 2026-11-08T02:42Z |
|
domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ Contract reviewServed-tier: Inputs read: card #31215 (body; comments 6711625441, 6711801309, 6711840578, 6711854159, 6715940083, 6716292410), PR #31336 (body, 16-file list, ① Derived judgmentsThe four things the contract docblock binds the implementer to — each judged one by one:
Accept-set and public-surface changes the diff implies:
Findings: no must-fix. Should-fix: none. Nit (no effect on correctness or contract): ② Semver level
③ Boundary flagsReport 6716292410:
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ 席位意见:接受。新增按用户读取「实际权限集及来源」的接口(默认绑定、岗位、直接分配),实现 spec #31238 已声明的 席位核对 — ACCEPT ·
|
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。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:resolveUserAuthzGrantstakeswithPermissionOriginsand then returnspermissionOrigins, a map from each name inpermissionsto the spec'sEffectivePermissionSetOrigin[]. Each origin is recorded at the site that derives the name: the grant-row loop recordsdirect, the position-binding loop recordsposition(ordefaultfor theeveryoneanchor), the configured-owner branch recordsderived: platform_admin_config, and theai_accessbranch recordsderived: ai_access. Theai_accessread uses the memoisedsys_userrow. 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):mayDescribeAnotherUser.readSysUserRow). An unknown or empty id gets the samePermissionDeniedErroras a user you may not manage: one constructor, no id in it.vetOrganizationClaimexactly as explain vets it, with origins on.humanBaselineFor) is appended last asdefault, and a set already listed keeps its place.@objectstack/rest: addsGET {basePath}/security/users/:id/effective-accessinsecurity-routes.ts, beside explain, through the family's one emitter.VALIDATION_ERRORfor any query parameter (closed setEFFECTIVE_ACCESS_QUERY_PARAMSis empty), 501 when the service lacks the member, 403PERMISSION_DENIED, classified refusals as explain classifies them, 500INTERNAL_ERROR.my-delegable-scope.sdk,client: 'security.describeEffectiveAccess'. The registrar's docblock names the route.@objectstack/client:security.describeEffectiveAccess(userId)returnsEffectiveAccess, pinned both ways inreturn-type-precision.test.ts..changeset/31215-effective-access-read.md, patch for all four packages, carrying both declaration lines (kindnone, sopatch).No file in
packages/specis touched.Why
The user page's permission-set tab lists
sys_user_permission_setrows only, so sets that arrive through theeveryonedefault binding or through a position never show. Seat decision 6711854159 chose route A: the spec half first (#31238, PR #31256, merged8b16445f), then this server half.Tests
New pins
packages/plugins/plugin-security/src/effective-access.test.ts(12) runs the real body throughresolveUserAuthzGrantsagainst a fake engine and catalog:forecastingdirect,crm_sales_userfrom positionsales_rep,member_default/app_defaultdefault, in resolver order;crm_sales_userreached by a direct row and two positions is listed once with all three origins;everyoneanchor also binds is listed once;ai_seatisderived: ai_access, and the configured owner'sadmin_full_accessisderived: platform_admin_configwithplatform_adminfirst inpositions;external_defaultcarriesposition: org_externalplusdefault, andeveryoneis not held;code,status,messageanddetailsall equal); amanage_userscaller 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):everyone;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; 500INTERNAL_ERROR.packages/client/src/return-type-precision.test.ts:toEqualTypeOfplus a@ts-expect-errorwrong-shape pin.Ablations (each restored,
git diff HEADempty after)Every mutation went through
scripts/ablation-replace.mjs, whose anchor must hit. The plugin-security and core tests import the mutated module fromsrc, so no rebuild is needed.throw new ExplainUserNotFoundError(target)in place of the shared refusal. Mutation landed (anchor 1 to 0). Red:Gate removed (
if (false)in place of themayDescribeAnotherUsercheck). Red: the four refusal cases (Tests 4 failed | 8 passed (12)).Core
directorigin record deleted. Red: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 head24d01fe2; the merge brought no file of these four packages,packages/specincluded.pnpm --filter PKG typecheckexits 0 for all four packages (pre-merge head24d01fe2).eslint --no-inline-configover the changed files exits 0.94ee3ead:effective-access.test.ts12/12 andsecurity-routes.test.ts32/32 rerun green.Derived gates (head 94ee3ea)
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 116 commands for this diff. Each ran with its exit code captured before any pipe, and the record carries:: exit Non every line.--ranreconciles: 116 derived, 114 run, 2 NOT-MEASURED (derived from a recorded exit 3), 0 UNRUN.94ee3ead:check:engine-double-contract: the effective-access test's fakefindOnenow opens withassertEngineFindOnePredicate, and--writeadded the pinned-ledger row.check:system-context-census: the shared gate'sisSystemread moved intomayDescribeAnotherUser, so row 9 ofcontent/docs/permissions/system-context.mdxnow names it besideexplainAccessForCaller(and the effective-access read), and--writeregenerated the register.check:test-source-alias:security-routes.test.tsimports@objectstack/objectqlat module top, so the lazy load is paid during collection.check-plugin-teardown-shape.mjs --self-test(fetched its pinned fixture commit) andspec check:skill-examples(client-react dist present). Both exit 0.check:dual-build-cjs-loads(no dist for 37 packages outside this closure) andcheck:i18n(the workspace CLI is not built). This diff adds no translation key and no new entry point.Acceptance notes
404; this read refuses it with the same403as a forbidden id, for every caller,manage_usersincluded (claim scope line and seat note).mayDescribeAnotherUser, with the same checks in the same order and the same fail-closed catches. Its ownPermissionDeniedErrortext and details are unchanged.vetOrganizationClaim, as explain). Amanage_userscaller 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.Verification
Run
pnpm --filter @objectstack/plugin-security exec vitest run src/effective-access.test.ts(12/12) andpnpm --filter @objectstack/rest exec vitest run src/security-routes.test.ts(the effective-access describe, 7/7).Generated by Claude Code