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
12 changes: 12 additions & 0 deletions .changeset/15196-grant-readers-by-name-plugin-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@objectstack/plugin-auth": patch
---

The permission-set grant readers in `@objectstack/plugin-auth` read which set a grant holds from its name column, `sys_user_permission_set.permission_set` (ADR-0131 D4)

Clause-②: no

- **What reads the name now.** The last-administrator guard (`registerLastAdminGuard`) counts a grant-anchored platform administrator from an unscoped, in-window grant whose `permission_set` is `admin_full_access`, provided the `admin_full_access` row of that name is active. The default-organization bootstrap (`ensureDefaultOrganization`) finds the platform administrator by the same grant name. The self-registration grant checks by name whether the new user already holds the declared set. None of them reads `permission_set_id` for this any more.
- **The guard treats `permission_set` as a standing column.** A write that clears the name on the last administrator's grant is refused like any other revocation. A write that re-points `permission_set_id` without a name is treated as taking the standing away, because the platform derives the new name only after the guard runs. A write that only repeats the grant's own id keeps the standing.
- **A grant whose name is empty.** A grant written before the name column existed has no name until `@objectstack/plugin-security`'s one-time backfill names it at `kernel:bootstrapped`. The guard does not count such a grant as an administrator. When no administrator is counted, such a grant, if unscoped and in-window, is evidence that the environment is not a fresh install, so the write is refused instead of the bootstrap window opening. The default-organization bootstrap answers `no_admin` for it and binds no owner; that answer records no decision, so a later trigger binds once the grant has its name.
- **Nothing to migrate.**
13 changes: 13 additions & 0 deletions .changeset/15196-grant-readers-by-name-plugin-security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/plugin-security": patch
---

The permission-set grant readers in `@objectstack/plugin-security` read which set a grant holds from its name column, `sys_user_permission_set.permission_set` (ADR-0131 D4)

Clause-②: no

- **What reads the name now.** The explain engine's dropped-grant provenance (`buildContextForUser`: an expired grant, or a grant of a deactivated set), the platform-admin bootstrap's existing-holder check (`bootstrapPlatformAdmin`, and the seed-ownership claim's `findExistingPlatformAdmin`), the organization-admin reconcile (`reconcileOrgAdminGrant`, `backfillOrgAdminGrants`) and the delegated-administration gate's judgement of a stored grant it is asked to change or delete. Each reads the grant's `permission_set` instead of its `permission_set_id`. Deactivation is still read from the `sys_permission_set` row, now found by that name: the grant's own organization's row, else the organization-less one. The authorization resolver in `@objectstack/core` still reads the id, and nobody's resolved permissions change.
- **A grant whose name is empty.** A grant written before the name column existed has no name until the one-time backfill names it at `kernel:bootstrapped`, and the backfill leaves a grant unnamed when its id names no set row or another organization's set row. Such a grant grants nothing through these readers. Explain reports nothing for it. The organization-admin reconcile still finds it through its id when it revokes the grant or checks for a duplicate before inserting one. The delegated-administration gate refuses a delegate's change to it; a tenant administrator is not affected. The platform-admin bootstrap does not promote a second administrator while an unscoped grant on the `admin_full_access` row is still unnamed. It returns `reason: 'admin_grant_unnamed'` without an `adminUserId`, logs a warning, and the next boot reads the grant by its name.
- **Within the organization-admin reconcile,** a pair that already holds the organization-admin set by name, through any organization's copy of it, gets no second grant.
- The earlier release notes for the name column said no reader used it yet. That is no longer true of the readers listed above.
- **Nothing to migrate.**
9 changes: 8 additions & 1 deletion packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ import {
import type { TenancyService } from './tenancy-service.js';
import { OtpSendGuard, assertOtpCooldownSeconds } from './otp-send-guard.js';
import type { CounterStore } from './rate-limit-storage.js';
import { GRANT_SET_NAME_FIELD } from './grant-set-name.js';
import {
isLastLocalCredentialHolder,
LAST_LOCAL_CREDENTIAL_CODE,
Expand Down Expand Up @@ -5270,8 +5271,14 @@ export class AuthManager {
);
return;
}
// [ADR-0131 D4] Already held? Asked BY NAME — the grant's
// `permission_set`, the reference a grant keeps once its id column is
// dropped. A self-registrant is a user created moments ago, so every
// grant it holds was written with its name; any of them naming this set,
// in any organization, settles the question and no second grant is
// written.
const existing: any[] = await sys.find('sys_user_permission_set', {
where: { user_id: userId, permission_set_id: row.id },
where: { user_id: userId, [GRANT_SET_NAME_FIELD]: row.name },
limit: 1,
});
if (existing.length > 0) return;
Expand Down
6 changes: 3 additions & 3 deletions packages/plugins/plugin-auth/src/auth-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1319,7 +1319,7 @@ describe('AuthPlugin', () => {
const tables: Record<string, any[]> = {
sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }],
sys_user_permission_set: [
{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null },
{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null },
],
sys_member: [],
sys_organization: [],
Expand Down Expand Up @@ -1572,7 +1572,7 @@ describe('AuthPlugin', () => {
const tables: Record<string, any[]> = {
sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }],
sys_user_permission_set: [
{ id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', organization_id: null },
{ id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null },
],
// The platform admin already exists; the default-org bootstrap binds it
// as `owner` on kernel:ready. A member-less seeded user is added later.
Expand Down Expand Up @@ -1714,7 +1714,7 @@ describe('AuthPlugin', () => {

// The admin's grant lands: the bootstrap creates the organization, and
// that moment runs the pass — no restart, no app:seeded.
ql.tables.sys_user_permission_set.push({ id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', organization_id: null });
ql.tables.sys_user_permission_set.push({ id: 'ups1', user_id: 'admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null });
await fireBootstrapWrite({ object: 'sys_user_permission_set', operation: 'insert' });
await vi.waitFor(() => {
expect(ql.tables.sys_member.find((m: any) => m.user_id === 'early_u5')).toMatchObject({ role: 'member' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,11 @@ type Row = Record<string, any>;
function rig(seed: Partial<Record<string, Row[]>> = {}) {
const tables: Record<string, Row[]> = {
sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }],
// The platform admin `u1`, through the legacy grant anchor `single` reads.
sys_user_permission_set: [{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null }],
// The platform admin `u1`, through the legacy grant anchor `single` reads
// — by the set's name, which every platform grant writer stores (ADR-0131 D4).
sys_user_permission_set: [
{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null },
],
sys_organization: [],
sys_member: [],
sys_migration: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ function makeQl(seed: Partial<Record<string, Row[]>> = {}) {
const tables: Record<string, Row[]> = {
sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }],
sys_user_permission_set: [
{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', organization_id: null },
{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null },
],
sys_member: [],
sys_organization: [],
Expand Down Expand Up @@ -135,8 +135,8 @@ describe('ensureDefaultOrganization (plugin-auth home)', () => {
it('picks the OLDEST cross-tenant admin grant', async () => {
const ql = makeQl({
sys_user_permission_set: [
{ id: 'b', user_id: 'u_newer', permission_set_id: 'ps_admin', organization_id: null, created_at: '2026-01-02T00:00:00Z' },
{ id: 'a', user_id: 'u_older', permission_set_id: 'ps_admin', organization_id: null, created_at: '2026-01-01T00:00:00Z' },
{ id: 'b', user_id: 'u_newer', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null, created_at: '2026-01-02T00:00:00Z' },
{ id: 'a', user_id: 'u_older', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null, created_at: '2026-01-01T00:00:00Z' },
],
});
await ensureDefaultOrganization(ql);
Expand Down
12 changes: 10 additions & 2 deletions packages/plugins/plugin-auth/src/ensure-default-organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
import { matchesConfiguredPlatformAdmin, resolvePlatformAdminEmails } from '@objectstack/core';
import { postureEnforcesWall } from '@objectstack/spec/security';
import { resolveTenancyPosture } from '@objectstack/types';
import { GRANT_SET_NAME_FIELD } from './grant-set-name.js';

interface BootstrapLogger {
info: (message: string, meta?: Record<string, any>) => void;
Expand Down Expand Up @@ -465,11 +466,18 @@ export async function ensureDefaultOrganization(
if (adminPs.length === 0 || !adminPs[0].id) {
return { defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' };
}
const adminPsId = adminPs[0].id;
// [ADR-0131 D4] The grants NAMING the set (`permission_set`), not the
// grants carrying the row's id. A grant that names nothing yet — an
// upgraded deployment's, before the one-time backfill names it at
// `kernel:bootstrapped` — is not read as the admin: this helper CONFERS
// (an owner membership and the seeded rows), and a grant that names no set
// confers nothing. The answer is then `no_admin`, which decides nothing
// (`default-org-bootstrap-once.ts` records no decision on it), so a later
// trigger binds once the grant names its set.
const adminGrants = await tryFind(
ql,
'sys_user_permission_set',
{ permission_set_id: adminPsId, organization_id: null },
{ [GRANT_SET_NAME_FIELD]: 'admin_full_access', organization_id: null },
50,
);
if (adminGrants.length === 0) {
Expand Down
Loading
Loading