Skip to content

Commit fb7da54

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-22738-lookup-title-exposure
2 parents c4f5cfa + 149294c commit fb7da54

33 files changed

Lines changed: 2496 additions & 1094 deletions

‎.changeset/15204-s1-position-permission-sets.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ The one-line fix for an app that bound sets to its positions with a boot-time bi
3737

3838
**`@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).
3939

40-
**`@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.
40+
**`@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.
4141

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
---
4+
5+
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)
6+
7+
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)
8+
9+
<!-- 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 -->
10+
11+
**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.
12+
13+
**The last-administrator guard.**
14+
15+
- 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.
16+
- 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`.
17+
- 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.
18+
- 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.
19+
20+
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`.
21+
22+
**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.
23+
24+
**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.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-sharing': minor
3+
---
4+
5+
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)
6+
7+
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)
8+
9+
<!-- 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 -->
10+
11+
**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.
12+
13+
- 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.
14+
- 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.
15+
- 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.

‎.changeset/15204-s3-boot-report.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
A boot report lists, per organization, every security catalog reference whose name resolves nowhere (ADR-0131 D3/D4)
6+
7+
Clause-②: no
8+
9+
ADR-0131 D4 makes every reference to a position or a permission set a name, resolved through the security catalog (the environment registry). A stored reference whose name the catalog does not hold confers nothing, while Setup still lists it. At every boot, at `kernel:bootstrapped` and after the two one-time backfills, `@objectstack/plugin-security` now reads the stored references and logs what the catalog does not resolve. The report writes nothing: no row, no definition, no ledger entry.
10+
11+
- **Where to read it.** In the boot log, as `[security]` lines at `error`, one line per kind of finding. Each line's metadata lists the organizations (`organizationId`, `null` for organization-less rows) with a count and the names or row ids, at most 50 per list. A clean boot logs one `info` line that says every reference resolves.
12+
- **What it lists, per organization:**
13+
- `sys_user_position` assignments whose `position` the catalog does not resolve;
14+
- `sys_user_permission_set` grants whose `permission_set` the catalog does not resolve;
15+
- `sys_user_permission_set` grants with no `permission_set` at all. The one-time name backfill could not name them: the set row is missing, belongs to another organization, or carries a name the catalog does not resolve (a row-only set). An unnamed grant confers nothing;
16+
- `sys_position` rows whose name the catalog does not resolve, by name and organization. Under a walled posture these are the positions an organization authored in Setup: the catalog is environment-level, so they have no catalog home, and their assignments distribute no permission set. Under `single` these are the row-only positions the row-only backfill could not define, with `refusedNames` naming the ones whose names the metadata door refuses. The backfill reports that class once and records its verdict; this report lists it at every boot.
17+
- **And under `single` only:** position names carried by more than one `sys_position` row (two organizations' rows, or an organization's row beside a package's or a built-in's seeded row). A position name is unique per deployment under `single`, so each is reported as conflicting, never guessed and never merged.
18+
- **What clears a line.** The next boot re-reads everything. A reference whose definition is declared again (its package or its environment definition) leaves the report. So does a walled organization's position once a definition of that name is saved through the metadata door. Otherwise remove the row, or re-point it at a name the catalog holds.
19+
- **A read that did not happen is not a finding.** If a scan or the catalog read fails, the report says which store it could not read, at `warn`, and reports nothing for that boot.
20+
- **The grant-name backfill's warnings now say what such a grant is worth.** A grant the backfill could not name, because its set row's name is not in the catalog or its name write did not land, confers nothing until a later boot names it. The warning used to say these grants still resolved by id. That stopped being true when the authorization resolver began reading grants by name.
21+
- **Nothing to migrate.** No configuration is needed.

‎content/docs/permissions/authorization.mdx‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -437,17 +437,19 @@ not re-derived — that would replicate the resolver's aggregation).
437437

438438
**Absent is ACTIVE.** Only a ledger row whose `active` reads false takes a
439439
grant away; a permission set or position with no ledger row is in effect. The
440-
last-administrator guard's enumeration still reads the catalogue row's
441-
`active`, so it counts a row-deactivated `admin_full_access` as off and refuses
442-
more than it must, never less.
443-
444-
**Switching the break-glass set off is refused.** A ledger row switching
445-
`admin_full_access` off would un-make every platform admin who holds it
446-
*through a grant row*, in one write — and switching it back on needs the
447-
permission just lost. The last-administrator guard refuses such a ledger
448-
write outright, whatever the administrator population (ADR-0135 D5.2), and
449-
refuses one whose target row it cannot read. Switching it back on is never
450-
refused.
440+
last-administrator guard reads the same two places the resolver does: whether
441+
the catalogue holds `admin_full_access`, and whether the ledger switches it
442+
off. It reads no catalogue row, so a row's `active` changes nothing it counts.
443+
444+
**Switching the break-glass set off is refused while it would leave no
445+
administrator.** A ledger row switching `admin_full_access` off un-makes every
446+
platform admin who holds it *through a grant row*, in one write — and
447+
switching it back on needs the permission just lost. The last-administrator
448+
guard judges such a ledger write like every other write that can take
449+
standing away (ADR-0135 D5.2): it is refused only when nobody who can sign in
450+
would still administer the environment. A configured administrator or an
451+
organization owner or admin still standing permits it. A ledger read that
452+
fails refuses the write. Switching it back on is never refused.
451453

452454
Deactivation is an incident-response control, so what it does **not** touch is
453455
deliberate. Administration surfaces keep listing and editing a deactivated

‎packages/plugins/plugin-auth/src/audience-posture.test.ts‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { describe, it, expect, vi } from 'vitest';
2323
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/objectql';
2424
import { ERROR_CODE_LEDGER } from '@objectstack/spec/api';
2525
import { AuthManager } from './auth-manager';
26+
import { bindTestSecurityCatalog, catalogFromTables } from './__tests__/security-catalog.testkit.js';
2627
import {
2728
assertAudienceConfig,
2829
classifyCreationMethod,
@@ -113,11 +114,19 @@ const createMemoryEngine = () => {
113114
const SECRET = 'test-secret-at-least-32-chars-long!!';
114115
const PASSWORD = 'S3cure!Passw0rd-4785';
115116

117+
/**
118+
* [ADR-0131 D3/D4] The sets the fixture's `sys_permission_set` rows name are
119+
* catalog definitions too — the admission asks the catalog whether a set
120+
* grants, and the grant is written with the row's id.
121+
*/
122+
const bindRowCatalog = (engine: any) =>
123+
bindTestSecurityCatalog(engine, () => catalogFromTables({ sys_permission_set: engine.tables.get('sys_permission_set') }));
124+
116125
const makeManager = (engine: any, config: Record<string, unknown> = {}) =>
117126
new AuthManager({
118127
secret: SECRET,
119128
baseUrl: 'http://localhost:3000',
120-
dataEngine: engine,
129+
dataEngine: bindRowCatalog(engine),
121130
...config,
122131
} as any);
123132

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

818+
it('[ADR-0131 D3] a declared set the activation ledger switches OFF refuses admission — never an ungranted admit', async () => {
819+
const engine = createMemoryEngine();
820+
seedExistingUser(engine);
821+
seedPermissionSet(engine, 'member_default');
822+
engine.tables.set('sys_metadata_activation', [
823+
{ id: 'act_member', metadata_type: 'permission', name: 'member_default', package_id: 'pkg', active: false },
824+
]);
825+
const manager = makeManager(engine, {
826+
audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' },
827+
});
828+
const res = await signUp(manager, 'user@anywhere.com');
829+
expect(res.status).toBe(403);
830+
expect((await res.json()).code).toBe(AUDIENCE_CONFIG_ERROR);
831+
expect((engine.tables.get('sys_user') ?? []).length).toBe(1);
832+
});
833+
834+
it('[ADR-0131 D3] the set ROW\'s `active` switches nothing: a row flagged off still admits and grants', async () => {
835+
// The resolver reads no catalog row, so the row flag is not a deactivation.
836+
const engine = createMemoryEngine();
837+
seedExistingUser(engine);
838+
seedPermissionSet(engine, 'member_default', { active: false });
839+
const manager = makeManager(engine, {
840+
audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' },
841+
});
842+
const res = await signUp(manager, 'anyone@anywhere.com');
843+
expect(res.status).toBeLessThan(300);
844+
await vi.waitFor(() => {
845+
expect((engine.tables.get('sys_user_permission_set') ?? []).length).toBe(1);
846+
});
847+
});
848+
809849
it('open: self-serve admitted and granted', async () => {
810850
const engine = createMemoryEngine();
811851
seedExistingUser(engine);

0 commit comments

Comments
 (0)