Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
7b51812
wip: PositionSchema.permissionSets; resolver reads positions, sets an…
claude Oct 10, 2026
8cbedf9
wip: SecurityPlugin binds the catalog read; everyone declares the bas…
claude Oct 10, 2026
1e3a4b1
wip: conversion function, static catalog testkit, golden query move r…
claude Oct 10, 2026
d6a43e5
wip: core suites read the catalog; admin-standing surface re-measured
claude Oct 10, 2026
df2ac6b
wip: runtime and plugin-security fixtures bind the catalog
claude Oct 10, 2026
4da892e
wip: plugin-security fixtures declare the catalog; everyone baseline …
claude Oct 10, 2026
ead16a0
wip: rest fixtures declare the catalog
claude Oct 10, 2026
cb7e21d
wip: plugin-auth fixtures declare the catalog
claude Oct 10, 2026
8dca0b1
wip: hono-server, sharing and automation fixtures declare the catalog
claude Oct 10, 2026
35e8fcd
wip: example personas declare permissionSets; spec docs and liveness …
claude Oct 10, 2026
58d33e5
fix(plugin-security): a Setup row edit carries the definition's permi…
claude Oct 10, 2026
4362ea1
wip: cloud-connection fixtures declare the catalog
claude Oct 10, 2026
e9fc856
wip: service-datasource, service-settings and approvals fixtures decl…
claude Oct 10, 2026
4a2d686
wip: changeset; pin the walled promotion path through the metadata door
claude Oct 10, 2026
e6baf14
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s1…
claude Oct 10, 2026
af4aa34
test: compact the per-package security-catalog test helper
claude Oct 10, 2026
15f5c97
spec: register the position-permission-sets-declared ADR-0087 semanti…
claude Oct 10, 2026
7564191
spec: the position form offers permissionSets
claude Oct 10, 2026
56d4949
core: deactivation of positions and permission sets is read from the …
claude Oct 10, 2026
4726c1e
changeset: deactivation moves to the activation ledger
claude Oct 10, 2026
6e05833
cloud-connection: the fake engines answer the activation ledger
claude Oct 10, 2026
862b773
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s1…
claude Oct 10, 2026
997aaa6
plugin-security: the #18535 test registry stamps the package id
claude Oct 10, 2026
055f5df
platform-objects: regenerate the metadata-form bundles for permission…
claude Oct 10, 2026
dca5407
platform-objects: translate the position form's permissionSets row in…
claude Oct 10, 2026
83b54d8
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s1…
claude Oct 10, 2026
295f3bc
plugin-auth: the last-admin guard reads the admin set from the catalo…
claude Oct 10, 2026
6508625
plugin-auth, plugin-sharing: the self-registration, default-organizat…
claude Oct 10, 2026
4168f03
plugin-sharing: pin the sharing-rule deactivation on the activation l…
claude Oct 10, 2026
4b7ea40
plugin-auth: fixtures declare the catalog their set rows name; the ro…
claude Oct 10, 2026
ef51f2e
plugin-auth: the guard's ledger and re-pricing pins judge ledger writ…
claude Oct 10, 2026
96f94c1
plugin-auth: the guard's path-4 pins switch the set off in the ledger…
claude Oct 10, 2026
d431257
plugin-auth, plugin-sharing: changesets; the admission and default-or…
claude Oct 10, 2026
bdb2704
changeset: plugin-auth's breaking change carries no migration prescri…
claude Oct 10, 2026
de4d000
plugin-auth: the guard's registration comment names the ledger, not t…
claude Oct 10, 2026
96fb3e9
patch round 1: the four red CI packages, and the changeset names the …
claude Oct 10, 2026
fb2855f
changeset: platform-objects ships the regenerated position form bundles
claude Oct 10, 2026
2c3a598
docs: a position's sets are its permissionSets; a junction row grants…
claude Oct 10, 2026
bf6bdf0
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s1…
claude Oct 10, 2026
bd916d3
Merge remote-tracking branch 'origin/claude/issue-15204-s1-position-p…
claude Oct 10, 2026
315d0df
Merge remote-tracking branch 'origin/claude/issue-15204-s1-position-p…
claude Oct 10, 2026
73c76fb
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s1…
claude Oct 10, 2026
d0cab9f
port #22750: render-projection-diff generates the base's git-ignored …
claude Oct 11, 2026
2aa6ad4
plugin-sharing: the changeset declares the deactivation move breaking…
claude Oct 11, 2026
3f38a2b
Merge remote-tracking branch 'origin/claude/issue-15204-s1-position-p…
claude Oct 11, 2026
f4db2f3
Merge remote-tracking branch 'origin/main' into claude/issue-15204-s2…
claude Oct 11, 2026
66c4c5a
docs, changeset: the last-admin guard's ledger switch-off is the rule…
claude Oct 11, 2026
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
2 changes: 1 addition & 1 deletion .changeset/15204-s1-position-permission-sets.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/spec': minor
'@objectstack/core': minor
Expand Down Expand Up @@ -37,7 +37,7 @@

**`@objectstack/plugin-security`.** The plugin binds the catalog read (engine registry and metadata service) to its engine at `start()`. At `kernel:ready` the built-in `everyone` anchor declares the deployment's baseline in its `permissionSets`: the app's `isDefault` set and the platform's `member_default`. It is judged by the same high-privilege check against the stack's declared capabilities that the boot binding used. Under a wall the envelope now carries `member_default` beside the app's set, as it did under `single`. `member_default` carries no system or tab permission, and enforcement already applied it to every human as the additive baseline. A Setup edit of a position row under `single` keeps the `permissionSets` the stored definition names (the row has no column for it).

**`@objectstack/plugin-auth`.** The last-administrator guard follows what the resolver reads. Its standing-key map judges `sys_metadata_activation` (`metadata_type`, `name`, `active`) in place of `sys_permission_set`. A new hook refuses a ledger write that switches `admin_full_access` off (`refuseLedgerSwitchingAdminOff`). Its `sys_permission_set` hooks stay registered, unchanged.
**`@objectstack/plugin-auth`.** The last-administrator guard follows what the resolver reads. Its standing-key map judges `sys_metadata_activation` (`metadata_type`, `name`, `active`) in place of `sys_permission_set`. A ledger write that switches `admin_full_access` off is refused only when it would leave no administrator who can sign in, and switching it back on is never refused. The guard no longer judges `sys_permission_set` writes, because the resolver no longer reads that row.

**`@objectstack/platform-objects`.** The position metadata form gains a `permissionSets` row, translated in zh-CN, ja-JP and es-ES.

Expand Down
24 changes: 24 additions & 0 deletions .changeset/15204-s2b-plugin-auth-catalog-readers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/plugin-auth': minor
---

feat(plugin-auth)!: the last-administrator guard, the self-registration admission and the default-organization step read the security catalog and the activation ledger, never a `sys_permission_set` row (ADR-0131 D3/D4)

Clause-②: yes (narrowing: a zero-administrator write is refused when an unscoped grant names a set the security catalog does not hold, and self-registration is refused when its declared set is not in the catalog or is switched off in the activation ledger; widening: writes to a `sys_permission_set` row are no longer judged by the guard, and a ledger write switching `admin_full_access` off is refused only when it would leave no administrator)

<!-- adr-0087: not-required (no-migration-prescription) no authorable metadata key changes and nothing for an upgrader to rewrite: three runtime readers ask the store the resolver already reads (whose own move is registered as position-permission-sets-declared), and two guard exports with no importer outside this package are removed -->

**BREAKING**, shipped as `minor` under the launch-window convention for breaking changes. It finishes, for `plugin-auth`, the move the authorization resolver made in the same release: a permission set exists when the security catalog bound to the engine holds its definition, and it is switched off by a `sys_metadata_activation` row of type `permission` whose `active` is false. No `sys_permission_set` row decides either any more, its `active` column included.

**The last-administrator guard.**

- A grant-anchored platform administrator is counted the way the resolver derives one: an unscoped, in-window grant naming `admin_full_access`, which the catalog holds and the ledger leaves on. An engine with no catalog bound counts none, as the resolver grants none.
- Switching `admin_full_access` off in the ledger (an insert, an update of its row, or an update moving another row onto that pair) is judged like every other standing write. It is refused only when it would leave no administrator who can sign in. A config-anchored or organization administrator still standing permits it. The flat refusal of every such write is gone, and with it `refuseLedgerSwitchingAdminOff`.
- Writes to a `sys_permission_set` row (delete, rename, `active`, `organization_id`) are no longer judged. The resolver reads no row, so none of them moves an administrator. `PERMISSION_SET_STANDING_KEYS` is removed, and with it the guard's two `sys_permission_set` hooks.
- With zero administrators, the guard still tells the bootstrap window from an emptied environment. The evidence is now an unscoped, in-window grant naming a set the catalog does not hold (or naming none), or `admin_full_access` switched off in the ledger while such grants name it. Its remedy is to switch the set back on, and that write is permitted.

Two exports of the guard are removed, with nothing in their place: `PERMISSION_SET_STANDING_KEYS` (the table it judged is no longer read) and `refuseLedgerSwitchingAdminOff` (`registerLastAdminGuard` judges ledger writes itself). No package in this repository imports either outside `plugin-auth`.

**Self-registration.** The admission asks whether the declared `selfRegistrationPermissionSet` would grant: the catalog holds it and the ledger leaves it on. A `sys_permission_set` row of that name must also exist, because the grant's `permission_set_id` is written with its id. The row's `active` is no longer read, at admission or when the grant is written.

**The default organization.** The platform administrator the default-organization step binds as owner is found through a set the catalog holds and the ledger leaves on, not through a `sys_permission_set` row.
15 changes: 15 additions & 0 deletions .changeset/15204-s2b-plugin-sharing-ledger-deactivation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/plugin-sharing': minor
---

feat(plugin-sharing)!: a sharing rule reads a position's deactivation from the activation ledger, as the authorization resolver does (ADR-0131 D3, ADR-0126 §4)

Clause-②: yes (narrowing: a position switched off in the activation ledger stops receiving sharing-rule shares in every organization; widening: a position whose `sys_position` row reads `active: false` receives them again)

<!-- adr-0087: not-required (no-migration-prescription) no authorable metadata key changes; the sharing-rule evaluator reads deactivation from the store the resolver already reads, whose own move is registered as position-permission-sets-declared -->

**BREAKING**, shipped as `minor` under the launch-window convention for breaking changes. A position's deactivation now comes from `sys_metadata_activation` instead of the `sys_position` row's `active` column, so the shares a sharing rule materializes follow the same switch the authorization resolver reads.

- A sharing rule whose recipient is a position materializes no share while that position is switched off. Switched off means a `sys_metadata_activation` row of type `position` naming it, with `active` false (a driver `0` included). The switch is deployment-wide, like the ledger, so it no longer depends on the rule's organization.
- The `sys_position` row's `active` is no longer read. A deactivation made in Setup writes that column, so until the upgrade ceremony converts it into a ledger row it stops neither the resolver nor sharing rules.
- A name with no ledger row keeps sharing, including a membership-derived position name. A composition that registers no ledger object issues no ledger read. A ledger read that fails still grants for the pass, as the row read did.
24 changes: 13 additions & 11 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -437,17 +437,19 @@ not re-derived — that would replicate the resolver's aggregation).

**Absent is ACTIVE.** Only a ledger row whose `active` reads false takes a
grant away; a permission set or position with no ledger row is in effect. The
last-administrator guard's enumeration still reads the catalogue row's
`active`, so it counts a row-deactivated `admin_full_access` as off and refuses
more than it must, never less.

**Switching the break-glass set off is refused.** A ledger row switching
`admin_full_access` off would un-make every platform admin who holds it
*through a grant row*, in one write — and switching it back on needs the
permission just lost. The last-administrator guard refuses such a ledger
write outright, whatever the administrator population (ADR-0135 D5.2), and
refuses one whose target row it cannot read. Switching it back on is never
refused.
last-administrator guard reads the same two places the resolver does: whether
the catalogue holds `admin_full_access`, and whether the ledger switches it
off. It reads no catalogue row, so a row's `active` changes nothing it counts.

**Switching the break-glass set off is refused while it would leave no
administrator.** A ledger row switching `admin_full_access` off un-makes every
platform admin who holds it *through a grant row*, in one write — and
switching it back on needs the permission just lost. The last-administrator
guard judges such a ledger write like every other write that can take
standing away (ADR-0135 D5.2): it is refused only when nobody who can sign in
would still administer the environment. A configured administrator or an
organization owner or admin still standing permits it. A ledger read that
fails refuses the write. Switching it back on is never refused.

Deactivation is an incident-response control, so what it does **not** touch is
deliberate. Administration surfaces keep listing and editing a deactivated
Expand Down
42 changes: 41 additions & 1 deletion packages/plugins/plugin-auth/src/audience-posture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { describe, it, expect, vi } from 'vitest';
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/objectql';
import { ERROR_CODE_LEDGER } from '@objectstack/spec/api';
import { AuthManager } from './auth-manager';
import { bindTestSecurityCatalog, catalogFromTables } from './__tests__/security-catalog.testkit.js';
import {
assertAudienceConfig,
classifyCreationMethod,
Expand Down Expand Up @@ -113,11 +114,19 @@ const createMemoryEngine = () => {
const SECRET = 'test-secret-at-least-32-chars-long!!';
const PASSWORD = 'S3cure!Passw0rd-4785';

/**
* [ADR-0131 D3/D4] The sets the fixture's `sys_permission_set` rows name are
* catalog definitions too — the admission asks the catalog whether a set
* grants, and the grant is written with the row's id.
*/
const bindRowCatalog = (engine: any) =>
bindTestSecurityCatalog(engine, () => catalogFromTables({ sys_permission_set: engine.tables.get('sys_permission_set') }));

const makeManager = (engine: any, config: Record<string, unknown> = {}) =>
new AuthManager({
secret: SECRET,
baseUrl: 'http://localhost:3000',
dataEngine: engine,
dataEngine: bindRowCatalog(engine),
...config,
} as any);

Expand Down Expand Up @@ -806,6 +815,37 @@ describe('end of the chain: better-auth pipeline over the memory engine (#11739)
expect((engine.tables.get('sys_user') ?? []).length).toBe(1);
});

it('[ADR-0131 D3] a declared set the activation ledger switches OFF refuses admission — never an ungranted admit', async () => {
const engine = createMemoryEngine();
seedExistingUser(engine);
seedPermissionSet(engine, 'member_default');
engine.tables.set('sys_metadata_activation', [
{ id: 'act_member', metadata_type: 'permission', name: 'member_default', package_id: 'pkg', active: false },
]);
const manager = makeManager(engine, {
audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' },
});
const res = await signUp(manager, 'user@anywhere.com');
expect(res.status).toBe(403);
expect((await res.json()).code).toBe(AUDIENCE_CONFIG_ERROR);
expect((engine.tables.get('sys_user') ?? []).length).toBe(1);
});

it('[ADR-0131 D3] the set ROW\'s `active` switches nothing: a row flagged off still admits and grants', async () => {
// The resolver reads no catalog row, so the row flag is not a deactivation.
const engine = createMemoryEngine();
seedExistingUser(engine);
seedPermissionSet(engine, 'member_default', { active: false });
const manager = makeManager(engine, {
audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' },
});
const res = await signUp(manager, 'anyone@anywhere.com');
expect(res.status).toBeLessThan(300);
await vi.waitFor(() => {
expect((engine.tables.get('sys_user_permission_set') ?? []).length).toBe(1);
});
});

it('open: self-serve admitted and granted', async () => {
const engine = createMemoryEngine();
seedExistingUser(engine);
Expand Down
47 changes: 32 additions & 15 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security';
import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai';
import { createObjectQLAdapterFactory, withSystemContext, withSystemReadContext } from './objectql-adapter.js';
import { permissionSetInEffect } from './catalog-set-in-effect.js';
import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js';
import { runWithAuthActorScope, setAuthActorResolver } from './auth-actor-attribution.js';
import {
Expand Down Expand Up @@ -4763,10 +4764,11 @@ export class AuthManager {
const resolvable = await this.selfRegistrationSetResolvable(setName);
if (!resolvable) {
const message =
`the declared self-registration permission set '${setName}' cannot be resolved in ` +
'sys_permission_set (missing, deactivated, or the permission-set store is unavailable) — ' +
`the declared self-registration permission set '${setName}' cannot be resolved ` +
'(not in the security catalog, switched off in sys_metadata_activation, without a ' +
'sys_permission_set row to grant it by, or the store is unavailable) — ' +
'self-registration is refused rather than admitting an ungranted user. ' +
'Create/activate the set, or point selfRegistrationPermissionSet at an existing one.';
'Declare or switch on the set, or point selfRegistrationPermissionSet at an existing one.';
this.audienceLogError(`[audience] ${message}`);
return { error: AUDIENCE_CONFIG_ERROR, errorDescription: message };
}
Expand Down Expand Up @@ -5004,10 +5006,25 @@ export class AuthManager {
}
}

/** At least one ACTIVE `sys_permission_set` row carries the declared name. */
/**
* [ADR-0131 D3/D4] The declared set would GRANT: the security catalog holds
* it and the activation ledger does not switch it off — the resolver's own
* reading ({@link permissionSetInEffect}) — and a `sys_permission_set` row
* carries the id the grant's required `permission_set_id` is written with
* ({@link settleSelfRegistrationGrant}). The row's `active` is not read.
* A read that fails answers `false`: the admission refuses.
*/
private async selfRegistrationSetResolvable(setName: string): Promise<boolean> {
const engine = this.config.dataEngine;
if (!engine || typeof (engine as any).find !== 'function') return false;
try {
const reader = withSystemReadContext(engine) as any;
if (!(await permissionSetInEffect(engine, (object, query) => reader.find(object, query), setName))) return false;
} catch {
return false;
}
const rows = await this.findPermissionSetRows(setName);
return rows.some((r) => r?.active !== false && typeof r?.id === 'string' && r.id);
return rows.some((r) => typeof r?.id === 'string' && r.id);
}

private async findPermissionSetRows(setName: string): Promise<any[]> {
Expand Down Expand Up @@ -5238,12 +5255,14 @@ export class AuthManager {
} catch {
organizationId = null;
}
// ACTIVE is a selection predicate — a deactivated set legitimately does
// not resolve, so it stays a filter. A missing or blank `id` is NOT a
// selection: it is a MALFORMED row, and dropping it silently is the
// [ADR-0131 D3] The row is read for the id the grant's required
// `permission_set_id` is written with, and for nothing else: whether the
// set grants was asked at admission, of the catalog and the activation
// ledger, and the row's `active` is not read. A missing or blank `id` is
// a MALFORMED row, and dropping it silently is the
// opposite defect from a dead limb, wrong in two ways that both look
// normal from outside. (1) The family narrows to nothing and the report
// below blames "no active row named X" while an active row named X is
// below blames "no row named X" while a row named X is
// sitting right there — and that report is the only signal, because
// nothing retries this. (2) Worse, when the malformed row is the
// ORG-SCOPED one, the `organization_id == null` arm below then resolves
Expand All @@ -5253,15 +5272,13 @@ export class AuthManager {
// "unambiguous single row" this code believes it selected is not that.
// So a malformed candidate REFUSES the grant and names itself, which is
// the same gap-not-empty-answer direction the rest of this method takes.
const candidates = (await this.findPermissionSetRows(staged.setName)).filter(
(r) => r?.active !== false,
);
const candidates = await this.findPermissionSetRows(staged.setName);
const malformed = candidates.filter((r) => !(typeof r?.id === 'string' && r.id));
if (malformed.length > 0) {
this.reportUngrantedSelfRegistrant(
userId,
staged.setName,
`${malformed.length} active sys_permission_set row(s) named '${staged.setName}' carry no usable id, ` +
`${malformed.length} sys_permission_set row(s) named '${staged.setName}' carry no usable id, ` +
'so which row this grant would resolve to cannot be decided — refusing rather than ' +
'silently dropping them and granting whichever row is left',
);
Expand All @@ -5277,8 +5294,8 @@ export class AuthManager {
userId,
staged.setName,
organizationId
? `no active sys_permission_set row named '${staged.setName}' resolves for organization ${organizationId}`
: `no active sys_permission_set row named '${staged.setName}' resolves`,
? `no sys_permission_set row named '${staged.setName}' resolves for organization ${organizationId}`
: `no sys_permission_set row named '${staged.setName}' resolves`,
);
return;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AuthPlugin } from './auth-plugin';
import { AuthManager } from './auth-manager';
import type { PluginContext } from '@objectstack/core';
import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/objectql';
import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js';

describe('AuthPlugin', () => {
let mockContext: PluginContext;
Expand Down Expand Up @@ -1355,6 +1356,8 @@ describe('AuthPlugin', () => {
hookCapture = createHookCapture();
middlewares = [];
ql = makeQl();
// [ADR-0131 D3/D4] The set the fixture's rows name is a catalog definition.
bindCatalogFromTables(ql, ql.tables);
mockContext.hook = hookCapture.hookFn;
mockContext.getService = vi.fn((name: string) => {
if (name === 'manifest') return { register: vi.fn() };
Expand Down Expand Up @@ -1610,6 +1613,8 @@ describe('AuthPlugin', () => {
delete process.env.OS_SKIP_MEMBERSHIP_BACKFILL;
hookCapture = createHookCapture();
ql = makeQl();
// [ADR-0131 D3/D4] The set the fixture's rows name is a catalog definition.
bindCatalogFromTables(ql, ql.tables);
mockContext.hook = hookCapture.hookFn;
mockContext.getService = vi.fn((name: string) => {
if (name === 'manifest') return { register: vi.fn() };
Expand Down
Loading
Loading