Skip to content

Commit 4ca358d

Browse files
hotlongclaude
andauthored
feat(plugin-auth): a membership that ends takes the session's claim on that organization with it — re-pointed if the user belongs elsewhere, revoked if not (#15879)
* wip(qa): #15784 membership-removal census probe (temporary, removed before review) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(plugin-auth): revoke or re-point a session whose membership ended (#15784) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(plugin-auth,dogfood): pin the membership-ended session trigger; regenerate i18n bundles (#15784) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(plugin-auth,docs): strip tracker ids from runtime prose, make the test double judgeable, re-derive the two censuses (#15784) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(platform-objects): regenerate the i18n source-hash companions for the widened revoke_reason description (#15784) The three translated locales' *.source-hashes.generated.ts files were left stale by the field-description change: the hashes are keyed on the source strings, so check:i18n read all three bundle sets as DRIFTED. Regenerated with the whole pass (pnpm i18n:extract), which emits both families together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent abdceef commit 4ca358d

17 files changed

Lines changed: 1031 additions & 41 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
'@objectstack/plugin-auth': minor
4+
---
5+
6+
`sys_session.revoke_reason` accepts `organization_membership_ended` — "Remove member" now actually signs the person out
7+
8+
Removing a member deleted the `sys_member` row and left the session alive, for up to seven
9+
days. #15409 closed the security half per request (a session whose `activeOrganizationId`
10+
is not backed by a membership resolves with no active organization). This is the courtesy
11+
half an admin was promised, and it is **never the enforcement**: a trigger can be missed,
12+
an evaluation cannot.
13+
14+
- **New `revoke_reason` value, `organization_membership_ended`** — an accept-set widening
15+
on a published system object, hence `minor` on `@objectstack/platform-objects`. Every
16+
reason before it is a timer (`idle_timeout`, `absolute_max`, `concurrent_cap`) or an
17+
interactive revoke (`user_revoked`, `admin`); this is the first authorization-event
18+
cause. There is no Zod enum behind the column — it is free `text` — so the field's own
19+
description is the published vocabulary, and that is where the value is declared. The
20+
string deliberately matches the one the API-key arm of the same ruling family already
21+
mints for this event (`authRefusal.reason` in `resolve-authz-context.ts`), so one grep
22+
finds every place the platform acts on a membership ending.
23+
- **The trigger acts on the ORGANIZATION'S CLAIM, never on the user** (maintainer ruling,
24+
decision batch #49 item 4, option B). A user who still holds another membership is
25+
**re-pointed** to it — never signed out of organizations they legitimately belong to. A
26+
user with no remaining membership has their session revoked through the existing
27+
`revoked_at` / `revoke_reason` mechanism, which expires it in place: better-auth returns
28+
nothing on the next request and the Console's existing 401 → login redirect handles it,
29+
with **no client change**.
30+
- **The seam is an engine hook on `sys_member`**, not a hook on better-auth's
31+
`/organization/remove-member`. A census measured that the endpoint, a direct delete, a
32+
bulk delete, the cascade from a `sys_user` delete and an organization re-point all reach
33+
the hook, while an endpoint hook would have reached one of them. Same precedent as
34+
`last-admin-guard.ts`.
35+
- **New public surface on `@objectstack/plugin-auth`**`MEMBERSHIP_ENDED_REVOKE_REASON`,
36+
`endSessionClaimsForEndedMembership` and `registerMembershipEndedSessionTrigger`, hence
37+
`minor` rather than `patch`.
38+
39+
Known open by measurement, not by omission: a raw driver delete bypasses the trigger
40+
entirely, and cloud's package-uninstall sample-data purge is one (filed as cloud#2003). The
41+
per-request check covers it; the courtesy does not.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ that silently does not happen.
9797
| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3857` |
9898
| 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` |
9999
| 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` |
100-
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1412` |
100+
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1427` |
101101
| 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` |
102102
| 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:142` |
103103
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:250` |

content/docs/permissions/tenant-audit-census.mdx

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ are reported as `undecidable` rather than assumed either way.
9898

9999
The same holds twice over for the context. An options argument spelled as a
100100
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
101-
forwarding shim cannot, and **67 of the 219 sites are spelled that way**. A
101+
forwarding shim cannot, and **67 of the 221 sites are spelled that way**. A
102102
context resolved from an inline literal or a local `const` can be tested for
103103
`isSystem`; one arriving from a helper call cannot.
104104

@@ -147,10 +147,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
147147

148148
| carried figure | where it survives | this census |
149149
| :--- | :--- | ---: |
150-
| 175 write call sites | quoted in the merged changeset | **219** |
150+
| 175 write call sites | quoted in the merged changeset | **221** |
151151
| 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **32** more whose options argument is unreadable |
152-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **147 of 219** decidable, **72** undecidable |
153-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 101 decidably elevated, 0 decidably not, 101 undecidable |
152+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **147 of 221** decidable, **74** undecidable |
153+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 103 decidably elevated, 0 decidably not, 101 undecidable |
154154
| 141 and 132, two independent re-derivations | the card that filed this work ||
155155

156156
**The differences are not reconciled, and deliberately so.** The old census's
@@ -167,11 +167,11 @@ would report a smaller number and would not say so.
167167

168168
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
169169
figure has no surviving corroboration anywhere in the tree.** This census reads
170-
101 of 219 (46%) as decidably elevated, with 101 more whose elevation is a
170+
103 of 221 (47%) as decidably elevated, with 101 more whose elevation is a
171171
run-time fact — so the claim is neither confirmed nor refuted, and the honest
172172
answer is that a static reading cannot settle it.
173173

174-
**Cite `9 / 219`, and say what it is**: the sites whose options argument was
174+
**Cite `9 / 221`, and say what it is**: the sites whose options argument was
175175
READ and holds no tenant context, against a decidably tenancy-enabled object.
176176
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
177177
without tenant context" — **32 further sites** have an options argument this
@@ -183,31 +183,31 @@ cannot read, and they are neither in nor out.
183183

184184
| what | count |
185185
| :--- | ---: |
186-
| write call sites on the application surface | **219** |
186+
| write call sites on the application surface | **221** |
187187
| …whose object name is statically decidable | 147 |
188-
| …whose object name is chosen at run time | 72 |
188+
| …whose object name is chosen at run time | 74 |
189189
| …against an object with tenancy ENABLED | 147 |
190190
| …against an object that declares tenancy off | 0 |
191-
| threading a tenant context | 135 |
191+
| threading a tenant context | 137 |
192192
| PROVABLY carrying none (options read, no context key) | **17** |
193193
| …of those, against a decidably tenancy-enabled object | **9** |
194194
| options argument UNREADABLE — may or may not carry one | 67 |
195195
| …of those, against a decidably tenancy-enabled object | 32 |
196-
| threading a decidably ELEVATED (`isSystem`) context | 101 |
196+
| threading a decidably ELEVATED (`isSystem`) context | 103 |
197197
| threading a context that is decidably NOT elevated | 0 |
198198
| threading a context whose elevation is a run-time fact | 101 |
199199

200200
| how the instrument reached the site | count |
201201
| :--- | ---: |
202-
| receiver carried a readable engine type | 174 |
202+
| receiver carried a readable engine type | 176 |
203203
| receiver erased, placed by the object NAME | 19 |
204204
| receiver erased, placed by an `object: string` PARAMETER | 15 |
205205
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 11 |
206206

207207
| object name spelled inline | 108 |
208208
| object name spelled through a `const` | 39 |
209209
| object name is an `object: string` parameter | 19 |
210-
| object name is some other run-time expression | 53 |
210+
| object name is some other run-time expression | 55 |
211211

212212
The corpus walked is every tracked non-test source under `packages/services/`
213213
and `packages/plugins/`; calls to a same-named method on something that is not
@@ -224,13 +224,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
224224
their values are not compared. The reasoning, and the measurement behind it,
225225
are in `scripts/check-tenant-audit-census.mjs`.
226226

227-
Measured on 2026-09-03 at `631038b03`.
227+
Measured on 2026-09-05 at `8a7446de8`.
228228

229229
| corpus scale (not enforced) | count |
230230
| :--- | ---: |
231-
| tracked non-test sources scanned | 542 |
232-
| engine-shaped types recognised | 57 |
231+
| tracked non-test sources scanned | 547 |
232+
| engine-shaped types recognised | 58 |
233233
| declared objects in the registry | 298 |
234-
| same-named calls subtracted as non-engine | 130 |
234+
| same-named calls subtracted as non-engine | 134 |
235235

236236
{/* END GENERATED: tenant-audit-census */}

docs/audits/2026-08-tenant-audit-write-call-sites.counts.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
2929

3030
| Measure | Value |
3131
|---|---:|
32-
| Write call sites | 219 |
32+
| Write call sites | 221 |
3333
| Object name statically decidable | 147 |
34-
| Object name chosen at run time | 72 |
34+
| Object name chosen at run time | 74 |
3535
| Against a tenancy-enabled object | 147 |
3636
| Against an object declaring tenancy off | 0 |
37-
| Threading a tenant context | 135 |
37+
| Threading a tenant context | 137 |
3838
| Provably carrying none | 17 |
3939
| …and decidably tenancy-enabled | 9 |
4040
| Options argument unreadable | 67 |
4141
| …and decidably tenancy-enabled | 32 |
42-
| Threading a decidably elevated context | 101 |
42+
| Threading a decidably elevated context | 103 |
4343
| Threading a decidably non-elevated context | 0 |
4444
| Threading a context of undecidable elevation | 101 |
4545

@@ -52,14 +52,14 @@ holds still. They are required to be HERE and to say WHEN they were true;
5252
their values are not compared. The reasoning, and the measurement behind it,
5353
are in `scripts/check-tenant-audit-census.mjs`.
5454

55-
Measured on 2026-09-03 at `631038b03`.
55+
Measured on 2026-09-05 at `8a7446de8`.
5656

5757
| corpus scale (not enforced) | count |
5858
| :--- | ---: |
59-
| tracked non-test sources scanned | 542 |
60-
| engine-shaped types recognised | 57 |
59+
| tracked non-test sources scanned | 547 |
60+
| engine-shaped types recognised | 58 |
6161
| declared objects in the registry | 298 |
62-
| same-named calls subtracted as non-engine | 130 |
62+
| same-named calls subtracted as non-engine | 134 |
6363

6464
## Every site
6565

@@ -92,6 +92,7 @@ Measured on 2026-09-03 at `631038b03`.
9292
| `packages/plugins/plugin-auth/src/backfill-account-issuer.ts` | `update` | `sys_account` | enabled | elevated | 1 |
9393
| `packages/plugins/plugin-auth/src/ensure-default-organization.ts` | `insert` | `object` | undecidable | elevated | 1 |
9494
| `packages/plugins/plugin-auth/src/member-role-canonical.ts` | `update` | `MEMBER_OBJECT` | undecidable | elevated | 1 |
95+
| `packages/plugins/plugin-auth/src/membership-ended-session.ts` | `update` | `SystemObjectName.SESSION` | undecidable | elevated | 2 |
9596
| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `delete` | `m` | undecidable | options unreadable | 1 |
9697
| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `insert` | `m` | undecidable | options unreadable | 1 |
9798
| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `update` | `m` | undecidable | options unreadable | 1 |

packages/platform-objects/src/apps/translations/en.objects.generated.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,11 +328,11 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
328328
},
329329
revoked_at: {
330330
label: "Revoked At",
331-
help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin). System-managed."
331+
help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin / organization membership ended). System-managed."
332332
},
333333
revoke_reason: {
334334
label: "Revoke Reason",
335-
help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, …)."
335+
help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, user_revoked, admin, organization_membership_ended, …)."
336336
},
337337
active_organization_id: {
338338
label: "Active Organization"

packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,11 +328,11 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
328328
},
329329
revoked_at: {
330330
label: "Revoked At",
331-
help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin). System-managed."
331+
help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin / organization membership ended). System-managed."
332332
},
333333
revoke_reason: {
334334
label: "Revoke Reason",
335-
help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, …)."
335+
help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, user_revoked, admin, organization_membership_ended, …)."
336336
},
337337
active_organization_id: {
338338
label: "Organización activa"

packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -567,9 +567,9 @@ export const esESGeneratedSourceHashes: Readonly<Record<string, string>> = {
567567
"objects.sys_secret.fields.id.label": "00b0385c9c152888",
568568
"objects.sys_session.fields.last_activity_at.help": "f7851e9373505e73",
569569
"objects.sys_session.fields.last_activity_at.label": "43bd2f1b231bc12b",
570-
"objects.sys_session.fields.revoke_reason.help": "ff2c9a1aa9be356d",
570+
"objects.sys_session.fields.revoke_reason.help": "fcd18baf6b341b7f",
571571
"objects.sys_session.fields.revoke_reason.label": "13b0146153a2ecf8",
572-
"objects.sys_session.fields.revoked_at.help": "62d1b62cde5ce487",
572+
"objects.sys_session.fields.revoked_at.help": "77a508fdceda2f6d",
573573
"objects.sys_session.fields.revoked_at.label": "054f918e632528c7",
574574
"objects.sys_setting.fields.scope.options.global": "5e377106508d2ecd",
575575
"objects.sys_setting_audit._views.recent.label": "62d27bb9d0349c99",

packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,11 +328,11 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
328328
},
329329
revoked_at: {
330330
label: "Revoked At",
331-
help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin). System-managed."
331+
help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin / organization membership ended). System-managed."
332332
},
333333
revoke_reason: {
334334
label: "Revoke Reason",
335-
help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, …)."
335+
help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, user_revoked, admin, organization_membership_ended, …)."
336336
},
337337
active_organization_id: {
338338
label: "アクティブ組織"

packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -560,9 +560,9 @@ export const jaJPGeneratedSourceHashes: Readonly<Record<string, string>> = {
560560
"objects.sys_secret.fields.id.label": "00b0385c9c152888",
561561
"objects.sys_session.fields.last_activity_at.help": "f7851e9373505e73",
562562
"objects.sys_session.fields.last_activity_at.label": "43bd2f1b231bc12b",
563-
"objects.sys_session.fields.revoke_reason.help": "ff2c9a1aa9be356d",
563+
"objects.sys_session.fields.revoke_reason.help": "fcd18baf6b341b7f",
564564
"objects.sys_session.fields.revoke_reason.label": "13b0146153a2ecf8",
565-
"objects.sys_session.fields.revoked_at.help": "62d1b62cde5ce487",
565+
"objects.sys_session.fields.revoked_at.help": "77a508fdceda2f6d",
566566
"objects.sys_session.fields.revoked_at.label": "054f918e632528c7",
567567
"objects.sys_setting_audit._views.recent.label": "62d27bb9d0349c99",
568568
"objects.sys_setting_audit.fields.id.label": "00b0385c9c152888",

packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,11 +328,11 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
328328
},
329329
revoked_at: {
330330
label: "Revoked At",
331-
help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin). System-managed."
331+
help: "When set, this session was revoked (idle / absolute-max / concurrent-cap / admin / organization membership ended). System-managed."
332332
},
333333
revoke_reason: {
334334
label: "Revoke Reason",
335-
help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, …)."
335+
help: "Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, user_revoked, admin, organization_membership_ended, …)."
336336
},
337337
active_organization_id: {
338338
label: "当前组织"

0 commit comments

Comments
 (0)