You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 9d13b5f
Browse filesBrowse the repository at this point in the historyBrowse files
fix(plugin-auth,platform-objects): revoke a session by its row id, resolved server-side (#24791)
Security fix (maintainer-ordered; no public card; detail withheld under
the disclosure ruling).
Clause-②: no
## The invariant
Setup → Diagnostics → Sessions (and Account → Active Sessions): a row's
**Revoke Session** ends the session it names, and that session's next
request is refused. It works for the session's owner and for a platform
administrator, and for nobody else. No request to or answer from the
revoke path carries a session token, and `sys_session.token` stays
`internal: true`.
## What changed
- **`packages/platform-objects`**: `sys_session` `revoke_session` is
keyed on the row's `id`, which every list row carries. It sets
`recordIdParam: 'sessionId'`, and `recordIdField` falls back to its
default `id`. It posts to `POST /api/v1/auth/sys-session/revoke`. The
`token` field is unchanged: `internal`, `hidden`, `readonly`. The
console runtime is generic, so objectui needs no change.
- **`packages/plugins/plugin-auth`**: new ObjectStack mount `POST
/api/v1/auth/sys-session/revoke`. The handler is
`sys-session-revoke.ts`, the mount is in `auth-plugin.ts`, and the route
is in `auth-route-ledger.ts` plus its conformance pin. The server looks
up the session from the id through better-auth's own storage adapter,
which hides ended sessions and re-attaches the internal column
server-side. The token is used only inside the server.
- **The owner lane** forwards the request in-process to better-auth's
`POST /revoke-session`, with the caller's own credentials. That route's
ownership and freshness checks still apply, and so do the existing
admission guard and the `user_revoked` tombstone, all unchanged.
- **The administrator lane** handles another user's session. The
ADR-0068 D2 `judgePlatformAdmin` admits the caller (held sessions are
refused per ADR-0069 D3). The session is then tombstoned in place with
`revoke_reason: admin` (ADR-0069 D4). This is the same row shape
`membership-ended-session.ts` and `forced-reset-revocation.ts` write.
The reason value comes from the interactive-revoke ledger, so the two
cannot drift. better-auth's `/admin/revoke-user-session` is not used as
the delegate: it authorizes on the legacy `role` scalar, which ADR-0068
D2 administrators do not carry.
- **Every other signed-in caller** gets one `404 RESOURCE_NOT_FOUND`,
byte-identical to the answer for an id that names no live session. An
anonymous caller gets `401 UNAUTHENTICATED`, and a body without
`sessionId` gets `400 INVALID_REQUEST`.
- The route is not under `/api/v1/auth/admin/`, because its first lane
admits the owner. It belongs to the `sys-oauth-application/register`
family.
- **`session-tombstone.ts`**: one comment sentence. The `admin` reason
now has a third writer.
- **Tenant-audit census**:
`content/docs/permissions/tenant-audit-census.mdx` and
`docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` were
regenerated with `node scripts/tenant-audit-census.mjs --write`, and the
page's hand-written figures were moved from 231 to 232. The
administrator lane's tombstone write is a new engine write call site,
and `check-tenant-audit-census` requires it to be counted.
- **Changeset**: `.changeset/sys-session-revoke-by-id.md`, a patch for
`@objectstack/plugin-auth` and `@objectstack/platform-objects`.
## Tests
All on this branch. `plugin-auth` resolves the subject through relative
source imports, so its runs read `src/`.
- **New pins**, in
`packages/plugins/plugin-auth/src/sys-session-revoke.test.ts`. They run
through the plugin's real route registration on a real Hono app, in
front of a real `AuthManager`, with cookies minted by better-auth's own
sign-in. Every revoke asserts both halves: the cookie stops
authenticating, and the row survives with its cause. Every refusal
asserts that the target still authenticates and its row is untouched, as
`status` plus `code`.
- an administrator revoking another user's session by id ends it, so
that session's next request is refused (`revoke_reason: admin`, and the
administrator's own session is untouched);
- a session's owner revoking their own other session by id ends that one
and keeps the current one (`user_revoked`);
- an already-ended session reads not-found, and its record keeps the
first ending;
- a signed-in caller without platform-admin standing cannot revoke
another user's session (`404 RESOURCE_NOT_FOUND`, byte-identical to an
id that names nothing);
- an anonymous caller is refused before anything is revoked (`401
UNAUTHENTICATED`);
- a request that names no session is refused, and nothing is revoked
(`400 INVALID_REQUEST`);
- the administrator lane records the interactive-revoke ledger's own
`admin` value.
- Each answer above is also asserted to carry no session token.
- **New pins**, in
`packages/platform-objects/src/platform-objects.test.ts`:
- `revoke_session` identifies its session by the row id and posts
`sessionId` to the new route;
- `sys_session.token` stays `internal`, `hidden` and `readonly`;
- no identity action identifies its record by a field the server never
serves. This sweep covers every identity object and has a positive
control.
- `pnpm --filter @objectstack/plugin-auth exec vitest run
--maxWorkers=2` reported Test Files 166 passed (166) and Tests 3185
passed, 10 skipped (3195).
- `pnpm --filter @objectstack/plugin-auth typecheck` passed: src,
examples and the test layer (`check:test-typecheck: OK`).
- `pnpm --filter @objectstack/platform-objects test` reported Test Files
79 passed (79) and Tests 1198 passed (1198). `typecheck` passed.
- **After merging `origin/main`** (74f7815): `pnpm install
--frozen-lockfile`, `@objectstack/spec` build, then `check:generated`
("All 14 generated artifacts are up to date"), then `plugin-auth` and
`platform-objects` typecheck. All passed.
- **Ablations.** Each one mutated one line, through
`scripts/ablation-replace.mjs` in wrap mode (anchor hit 1 to 0, blob
changed). Each restore was proven: blob equals HEAD, and `git diff HEAD`
is empty. The direction was predicted before each run, and every result
matched the prediction.
- A1, the standing check removed from the administrator lane: 1 red, the
non-admin pin.
- A2, the administrator tombstone write removed: 2 red, the admin pin
and the already-ended pin.
- A3, the owner lane removed: 1 red, the owner pin.
- A4, the action re-keyed to `token`: 2 red, the declaration pin and the
sweep.
## Gates
Derived by `node scripts/pm/dispatch-gates.mjs --commands` at 3270bb1
(the final head), then reconciled with `--ran` against recorded exit
codes. Result: **103 derived, 100 run with exit 0, 3 not measured, 0
unrun.**
- Code-reading families ran at 300e9d4. The only later commit
(3270bb1) changes the two census docs. Every family that reads those
docs was derived again and re-run at 3270bb1: the 30 doc families,
plus the census, doc-authoring, docs-audit, nul-bytes, issue-citations,
comment-mask and auth-mount-ledger families.
- **NOT MEASURED** (each exited 3 on a prerequisite; CI runs all three):
- `check-plugin-teardown-shape --self-test`, reason: this shallow
checkout cannot reach the fixture commit;
- `check:dual-build-cjs-loads`, reason: it needs every package's
`dist/`;
- `check:i18n`, reason: it needs the built CLI closure. No translatable
string changed: the action's label, confirm text and success message are
byte-identical.
- `check:tenant-audit-census` went red on the new write site. It passed
after the census was regenerated (232 write call sites).
- `test:repo` and the full CI battery are left to CI.
## Acceptance notes
- `content/docs/permissions/tenant-audit-census.mdx` records its
measurement commit as the merge head the census was regenerated on. Per
that page, the measurement date and sha are unenforced.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(plugin-auth,platform-objects): Revoke Session on a `sys_session` row ends the session it names
7
+
8
+
Clause-②: no
9
+
10
+
-**The action.** The `sys_session``revoke_session` row action (Setup → Sessions, Account → Active Sessions) posts the row's `id` as `sessionId` to a new plugin-auth route, `POST /api/v1/auth/sys-session/revoke`. The server resolves the session from the id. The session token stays `internal: true`: it is not served to the console or to any other client, and no request to or answer from this route carries it.
11
+
-**Who may revoke.** The session's owner: the route hands the request to better-auth's own `POST /api/v1/auth/revoke-session`, so that route's checks and its `user_revoked` record apply exactly as before. Another user's session: a platform administrator only (the ADR-0068 D2 standing every `/api/v1/auth/admin/*` mount judges). The session is ended in place with `revoke_reason: admin` (ADR-0069 D4), stays listed under the Revoked view, and its next request is unauthenticated.
12
+
-**Refusals.** An anonymous caller: `401 UNAUTHENTICATED`. A body without `sessionId`: `400 INVALID_REQUEST`. Every other caller, and an id that names no live session: `404 RESOURCE_NOT_FOUND`, one answer for every cause. A platform administrator whose own sign-in is held by the authentication policy: `403` with the policy's code.
13
+
-**Unchanged.**`POST /api/v1/auth/revoke-session`, "Sign out other devices", and every `sys_session` read path.
Copy file name to clipboardExpand all lines: content/docs/permissions/tenant-audit-census.mdx
+14-14Lines changed: 14 additions & 14 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way.
122
122
123
123
The same holds twice over for the context. An options argument spelled as a
124
124
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
125
-
forwarding shim cannot, and **50 of the 231 sites are spelled that way**. A
125
+
forwarding shim cannot, and **50 of the 232 sites are spelled that way**. A
126
126
context resolved from an inline literal or a local `const` can be tested for
127
127
`isSystem`; one arriving from a helper call cannot.
128
128
@@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
187
187
188
188
| carried figure | where it survives | this census |
189
189
| :--- | :--- | ---: |
190
-
| 175 write call sites | quoted in the merged changeset |**231**|
190
+
| 175 write call sites | quoted in the merged changeset |**232**|
191
191
| 24 carrying no tenant context | quoted in the merged changeset |**2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
192
-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card |**156 of 231** decidable, **75** undecidable |
193
-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration**|**not reproduced**: 130 decidably elevated, 0 decidably not, 93 undecidable |
192
+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card |**156 of 232** decidable, **76** undecidable |
193
+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration**|**not reproduced**: 131 decidably elevated, 0 decidably not, 93 undecidable |
194
194
| 141 and 132, two independent re-derivations | the card that filed this work | — |
195
195
196
196
**The differences are not reconciled, and deliberately so.** The old census's
@@ -207,11 +207,11 @@ would report a smaller number and would not say so.
207
207
208
208
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
209
209
figure has no surviving corroboration anywhere in the tree.** This census reads
210
-
130 of 231 (56%) as decidably elevated, with 93 more whose elevation is a
210
+
131 of 232 (56%) as decidably elevated, with 93 more whose elevation is a
211
211
run-time fact — so the claim is neither confirmed nor refuted, and the honest
212
212
answer is that a static reading cannot settle it.
213
213
214
-
⇒ **Cite `2 / 231`, and say what it is**: the sites whose options argument was
214
+
⇒ **Cite `2 / 232`, and say what it is**: the sites whose options argument was
215
215
READ and holds no tenant context, against a decidably tenancy-enabled object.
216
216
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
217
217
without tenant context" — **29 further sites** have an options argument this
@@ -223,31 +223,31 @@ cannot read, and they are neither in nor out.
223
223
224
224
| what | count |
225
225
| :--- | ---: |
226
-
| write call sites on the application surface |**231**|
226
+
| write call sites on the application surface |**232**|
227
227
| …whose object name is statically decidable | 156 |
228
-
| …whose object name is chosen at run time |75|
228
+
| …whose object name is chosen at run time |76|
229
229
| …against an object with tenancy ENABLED | 155 |
230
230
| …against an object that declares tenancy off | 1 |
231
-
| threading a tenant context |173|
231
+
| threading a tenant context |174|
232
232
| PROVABLY carrying none (options read, no context key) |**8**|
233
233
| …of those, against a decidably tenancy-enabled object |**2**|
234
234
| options argument UNREADABLE — may or may not carry one | 50 |
235
235
| …of those, against a decidably tenancy-enabled object | 29 |
236
-
| threading a decidably ELEVATED (`isSystem`) context |130|
236
+
| threading a decidably ELEVATED (`isSystem`) context |131|
237
237
| threading a context that is decidably NOT elevated | 0 |
238
238
| threading a context whose elevation is a run-time fact | 93 |
239
239
240
240
| how the instrument reached the site | count |
241
241
| :--- | ---: |
242
-
| receiver carried a readable engine type |190|
242
+
| receiver carried a readable engine type |191|
243
243
| receiver erased, placed by the object NAME | 31 |
244
244
| receiver erased, placed by an `object: string` PARAMETER | 5 |
245
245
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
246
246
247
247
| object name spelled inline | 107 |
248
248
| object name spelled through a `const`| 49 |
249
249
| object name is an `object: string` parameter | 7 |
250
-
| object name is some other run-time expression |68|
250
+
| object name is some other run-time expression |69|
251
251
252
252
### Subtractions the census could NOT defend — enforced
253
253
@@ -297,11 +297,11 @@ holds still. They are required to be HERE and to say WHEN they were true;
297
297
their values are not compared. The reasoning, and the measurement behind it,
298
298
are in `scripts/check-tenant-audit-census.mjs`.
299
299
300
-
Measured on 2026-10-20 at `1f2b0cb73`.
300
+
Measured on 2026-10-20 at `300e9d436`.
301
301
302
302
| corpus scale (not enforced) | count |
303
303
| :--- | ---: |
304
-
| tracked non-test sources scanned |674|
304
+
| tracked non-test sources scanned |675|
305
305
| engine-shaped types recognised | 80 |
306
306
| declared objects in the registry | 118 |
307
307
| same-named calls subtracted as non-engine | 166 |
{route: 'POST /api/v1/auth/admin/set-user-manager',family: 'objectstack-mount',source: 'objectstack',disposition: 'server-only',note: 'no SDK method builds this URL — the sys_user set_user_manager action posts it directly; sets or clears sys_user.manager_id, which drives the approvals `manager` rung and the own_and_reports scope and had no product write surface at all (the ADR-0092 D2 managed-update whitelist is {name,image,locale}). Runs under a system context so it reaches the column BY CONTEXT, not by whitelist — no Tier-1 list moves and the column keeps readonly: true; platform-admin gated (ADR-0068), ADR-0057 / ADR-0092'},
244
244
{route: 'POST /api/v1/auth/admin/unlock-user',family: 'objectstack-mount',source: 'objectstack',disposition: 'server-only',note: 'no SDK method builds this URL — the sys_user unlock_user action posts it directly; clears a brute-force lockout (sys_user.locked_until / failed_login_count), a custom per-identity mechanism with no better-auth endpoint; platform-admin gated (ADR-0068), ADR-0069 D2'},
245
245
{route: 'POST /api/v1/auth/sys-oauth-application/register',family: 'objectstack-mount',source: 'objectstack',disposition: 'server-only',note: 'no SDK method builds this URL — the sys_oauth_application create action posts it directly; session-required self-service wrapper over better-auth /oauth2/create-client that splits the Console\'s newline-separated redirect-URL textarea into the redirect_uris array the vendor schema requires'},
246
+
{route: 'POST /api/v1/auth/sys-session/revoke',family: 'objectstack-mount',source: 'objectstack',disposition: 'server-only',note: 'no SDK method builds this URL — the sys_session revoke_session row action posts it directly with the row id; the server resolves the session from the id, so the session token never travels to or from the console. The session owner is re-dispatched to better-auth /revoke-session; another user\'s session is revocable by a platform admin only (ADR-0068 D2), tombstoned with reason admin (ADR-0069 D4)'},
0 commit comments