') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat(plugin-auth): platform-admin re-anchor L3 — re-point ensureDefaultOrganization to the config anchor; re-price last-admin-guard as its own reviewed step by os-steve · Pull Request #13685 · objectstack-ai/objectstack · GitHub
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
5 changes: 5 additions & 0 deletions .changeset/plugin-auth-default-org-config-anchor.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/plugin-auth': minor
---

Re-point the default-organization bootstrap at the platform-admin config anchor (#11973, #11663 leg L3). `ensureDefaultOrganization` now resolves "which user is the platform admin" from `OS_PLATFORM_OWNER_EMAIL` first — the first declared entry with a stored, email-verified `sys_user` account, matched through `@objectstack/core`'s own `matchesConfiguredPlatformAdmin`, the same oracle the authorization derivation reads — and falls back to the legacy unscoped `admin_full_access` grant row (which still anchors `single`-posture deployments and the honoured migration window). Its re-run trigger widens from `sys_user_permission_set` inserts to the new exported predicate `isDefaultOrganizationBootstrapTrigger`: `sys_user` inserts and `email`/`email_verified` updates (how a config-anchored administrator comes into standing — on fresh walled rigs no grant insert ever fires any more), plus the legacy grant-insert arm unchanged. `single`-posture behaviour is unchanged: with the variable unset, the config half costs no read and the grant anchor decides exactly as before.
4 changes: 2 additions & 2 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,7 +97,7 @@ that silently does not happen.
| 8 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `security-plugin.ts:3827` |
| 9 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `anonymous-deny.ts:154` |
| 10 | Permission-set projection middleware skipped | plugin-security | Lose: projection of permission-set-derived columns | `permission-set-projection.ts:1015` |
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1288` |
| 11 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `auth-plugin.ts:1296` |
| 12 | Per-request performance timings disclosed | observability | Get: timing headers a normal caller cannot pull | `perf-timing.ts:474` |
| 13 | Permission-set **overlay discard** skips the tenant-admin assertion | plugin-security | Get: an overlay can be discarded with no authenticated tenant administrator | `permission-set-overlay-discard.ts:142` |
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` |
Expand DownExpand Up@@ -198,7 +198,7 @@ assuming `isSystem` covers it is a documented source of bugs.
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9457`–`9474` |
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1451` (#3493 / #6640) |
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:273` |
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1240`, `:1269`; `domains/actions.ts:404` |

---
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,9 +6,11 @@
* A, verbatim 「全部同意」).
*
* ⛔ Nothing here may become a refusal: boot proceeds in EVERY shape below,
* including the one that warns. The two refusals around this check
* (`walled_owner_email_undeclared` at boot, `walled_owner_not_verified` at
* elevation) are pinned by their own suites and are untouched.
* including the one that warns. The fail-closed clauses around this check —
* the `walled_owner_email_undeclared` boot refusal, and ([#11973] since the
* #11663 L4 re-anchor) the derivation site resolving an unverified declared
* address non-admin per request — are pinned by their own suites and are
* untouched.
*
* The load-bearing half of this file is the CONTROLS. A warning that fires on
* every boot satisfies "the dead-end shape warns" just as well as a correct
Expand DownExpand Up@@ -110,8 +112,12 @@ describe('#11640 — the dead-end shape warns, by name and with the remedy', ()
// exactly here.
expect(msg).toContain('Either one alone clears this');
// …and it says what goes wrong if nothing is wired, in the vocabulary of
// the refusal the owner will actually hit.
expect(msg).toContain('walled_owner_not_verified');
// the dead end the owner will actually hit ([#11973]: no elevation
// refusal exists post-L4 — an unverified declared address simply resolves
// no standing at the derivation site).
expect(msg).toContain('NO platform-admin standing');
expect(msg).toContain('derived at request time');
expect(msg).not.toContain('walled_owner_not_verified');
});

it('⛔ it is a WARNING, never a refusal — the text promises boot continues', () => {
Expand DownExpand Up@@ -287,7 +293,10 @@ describe('#12751 — the warning follows the owner account state', () => {
const msg = resolveWalledOwnerVerificationPathWarning(nothingWired('owner-unverified'));
expect(msg).toContain(WALLED_OWNER_NO_VERIFICATION_PATH);
expect(msg).toContain('ALREADY EXISTS');
expect(msg).toContain('walled_owner_not_verified');
// [#11973] The dead end in the derivation's own vocabulary — the retired
// elevation refusal token must be gone.
expect(msg).toContain('NO platform-admin standing');
expect(msg).not.toContain('walled_owner_not_verified');
});

it('a populated store with NO owner account warns — the bootstrap window is spent and an invitee arrives unverified', () => {
Expand Down
21 changes: 21 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1085,6 +1085,27 @@ describe('AuthPlugin', () => {
expect(ql.insert).not.toHaveBeenCalled();
});

// [#11973 / #11663 L3] The trigger set widened to the #11343 `sys_user`
// arms: a config-anchored administrator comes into standing through a
// `sys_user` insert (operator-provisioned, arrives verified) or a
// verifying/email update — with no grant insert ever firing post-L4.
it('single-org: re-runs after a sys_user insert and after an email_verified update (#11973)', async () => {
await boot();
const runAll = async (opCtx: any) => {
for (const mw of middlewares) await mw(opCtx, async () => {});
};
await runAll({ object: 'sys_user', operation: 'insert' });
expect(ql.tables.sys_member).toHaveLength(1);
// The verifying update fires the bootstrap too (idempotent second pass).
await runAll({ object: 'sys_user', operation: 'update', data: { email_verified: true } });
expect(ql.tables.sys_member).toHaveLength(1);
// A sys_user update touching NEITHER standing column costs no run at
// all — asserted on `find`, which any fired pass must call first.
ql.find.mockClear();
await runAll({ object: 'sys_user', operation: 'update', data: { name: 'renamed' } });
expect(ql.find).not.toHaveBeenCalled();
});

it('single-org: idempotent — second kernel:ready pass is a no-op', async () => {
await boot();
await hookCapture.trigger('kernel:ready');
Expand Down
32 changes: 20 additions & 12 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,10 @@ import {
readMcpServerEnabledEnv,
type AuthManagerOptions,
} from './auth-manager.js';
import { ensureDefaultOrganization } from './ensure-default-organization.js';
import {
ensureDefaultOrganization,
isDefaultOrganizationBootstrapTrigger,
} from './ensure-default-organization.js';
import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js';
import { runAttributedToUser } from './auth-actor-attribution.js';
import type { AuthEventAuditSurface } from './auth-session-audit.js';
Expand DownExpand Up@@ -181,10 +184,13 @@ export interface AuthPluginOptions extends Partial<AuthConfig> {
* organization, so sessions carry no `activeOrganizationId` and better-auth
* `organization/invite-member` has no org to resolve — i.e. no way to add a
* user at all. When enabled (default), the plugin idempotently creates the
* `Default Organization` (slug `default`) and binds the first platform
* admin as `owner`, on `kernel:ready` and after every
* `sys_user_permission_set` insert. Inert in multi-org mode — the
* enterprise organizations package owns the bootstrap there.
* `Default Organization` (slug `default`) and binds the platform admin as
* `owner`, on `kernel:ready` and after every write matched by
* `isDefaultOrganizationBootstrapTrigger` (a `sys_user` insert or
* email/email_verified update — the config-anchor trigger set — plus the
* legacy `sys_user_permission_set` insert that `single`-posture first-user
* promotion still writes). Inert in multi-org mode — the enterprise
* organizations package owns the bootstrap there.
* @default true
*/
autoDefaultOrganization?: boolean;
Expand DownExpand Up@@ -1047,18 +1053,20 @@ export class AuthPlugin implements Plugin {
}
};
ctx.hook('kernel:ready', runEnsure);
// Re-run after every admin grant — covers the "first sign-up promoted
// to platform admin" case where kernel:ready fired before any user
// existed (same wiring the multi-org bootstrap uses).
// [#11973 / #11663 L3] Re-run after every write that can move the
// population answer, judged by the ONE exported trigger predicate: a
// `sys_user` insert or email/email_verified update (the #11343 trigger
// set — how a CONFIG-anchored admin comes into standing), and the
// legacy `sys_user_permission_set` insert (how `single`-posture
// first-user promotion lands standing, Choice 4A — retired with the
// legacy-grant removal leg). The enterprise organizations package's
// walled wiring should consume the same predicate.
try {
const ql = ctx.getService<IObjectQLEngine>('objectql');
if (ql && typeof ql.registerMiddleware === 'function') {
ql.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
await next();
if (
opCtx?.object === 'sys_user_permission_set' &&
(opCtx?.operation === 'insert' || opCtx?.operation === 'create')
) {
if (isDefaultOrganizationBootstrapTrigger(opCtx)) {
await runEnsure();
}
});
Expand Down
157 changes: 155 additions & 2 deletions packages/plugins/plugin-auth/src/ensure-default-organization.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,8 +4,34 @@
// Covers the idempotency short-circuits, the create/reuse paths, and the
// injectable seed-ownership step (enterprise injects it; open path omits it).

import { describe, it, expect, vi } from 'vitest';
import { ensureDefaultOrganization } from './ensure-default-organization.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { resetPlatformAdminEmailMemo } from '@objectstack/core';
import {
ensureDefaultOrganization,
isDefaultOrganizationBootstrapTrigger,
} from './ensure-default-organization.js';

// [#11973] The config anchor reads `OS_PLATFORM_OWNER_EMAIL` live (memoized on
// the raw value), so every case in this file pins the variable's state instead
// of inheriting the ambient environment's.
const ENV = 'OS_PLATFORM_OWNER_EMAIL';
let ambientOwnerEmail: string | undefined;
beforeEach(() => {
ambientOwnerEmail = process.env[ENV];
delete process.env[ENV];
resetPlatformAdminEmailMemo();
});
afterEach(() => {
if (ambientOwnerEmail === undefined) delete process.env[ENV];
else process.env[ENV] = ambientOwnerEmail;
resetPlatformAdminEmailMemo();
});

/** Declare the deployment's administrators and drop the raw-value memo. */
function declare(value: string): void {
process.env[ENV] = value;
resetPlatformAdminEmailMemo();
}

type Row = Record<string, any>;

Expand All@@ -17,6 +43,7 @@ function makeQl(seed: Partial<Record<string, Row[]>> = {}) {
],
sys_member: [],
sys_organization: [],
sys_user: [],
...seed,
};
const matches = (row: Row, where: Row) =>
Expand DownExpand Up@@ -179,4 +206,130 @@ describe('ensureDefaultOrganization (plugin-auth home)', () => {
expect(sink.seen).toHaveLength(1);
});
});

// [#11973 / #11663 L3] The config-anchored population — design §2 step 5.
describe('config-anchored population (#11973)', () => {
const OWNER = 'owner@corp.example';

it('finds a declared, VERIFIED administrator with NO grant row anywhere (post-L4 walled population)', async () => {
declare(OWNER);
const ql = makeQl({
sys_user_permission_set: [],
sys_user: [{ id: 'u_cfg', email: OWNER, email_verified: true }],
});
const res = await ensureDefaultOrganization(ql);
expect(res.memberCreated).toBe(true);
expect(ql.tables.sys_member[0]).toMatchObject({ user_id: 'u_cfg', role: 'owner' });
});

it('prefers the config anchor over the legacy grant anchor (the derivation prefers config)', async () => {
declare(OWNER);
const ql = makeQl({
sys_user: [{ id: 'u_cfg', email: OWNER, email_verified: true }],
});
// The default fixture also carries the legacy grant admin `u1`.
await ensureDefaultOrganization(ql);
expect(ql.tables.sys_member[0].user_id).toBe('u_cfg');
});

it('an UNVERIFIED declared account confers nothing — falls back to the legacy grant anchor', async () => {
declare(OWNER);
const ql = makeQl({
sys_user: [{ id: 'u_cfg', email: OWNER, email_verified: false }],
});
await ensureDefaultOrganization(ql);
expect(ql.tables.sys_member[0].user_id).toBe('u1');
});

it('declared but nobody registered, and no grants: no_admin — the trigger set re-runs it later', async () => {
declare(OWNER);
const ql = makeQl({ sys_user_permission_set: [] });
const res = await ensureDefaultOrganization(ql);
expect(res).toMatchObject({ defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' });
});

it('operator order decides between several declared administrators with standing', async () => {
declare('first@corp.example,second@corp.example');
const ql = makeQl({
sys_user_permission_set: [],
sys_user: [
{ id: 'u_second', email: 'second@corp.example', email_verified: true },
{ id: 'u_first', email: 'first@corp.example', email_verified: true },
],
});
await ensureDefaultOrganization(ql);
expect(ql.tables.sys_member[0].user_id).toBe('u_first');
});

it('an entry with no verified account is passed over for the next declared entry', async () => {
declare('first@corp.example,second@corp.example');
const ql = makeQl({
sys_user_permission_set: [],
sys_user: [{ id: 'u_second', email: 'second@corp.example', email_verified: true }],
});
await ensureDefaultOrganization(ql);
expect(ql.tables.sys_member[0].user_id).toBe('u_second');
});

it('a REFUSED variable (unparseable entry) fails the whole list closed — legacy anchor answers', async () => {
declare(`${OWNER},not an email`);
const ql = makeQl({
sys_user: [{ id: 'u_cfg', email: OWNER, email_verified: true }],
});
await ensureDefaultOrganization(ql);
// Choice 2B: the whole variable is refused, never the one entry — so the
// verified declared account confers nothing and the grant admin is bound.
expect(ql.tables.sys_member[0].user_id).toBe('u1');
});

it('queries the VERBATIM spelling too — an imported row that is not stored lowercased is found', async () => {
declare('Ada@Example.com');
const ql = makeQl({
sys_user_permission_set: [],
// The fake driver is an exact-match store, so the normalized
// (lowercased) lookup misses this row; only the as-typed spelling hits.
sys_user: [{ id: 'u_ada', email: 'Ada@Example.com', email_verified: true }],
});
await ensureDefaultOrganization(ql);
expect(ql.tables.sys_member[0].user_id).toBe('u_ada');
});

// The Choice 4A pin the PM asked for by name: with the variable UNSET, a
// verified `sys_user` row is NOT a population candidate. If the re-point
// leaked into the `single` branch (any verified user read as an admin
// candidate), `u_other` would win the bind below and this goes red.
it('config UNSET: a verified sys_user row is NOT an admin candidate — the grant anchor decides (Choice 4A)', async () => {
const ql = makeQl({
sys_user: [{ id: 'u_other', email: 'other@corp.example', email_verified: true }],
});
const res = await ensureDefaultOrganization(ql);
expect(res.memberCreated).toBe(true);
expect(ql.tables.sys_member[0].user_id).toBe('u1');
// …and the config half cost no sys_user read at all.
expect(ql.find).not.toHaveBeenCalledWith('sys_user', expect.anything(), expect.anything());
});
});
});

// [#11973 / #11663 L3, design H4] The trigger predicate — one definition for
// every wiring (plugin-auth's middleware here; the enterprise organizations
// package's walled wiring is asked to consume the same export).
describe('isDefaultOrganizationBootstrapTrigger', () => {
it.each([
[{ object: 'sys_user', operation: 'insert' }, true],
[{ object: 'sys_user', operation: 'create' }, true],
[{ object: 'sys_user', operation: 'update', data: { email_verified: true } }, true],
[{ object: 'sys_user', operation: 'update', data: { email: 'x@y.example' } }, true],
[{ object: 'sys_user', operation: 'update', data: { name: 'renamed' } }, false],
[{ object: 'sys_user', operation: 'update' }, false],
[{ object: 'sys_user', operation: 'delete' }, false],
[{ object: 'sys_user_permission_set', operation: 'insert' }, true],
[{ object: 'sys_user_permission_set', operation: 'create' }, true],
[{ object: 'sys_user_permission_set', operation: 'update', data: { organization_id: null } }, false],
[{ object: 'sys_member', operation: 'insert' }, false],
[{ object: 'task', operation: 'insert' }, false],
[{}, false],
])('%j → %s', (opCtx, expected) => {
expect(isDefaultOrganizationBootstrapTrigger(opCtx as any)).toBe(expected);
});
});
Loading
Loading