Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/session-interactive-revoke-tombstone.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@objectstack/plugin-auth": patch
"@objectstack/platform-objects": patch
---

fix(plugin-auth,platform-objects): record the `admin` cause an interactive session revoke never could (#7732)

`sys_session.revoked_at` / `revoke_reason` are declared `readonly` and
documented "System-managed", and `revoked_at`'s description names all four
causes they capture: *idle / absolute-max / concurrent-cap / admin* (ADR-0069
D4). Three of them worked — `enforceSessionControls` and `enforceConcurrentCap`
expire the row in place and stamp both columns. The fourth could not: an
admin or user-initiated revoke reaches better-auth's `deleteSession` /
`deleteUserSessions`, which **delete the row**, and a deleted row carries no
`revoke_reason`. The audit trail was inert for the single cause an audit most
wants.

**What changes.** An interactive revoke now ends the session by stamping it
rather than deleting it — the same shape the automatic path already writes
(`expires_at` into the past plus both columns). Five endpoints are covered:
`POST /revoke-session`, `/revoke-sessions`, `/revoke-other-sessions`,
`/admin/revoke-user-session` and `/admin/revoke-user-sessions`. Self-service
revocations record `revoke_reason: 'user_revoked'` and the two admin routes
record `'admin'`, because the column is the only thing in the row that says who
ended the session and recording `admin` for a user signing out their own other
device would be a *wrong* audit record rather than a vague one.

The substitution happens at the better-auth → ObjectQL adapter, so better-auth's
whole session-delete hook lifecycle still runs — **OIDC back-channel logout
still fires on a revoke**. `sys_session`'s field declarations are unchanged.

**Revoked rows are also retained.** better-auth's one expiry-driven collector
(inside `GET /get-session`) would otherwise delete the new tombstone the moment
the revoked client next polled, leaving the trail exactly as inert as before —
which is why the automatic path's stamps were already best-effort. A revoked
row is now invisible to better-auth's own session reads, so that collector never
sees it. The revoked session therefore stops authenticating *harder* than before
(`findSession` answers nothing at all, rather than answering an expired row),
and its record survives. User-deletion routes still see and physically remove
these rows: erasing a user erases their sessions.

**Behaviour worth knowing about:** a revoked session no longer disappears from
the database. The `My Sessions` and `All` views on `sys_session` filter revoked
rows out, so the Sessions list looks exactly as it did; a new **Revoked** view
exposes `revoked_at` / `revoke_reason` for auditing. There is no retention
window or sweeper for `sys_session` — revoked rows are kept indefinitely, the
same way a session abandoned without signing out already was.

A normal sign-out is untouched: it still deletes the row and writes no
`revoke_reason`. Signing yourself out is not a revocation, and whether it earns
an audit record is a separate open question (#7675).
Original file line numberDiff line numberDiff line change
Expand Up@@ -349,6 +349,9 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
all_sessions: {
label: "All"
},
revoked: {
label: "Revoked"
}
},
_actions: {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -349,6 +349,9 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
all_sessions: {
label: "Todas"
},
revoked: {
label: "Revocadas"
}
},
_actions: {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -349,6 +349,9 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
all_sessions: {
label: "すべて"
},
revoked: {
label: "取り消し済み"
}
},
_actions: {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -349,6 +349,9 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
all_sessions: {
label: "全部"
},
revoked: {
label: "已撤销"
}
},
_actions: {
Expand Down
24 changes: 23 additions & 1 deletion packages/platform-objects/src/identity/sys-session.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,14 +71,25 @@ export const SysSession = ObjectSchema.create({
},
],

// [#7732] A `revoked_at` row is a TOMBSTONE — an ended session kept as the
// ADR-0069 D4 audit record of its ending, not a session. Since the
// interactive revoke stamps the row instead of deleting it, the two
// session-listing views filter tombstones out (`revoke_session` still makes
// the row leave the grid, exactly as it did when the row was deleted), and
// the audit trail those columns exist for gets a view of its own — otherwise
// the fields would be written and still readable nowhere, which is the same
// declared-≠-enforced gap one layer up.
listViews: {
mine: {
type: 'grid',
name: 'mine',
label: 'My Sessions',
data: { provider: 'object', object: 'sys_session' },
columns: ['ip_address', 'active_organization_id', 'created_at', 'expires_at'],
filter: [{ field: 'user_id', operator: 'equals', value: '{current_user_id}' }],
filter: [
{ field: 'user_id', operator: 'equals', value: '{current_user_id}' },
{ field: 'revoked_at', operator: 'is_null' },
],
sort: [{ field: 'created_at', order: 'desc' }],
pagination: { pageSize: 50 },
},
Expand All@@ -88,9 +99,20 @@ export const SysSession = ObjectSchema.create({
label: 'All',
data: { provider: 'object', object: 'sys_session' },
columns: ['user_id', 'ip_address', 'active_organization_id', 'created_at', 'expires_at'],
filter: [{ field: 'revoked_at', operator: 'is_null' }],
sort: [{ field: 'created_at', order: 'desc' }],
pagination: { pageSize: 50 },
},
revoked: {
type: 'grid',
name: 'revoked',
label: 'Revoked',
data: { provider: 'object', object: 'sys_session' },
columns: ['user_id', 'ip_address', 'revoked_at', 'revoke_reason', 'created_at'],
filter: [{ field: 'revoked_at', operator: 'is_not_null' }],
sort: [{ field: 'revoked_at', order: 'desc' }],
pagination: { pageSize: 50 },
},
},

fields: {
Expand Down
5 changes: 5 additions & 0 deletions packages/plugins/plugin-auth/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,11 @@ export * from './secondary-storage.js';
export * from './register-sso-provider.js';
export * from './send-verification-email.js';
export * from './objectql-adapter.js';
// [#7732] ADR-0069 D4's revoke-audit trail. Exported alongside the adapter it
// plugs into, so a host reading `sys_session` knows the one rule that governs
// those rows: a `revoked_at` row is a TOMBSTONE — an ended session kept as the
// audit record of its ending — never a live session.
export * from './session-tombstone.js';
// [#4586] The better-auth actor seam. Exported because a host that writes an
// identity table on better-auth's behalf (a control-plane provisioning hook,
// an SSO JIT path) must construct the SAME two-part context —
Expand Down
33 changes: 30 additions & 3 deletions packages/plugins/plugin-auth/src/objectql-adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,11 @@ import { createAdapterFactory } from 'better-auth/adapters';
import type { CleanedWhere, WhereOperator } from 'better-auth/adapters';
import { SystemObjectName } from '@objectstack/spec/system';
import { resolveAttributedUserId } from './auth-actor-attribution.js';
import {
filterRevokedSessionRows,
hideRevokedSessionRow,
reconcileSessionDelete,
} from './session-tombstone.js';

/**
* Mapping from better-auth model names to ObjectStack protocol object names.
Expand DownExpand Up@@ -671,10 +676,22 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
const objectName = resolveProtocolName(model);
const bridged = objectName !== model;
const filter = convertWhere(model, bridged ? remapWhere(where) : where);
const fields = bridged && select ? select.map(camelToSnake) : select;
let fields = bridged && select ? select.map(camelToSnake) : select;
// [#7732] A projection that omits `revoked_at` would make every row look
// live to the tombstone rule. Ask for it, then drop it again below if
// the caller did not.
const revokedAtIsBorrowed =
objectName === SystemObjectName.SESSION &&
Array.isArray(fields) &&
fields.length > 0 &&
!fields.includes('revoked_at');
if (revokedAtIsBorrowed) fields = [...(fields as string[]), 'revoked_at'];

const result = await dataEngine.findOne(objectName, { where: filter, fields });
if (!result) return null;
// [#7732] A revoked session is not a session — see `session-tombstone.ts`.
if (await hideRevokedSessionRow(objectName, result)) return null;
if (revokedAtIsBorrowed) delete (result as Record<string, unknown>).revoked_at;
const norm = normaliseLegacyDates(model, result);
return (bridged ? remapKeys(norm, snakeToCamel) : norm) as T;
},
Expand All@@ -693,12 +710,14 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
? [{ field: bridged ? camelToSnake(sortBy.field) : sortBy.field, order: sortBy.direction as 'asc' | 'desc' }]
: undefined;

const results = await dataEngine.find(objectName, {
const found = await dataEngine.find(objectName, {
where: filter,
limit: limit || 100,
offset,
orderBy,
});
// [#7732] A revoked session is not a session — see `session-tombstone.ts`.
const results = await filterRevokedSessionRows(objectName, found);

return results.map((r) => {
const norm = normaliseLegacyDates(model, r as Record<string, any>);
Expand DownExpand Up@@ -761,6 +780,10 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
const record = await dataEngine.findOne(objectName, { where: filter });
if (!record) return;

// [#7732] An interactive revoke ends the session by stamping it, not by
// deleting it — see `session-tombstone.ts` for the ledger and the seam.
if (!(await reconcileSessionDelete(dataEngine, objectName, record))) return;

await dataEngine.delete(objectName, { where: { id: record.id } });
},

Expand All@@ -771,8 +794,12 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
const bridged = objectName !== model;
const filter = convertWhere(model, bridged ? remapWhere(where) : where);

const records = await dataEngine.find(objectName, { where: filter });
const found = await dataEngine.find(objectName, { where: filter });
// [#7732] Same rule, per row: a matched session the platform has
// already tombstoned is left exactly as it is.
const records = await filterRevokedSessionRows(objectName, found);
for (const record of records) {
if (!(await reconcileSessionDelete(dataEngine, objectName, record))) continue;
await dataEngine.delete(objectName, { where: { id: record.id } });
}
return records.length;
Expand Down
Loading
Loading