Skip to content

fix(security): explain resolves the ONE authorization aggregation (#6352) - #6764

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-6352-explain-context-converge
Aug 8, 2026
Merged

fix(security): explain resolves the ONE authorization aggregation (#6352)#6764
os-zhuang merged 1 commit into
mainfrom
claude/issue-6352-explain-context-converge

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #6352

Convergence (option 1), per the findings-sweep promotion's ruled direction. No read-shape obstacle was found: resolveUserAuthzGrants(ql, userId, opts) is already the userId-driven arbitrary-user entry point (service-automation uses it for runAs:'user', #3356), so the fallback parity-pin route was not needed. No packages/core change.

The premise held, but one of its sub-claims did not

The card filed this as a dormant drift risk: "今天两者一致的,没有已知的用户可见错误". Measured against origin/main before touching anything, that is false — the two had already drifted, and the drift is user-visible today.

One probe, one ql serving identical rows to both implementations:

input resolveUserAuthzGrants (enforcement) buildContextForUser (explain, before)
positions ['org_admin', 'hr_specialist', 'everyone'] ['hr_specialist', 'everyone']
permissions ['payroll_reader', 'hr_tools', 'ai_seat'] ['payroll_reader']
systemPermissions ['manage_users'] absent
posture 'MEMBER' absent
email / org_user_ids present absent

Five aggregation inputs the mirror never read: the sys_member role projection (ADR-0095 D3), position-bound permission sets (sys_position_permission_set), the everyone anchor's bound sets (ADR-0090 D5), the platform_admin position projection (ADR-0068 D2), and the ADR-0024 ai_seat synthesis.

Why that is a live defect and not cosmetics. resolvePermissionSetsForContext resolves permission sets by name from context.positions ∪ context.permissions, and a set carried by a POSITION only ever becomes a name inside the resolver (step 6a). So for any user whose grants arrive through a position — the ordinary way an organization grants access — explain resolved strictly fewer sets than enforcement and reported a denial the runtime never made. A security panel answering "you have no access" about access the user has is exactly the failure the panel exists to prevent.

Worth recording for the next reader: the one criterion that had not drifted is the ADR-0091 validity window, because both sides already called the same isGrantActive / isGrantExpired module. Shared predicate, duplicated aggregation — the duplication was in which tables get read, never in the row rule.

The change

buildContextForUser calls resolveUserAuthzGrants. Everything the panel reports about who holds what now comes from the single governed resolver — positions, permission-set names, the platform_admin derivation, posture, accessible_org_ids, systemPermissions.

What stays explain-side is presentation, and additive:

  • collectGrantProvenance() re-reads the two grant tables purely to annotate rows the resolver dropped: expired grants (held until … — expired, so "why did access disappear" is answerable) and delegated_from origin. It feeds no verdict; both annotations are decided by the same shared ADR-0091 predicate, not by a re-derived one.
  • hasPlatformAdminGrant is now read back off the resolver's posture verdict (posture === 'PLATFORM_ADMIN'), not recomputed from the grant rows.

The returned context additionally carries systemPermissions, org_user_ids, posture, tabPermissions, email. Additive — no field removed or renamed.

One consequence documented in place rather than left for the next reader: resolveDelegatorContext inherits tenantId / org_user_ids from the live principal. buildContextForUser now returns the resolver's own org_user_ids, which without a known tenantId is the degenerate [userId] seed. The live principal's real org peer set is the better answer, so that assignment still overwrites it exactly as before; the doc bullet now says so.

check:authz-resolver — narrowed, not widened

The card warns against widening this gate's remit as a shortcut. The remit is untouched: GRANT_TABLES, the query-shaped criterion and the DELEGATORS list are unchanged. Only the ALLOW reason for explain-engine.ts changed, and it now claims less: the file still trips the heuristic, but only because of the explain-only provenance pass, and the parity invariant it used to defer to #6352 is named as pinned by tests rather than by this gate. The entry is still live (the file still queries both tables), so it is not dead weight.

node scripts/check-single-authz-resolver.mjs and its --self-test both pass.

Reverse verification — direction predicted first: RED

Predicted before running: restoring the deleted mirror should turn the new parity suite red, because the pins assert concrete resolver output the mirror cannot produce. Taken out with git checkout origin/main -- explain-engine.ts (never git stash).

9 of 49 red, each naming a specific dropped input:

× agrees with the enforcement resolver — org role + position-bound permission set + ai_seat
    expected [ 'hr_specialist', 'everyone' ] to deeply equal [ 'org_admin', 'hr_specialist', …(1) ]
× agrees with the enforcement resolver — everyone-anchor-bound permission set resolves
    expected [] to deeply equal [ 'company_directory' ]
× agrees with the enforcement resolver — unscoped admin_full_access derives platform_admin
    expected [ 'everyone' ] to deeply equal [ 'platform_admin', 'everyone' ]
× agrees with the enforcement resolver — organization_admin capability grant derives TENANT_ADMIN
    expected [ 'everyone' ] to deeply equal [ 'org_admin', 'everyone' ]
× derives hasPlatformAdminGrant from an UNSCOPED admin_full_access user grant
    expected undefined to be 'MEMBER'

Restored → 49/49 green.

Honest note on what did not go red: the ADR-0091 windows case passes against the mirror too — the shared predicate, again. It is in the suite because the window rule is load-bearing for the platform_admin derivation (an expired unscoped admin_full_access must confer no rung), not because it discriminates the two implementations.

Fixture fidelity — a defect the old shape was blind to

The buildContextForUser fakes returned every row for a table regardless of where. The mirror filtered in memory, so it produced the right answer anyway. The resolver delegates filtering to the engine, as the real ObjectQL engine does, so a fake that ignores where now reports grants nobody holds — which is what surfaced when quarter_close_admin (an expired grant) appeared in permissions. The fakes are replaced with one where-honouring stand-in (scalar equality + $in); the comment above it says why that is load-bearing rather than tidiness.

Non-vacuity

Under convergence a bare explain == core assertion is near-tautological, so each parity case also pins the concrete output the one aggregation must produce (positions, permission names, systemPermissions, accessible_org_ids, posture, platform_admin). The suite cannot pass by both sides resolving to nothing. Cases cover: org role projection, position-bound sets, the everyone anchor, both platform_admin polarities, organization_adminTENANT_ADMIN, and ADR-0091 windows. A seventh case pins that the explain-only annotations are additive — non-empty expiredGrants / delegatedPositions while positions / permissions stay byte-identical to enforcement's.

Verification

  • pnpm --filter @objectstack/plugin-security test828 passed (40 files; 822 before, +6 new)
  • pnpm --filter @objectstack/core test524 passed
  • Dependents: @objectstack/runtime 1702, @objectstack/service-automation 858, @objectstack/plugin-dev 45, @objectstack/verify 17 — all passed
  • pnpm --filter @objectstack/plugin-security typecheck → clean; repo-wide turbo run typecheck → 120/120
  • pnpm lint → clean
  • Every check:* step enumerated from .github/workflows/lint.yml, run one by one → all PASS, including check:authz-resolver and its --self-test. check:i18n, check:i18n-coverage and check:app-nav-i18n first reported PREREQUISITE NOT MET; re-run after a full workspace build, all three exit 0 ("could not run" is not a pass)
  • pnpm --filter @objectstack/spec check:generated → 10/10 up to date
  • node scripts/check-nul-bytes.mjs → OK; control-byte self-scan of the changed files → no hits

Scope

explain-engine.ts + its tests, the check:authz-resolver ALLOW reason, one changeset. No packages/core, no spec, no content/docs/releases/.

Two things checked and deliberately not touched: explain-engine.ts:720's provenance-blind Layer 1 collapse (explain's own remit, out of scope per the card), and #6684's member_default wildcard removal — buildContextForUser never referenced member_default, and the '*' grants in the explain fixtures belong to admin_full_access, which still ships one. Nothing to fix, nothing to file.


🤖 Generated with Claude Code

https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ


Generated by Claude Code

)

`buildContextForUser` was a hand-written second implementation of
`@objectstack/core`'s `resolveAuthzContext` aggregation, kept in step by two
comments claiming it mirrored the resolver. Measured over identical rows it did
not: it dropped the `sys_member` role positions, every position-bound permission
set (`sys_position_permission_set`), the `everyone` anchor's bound sets, the
`platform_admin` position projection, `systemPermissions`, the posture rung,
`email` and the `ai_seat` synthesis.

Permission sets resolve BY NAME from `positions` + `permissions`, and a
position-carried set only becomes a name inside the resolver — so any user
granted through a position was explained as holding nothing, and the panel
reported a denial enforcement never made.

It now calls `resolveUserAuthzGrants` and adds presentation only: the ADR-0091
expired / `delegated_from` row annotations the resolver correctly discards, and
`hasPlatformAdminGrant`, read back off the resolver's own posture verdict.

Pinned by a parity suite running both implementations over the same fixture rows
with per-case expected output, so it cannot pass by both sides resolving to
nothing. `check:authz-resolver`'s ALLOW entry is narrowed to what still trips its
heuristic — the explain-only provenance pass — without touching its remit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 8, 2026 2:29pm

Request Review

@github-actions github-actions Bot added the size/l label Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security.

13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-security)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/plugin-security)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/access-recipes.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/explain.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/permissions-matrix.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/plugin-security)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-security)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-security)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-security)
  • content/docs/ui/audience-based-interfaces.mdx (via packages/plugins/plugin-security)
  • content/docs/ui/dashboards.mdx (via @objectstack/plugin-security)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding: explain-engine buildContextForUser 手工镜像 resolveAuthzContext 的授权聚合,两者无任何 parity 断言,只靠注释保持一致

2 participants