Uh oh!
There was an error while loading. Please reload this page.
fix(core): a lapsed sys_member row confers no org role either (#10982) - #11088
Conversation
`resolveUserAuthzGrants` reads `sys_member` once and derives two facts from it — `accessible_org_ids` (ADR-0105 D2) and the org-administration role projection into `positions` (ADR-0095 D3). Only the first applied the ADR-0091 validity window, so a lapsed membership granted no org access while still conferring its better-auth role: two answers from one row. The role projection now drops out-of-window rows BEFORE the derivation — the shape step 6 already gives `sys_user_permission_set`, so a lapsed membership can no more yield `org_owner` than an expired `admin_full_access` can yield `platform_admin`. Fail-closed per ADR-0091 D2. Maintainer ruling, 2026-08-22 live session (item 2): a lapsed membership is no membership, not merely no org access. `sys_member` declares neither bound today and `isGrantActive` reads an absent bound as unbounded, so no shipped row changes answer — asserted directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift CheckThis PR changes 2 package(s): ⛔ 2 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 24 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 4134c5b1229602b4561bbe33c0e66e9ffebfbc2a && git checkout 4134c5b1229602b4561bbe33c0e66e9ffebfbc2a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ab47f6974a275586355f06abd83a0975b8637d12 b21538c3f53ffbfc33bc27fde0ba35d68e753637 && git checkout -B drift-repro ab47f6974a275586355f06abd83a0975b8637d12 && git merge --no-ff b21538c3f53ffbfc33bc27fde0ba35d68e753637
node scripts/docs-audit/affected-docs.mjs --json ab47f6974a275586355f06abd83a0975b8637d12
|
os-warren
commented
Aug 22, 2026
CI is green on Verified by enumerating jobs rather than reading a check-suite conclusion: two ⛔ This PR stays draft on purpose and is NOT waiting on me. The card carries As the PM seat that dispatched this card, I will not mark it ready, will not arm auto-merge, and will not clear the label. Earlier today I did exactly that on PR #11064 — flipped a PR in this same posture to ready and armed auto-merge, and it merged with zero reviews recorded, leaving the label in place so the control still looked intact. Recording that here so this PR's posture is read as deliberate rather than stalled. Note for the reviewer: the PR body flags one open question the seat deliberately did not decide — whether the resolver's fellow-org peer read (step 5) should also honour the window. No code here touches it. Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32596638074 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10982
resolveUserAuthzGrantsreadssys_memberonce and derives two facts from that one read. Only one of them applied the ADR-0091 validity window.accessible_org_ids— thegroupposture's read reach (ADR-0105 D2)positions(ADR-0095 D3)So a membership outside its window granted no org access while still conferring its better-auth role — one row, two answers.
The ruling
Maintainer, 2026-08-22 live session (item 2): A — the role projection honours the window. A lapsed membership is no membership, not merely no org access. Fail-closed per ADR-0091 D2.
The change
One
isGrantActivecall in theactiveMembersfilter, placed before the derivation — the shape §6 already givessys_user_permission_set, so a lapsed membership can no more yieldorg_ownerthan an expiredadmin_full_accesscan yieldplatform_admin. The org-scoping half is unchanged; with no active org every membership still contributes.⛔ This does not add
valid_from/valid_untiltosys_member, and does not touchpackages/spec.Premise re-derived on the current tree — one delta, reported not worked around
The card measured at
38bc74ed1. Re-derived at merge base93304c260: the defect is exactly as filed — both derivations still read the sameconst members = await tryFind(ql, 'sys_member', …)(one read, line 367), (a) windows at line 370, (b) did not.lapsed-own-membership-among-active-peersgolden inresolve-authz-context.batch-equivalence.test.tsto be flipped. That file does not exist onmain. PR #10981, which added it, was closed unmerged (superseded by an earlier claim on #10825); PR #10980 was closed too, so neither the batching nor the fixture landed, and #10825 is still open and queued.git log -Sacross all refs finds the fixture name nowhere. There was no golden to flip and nothing was deleted. The four directions are pinned instead in the file that does exist,resolve-authz-context.test.ts, inside its existinggrant validity windows (ADR-0091 D1/D2)block — beside the §4 and §6 cases, which is where a reader looks for this. See "Hand-off" below.What is pinned — four directions, seven tests
All in
resolve-authz-context.test.ts→#10982 — a lapsed sys_member row confers no role either.positionsno longer carriesorg_member;accessible_org_idsis still[].everyoneis asserted present so the negative cannot pass by the resolver having returned nothing.valid_from) is equally inert — the window is half-open at both ends, not just the upper one.sys_memberdeclares neither column, so every shipped row looks like this one and must be unaffected. This is the leg that says the change is safe to land now.The escalation case, before and after — measured, not inferred
Fixture: the lapsed row's
roleisowner, with asys_positionrow namedorg_ownerbound to theorganization_adminset.positions: ["org_owner","everyone"],permissions: ["organization_admin"],posture: "TENANT_ADMIN",accessible_org_ids: []— the card's exact defect, as an envelope.org_ownerabsent,organization_adminnever resolved,posture: "MEMBER",accessible_org_ids: [].derivePosturereads held capability grants, never the role (ADR-0095 D2/D3). The role reachesTENANT_ADMINtwo ways: (P)org_ownerresolves asys_positionrow whose bound set is an org-admin grant — entirely inside this resolver, and this fix closes it; (D) plugin-security'sreconcileOrgAdminGrantprovisions a directsys_user_permission_setrow from the role. This fix deliberately does not reach into (D): that row is standing authority in its own right carrying its own ADR-0091 window, and revoking someone else's grant row from a read path is not this resolver's job. A seventh test pins (D) explicitly so the boundary is a measured fact rather than an assumption.No ADR-0112 envelope is involved anywhere in this change — nothing here throws or returns a refusal; the resolver is fail-closed by omission. So there is no
code/statuspair to assert, and none is invented.Ablation — signature predicted in writing first
Predicted before mutating (
isGrantActive(m, nowMs) &&removed from the filter, restoringmain's shape, with an on-disk marker): 5 failed / 55 passed of 60, failures being tests 1, 2, 3, and the two escalation legs; tests 4 (unbounded) and 5 (active owner) predicted to stay green in both directions.Observed: 5 failed | 55 passed (60) — the exact five predicted, with tests 4 and 5 green. First failure:
expected [ 'org_owner', 'everyone' ] to not include 'org_owner'.On-disk proof of the mutation: marker present in
src(1), the fixed-form conjunct gone (0). Restore proved byte-identical —git hash-objecton the file returned991975da6dbcda6d9096cc3a38abcd096265945e, equal to whatgit rev-parsereports for that same path at HEAD (spelled HEAD, then a colon, then the file path — written out because the angle-bracket placeholder form is stripped from GitHub bodies) — withgit diff --exit-codeclean and empty porcelain, and then re-run to a real verdict rather than trusted: 60 passed (60).srcvsdist, in the falsifiable formpackages/core/distdid not exist in the worktree at all (the closure build builds core's dependencies), yet the suite ran — the test imports./resolve-authz-context.js, a relative specifier insidepackages/core/src/security/, which never consults the packageexportsmap (that map points exclusively intodist/). The package's vitest config declares no aliases.Made falsifiable rather than argued:
packages/corewas then built from the fixed source, sodist/index.jscompiled the fixed filter, and onlysrcwas mutated. Across all three points — before, during, after — the 12-filedistmd5 manifest was identical and the marker string appeared indistzero times. Had the suite resolved throughdistit would have stayed 60/60 green throughout; it went 5-red and back. The verdict followssrc.Zero-hit counter-check — positive control first
Positive control, run first: the instrument finds the four known windowed call sites in the resolver (lines 370, 393, 412, 433). It works on this corpus.
Then the zero-hit search, over the closed population rather than a grep count: the raw
membersarray is referenced in exactly three places — the read itself (367), theaccessible_org_idsloop (369, windowed at 370), and theactiveMembersfilter (391, windowed at 393). No derivation off the principal's own membership read bypasses the window any more.Declared narrowing: this counter-check is scoped to
resolveUserAuthzGrants, not to every reader ofsys_member.rolerepo-wide. A repo-wide.rolesweep returns ~38 candidate files and a hit count there would be a number, not a fact.Bounded in-place fix, declared
packages/qa/dogfood/test/authz-conformance.matrix.tsis a ledger of what is enforced, and itsgrant-validity-windowrow said in prose that "the org-administration role projection beside it is NOT window-filtered" — which this PR makes false. Leaving it is the stale-explanation-beside-changed-behaviour shape (#11046). Updated: theenforcementfield now names step 3, and the note records the ruling. Two by-products: it also cited #8802, which 404s in this repo — a dangling reference, replaced with #10982; and its remaining unfiltered-read list now names the resolver's fellow-org peer read explicitly (see Open question).spec-liveness-check.ymltriggers onpackages/qa/dogfood/**. All four ran and are green below. The file's last commits are #10600 and #9794, neither in flight today.Breaking vs not — argued in the open
Changeset is
patch, and the direction genuinely is a tightening: a membership that used to confer a role stops conferring it, which is normally major-shaped. It ispatchbecause the affected population is provably empty —sys_memberdeclares neithervalid_fromnorvalid_until, andisGrantActivereads an absent bound as unbounded, so no row any deployment can currently store is lapsed. That is asserted directly (direction 4), not reasoned about. Landing now is the cheap moment: once the columns exist, the same change becomes a migration carrying live semantics.Hand-off — please read before #10825 is re-dispatched
The deliberate pin the ruling relied on is gone with PR #10981. Whoever re-lands the batching will capture goldens from a
mainthat already honours the window, solapsed-own-membership-among-active-peerswill record the corrected answer as its baseline and the "flip is an act, not drift" property evaporates silently. The seven tests here are the durable replacement. Noted on #10825.Verification
Gate union derived on the final commit
b21538c3f, clean tree,node scripts/pm/dispatch-gates.mjswith no path arguments (it read the change set from the merge base itself: 4 committed paths, 0 working-tree, 0 untracked). Exit codes captured before any pipe; each row quotes the gate's own verdict line, never a bare exit status.check:authz-resolvercheck:nul-bytescheck:cross-package-test-inputs(+ script)check:kernel-hook-pairscheck:slot-lookupcheck:test-source-aliascheck:type-source-resolutioncheck:changeset-gate-self-testscheck:objectui-changesetcheck:query-options-erasurecheck:engine-double-contractcheck:where-matchercheck:type-check-coveragecheck:type-check-debt --re-measurecheck:empty-state·check:liveness·check:strictness-ledger·check:variant-docscheck-adr-0087-registration·check-changeset-no-major·check-ci-filter-parity·check-empty-changeset·check-plugin-teardown-shape·docs-audit/check-affected-docsNo gate printed a refusal —
PREREQUISITE NOT MET/cannot run/Nothing was checkedscored 0 occurrences, so none of these is an unmeasured green. The two ratchets ran against a fully built workspace closure (turbo run build --filter=./packages/* --filter=./packages/*/*, 70/70 successful) exactly aslint.ymldoes.check:optional-error-sinkis absent by construction — it computes its own population and scoressilentfor every card.check:type-check-debtreported a pre-existing surplus of 12 in@objectstack/plugin-auth's TEST_DEBT entry. Not this diff — this PR touches noplugin-authfile — and PR #11038 is open on that package, so lowering it here would be claiming another lane's work.Tests, on
b21538c3f:@objectstack/core(full package suite)resolve-authz-context.test.tsalone, verbose@objectstack/dogfoodtypecheck(tsc --noEmit)Everything heavy ran through
scripts/pm/os-verify-lock.sh; the wider farm is CI's run.Open question I am not deciding here
The resolver's fellow-org read (step 5,
sys_member {organization_id}→org_user_ids, the collaborator roster identity-table RLS scopes to) is not window-filtered. I have recorded it in the ledger as a roster rather than a grant — the same reasoning the #8710 ruling applied to approval routing — but whether a lapsed peer should stay visible to their former colleagues is an authorization-semantics call of exactly the kind this card's ruling settled for the role projection, and it is a different read from the one the ruling scoped. Flagged for the maintainer rather than decided by me, and no code here touches it.Out-of-scope finding
packages/plugins/plugin-auth/src/last-admin-guard.ts— itsSTANDING_KEY_EXCLUSIONSnote forsys_member.valid_fromstates that "the org-administration role projection it feedspositionsfrom is NOT window filtered", which this PR makes stale. The exclusion decision is unaffected (that guard counts administrators by grade alone), so this is prose accuracy, not a live defect. Not fixed here:plugin-authis out of this lane and PR #11038 is open on it. Filed separately and linked below.Generated by Claude Code