diff --git a/.changeset/d1-config-derived-owner.md b/.changeset/d1-config-derived-owner.md new file mode 100644 index 0000000000..573c12dcaf --- /dev/null +++ b/.changeset/d1-config-derived-owner.md @@ -0,0 +1,5 @@ +--- +'@objectstack/plugin-auth': patch +--- + +`ensureDefaultOrganization` resolves the platform admin through the L4 config-derived standing when no cross-tenant grant row exists (#13514 follow-through): the walled bootstrap mints no `sys_user_permission_set` row any more, so the ADR-0081 D1 default-org bootstrap dead-ended on `no_admin` forever on walled deployments — the enterprise organizations package invokes this same helper there. The fallback asks the same public predicates the derivation site asks (`resolvePlatformAdminEmails` + `isConfiguredPlatformAdminEmail` + the #11343 verified-email allow-list), oldest verified owner wins, and the grant row stays primary where it exists. diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts index ffe62f36cd..16f8e0a41c 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts @@ -36,6 +36,71 @@ function makeQl(seed: Partial> = {}) { }; } +describe('the L4 config-derived owner fallback (#13514 follow-through)', () => { + // Under a walled posture the bootstrap mints NO grant row, so the grant + // lookup answers nothing — the declared VERIFIED owner must be the admin, + // resolved with the same public predicates the derivation site asks. + const OWNER = 'owner@walled.example'; + const withOwnerEnv = async (value: string | undefined, fn: () => Promise) => { + const prev = process.env.OS_PLATFORM_OWNER_EMAIL; + if (value === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = value; + try { await fn(); } finally { + if (prev === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = prev; + } + }; + + it('no grant row + declared VERIFIED owner ⇒ the owner gets the default org', async () => + withOwnerEnv(OWNER, async () => { + const ql = makeQl({ + sys_user_permission_set: [], + sys_user: [ + { id: 'u_other', email: 'bystander@walled.example', email_verified: true, created_at: '2026-01-01' }, + { id: 'u_owner', email: OWNER, email_verified: true, created_at: '2026-01-02' }, + ], + }); + const res = await ensureDefaultOrganization(ql); + expect(res.defaultOrgCreated).toBe(true); + expect(res.memberCreated).toBe(true); + expect(ql.tables.sys_member[0]).toMatchObject({ user_id: 'u_owner', role: 'owner' }); + })); + + it('an UNVERIFIED declared owner stays no_admin — fail closed, the #11343 allow-list holds', async () => + withOwnerEnv(OWNER, async () => { + const ql = makeQl({ + sys_user_permission_set: [], + sys_user: [{ id: 'u_owner', email: OWNER, email_verified: false, created_at: '2026-01-02' }], + }); + const res = await ensureDefaultOrganization(ql); + expect(res.defaultOrgCreated).toBe(false); + expect(res.reason).toBe('no_admin'); + expect(ql.tables.sys_member).toEqual([]); + })); + + it('no declared owner at all stays no_admin — nobody is invented', async () => + withOwnerEnv(undefined, async () => { + const ql = makeQl({ + sys_user_permission_set: [], + sys_user: [{ id: 'u_owner', email: OWNER, email_verified: true, created_at: '2026-01-02' }], + }); + const res = await ensureDefaultOrganization(ql); + expect(res.defaultOrgCreated).toBe(false); + expect(res.reason).toBe('no_admin'); + })); + + it('a grant row still WINS over the config fallback — the historical spelling stays primary', async () => + withOwnerEnv(OWNER, async () => { + const ql = makeQl({ + sys_user: [{ id: 'u_owner', email: OWNER, email_verified: true, created_at: '2026-01-02' }], + }); + const res = await ensureDefaultOrganization(ql); + expect(res.defaultOrgCreated).toBe(true); + // makeQl's default grant row names u1 — that row, not the config owner. + expect(ql.tables.sys_member[0]).toMatchObject({ user_id: 'u1', role: 'owner' }); + })); +}); + describe('ensureDefaultOrganization (plugin-auth home)', () => { it('creates the default org and binds the admin as owner', async () => { const ql = makeQl(); diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.ts index 5d7bbaf4fd..f2d701be94 100644 --- a/packages/plugins/plugin-auth/src/ensure-default-organization.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.ts @@ -85,6 +85,9 @@ interface BootstrapLogger { * tests — survive it perfectly, which is why no suite would catch it. The * property-access call form below keeps the receiver. */ +import { resolvePlatformAdminEmails, isConfiguredPlatformAdminEmail } from '@objectstack/core'; +import { isEmailVerifiedUserRow } from '@objectstack/types'; + function logDurabilityFailure( logger: BootstrapLogger | undefined, message: string, @@ -164,29 +167,44 @@ export async function ensureDefaultOrganization( return { defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }; } - // 1. Find the platform admin permission-set id. - const adminPs = await tryFind(ql, 'sys_permission_set', { name: 'admin_full_access' }, 1); - if (adminPs.length === 0 || !adminPs[0].id) { - return { defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }; - } - const adminPsId = adminPs[0].id; - - // 2. Find the platform admin user (oldest cross-tenant grant). - const adminGrants = await tryFind( - ql, - 'sys_user_permission_set', - { permission_set_id: adminPsId, organization_id: null }, - 50, - ); - if (adminGrants.length === 0) { - return { defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }; - } - const sortedGrants = [...adminGrants].sort((a, b) => { + const oldestFirst = (a: any, b: any) => { const ta = a.created_at ? new Date(a.created_at).getTime() : 0; const tb = b.created_at ? new Date(b.created_at).getTime() : 0; return ta - tb; - }); - const adminUserId: string | undefined = sortedGrants[0]?.user_id; + }; + + // 1-2. Resolve the platform admin. The cross-tenant grant row is the + // historical spelling and still the primary answer where it exists + // (`single` posture first-user promotion, Choice 4A; legacy walled + // grants). Since #13514 (L4) a WALLED bootstrap mints no row at all — + // standing is config-derived at the authorization derivation site — so a + // missing row is no longer a verdict: fall back to the DECLARED VERIFIED + // OWNER, resolved with the same public predicates the derivation site + // asks (`resolvePlatformAdminEmails` + row-side membership + the #11343 + // verified-email allow-list), oldest wins — the bootstrap's own tiebreak. + // Without this fallback the walled default-org bootstrap dead-ends on + // `no_admin` forever, which is how cloud's EE guided-path suite caught it. + let adminUserId: string | undefined; + const adminPs = await tryFind(ql, 'sys_permission_set', { name: 'admin_full_access' }, 1); + if (adminPs.length > 0 && adminPs[0].id) { + const adminGrants = await tryFind( + ql, + 'sys_user_permission_set', + { permission_set_id: adminPs[0].id, organization_id: null }, + 50, + ); + adminUserId = [...adminGrants].sort(oldestFirst)[0]?.user_id; + } + if (!adminUserId) { + const config = resolvePlatformAdminEmails(); + if (config.emails.length > 0) { + const users = await tryFind(ql, 'sys_user', {}, 50); + const owners = users + .filter((u: any) => isConfiguredPlatformAdminEmail(u?.email, config) && isEmailVerifiedUserRow(u)) + .sort(oldestFirst); + adminUserId = owners[0]?.id; + } + } if (!adminUserId) { return { defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }; }