diff --git a/.changeset/config.json b/.changeset/config.json index 549fdfce38..99350f52d5 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -41,7 +41,6 @@ "@objectstack/plugin-email", "@objectstack/plugin-hono-server", "@objectstack/mcp", - "@objectstack/plugin-org-scoping", "@objectstack/plugin-reports", "@objectstack/plugin-security", "@objectstack/plugin-sharing", diff --git a/.objectui-sha b/.objectui-sha index c4974acc1e..59c8804558 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -c27bd3264f394bec5f5f71326118bfb115fbe884 +195121ade6ec921e5786030093f0b23fc58d02f7 diff --git a/packages/cli/package.json b/packages/cli/package.json index df35d6e2bc..e73f8ce5ac 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -59,7 +59,6 @@ "@objectstack/plugin-auth": "workspace:*", "@objectstack/plugin-email": "workspace:*", "@objectstack/plugin-hono-server": "workspace:*", - "@objectstack/plugin-org-scoping": "workspace:*", "@objectstack/plugin-reports": "workspace:*", "@objectstack/plugin-security": "workspace:*", "@objectstack/plugin-sharing": "workspace:*", diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 0336b26c78..6f911fe7e7 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1279,21 +1279,28 @@ export default class Serve extends Command { } } - // Pair: OrgScopingPlugin (multi-tenant) — optional, must register BEFORE SecurityPlugin - // OrgScopingPlugin provides `organization_id` auto-stamp, per-org - // seed-replay, and default-org bootstrap. SecurityPlugin probes - // the `org-scoping` service at start() time and conditionally - // strips the wildcard `tenant_isolation` RLS when this plugin - // is absent — so registration order matters. + // Pair: OrganizationsPlugin (multi-org, ENTERPRISE) — must register + // BEFORE SecurityPlugin. The multi-org runtime (`organization_id` + // auto-stamp, per-org seed replay, multi-org default-org bootstrap) + // lives in the closed-source `@objectstack/organizations` package + // (ADR-0081 D2; it registers the historical `org-scoping` service + // SecurityPlugin probes at start() to keep vs strip the wildcard + // `tenant_isolation` RLS — so registration order matters). Without + // it, deployments are single-org: the open member-management + // basics (plugin-auth's default-org bootstrap + better-auth + // invitations) still work. const multiTenant = resolveMultiOrgEnabled(); if (multiTenant) { try { - const orgScopingPkg = '@objectstack/plugin-org-scoping'; - const { OrgScopingPlugin } = await import(/* webpackIgnore: true */ orgScopingPkg); - await kernel.use(new OrgScopingPlugin()); - trackPlugin('OrgScoping'); + const organizationsPkg = '@objectstack/organizations'; + const mod: any = await import(/* webpackIgnore: true */ organizationsPkg); + await kernel.use(new mod.OrganizationsPlugin()); + trackPlugin('Organizations'); } catch { - // optional — multi-tenant mode requested but plugin not installed + // Requested multi-org without the enterprise package — loud, + // not silent: RLS tenant policies will be STRIPPED and every + // org boundary is inert until the package is installed. + console.warn(chalk.yellow(' ⚠ OS_MULTI_ORG_ENABLED=true but @objectstack/organizations (enterprise) is not installed — running single-org.')); } } diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index 141a269d12..4886919ea9 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -42,7 +42,7 @@ export default class Verify extends Command { default: false, }), 'multi-tenant': Flags.boolean({ - description: 'Boot org-scoped (register plugin-org-scoping) so tenant-isolation RLS policies apply (also honors $OS_MULTI_ORG_ENABLED)', + description: 'Boot org-scoped (register the enterprise @objectstack/organizations plugin) so tenant-isolation RLS policies apply (also honors $OS_MULTI_ORG_ENABLED)', default: false, }), json: Flags.boolean({ description: 'Emit the structured report as JSON', default: false }), diff --git a/packages/dogfood/test/authz-conformance.matrix.ts b/packages/dogfood/test/authz-conformance.matrix.ts index ecfb9b4a37..6157af55be 100644 --- a/packages/dogfood/test/authz-conformance.matrix.ts +++ b/packages/dogfood/test/authz-conformance.matrix.ts @@ -41,7 +41,7 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ { id: 'controlled-by-parent', summary: 'master-detail controlled_by_parent', state: 'enforced', enforcement: 'plugin-security/security-plugin.ts computeControlledByParentFilter + assertControlledByParentWrite', proof: 'controlled-by-parent.dogfood.test.ts' }, { id: 'multi-tenant', summary: 'organization isolation', state: 'enforced', - enforcement: 'plugin-org-scoping + wildcard tenant RLS', proof: 'rls-multitenant.dogfood.test.ts' }, + enforcement: '@objectstack/organizations (enterprise) + wildcard tenant RLS', proof: 'rls-multitenant.dogfood.test.ts' }, { id: 'anonymous-deny', summary: 'secure-by-default anonymous posture (capability)', state: 'enforced', enforcement: 'rest/rest-server.ts enforceAuth (requireAuth)', proof: 'showcase-anonymous-deny.dogfood.test.ts' }, { id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced', diff --git a/packages/dogfood/test/rls-multitenant.dogfood.test.ts b/packages/dogfood/test/rls-multitenant.dogfood.test.ts index 112643141a..c51c45c95b 100644 --- a/packages/dogfood/test/rls-multitenant.dogfood.test.ts +++ b/packages/dogfood/test/rls-multitenant.dogfood.test.ts @@ -30,7 +30,19 @@ import crmStack from '@objectstack/example-crm'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { runRlsProofs, formatRlsReport, type RlsReport } from '@objectstack/verify'; -describe('objectstack verify RLS: CRM multi-tenant (#1994 org-scoped)', () => { +// The multi-org runtime moved to the ENTERPRISE `@objectstack/organizations` +// package (ADR-0081 D2) — not part of this open workspace. Skip (loudly) when +// it isn't linked in; enterprise/cloud CI, which ships the package, runs this. +const organizationsPkg = '@objectstack/organizations'; +const organizationsAvailable = await import(/* webpackIgnore: true */ organizationsPkg) + .then(() => true) + .catch(() => false); +if (!organizationsAvailable) { + // eslint-disable-next-line no-console + console.warn('[dogfood] @objectstack/organizations (enterprise) not installed — skipping the multi-org RLS gate'); +} + +describe.skipIf(!organizationsAvailable)('objectstack verify RLS: CRM multi-tenant (#1994 org-scoped)', () => { let stack: VerifyStack; let report: RlsReport; diff --git a/packages/platform-objects/src/apps/setup-nav.contributions.ts b/packages/platform-objects/src/apps/setup-nav.contributions.ts index e3c44e9293..4aecb9191b 100644 --- a/packages/platform-objects/src/apps/setup-nav.contributions.ts +++ b/packages/platform-objects/src/apps/setup-nav.contributions.ts @@ -57,10 +57,22 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ priority: BASE_PRIORITY, items: [ { id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user', icon: 'user' }, + // The ACTIVE organization's record page (Members / Invitations / Teams + // tabs with the better-auth row actions), rendered inside the app shell + // (ADR-0081). `{current_org_id}` resolves from the session's active + // organization; unresolved (e.g. org-less admin before bootstrap) it + // falls back to the sys_organization list — one row in single-org. + { id: 'nav_organization', type: 'object', label: 'Organization', objectName: 'sys_organization', recordId: '{current_org_id}', icon: 'building-2' }, { id: 'nav_business_units', type: 'object', label: 'Business Units', objectName: 'sys_business_unit', icon: 'building', requiresObject: 'sys_business_unit' }, - { id: 'nav_teams', type: 'object', label: 'Teams', objectName: 'sys_team', icon: 'users-round', requiresService: 'org-scoping' }, + // Teams / Invitations no longer gate on `org-scoping` (ADR-0081 D1): + // the better-auth organization capability is always mounted, and + // plugin-auth's single-org default-org bootstrap guarantees an org to + // invite into — these are the OPEN member-management basics. Only the + // org LIST below keeps the gate: browsing organizations is meaningful + // only when more than one can exist (enterprise multi-org). + { id: 'nav_teams', type: 'object', label: 'Teams', objectName: 'sys_team', icon: 'users-round' }, { id: 'nav_organizations', type: 'object', label: 'Organizations', objectName: 'sys_organization', icon: 'building-2', requiresService: 'org-scoping' }, - { id: 'nav_invitations', type: 'object', label: 'Invitations', objectName: 'sys_invitation', icon: 'mail', requiresService: 'org-scoping' }, + { id: 'nav_invitations', type: 'object', label: 'Invitations', objectName: 'sys_invitation', icon: 'mail' }, ], }, { diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 24791880cf..2168e256a0 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -63,6 +63,7 @@ export const en: TranslationData = { // People & Organization nav_users: { label: 'Users' }, + nav_organization: { label: 'Organization' }, nav_business_units: { label: 'Business Units' }, nav_teams: { label: 'Teams' }, nav_organizations: { label: 'Organizations' }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index cc34eb598f..fdb4ede768 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -45,6 +45,7 @@ export const esES: TranslationData = { nav_system_overview: { label: 'Resumen del Sistema' }, nav_users: { label: 'Usuarios' }, + nav_organization: { label: 'Organización' }, nav_business_units: { label: 'Unidades de negocio' }, nav_teams: { label: 'Equipos' }, nav_organizations: { label: 'Organizaciones' }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index d590bb124a..b831242b21 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -45,6 +45,7 @@ export const jaJP: TranslationData = { nav_system_overview: { label: 'システム概要' }, nav_users: { label: 'ユーザー' }, + nav_organization: { label: '組織' }, nav_business_units: { label: 'ビジネスユニット' }, nav_teams: { label: 'チーム' }, nav_organizations: { label: '組織' }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index 9c6b0af25b..90be67d37e 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -46,6 +46,7 @@ export const zhCN: TranslationData = { nav_system_overview: { label: '系统概览' }, nav_users: { label: '用户' }, + nav_organization: { label: '组织' }, nav_business_units: { label: '业务单元' }, nav_teams: { label: '团队' }, nav_organizations: { label: '组织' }, diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index 31bf7b6d82..ecfc1b4cfa 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -50,7 +50,7 @@ export const SysInvitation = ObjectSchema.create({ // sys_organization.create_organization). The recipient-side // accept/reject actions below stay record-gated — they are // unreachable in single-org anyway (no invitation rows exist). - visible: 'features.multiOrgEnabled != false', + visible: 'features.organization != false', successMessage: 'Invitation sent', refreshAfter: true, params: [ @@ -68,7 +68,7 @@ export const SysInvitation = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/cancel-invitation', recordIdParam: 'invitationId', - visible: 'features.multiOrgEnabled != false', + visible: 'features.organization != false', confirmText: 'Cancel this invitation? The recipient will no longer be able to accept it.', successMessage: 'Invitation canceled', refreshAfter: true, @@ -82,7 +82,7 @@ export const SysInvitation = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/invite-member', bodyExtra: { resend: true }, - visible: 'features.multiOrgEnabled != false', + visible: 'features.organization != false', successMessage: 'Invitation resent', refreshAfter: true, params: [ diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index 2bb17b2ee0..e6c7f614a6 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -51,11 +51,11 @@ export const SysMember = ObjectSchema.create({ locations: ['list_toolbar'], type: 'api', target: '/api/v1/auth/organization/add-member', - // Org-membership mutations are multi-org-only: the better-auth - // endpoints resolve an active org that does not exist in single-org - // mode, so these actions would fail at the API. Gate every one on - // the multi-org flag (mirrors sys_organization.create_organization). - visible: 'features.multiOrgEnabled != false', + // Gated on the org CAPABILITY, not multi-org (ADR-0081 D1): the + // better-auth endpoints resolve the session's active org, which + // single-org mode now guarantees via plugin-auth's default-org + // bootstrap. Same gate on every membership mutation below. + visible: 'features.organization != false', successMessage: 'Member added', refreshAfter: true, params: [ @@ -73,7 +73,7 @@ export const SysMember = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/update-member-role', recordIdParam: 'memberId', - visible: 'features.multiOrgEnabled != false', + visible: 'features.organization != false', successMessage: 'Member role updated', refreshAfter: true, params: [ @@ -90,7 +90,7 @@ export const SysMember = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/remove-member', recordIdParam: 'memberIdOrEmail', - visible: 'features.multiOrgEnabled != false', + visible: 'features.organization != false', confirmText: 'Remove this member from the organization? They will lose access to all org resources.', successMessage: 'Member removed', refreshAfter: true, @@ -112,7 +112,7 @@ export const SysMember = ObjectSchema.create({ target: '/api/v1/auth/organization/update-member-role', recordIdParam: 'memberId', bodyExtra: { role: 'owner' }, - visible: "record.role != 'owner' && features.multiOrgEnabled != false", + visible: "record.role != 'owner' && features.organization != false", confirmText: 'Transfer ownership of this organization to the selected member? You will be demoted to admin and lose owner-only privileges.', successMessage: 'Ownership transferred', refreshAfter: true, diff --git a/packages/platform-objects/src/identity/sys-team-member.object.ts b/packages/platform-objects/src/identity/sys-team-member.object.ts index 174f6df910..a35a66f488 100644 --- a/packages/platform-objects/src/identity/sys-team-member.object.ts +++ b/packages/platform-objects/src/identity/sys-team-member.object.ts @@ -45,7 +45,7 @@ export const SysTeamMember = ObjectSchema.create({ // Team membership lives under organizations — multi-org-only. Gate // both mutations so they vanish in single-org (mirrors // sys_organization.create_organization). - visible: 'features.multiOrgEnabled != false', + visible: 'features.organization != false', successMessage: 'Team member added', refreshAfter: true, params: [ @@ -66,7 +66,7 @@ export const SysTeamMember = ObjectSchema.create({ locations: ['list_item'], type: 'api', target: '/api/v1/auth/organization/remove-team-member', - visible: 'features.multiOrgEnabled != false', + visible: 'features.organization != false', confirmText: 'Remove this user from the team? They will lose any team-scoped access.', successMessage: 'Team member removed', refreshAfter: true, diff --git a/packages/platform-objects/src/identity/sys-team.object.ts b/packages/platform-objects/src/identity/sys-team.object.ts index bc58983ba9..564aad3fc4 100644 --- a/packages/platform-objects/src/identity/sys-team.object.ts +++ b/packages/platform-objects/src/identity/sys-team.object.ts @@ -48,7 +48,7 @@ export const SysTeam = ObjectSchema.create({ // Teams are nested inside organizations — a multi-org-only concept. // Gate every team mutation on the multi-org flag so the affordances // disappear in single-org (mirrors sys_organization.create_organization). - visible: 'features.multiOrgEnabled != false', + visible: 'features.organization != false', successMessage: 'Team created', refreshAfter: true, params: [ @@ -69,7 +69,7 @@ export const SysTeam = ObjectSchema.create({ target: '/api/v1/auth/organization/update-team', recordIdParam: 'teamId', bodyShape: { wrap: 'data' }, - visible: 'features.multiOrgEnabled != false', + visible: 'features.organization != false', successMessage: 'Team updated', refreshAfter: true, params: [ @@ -88,7 +88,7 @@ export const SysTeam = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/remove-team', recordIdParam: 'teamId', - visible: 'features.multiOrgEnabled != false', + visible: 'features.organization != false', confirmText: 'Delete this team? Members will lose any team-scoped access. This cannot be undone.', successMessage: 'Team deleted', refreshAfter: true, diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 78278de26e..37d5f88762 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -47,13 +47,14 @@ export const SysUser = ObjectSchema.create({ locations: ['list_toolbar'], type: 'api', target: '/api/v1/auth/organization/invite-member', - // Org invitations are a multi-org-only flow (the endpoint resolves - // an active org that does not exist in single-org mode). Hide the - // affordance unless multi-org is enabled — matching the - // `create_organization` gate on sys_organization. This action is the - // most exposed of the set because the Users list is always reachable - // in single-org, unlike the org/membership lists. - visible: 'features.multiOrgEnabled != false', + // Gated on the org CAPABILITY, not multi-org (ADR-0081 D1): the + // better-auth organization plugin is always mounted, and single-org + // mode now bootstraps a Default Organization (plugin-auth) so the + // endpoint's active-org resolution works there too. This is THE + // "add a teammate" affordance — the Users list is always reachable — + // and every add flows through better-auth invitations, never bespoke + // sys_user CRUD. + visible: 'features.organization != false', successMessage: 'Invitation sent', refreshAfter: true, params: [ diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index a04ce6990d..5762c03a3d 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -2266,4 +2266,107 @@ describe('AuthManager', () => { expect(m.isClientIpAllowed('')).toBe(true); }); }); + + // ADR-0081 D1 — default active-org stamp on session create. + describe('composeDatabaseHooks – session.create.before active-org default', () => { + const OWNER_ROW = { id: 'm1', organization_id: 'org_owner', user_id: 'u1', role: 'owner' }; + const MEMBER_ROW = { id: 'm2', organization_id: 'org_member', user_id: 'u1', role: 'member' }; + + function makeEngine(rows: any[]) { + return { + findOne: vi.fn(async (_model: string, q: any) => { + const where = q?.where ?? {}; + return ( + rows.find( + (r) => + (!where.user_id || r.user_id === where.user_id) && + (!where.role || r.role === where.role), + ) ?? null + ); + }), + } as any; + } + + function hooksFor(config: any) { + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + ...config, + }); + return (manager as any).composeDatabaseHooks(config.databaseHooks) as any; + } + + it('stamps activeOrganizationId from the owner membership (owner preferred)', async () => { + const hooks = hooksFor({ dataEngine: makeEngine([MEMBER_ROW, OWNER_ROW]) }); + const result = await hooks.session.create.before({ userId: 'u1' }); + expect(result?.data?.activeOrganizationId).toBe('org_owner'); + }); + + it('falls back to any membership when the user owns nothing', async () => { + const hooks = hooksFor({ dataEngine: makeEngine([MEMBER_ROW]) }); + const result = await hooks.session.create.before({ userId: 'u1' }); + expect(result?.data?.activeOrganizationId).toBe('org_member'); + }); + + it('leaves a pre-set activeOrganizationId alone', async () => { + const engine = makeEngine([OWNER_ROW]); + const hooks = hooksFor({ dataEngine: engine }); + const result = await hooks.session.create.before({ + userId: 'u1', + activeOrganizationId: 'org_explicit', + }); + // Draft already carries an org — no stamp, no lookup needed. + expect(result?.data?.activeOrganizationId ?? 'org_explicit').toBe('org_explicit'); + expect(engine.findOne).not.toHaveBeenCalled(); + }); + + it('no-ops (org-less session) when the user has no memberships', async () => { + const hooks = hooksFor({ dataEngine: makeEngine([]) }); + const result = await hooks.session.create.before({ userId: 'u1' }); + expect(result?.data?.activeOrganizationId).toBeUndefined(); + }); + + it('chains the HOST session hook first and keeps its choice', async () => { + const hostHook = vi.fn(async (session: any) => ({ + data: { ...session, activeOrganizationId: 'org_host' }, + })); + const hooks = hooksFor({ + dataEngine: makeEngine([OWNER_ROW]), + databaseHooks: { session: { create: { before: hostHook } } }, + }); + const result = await hooks.session.create.before({ userId: 'u1' }); + expect(hostHook).toHaveBeenCalledTimes(1); + expect(result?.data?.activeOrganizationId).toBe('org_host'); + }); + + it('fills the field when the host hook declines (returns undefined)', async () => { + const hostHook = vi.fn(async () => undefined); + const hooks = hooksFor({ + dataEngine: makeEngine([OWNER_ROW]), + databaseHooks: { session: { create: { before: hostHook } } }, + }); + const result = await hooks.session.create.before({ userId: 'u1' }); + expect(result?.data?.activeOrganizationId).toBe('org_owner'); + }); + + it('autoActiveOrganization: false restores the raw host behaviour', async () => { + const hooks = hooksFor({ + dataEngine: makeEngine([OWNER_ROW]), + autoActiveOrganization: false, + }); + expect(hooks.session?.create?.before).toBeUndefined(); + }); + + it('never breaks session create on engine errors', async () => { + const engine = { findOne: vi.fn(async () => { throw new Error('db down'); }) } as any; + const hooks = hooksFor({ dataEngine: engine }); + const result = await hooks.session.create.before({ userId: 'u1' }); + expect(result?.data?.activeOrganizationId).toBeUndefined(); + }); + + it('keeps the account.create.after identity stamp intact', async () => { + const hooks = hooksFor({ dataEngine: makeEngine([]) }); + expect(typeof hooks.account?.create?.after).toBe('function'); + }); + }); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 0692de7be2..89e44200b6 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -255,6 +255,17 @@ export interface AuthManagerOptions extends Partial { */ appName?: string; + /** + * ADR-0081 D1 — default active-org on session create. When enabled + * (default), a `session.create.before` hook stamps `activeOrganizationId` + * from the caller's `sys_member` row (owner-preferred) whenever the draft + * lacks one. A host-supplied `session.create.before` (see + * {@link databaseHooks}) chains FIRST and keeps precedence. Set `false` + * to restore the raw better-auth behaviour (sessions start org-less). + * @default true + */ + autoActiveOrganization?: boolean; + /** * Pass-through to better-auth's `databaseHooks` option. better-auth fires * these around its own adapter writes (e.g. when `genericOAuth` creates @@ -2303,9 +2314,11 @@ export class AuthManager { /** * Compose the framework's identity-source stamp (`account.create.after`) - * with any host-supplied `databaseHooks`, preserving BOTH. The cloud passes + * and the default active-org stamp (`session.create.before`) with any + * host-supplied `databaseHooks`, preserving ALL. The cloud passes * `user.create.after` (personal-org provisioning) + `session.create.before` - * (active-org) — different model/op, so no collision — but if a host ever + * (active-org) — its session hook chains FIRST and this default only fills + * the field when still unset, so the host keeps precedence. If a host ever * adds its own `account.create.after` we chain it after the stamp rather * than silently dropping one. */ @@ -2320,6 +2333,64 @@ export class AuthManager { return hostAccountAfter(account, ctx); } : stamp; + + // ADR-0081 D1 — default active-org on session create. Without it, a user + // with memberships logs in with `activeOrganizationId = null`: better-auth + // org endpoints can't resolve an active org (single-org invite dead-end) + // and `{current_org_id}` nav tokens fall back to list views. Resolve the + // caller's sys_member row (owner-preferred, else oldest) and stamp the + // draft. Host hook runs first and wins; errors are swallowed so login + // never fails on this bookkeeping. Opt out via `autoActiveOrganization: + // false`. + const hostSessionBefore = (host as any)?.session?.create?.before; + const defaultActiveOrg = async (session: any) => { + try { + if (!session || session.activeOrganizationId) return; + const userId = session.userId; + if (!userId) return; + const engine = this.config.dataEngine; + if (!engine) return; + // sys_member is org/user-scoped in host stacks — read with the system + // context so the pre-session lookup (no org on the caller yet) works. + const reader = withSystemReadContext(engine); + let row: any; + try { + row = await reader.findOne('sys_member', { + where: { user_id: userId, role: 'owner' }, + }); + } catch { + row = undefined; + } + if (!row?.organization_id) { + try { + row = await reader.findOne('sys_member', { where: { user_id: userId } }); + } catch { + row = undefined; + } + } + const orgId = row?.organization_id; + if (!orgId) return; + return { data: { ...session, activeOrganizationId: orgId } }; + } catch { + return; // never break session create + } + }; + const sessionBefore = + this.config.autoActiveOrganization !== false + ? async (session: any, ctx: any) => { + let draft = session; + if (hostSessionBefore) { + const hostResult = await hostSessionBefore(session, ctx); + if (hostResult && typeof hostResult === 'object' && 'data' in hostResult) { + draft = (hostResult as any).data; + } + // The host hook fully handled it → keep its result shape. + if (draft?.activeOrganizationId) return { data: draft }; + } + return (await defaultActiveOrg(draft)) ?? (draft === session ? undefined : { data: draft }); + } + : hostSessionBefore; + return { ...(host ?? {}), account: { @@ -2329,6 +2400,17 @@ export class AuthManager { after, }, }, + ...(sessionBefore + ? { + session: { + ...((host as any)?.session ?? {}), + create: { + ...((host as any)?.session?.create ?? {}), + before: sessionBefore, + }, + }, + } + : {}), } as BetterAuthOptions['databaseHooks']; } diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index e9c17289a9..8d9af79b6d 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -1000,4 +1000,111 @@ describe('AuthPlugin', () => { expect(mockContext.registerService).toHaveBeenCalled(); }); }); + + // ADR-0081 D1 — single-org default-organization bootstrap. + describe('Single-org default-org bootstrap', () => { + const OLD_ENV = process.env.OS_MULTI_ORG_ENABLED; + let hookCapture: ReturnType; + let middlewares: any[]; + let ql: any; + + const makeQl = () => { + const tables: Record = { + 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 }, + ], + sys_member: [], + sys_organization: [], + }; + const matches = (row: any, where: any) => + Object.entries(where ?? {}).every(([k, v]) => (v === null ? row[k] == null : row[k] === v)); + return { + tables, + registerMiddleware: (mw: any) => middlewares.push(mw), + find: vi.fn(async (object: string, q: any) => + (tables[object] ?? []).filter((r) => matches(r, q?.where)).slice(0, q?.limit ?? 100), + ), + insert: vi.fn(async (object: string, data: any) => { + (tables[object] ??= []).push(data); + return data; + }), + }; + }; + + beforeEach(() => { + delete process.env.OS_MULTI_ORG_ENABLED; + hookCapture = createHookCapture(); + middlewares = []; + ql = makeQl(); + mockContext.hook = hookCapture.hookFn; + mockContext.getService = vi.fn((name: string) => { + if (name === 'manifest') return { register: vi.fn() }; + if (name === 'objectql') return ql; + return undefined; + }); + }); + + afterEach(() => { + if (OLD_ENV === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = OLD_ENV; + }); + + const boot = async (options: any = {}) => { + authPlugin = new AuthPlugin({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + ...options, + }); + await authPlugin.init(mockContext); + await authPlugin.start(mockContext); + }; + + it('single-org: creates the default org + owner membership on kernel:ready', async () => { + await boot(); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_organization[0]).toMatchObject({ slug: 'default' }); + expect(ql.tables.sys_member[0]).toMatchObject({ user_id: 'u1', role: 'owner' }); + }); + + it('single-org: re-runs after a sys_user_permission_set insert (first-signup promotion)', async () => { + await boot(); + expect(middlewares.length).toBeGreaterThan(0); + const runAll = async (opCtx: any) => { + for (const mw of middlewares) await mw(opCtx, async () => {}); + }; + await runAll({ object: 'sys_user_permission_set', operation: 'insert' }); + expect(ql.tables.sys_member).toHaveLength(1); + // Unrelated inserts do not re-trigger (membership already exists, but + // verify no new insert attempt is even made). + ql.insert.mockClear(); + await runAll({ object: 'task', operation: 'insert' }); + expect(ql.insert).not.toHaveBeenCalled(); + }); + + it('single-org: idempotent — second kernel:ready pass is a no-op', async () => { + await boot(); + await hookCapture.trigger('kernel:ready'); + const orgCount = ql.tables.sys_organization.length; + const memberCount = ql.tables.sys_member.length; + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_organization).toHaveLength(orgCount); + expect(ql.tables.sys_member).toHaveLength(memberCount); + }); + + it('multi-org: bootstrap is NOT wired (enterprise organizations package owns it)', async () => { + process.env.OS_MULTI_ORG_ENABLED = 'true'; + await boot(); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_organization).toHaveLength(0); + expect(ql.tables.sys_member).toHaveLength(0); + }); + + it('autoDefaultOrganization: false opts out', async () => { + await boot({ autoDefaultOrganization: false }); + await hookCapture.trigger('kernel:ready'); + expect(ql.tables.sys_organization).toHaveLength(0); + expect(ql.tables.sys_member).toHaveLength(0); + }); + }); }); diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 490f9d4b2c..2aac1b1fd0 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -10,7 +10,9 @@ import { SystemOverviewDatasets, } from '@objectstack/platform-objects/apps'; import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platform-objects/pages'; +import { resolveMultiOrgEnabled } from '@objectstack/types'; import { AuthManager, type AuthManagerOptions } from './auth-manager.js'; +import { ensureDefaultOrganization } from './ensure-default-organization.js'; import { runSetInitialPassword } from './set-initial-password.js'; import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain } from './register-sso-provider.js'; import { @@ -54,6 +56,20 @@ export interface AuthPluginOptions extends Partial { */ additionalOrgRoles?: string[]; + /** + * ADR-0081 D1 — single-org default-organization bootstrap. In single-org + * mode (`OS_MULTI_ORG_ENABLED` unset/false) nothing else ever creates an + * 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 true + */ + autoDefaultOrganization?: boolean; + /** * Pass-through to better-auth's `databaseHooks` option. Used by * platform consumers (objectos kernel) to attach a @@ -404,6 +420,49 @@ export class AuthPlugin implements Plugin { await this.maybeSeedDevAdmin(ctx); }); + // ADR-0081 D1 — single-org default-organization bootstrap. Multi-org + // keeps its existing owner (the enterprise organizations package, which + // runs the same idempotent helper with the seed-ownership step injected); + // one crisp owner per mode. + if (this.options.autoDefaultOrganization !== false && !resolveMultiOrgEnabled()) { + const runEnsure = async () => { + try { + const ql: any = ctx.getService('objectql'); + if (!ql) return; + const res = await ensureDefaultOrganization(ql, { logger: ctx.logger }); + if (res.defaultOrgCreated) { + ctx.logger.info( + `[auth] created Default Organization ${res.defaultOrgId} for the platform admin (single-org)`, + ); + } + } catch (e) { + ctx.logger.warn?.('[auth] ensureDefaultOrganization failed', { + error: (e as Error).message, + }); + } + }; + 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). + try { + const ql: any = ctx.getService('objectql'); + if (ql && typeof ql.registerMiddleware === 'function') { + ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { + await next(); + if ( + opCtx?.object === 'sys_user_permission_set' && + (opCtx?.operation === 'insert' || opCtx?.operation === 'create') + ) { + await runEnsure(); + } + }); + } + } catch { + /* objectql optional in mock mode — the kernel:ready pass still runs */ + } + } + // Identity-source provenance for accounts created OUTSIDE better-auth's // `databaseHooks` — @better-auth/scim creates `sys_account` at the adapter // level, which BYPASSES `account.create.after` / `stampIdentitySource`. This diff --git a/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts new file mode 100644 index 0000000000..52367d8f5f --- /dev/null +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +// ADR-0081 D1 — the default-org bootstrap helper (open home: plugin-auth). +// 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'; + +type Row = Record; + +function makeQl(seed: Partial> = {}) { + const tables: Record = { + 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 }, + ], + sys_member: [], + sys_organization: [], + ...seed, + }; + const matches = (row: Row, where: Row) => + Object.entries(where ?? {}).every(([k, v]) => (v === null ? row[k] == null : row[k] === v)); + return { + tables, + find: vi.fn(async (object: string, q: any) => + (tables[object] ?? []).filter((r) => matches(r, q?.where)).slice(0, q?.limit ?? 100), + ), + insert: vi.fn(async (object: string, data: Row) => { + (tables[object] ??= []).push(data); + return data; + }), + }; +} + +describe('ensureDefaultOrganization (plugin-auth home)', () => { + it('creates the default org and binds the admin as owner', async () => { + const ql = makeQl(); + const res = await ensureDefaultOrganization(ql); + expect(res.defaultOrgCreated).toBe(true); + expect(res.memberCreated).toBe(true); + expect(ql.tables.sys_organization[0]).toMatchObject({ slug: 'default', name: 'Default Organization' }); + expect(ql.tables.sys_member[0]).toMatchObject({ user_id: 'u1', role: 'owner', organization_id: res.defaultOrgId }); + }); + + it('no-ops when there is no platform admin yet', async () => { + const ql = makeQl({ sys_user_permission_set: [] }); + const res = await ensureDefaultOrganization(ql); + expect(res).toMatchObject({ defaultOrgCreated: false, memberCreated: false, reason: 'no_admin' }); + expect(ql.insert).not.toHaveBeenCalled(); + }); + + it('respects an admin who already belongs to an org', async () => { + const ql = makeQl({ sys_member: [{ id: 'm0', user_id: 'u1', organization_id: 'org_x' }] }); + const res = await ensureDefaultOrganization(ql); + expect(res.reason).toBe('admin_already_in_org'); + expect(ql.insert).not.toHaveBeenCalled(); + }); + + it('reuses a pre-existing slug=default org instead of minting a new one', async () => { + const ql = makeQl({ sys_organization: [{ id: 'org_default', slug: 'default', name: 'Default Organization' }] }); + const res = await ensureDefaultOrganization(ql); + expect(res.defaultOrgCreated).toBe(false); + expect(res.defaultOrgId).toBe('org_default'); + expect(res.memberCreated).toBe(true); + }); + + 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' }, + ], + }); + await ensureDefaultOrganization(ql); + expect(ql.tables.sys_member[0].user_id).toBe('u_older'); + }); + + it('runs the injected claimSeedOwnership step (enterprise path) and reports the count', async () => { + const ql = makeQl(); + const claim = vi.fn(async () => [{ count: 3 }, { count: 2 }]); + const res = await ensureDefaultOrganization(ql, { claimSeedOwnership: claim }); + expect(claim).toHaveBeenCalledWith(ql, res.defaultOrgId, 'u1', expect.any(Object)); + expect(res.ownershipClaimed).toBe(5); + }); + + it('skips seed-ownership when not injected (open single-org path)', async () => { + const res = await ensureDefaultOrganization(makeQl()); + expect(res.ownershipClaimed).toBe(0); + }); + + it('a failing injected claim never undoes the owner bind', async () => { + const ql = makeQl(); + const res = await ensureDefaultOrganization(ql, { + claimSeedOwnership: vi.fn(async () => { throw new Error('seed pipeline down'); }), + }); + expect(res.memberCreated).toBe(true); + expect(ql.tables.sys_member).toHaveLength(1); + }); +}); diff --git a/packages/plugins/plugin-org-scoping/src/ensure-default-organization.ts b/packages/plugins/plugin-auth/src/ensure-default-organization.ts similarity index 66% rename from packages/plugins/plugin-org-scoping/src/ensure-default-organization.ts rename to packages/plugins/plugin-auth/src/ensure-default-organization.ts index b9bd417511..39d0a03f1f 100644 --- a/packages/plugins/plugin-org-scoping/src/ensure-default-organization.ts +++ b/packages/plugins/plugin-auth/src/ensure-default-organization.ts @@ -1,44 +1,57 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * ensureDefaultOrganization — multi-tenant bootstrap helper. + * ensureDefaultOrganization — default-org bootstrap helper (ADR-0081 D1). * - * In multi-tenant deployments the freshly-promoted platform admin - * (`admin_full_access` granted with `organization_id IS NULL`) needs - * at least one `sys_organization` to carry an `activeOrganizationId` - * on their session. Without it, the default `tenant_isolation` RLS - * policy filters everything to zero rows and the admin sees an empty - * console even though they have full access. + * The platform admin (`admin_full_access` granted with `organization_id IS + * NULL`) needs at least one `sys_organization` so their sessions can carry an + * `activeOrganizationId`. Without it: + * - multi-org: the default `tenant_isolation` RLS policy filters everything + * to zero rows and the admin sees an empty console; + * - single-org: better-auth `organization/invite-member` has no active org + * to resolve, so there is NO way to add a user at all — the gap ADR-0081 + * closes. + * + * This helper HOME is plugin-auth (the open member-management basics). The + * enterprise organizations package reuses it for the multi-org bootstrap and + * injects its seed-ownership step via `claimSeedOwnership` (that machinery is + * part of the per-org seed pipeline, not of the basics). * * Strategy (idempotent, run on `kernel:ready` and after every * `sys_user_permission_set` insert): * - * 1. Find the platform admin (oldest `sys_user_permission_set` row - * with `permission_set_id = admin_full_access` and - * `organization_id IS NULL`). If none, no-op. - * 2. If that user already has any `sys_member` row, no-op (they - * either created their own org or were invited into one — we - * respect that and never auto-create a "Default Organization" - * behind their back). - * 3. Re-use a pre-existing `slug='default'` org if present; - * otherwise create one. Stable slug keeps human-readable URLs - * predictable across cold-boots. - * 4. Insert a `sys_member { role: 'owner' }` linking the admin to - * the default org. - * - * This is the ONLY framework-side auto-provisioning of an org. - * Subsequent users must accept an invitation or explicitly create - * their first organization — `claimOrphanOrgRows` / `cloneOrgSeedData` - * handle the seed-data side for those flows. + * 1. Find the platform admin (oldest `sys_user_permission_set` row with + * `permission_set_id = admin_full_access` and `organization_id IS + * NULL`). If none, no-op. + * 2. If that user already has any `sys_member` row, no-op (they either + * created their own org or were invited into one — we respect that and + * never auto-create a "Default Organization" behind their back). + * 3. Re-use a pre-existing `slug='default'` org if present; otherwise + * create one. Stable slug keeps human-readable URLs predictable across + * cold-boots. + * 4. Insert a `sys_member { role: 'owner' }` linking the admin to the + * default org. + * 5. (optional, injected) hand the org's seeded rows to the admin. */ -import { claimOrgSeedOwnership } from './claim-org-seed-ownership.js'; +interface BootstrapLogger { + info: (message: string, meta?: Record) => void; + warn: (message: string, meta?: Record) => void; +} -interface EnsureOptions { - logger?: { - info: (message: string, meta?: Record) => void; - warn: (message: string, meta?: Record) => void; - }; +export interface EnsureDefaultOrganizationOptions { + logger?: BootstrapLogger; + /** + * Optional seed-ownership handoff, run after the owner bind (best-effort). + * The enterprise organizations package injects `claimOrgSeedOwnership` + * here; the open single-org path has no per-org seed pipeline and omits it. + */ + claimSeedOwnership?: ( + ql: any, + organizationId: string, + userId: string, + options: { logger?: BootstrapLogger }, + ) => Promise>; } const SYSTEM_CTX = { isSystem: true }; @@ -86,7 +99,7 @@ export interface EnsureDefaultOrganizationResult { */ export async function ensureDefaultOrganization( ql: any, - options: EnsureOptions = {}, + options: EnsureDefaultOrganizationOptions = {}, ): Promise { const logger = options.logger; if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') { @@ -147,7 +160,7 @@ export async function ensureDefaultOrganization( metadata: null, }); if (!orgRow) { - logger?.warn?.('[org-scoping] failed to create default organization for platform admin'); + logger?.warn?.('[default-org] failed to create default organization for platform admin'); return { defaultOrgCreated: false, memberCreated: false, reason: 'org_insert_failed' }; } defaultOrgId = orgRow?.id ?? newOrgId; @@ -162,7 +175,7 @@ export async function ensureDefaultOrganization( role: 'owner', }); if (!memRow) { - logger?.warn?.('[org-scoping] failed to bind platform admin to default organization'); + logger?.warn?.('[default-org] failed to bind platform admin to default organization'); return { defaultOrgCreated, defaultOrgId, @@ -171,20 +184,19 @@ export async function ensureDefaultOrganization( }; } logger?.info?.( - `[org-scoping] bound platform admin to default organization (${defaultOrgId})`, + `[default-org] bound platform admin to default organization (${defaultOrgId})`, { userId: adminUserId, defaultOrgId }, ); - // 6. Hand the default org's seeded rows (owner_id NULL) to the admin so - // owner-keyed UX works out of the box — the multi-tenant companion to the - // single-tenant first-admin handoff. Best-effort; never undoes the bind. + // 6. Optional injected seed-ownership handoff (owner-keyed UX works out of + // the box). Best-effort; never undoes the bind. let ownershipClaimed = 0; - if (defaultOrgId) { + if (defaultOrgId && options.claimSeedOwnership) { try { - const claims = await claimOrgSeedOwnership(ql, defaultOrgId, adminUserId, { logger }); + const claims = await options.claimSeedOwnership(ql, defaultOrgId, adminUserId, { logger }); ownershipClaimed = claims.reduce((s, c) => s + c.count, 0); } catch (e) { - logger?.warn?.('[org-scoping] default-org seed ownership handoff failed', { + logger?.warn?.('[default-org] seed ownership handoff failed', { error: (e as Error).message, }); } diff --git a/packages/plugins/plugin-auth/src/index.ts b/packages/plugins/plugin-auth/src/index.ts index 355e5cd794..d349b2258a 100644 --- a/packages/plugins/plugin-auth/src/index.ts +++ b/packages/plugins/plugin-auth/src/index.ts @@ -10,6 +10,7 @@ export * from './auth-plugin.js'; export * from './auth-manager.js'; +export * from './ensure-default-organization.js'; export * from './set-initial-password.js'; export * from './register-sso-provider.js'; export * from './objectql-adapter.js'; diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json index daf621b318..2179563bce 100644 --- a/packages/plugins/plugin-dev/package.json +++ b/packages/plugins/plugin-dev/package.json @@ -23,7 +23,6 @@ "@objectstack/objectql": "workspace:^", "@objectstack/plugin-auth": "workspace:^", "@objectstack/plugin-hono-server": "workspace:^", - "@objectstack/plugin-org-scoping": "workspace:^", "@objectstack/plugin-security": "workspace:^", "@objectstack/rest": "workspace:^", "@objectstack/runtime": "workspace:^", diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index 230a249444..cd3cb48ad3 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -564,18 +564,21 @@ export class DevPlugin implements Plugin { } // 5. Security Plugin (RBAC, RLS, field-level masking) - // OrgScopingPlugin (when multi-tenant) MUST register BEFORE SecurityPlugin - // because SecurityPlugin.start() probes the `org-scoping` service and - // caches the result for the lifetime of the plugin. + // OrganizationsPlugin (when multi-org; ENTERPRISE `@objectstack/organizations`, + // ADR-0081 D2) MUST register BEFORE SecurityPlugin because + // SecurityPlugin.start() probes the `org-scoping` service (the historical + // name the enterprise plugin keeps registering) and caches the result for + // the lifetime of the plugin. if (enabled('security')) { const multiTenant = resolveMultiOrgEnabled(); if (multiTenant) { try { - const { OrgScopingPlugin } = await import('@objectstack/plugin-org-scoping') as any; - this.childPlugins.push(new OrgScopingPlugin()); - ctx.logger.info(' ✔ Org-scoping plugin enabled (multi-tenant: organization_id auto-stamp, per-org seed)'); + const organizationsPkg = '@objectstack/organizations'; + const mod: any = await import(/* webpackIgnore: true */ organizationsPkg); + this.childPlugins.push(new mod.OrganizationsPlugin()); + ctx.logger.info(' ✔ Organizations plugin enabled (multi-org: organization_id auto-stamp, per-org seed)'); } catch { - ctx.logger.warn(' ✘ OS_MULTI_ORG_ENABLED=true but @objectstack/plugin-org-scoping not installed'); + ctx.logger.warn(' ✘ OS_MULTI_ORG_ENABLED=true but @objectstack/organizations (enterprise) not installed — running single-org'); } } try { diff --git a/packages/plugins/plugin-org-scoping/CHANGELOG.md b/packages/plugins/plugin-org-scoping/CHANGELOG.md deleted file mode 100644 index b460ba41fd..0000000000 --- a/packages/plugins/plugin-org-scoping/CHANGELOG.md +++ /dev/null @@ -1,690 +0,0 @@ -# @objectstack/plugin-org-scoping - -## 12.6.0 - -### Patch Changes - -- Updated dependencies [6cebf22] -- Updated dependencies [21420d9] - - @objectstack/spec@12.6.0 - - @objectstack/core@12.6.0 - - @objectstack/platform-objects@12.6.0 - -## 12.5.0 - -### Patch Changes - -- Updated dependencies [8b3d363] - - @objectstack/spec@12.5.0 - - @objectstack/core@12.5.0 - - @objectstack/platform-objects@12.5.0 - -## 12.4.0 - -### Patch Changes - -- Updated dependencies [60dc3ba] - - @objectstack/spec@12.4.0 - - @objectstack/core@12.4.0 - - @objectstack/platform-objects@12.4.0 - -## 12.3.0 - -### Patch Changes - -- Updated dependencies [e7eceec] - - @objectstack/spec@12.3.0 - - @objectstack/core@12.3.0 - - @objectstack/platform-objects@12.3.0 - -## 12.2.0 - -### Patch Changes - -- Updated dependencies [fce8ff4] -- Updated dependencies [3962023] -- Updated dependencies [2bb193d] -- Updated dependencies [0426d27] -- Updated dependencies [da807f7] -- Updated dependencies [4f5b791] - - @objectstack/spec@12.2.0 - - @objectstack/core@12.2.0 - - @objectstack/platform-objects@12.2.0 - -## 12.1.0 - -### Patch Changes - -- Updated dependencies [93e6d02] - - @objectstack/spec@12.1.0 - - @objectstack/core@12.1.0 - - @objectstack/platform-objects@12.1.0 - -## 12.0.0 - -### Patch Changes - -- Updated dependencies [a8df396] -- Updated dependencies [e695fe0] -- Updated dependencies [07f055c] -- Updated dependencies [7c09621] -- Updated dependencies [7709db4] -- Updated dependencies [2082109] -- Updated dependencies [7c09621] -- Updated dependencies [9860de4] -- Updated dependencies [069c205] - - @objectstack/spec@12.0.0 - - @objectstack/platform-objects@12.0.0 - - @objectstack/core@12.0.0 - -## 11.10.0 - -### Patch Changes - -- Updated dependencies [6a9397e] -- Updated dependencies [c0efe5d] - - @objectstack/spec@11.10.0 - - @objectstack/core@11.10.0 - - @objectstack/platform-objects@11.10.0 - -## 11.9.0 - -### Patch Changes - -- Updated dependencies [d3595d9] - - @objectstack/spec@11.9.0 - - @objectstack/core@11.9.0 - - @objectstack/platform-objects@11.9.0 - -## 11.8.0 - -### Patch Changes - -- Updated dependencies [53d491a] -- Updated dependencies [b84726b] - - @objectstack/platform-objects@11.8.0 - - @objectstack/spec@11.8.0 - - @objectstack/core@11.8.0 - -## 11.7.0 - -### Patch Changes - -- Updated dependencies [5178906] - - @objectstack/spec@11.7.0 - - @objectstack/platform-objects@11.7.0 - - @objectstack/core@11.7.0 - -## 11.6.0 - -### Patch Changes - -- @objectstack/spec@11.6.0 -- @objectstack/core@11.6.0 -- @objectstack/platform-objects@11.6.0 - -## 11.5.0 - -### Patch Changes - -- Updated dependencies [6ee4f04] -- Updated dependencies [c1e3a65] - - @objectstack/spec@11.5.0 - - @objectstack/core@11.5.0 - - @objectstack/platform-objects@11.5.0 - -## 11.4.0 - -### Patch Changes - -- Updated dependencies [5821c51] -- Updated dependencies [a0fce3f] - - @objectstack/spec@11.4.0 - - @objectstack/core@11.4.0 - - @objectstack/platform-objects@11.4.0 - -## 11.3.0 - -### Patch Changes - -- Updated dependencies [58e8e31] -- Updated dependencies [b4a5df0] - - @objectstack/spec@11.3.0 - - @objectstack/core@11.3.0 - - @objectstack/platform-objects@11.3.0 - -## 11.2.0 - -### Patch Changes - -- Updated dependencies [d0f4b13] -- Updated dependencies [302bdab] - - @objectstack/spec@11.2.0 - - @objectstack/core@11.2.0 - - @objectstack/platform-objects@11.2.0 - -## 11.1.0 - -### Patch Changes - -- Updated dependencies [cbc8c02] -- Updated dependencies [07c2773] -- Updated dependencies [d7a88df] -- Updated dependencies [4f8f108] -- Updated dependencies [ce0b4f6] -- Updated dependencies [90bce88] -- Updated dependencies [3209ec6] -- Updated dependencies [e011d42] -- Updated dependencies [6e5bdd5] -- Updated dependencies [9ccfcd6] -- Updated dependencies [ecf193f] -- Updated dependencies [51bec81] -- Updated dependencies [3e593a7] -- Updated dependencies [63d5403] - - @objectstack/platform-objects@11.1.0 - - @objectstack/core@11.1.0 - - @objectstack/spec@11.1.0 - -## 11.0.0 - -### Patch Changes - -- Updated dependencies [9b5bf3d] -- Updated dependencies [cb5b393] -- Updated dependencies [ab5718a] -- Updated dependencies [4845c12] -- Updated dependencies [c1a754a] -- Updated dependencies [6fbe91f] -- Updated dependencies [715d667] -- Updated dependencies [5eef4cf] -- Updated dependencies [72759e1] -- Updated dependencies [6c4fbd9] -- Updated dependencies [ef3ed67] -- Updated dependencies [cd51229] -- Updated dependencies [7697a0e] -- Updated dependencies [e7e04f1] -- Updated dependencies [cfd5ac4] -- Updated dependencies [2be5c1f] -- Updated dependencies [ad143ce] -- Updated dependencies [5c4a8c8] -- Updated dependencies [3afaeed] -- Updated dependencies [5737261] -- Updated dependencies [a619a3a] -- Updated dependencies [f44c1bd] -- Updated dependencies [8801c02] -- Updated dependencies [3d04e06] -- Updated dependencies [4a84c98] -- Updated dependencies [c715d25] -- Updated dependencies [aa33b02] -- Updated dependencies [d980f0d] -- Updated dependencies [a658523] -- Updated dependencies [82ff91c] -- Updated dependencies [638f472] - - @objectstack/platform-objects@11.0.0 - - @objectstack/spec@11.0.0 - - @objectstack/core@11.0.0 - -## 10.3.0 - -### Patch Changes - -- @objectstack/spec@10.3.0 -- @objectstack/core@10.3.0 -- @objectstack/platform-objects@10.3.0 - -## 10.2.0 - -### Patch Changes - -- Updated dependencies [b496498] - - @objectstack/spec@10.2.0 - - @objectstack/core@10.2.0 - - @objectstack/platform-objects@10.2.0 - -## 10.1.0 - -### Patch Changes - -- Updated dependencies [49da36e] -- Updated dependencies [ac79f16] - - @objectstack/spec@10.1.0 - - @objectstack/core@10.1.0 - - @objectstack/platform-objects@10.1.0 - -## 10.0.0 - -### Patch Changes - -- Updated dependencies [d7ff626] -- Updated dependencies [2a1b16b] -- Updated dependencies [2256e93] -- Updated dependencies [7108ff3] -- Updated dependencies [30c0313] -- Updated dependencies [e16f2a8] -- Updated dependencies [e411a82] -- Updated dependencies [ae271d0] -- Updated dependencies [61ed5c7] -- Updated dependencies [a581385] -- Updated dependencies [d5f6d29] -- Updated dependencies [220ce5b] -- Updated dependencies [3efe334] -- Updated dependencies [0df063e] -- Updated dependencies [ce13bb8] -- Updated dependencies [feead7e] -- Updated dependencies [6ca20b3] -- Updated dependencies [5f875fe] -- Updated dependencies [b469950] -- Updated dependencies [47d978a] - - @objectstack/spec@10.0.0 - - @objectstack/platform-objects@10.0.0 - - @objectstack/core@10.0.0 - -## 9.11.0 - -### Patch Changes - -- Updated dependencies [e7f6539] -- Updated dependencies [2365d07] -- Updated dependencies [6595b53] -- Updated dependencies [fa8964d] -- Updated dependencies [36138c7] -- Updated dependencies [a8e4f3b] -- Updated dependencies [4c213c2] -- Updated dependencies [2afb612] - - @objectstack/spec@9.11.0 - - @objectstack/core@9.11.0 - - @objectstack/platform-objects@9.11.0 - -## 9.10.0 - -### Minor Changes - -- f169558: feat(org-scoping): hand a default org's seeded records to its admin (multi-tenant ownership handoff) - - The multi-tenant companion to plugin-security's single-tenant `claimSeedOwnership`. - Seeded rows land `owner_id` NULL (the author leaves it unset; `cel`os.user.id``resolves to NULL at seed time). In multi-tenant mode`claimOrphanOrgRows`back-fills their`organization_id`, but `owner_id` stayed NULL — so "My" views, - owner reports and owner notifications were empty for the org's members. - - - New `claimOrgSeedOwnership(ql, organizationId, ownerUserId)` — assigns - `owner_id = ownerUserId` to an org's NULL-owned seed rows. Scoped to a single - org (never touches another tenant), idempotent, skips `managedBy` / `sys_*`, - and requires both `owner_id` and `organization_id` columns. - - `ensureDefaultOrganization` now calls it after binding the platform admin as - the default org's owner, so the default org's demo data is owned by the admin - out of the box — symmetric with the single-tenant first-admin handoff. - -### Patch Changes - -- Updated dependencies [db02bd5] -- Updated dependencies [641675d] -- Updated dependencies [94e9040] -- Updated dependencies [4331adb] -- Updated dependencies [1f88fd9] -- Updated dependencies [1f88fd9] - - @objectstack/spec@9.10.0 - - @objectstack/platform-objects@9.10.0 - - @objectstack/core@9.10.0 - -## 9.9.1 - -### Patch Changes - -- @objectstack/spec@9.9.1 -- @objectstack/core@9.9.1 -- @objectstack/platform-objects@9.9.1 - -## 9.9.0 - -### Patch Changes - -- Updated dependencies [84249a4] -- Updated dependencies [11af299] -- Updated dependencies [d5774b5] -- Updated dependencies [134043a] -- Updated dependencies [90108e0] -- Updated dependencies [9afeb2d] -- Updated dependencies [6bec07e] -- Updated dependencies [601cc11] -- Updated dependencies [575448d] - - @objectstack/spec@9.9.0 - - @objectstack/core@9.9.0 - - @objectstack/platform-objects@9.9.0 - -## 9.8.0 - -### Patch Changes - -- Updated dependencies [97c55b3] -- Updated dependencies [1b1f490] - - @objectstack/spec@9.8.0 - - @objectstack/core@9.8.0 - - @objectstack/platform-objects@9.8.0 - -## 9.7.0 - -### Patch Changes - -- @objectstack/spec@9.7.0 -- @objectstack/core@9.7.0 -- @objectstack/platform-objects@9.7.0 - -## 9.6.0 - -### Patch Changes - -- Updated dependencies [d1e930a] -- Updated dependencies [71578f2] -- Updated dependencies [5e3a301] -- Updated dependencies [5db2742] - - @objectstack/spec@9.6.0 - - @objectstack/core@9.6.0 - - @objectstack/platform-objects@9.6.0 - -## 9.5.1 - -### Patch Changes - -- Updated dependencies [ee72aae] - - @objectstack/spec@9.5.1 - - @objectstack/core@9.5.1 - - @objectstack/platform-objects@9.5.1 - -## 9.5.0 - -### Patch Changes - -- Updated dependencies [d08551c] -- Updated dependencies [5be7102] -- Updated dependencies [707aeed] -- Updated dependencies [7a103d4] -- Updated dependencies [4b01250] - - @objectstack/spec@9.5.0 - - @objectstack/platform-objects@9.5.0 - - @objectstack/core@9.5.0 - -## 9.4.0 - -### Patch Changes - -- Updated dependencies [060467a] -- Updated dependencies [0856476] -- Updated dependencies [b678d8c] -- Updated dependencies [b678d8c] -- Updated dependencies [b678d8c] - - @objectstack/spec@9.4.0 - - @objectstack/core@9.4.0 - - @objectstack/platform-objects@9.4.0 - -## 9.3.0 - -### Patch Changes - -- Updated dependencies [1ada658] -- Updated dependencies [3219191] -- Updated dependencies [290f631] -- Updated dependencies [50b7b47] -- Updated dependencies [f15d6f6] -- Updated dependencies [f8684ea] -- Updated dependencies [c802327] -- Updated dependencies [b4765be] - - @objectstack/spec@9.3.0 - - @objectstack/platform-objects@9.3.0 - - @objectstack/core@9.3.0 - -## 9.2.0 - -### Patch Changes - -- Updated dependencies [2f57b75] -- Updated dependencies [2f57b75] - - @objectstack/spec@9.2.0 - - @objectstack/core@9.2.0 - - @objectstack/platform-objects@9.2.0 - -## 9.1.0 - -### Patch Changes - -- Updated dependencies [b9062c9] - - @objectstack/spec@9.1.0 - - @objectstack/core@9.1.0 - - @objectstack/platform-objects@9.1.0 - -## 9.0.1 - -### Patch Changes - -- Updated dependencies [1817845] - - @objectstack/spec@9.0.1 - - @objectstack/core@9.0.1 - - @objectstack/platform-objects@9.0.1 - -## 9.0.0 - -### Patch Changes - -- Updated dependencies [4c3f693] -- Updated dependencies [0bf39f1] -- Updated dependencies [f533f42] -- Updated dependencies [1c83ee8] - - @objectstack/spec@9.0.0 - - @objectstack/core@9.0.0 - - @objectstack/platform-objects@9.0.0 - -## 8.0.1 - -### Patch Changes - -- @objectstack/spec@8.0.1 -- @objectstack/core@8.0.1 -- @objectstack/platform-objects@8.0.1 - -## 8.0.0 - -### Patch Changes - -- Updated dependencies [a46c017] -- Updated dependencies [b990b89] -- Updated dependencies [99111ec] -- Updated dependencies [d5a8161] -- Updated dependencies [5cf1f1b] -- Updated dependencies [9ef89d4] -- Updated dependencies [3306d2f] -- Updated dependencies [c262301] -- Updated dependencies [bc44195] -- Updated dependencies [9e2e229] - - @objectstack/spec@8.0.0 - - @objectstack/core@8.0.0 - - @objectstack/platform-objects@8.0.0 - -## 7.9.0 - -### Patch Changes - -- @objectstack/spec@7.9.0 -- @objectstack/core@7.9.0 -- @objectstack/platform-objects@7.9.0 - -## 7.8.0 - -### Patch Changes - -- Updated dependencies [06f2bbb] -- Updated dependencies [36719db] -- Updated dependencies [424ab26] - - @objectstack/spec@7.8.0 - - @objectstack/core@7.8.0 - - @objectstack/platform-objects@7.8.0 - -## 7.7.0 - -### Patch Changes - -- Updated dependencies [b391955] -- Updated dependencies [f06b64e] -- Updated dependencies [023bf93] -- Updated dependencies [764c747] - - @objectstack/spec@7.7.0 - - @objectstack/platform-objects@7.7.0 - - @objectstack/core@7.7.0 - -## 7.6.0 - -### Patch Changes - -- Updated dependencies [955d4c8] -- Updated dependencies [c4a4cbd] -- Updated dependencies [b046ec2] -- Updated dependencies [2170ad9] -- Updated dependencies [02d6359] -- Updated dependencies [7648242] -- Updated dependencies [8fa1e7f] -- Updated dependencies [7ae6abc] -- Updated dependencies [55866f5] -- Updated dependencies [60f9c45] - - @objectstack/spec@7.6.0 - - @objectstack/platform-objects@7.6.0 - - @objectstack/core@7.6.0 - -## 7.5.0 - -### Patch Changes - -- @objectstack/spec@7.5.0 -- @objectstack/core@7.5.0 -- @objectstack/platform-objects@7.5.0 - -## 7.4.1 - -### Patch Changes - -- @objectstack/spec@7.4.1 -- @objectstack/core@7.4.1 -- @objectstack/platform-objects@7.4.1 - -## 7.4.0 - -### Patch Changes - -- Updated dependencies [23c7107] -- Updated dependencies [c72daad] -- Updated dependencies [4404572] -- Updated dependencies [eea3f1b] -- Updated dependencies [e478e0c] -- Updated dependencies [4cc2ced] -- Updated dependencies [13632b1] -- Updated dependencies [f115182] -- Updated dependencies [2faf9f2] -- Updated dependencies [2faf9f2] -- Updated dependencies [2faf9f2] -- Updated dependencies [58b450b] -- Updated dependencies [82eb6cf] -- Updated dependencies [c381977] -- Updated dependencies [13d8653] -- Updated dependencies [ff3d006] -- Updated dependencies [5e831de] - - @objectstack/spec@7.4.0 - - @objectstack/platform-objects@7.4.0 - - @objectstack/core@7.4.0 - -## 7.3.0 - -### Patch Changes - -- Updated dependencies [5e7c554] - - @objectstack/spec@7.3.0 - - @objectstack/core@7.3.0 - - @objectstack/platform-objects@7.3.0 - -## 7.2.1 - -### Patch Changes - -- @objectstack/spec@7.2.1 -- @objectstack/core@7.2.1 -- @objectstack/platform-objects@7.2.1 - -## 7.2.0 - -### Patch Changes - -- @objectstack/spec@7.2.0 -- @objectstack/core@7.2.0 -- @objectstack/platform-objects@7.2.0 - -## 7.1.0 - -### Patch Changes - -- Updated dependencies [6228609] -- Updated dependencies [47a92f4] - - @objectstack/platform-objects@7.1.0 - - @objectstack/spec@7.1.0 - - @objectstack/core@7.1.0 - -## 7.0.0 - -### Minor Changes - -- 3a630b6: **Split organization-scoping from `@objectstack/plugin-security` into a new `@objectstack/plugin-org-scoping` package.** - - Per ADR-0002, "tenant" in ObjectStack means _physical_ isolation (one Environment = one database, handled by `@objectstack/driver-turso`'s multi-tenant router). The row-level `organization_id` scoping that previously lived inside SecurityPlugin is a different concept — _logical_ scoping inside a single DB — and now ships as its own plugin. - - ### Breaking changes — `@objectstack/plugin-security` - - - Removed the `multiTenant` constructor option. SecurityPlugin no longer touches `organization_id` on insert and no longer registers the `sys_organization` post-create seed pipeline. - - Wildcard `current_user.organization_id` RLS policies in the default permission sets are now stripped UNLESS the new `org-scoping` service is registered (i.e. unless `OrgScopingPlugin` is also installed). - - Removed export `cloneTenantSeedData` (now exposed as `cloneOrgSeedData` from `@objectstack/plugin-org-scoping`). - - `bootstrapPlatformAdmin()` no longer accepts a `multiTenant` flag and no longer auto-creates a default organization — that behavior moved to `ensureDefaultOrganization()` in the new plugin. - - ### Migration - - Single-tenant deployments — no action required. - - Multi-tenant deployments (previously `new SecurityPlugin({ multiTenant: true })`): - - ```diff - + import { OrgScopingPlugin } from '@objectstack/plugin-org-scoping'; - import { SecurityPlugin } from '@objectstack/plugin-security'; - - + await kernel.use(new OrgScopingPlugin()); // MUST be BEFORE SecurityPlugin - - await kernel.use(new SecurityPlugin({ multiTenant: true })); - + await kernel.use(new SecurityPlugin()); - ``` - - The runtime's `OS_MULTI_TENANT` env switch — read by `@objectstack/runtime/cloud/ArtifactKernelFactory`, `@objectstack/plugin-dev`, and the `objectstack` CLI's `serve` / `dev` / `start` commands — automatically registers `OrgScopingPlugin` when set to `true`, so projects driven by the CLI need no code changes. - -### Patch Changes - -- Updated dependencies [74470ad] -- Updated dependencies [d29617e] -- Updated dependencies [dc72172] -- Updated dependencies [d29617e] -- Updated dependencies [010757b] -- Updated dependencies [257954d] - - @objectstack/spec@7.0.0 - - @objectstack/platform-objects@7.0.0 - - @objectstack/core@7.0.0 - -## 6.9.0 - -### Initial release - -Extracted from `@objectstack/plugin-security` (which previously gated the same logic behind a `multiTenant: true` constructor option). The split lets single-tenant deployments install plugin-security without paying for organization-scoping middleware, and lets organization-scoping be reasoned about as a self-contained protocol with its own tests. - -#### Surface - -- `OrgScopingPlugin` — main plugin class. -- `claimOrphanOrgRows(ql, organizationId)` — adopt NULL-org seed rows into the first organization. -- `cloneOrgSeedData(ql, organizationId)` — clone the donor org's seed rows into a freshly-created org. -- `ensureDefaultOrganization(ql)` — bind the first platform admin to a `Default Organization` (slug `default`). - -#### Behavior - -When registered, the plugin installs three ObjectQL middlewares: - -1. Insert auto-stamp of `organization_id` from `ExecutionContext.tenantId`. -2. Post-insert pipeline on `sys_organization`: seed-replay → claim → clone. -3. Default-org bootstrap on `kernel:ready` and after every `sys_user_permission_set` insert. - -It also exposes itself as the `org-scoping` service so `@objectstack/plugin-security` can detect its presence and keep wildcard `current_user.organization_id` RLS policies. diff --git a/packages/plugins/plugin-org-scoping/LICENSE b/packages/plugins/plugin-org-scoping/LICENSE deleted file mode 100644 index 16bc23f404..0000000000 --- a/packages/plugins/plugin-org-scoping/LICENSE +++ /dev/null @@ -1,202 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute - must include a readable copy of the attribution notices - contained within such NOTICE file, excluding those notices - that do not pertain to any part of the Derivative Works, - in at least one of the following places: within a NOTICE - text file distributed as part of the Derivative Works; within - the Source form or documentation, if provided along with - the Derivative Works; or, within a display generated by the - Derivative Works, if and wherever such third-party notices - normally appear. The contents of the NOTICE file are for - informational purposes only and do not modify the License. - You may add Your own attribution notices within Derivative - Works that You distribute, alongside or as an addendum to - the NOTICE text from the Work, provided that such additional - attribution notices cannot be construed as modifying the - License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2026 ObjectStack - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/packages/plugins/plugin-org-scoping/README.md b/packages/plugins/plugin-org-scoping/README.md deleted file mode 100644 index 24fd320998..0000000000 --- a/packages/plugins/plugin-org-scoping/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# @objectstack/plugin-org-scoping - -> Row-level **Organization** isolation for ObjectStack — the LOGICAL multi-tenant building block. - -`@objectstack/plugin-org-scoping` makes `sys_organization` a first-class row-level scope: - -- **Insert auto-stamp** — fills `organization_id` from `ExecutionContext.tenantId` on every authenticated insert (when the target object declares the column). -- **Per-org seed replay** — every `sys_organization` insert triggers a copy of the app's demo dataset into the new org (via `seed-replayer`, or fallback `claimOrphanOrgRows` / `cloneOrgSeedData`). -- **Default-org bootstrap** — the first platform admin gets a `Default Organization` (slug `default`) bound as `owner` on `kernel:ready`, so the dashboard isn't empty after first sign-up. - -Pair with [`@objectstack/plugin-security`](../plugin-security/README.md) for full multi-tenant RBAC + RLS + Field-Level Security. Standalone install gives a single-tenant deployment. - -## Naming - -The word "tenant" in ObjectStack means **physical** isolation (one Environment = one database, per ADR-0002 and `@objectstack/driver-turso`'s multi-tenant router). This plugin is about **logical** row-level scoping inside a single database — orthogonal to physical tenancy. Hence "org-scoping", not "multi-tenant". - -## Install - -```bash -pnpm add @objectstack/plugin-org-scoping @objectstack/plugin-security -``` - -## Usage - -```ts -import { OrgScopingPlugin } from '@objectstack/plugin-org-scoping'; -import { SecurityPlugin } from '@objectstack/plugin-security'; - -// OrgScopingPlugin MUST be registered BEFORE SecurityPlugin — the -// latter probes `getService('org-scoping')` at start time to decide -// whether to keep wildcard `current_user.organization_id` RLS policies. -await kernel.use(new OrgScopingPlugin()); -await kernel.use(new SecurityPlugin()); -``` - -Or via the `OS_MULTI_ORG_ENABLED` env switch when using `@objectstack/runtime` / `@objectstack/plugin-dev`: - -```bash -OS_MULTI_ORG_ENABLED=true objectstack serve -``` - -## Options - -```ts -new OrgScopingPlugin({ - ensureDefaultOrganization: true, // default — auto-create slug="default" for first admin -}); -``` - -Set `ensureDefaultOrganization: false` to fully self-manage onboarding via invitations / a custom UI. - -## See also - -- ADR-0002 — Physical multi-tenancy & driver-turso router -- `@objectstack/plugin-security` — RBAC, RLS, Field-Level Security diff --git a/packages/plugins/plugin-org-scoping/objectstack.config.ts b/packages/plugins/plugin-org-scoping/objectstack.config.ts deleted file mode 100644 index 8823a2b006..0000000000 --- a/packages/plugins/plugin-org-scoping/objectstack.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineStack } from '@objectstack/spec'; -import { orgScopingObjects, orgScopingPluginManifestHeader } from './src/manifest'; - -export default defineStack({ - manifest: orgScopingPluginManifestHeader, - objects: orgScopingObjects as any, -}); diff --git a/packages/plugins/plugin-org-scoping/package.json b/packages/plugins/plugin-org-scoping/package.json deleted file mode 100644 index c82194d91b..0000000000 --- a/packages/plugins/plugin-org-scoping/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "@objectstack/plugin-org-scoping", - "version": "12.6.0", - "license": "Apache-2.0", - "description": "Organization-Scoping Plugin for ObjectStack — row-level Organization isolation, per-org seed replay, default-org bootstrap", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "require": "./dist/index.js" - } - }, - "scripts": { - "build": "tsup --config ../../../tsup.config.ts", - "test": "vitest run" - }, - "dependencies": { - "@objectstack/core": "workspace:*", - "@objectstack/platform-objects": "workspace:*", - "@objectstack/spec": "workspace:*" - }, - "devDependencies": { - "@types/node": "^26.1.0", - "typescript": "^6.0.3", - "vitest": "^4.1.10" - }, - "keywords": [ - "objectstack", - "plugin", - "organization", - "multi-tenant", - "row-level-security", - "scoping" - ], - "author": "ObjectStack", - "repository": { - "type": "git", - "url": "https://github.com/objectstack-ai/framework.git", - "directory": "packages/plugins/plugin-org-scoping" - }, - "homepage": "https://objectstack.ai/docs", - "bugs": "https://github.com/objectstack-ai/framework/issues", - "publishConfig": { - "access": "public" - }, - "files": [ - "dist", - "README.md" - ], - "engines": { - "node": ">=18.0.0" - } -} diff --git a/packages/plugins/plugin-org-scoping/src/claim-org-seed-ownership.test.ts b/packages/plugins/plugin-org-scoping/src/claim-org-seed-ownership.test.ts deleted file mode 100644 index 8ad6cbee68..0000000000 --- a/packages/plugins/plugin-org-scoping/src/claim-org-seed-ownership.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect, vi } from 'vitest'; -import { claimOrgSeedOwnership } from './claim-org-seed-ownership.js'; - -const ORG = 'org_1'; -const OWNER = 'usr_admin'; - -function makeQL(schemas: any[], rowsByObject: Record) { - const updates: { object: string; data: any }[] = []; - const ql: any = { - registry: { getAllObjects: () => schemas }, - find: vi.fn(async (object: string, query: any) => { - const all = rowsByObject[object] ?? []; - const w = query?.where ?? {}; - return all.filter((r) => { - if ('organization_id' in w && (r.organization_id ?? null) !== (w.organization_id ?? null)) return false; - if ('owner_id' in w && (r.owner_id ?? null) !== (w.owner_id ?? null)) return false; - return true; - }); - }), - update: vi.fn(async (object: string, data: any) => { - updates.push({ object, data }); - const row = (rowsByObject[object] ?? []).find((r) => r.id === data.id); - if (row) row.owner_id = data.owner_id; - return row; - }), - }; - return { ql, updates }; -} - -describe('claimOrgSeedOwnership', () => { - it('returns [] when registry is unavailable', async () => { - const ql: any = { find: vi.fn(), update: vi.fn() }; - expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([]); - }); - - it('no-ops without an org or owner', async () => { - const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] }]; - const { ql, updates } = makeQL(schemas, { crm_lead: [{ id: 'l1', organization_id: ORG, owner_id: null }] }); - expect(await claimOrgSeedOwnership(ql, '', OWNER)).toEqual([]); - expect(await claimOrgSeedOwnership(ql, ORG, '')).toEqual([]); - expect(updates).toHaveLength(0); - }); - - it('skips managedBy / sys_* and objects missing owner_id or organization_id', async () => { - const schemas = [ - { name: 'sys_user', managedBy: 'better-auth', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] }, - { name: 'sys_widget', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] }, - { name: 'crm_pricebook', fields: [{ name: 'organization_id' }] }, // no owner_id - { name: 'crm_global', fields: [{ name: 'owner_id' }] }, // no organization_id - ]; - const { ql, updates } = makeQL(schemas, { - sys_user: [{ id: 'u1', organization_id: ORG, owner_id: null }], - sys_widget: [{ id: 'w1', organization_id: ORG, owner_id: null }], - crm_pricebook: [{ id: 'p1', organization_id: ORG }], - crm_global: [{ id: 'g1', owner_id: null }], - }); - expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([]); - expect(updates).toHaveLength(0); - }); - - it('claims this org\'s NULL-owner rows only, leaving other orgs and human-owned rows untouched', async () => { - const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] }]; - const rows = [ - { id: 'l1', organization_id: ORG, owner_id: null }, // claimed - { id: 'l2', organization_id: ORG, owner_id: 'usr_someone' }, // already owned — untouched - { id: 'l3', organization_id: 'org_2', owner_id: null }, // other org — untouched - ]; - const { ql, updates } = makeQL(schemas, { crm_lead: rows }); - const result = await claimOrgSeedOwnership(ql, ORG, OWNER); - - expect(result).toEqual([{ object: 'crm_lead', count: 1 }]); - expect(updates).toHaveLength(1); - expect(updates[0].data).toMatchObject({ id: 'l1', owner_id: OWNER }); - expect(rows.find((r) => r.id === 'l2')!.owner_id).toBe('usr_someone'); - expect(rows.find((r) => r.id === 'l3')!.owner_id).toBeNull(); - }); - - it('is idempotent — a second run claims nothing', async () => { - const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] }]; - const { ql } = makeQL(schemas, { crm_lead: [{ id: 'l1', organization_id: ORG, owner_id: null }] }); - await claimOrgSeedOwnership(ql, ORG, OWNER); - expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([]); - }); -}); diff --git a/packages/plugins/plugin-org-scoping/src/claim-org-seed-ownership.ts b/packages/plugins/plugin-org-scoping/src/claim-org-seed-ownership.ts deleted file mode 100644 index 3b8d03f67a..0000000000 --- a/packages/plugins/plugin-org-scoping/src/claim-org-seed-ownership.ts +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * claimOrgSeedOwnership — hand an organization's seeded records to its owner. - * - * The multi-tenant twin of plugin-security's `claimSeedOwnership` (single-tenant - * first-admin handoff). Seeded rows land `owner_id = NULL` (the author leaves it - * unset and `cel`os.user.id`` resolves to NULL at seed time, since the owning - * admin does not exist yet). In multi-tenant mode those rows are scoped to an - * org by `claimOrphanOrgRows` / per-org replay, but their `owner_id` stays NULL - * — so "My" views, owner reports and owner notifications are empty for the org's - * members until ownership is assigned. - * - * This runs when the org's owner is established (e.g. `ensureDefaultOrganization` - * binds the platform admin as the default org's `owner`) and assigns - * `owner_id = ownerUserId` to that org's NULL-owned rows — the ownership - * companion to `claimOrphanOrgRows`'s `organization_id` back-fill. - * - * Scoped to a single org (`organization_id = organizationId`) so it never - * touches another tenant's rows. Idempotent: only NULL-owned rows are updated. - * `managedBy` and `sys_*` tables are skipped. - */ - -import type { ServiceObject } from '@objectstack/spec/data'; - -interface ClaimOwnershipOptions { - logger?: { - info: (message: string, meta?: Record) => void; - warn: (message: string, meta?: Record) => void; - }; -} - -const SYSTEM_CTX = { isSystem: true }; - -function hasField(schema: ServiceObject, field: string): boolean { - const fields: any = (schema as any)?.fields; - if (!fields) return false; - if (Array.isArray(fields)) return fields.some((f) => f?.name === field); - return Object.prototype.hasOwnProperty.call(fields, field); -} - -/** - * Assign `owner_id = ownerUserId` to every NULL-owned seed row of `organizationId`. - * - * Walks `ql.registry.getAllObjects()`, filters to schemas that - * (a) are not `managedBy` (skip sys_/auth/platform tables), - * (b) are not `sys_*`-namespaced, - * (c) declare BOTH `owner_id` and `organization_id`, - * and updates the org's unowned rows as `isSystem`. Returns a per-object summary. - */ -export async function claimOrgSeedOwnership( - ql: any, - organizationId: string, - ownerUserId: string, - options: ClaimOwnershipOptions = {}, -): Promise<{ object: string; count: number }[]> { - const logger = options.logger; - if (!organizationId || !ownerUserId) return []; - if (!ql || typeof ql.update !== 'function' || typeof ql.find !== 'function') return []; - const registry = (ql as any).registry; - if (!registry || typeof registry.getAllObjects !== 'function') { - logger?.warn?.('[org-scoping] claimOrgSeedOwnership: registry unavailable'); - return []; - } - - const schemas: ServiceObject[] = registry.getAllObjects(); - const results: { object: string; count: number }[] = []; - - for (const schema of schemas) { - if (!schema?.name) continue; - if ((schema as any).managedBy) continue; - if (schema.name.startsWith('sys_')) continue; - // Both columns are required: owner_id to assign, organization_id to scope. - if (!hasField(schema, 'owner_id') || !hasField(schema, 'organization_id')) continue; - - try { - const orphans = await ql.find( - schema.name, - { where: { organization_id: organizationId, owner_id: null }, limit: 10_000, fields: ['id'] }, - { context: SYSTEM_CTX }, - ); - const list: any[] = Array.isArray(orphans) - ? orphans - : Array.isArray(orphans?.records) - ? orphans.records - : []; - if (list.length === 0) continue; - - let updated = 0; - for (const row of list) { - if (!row?.id) continue; - try { - await ql.update(schema.name, { id: row.id, owner_id: ownerUserId }, { context: SYSTEM_CTX }); - updated += 1; - } catch (e) { - logger?.warn?.(`[org-scoping] claimOrgSeedOwnership failed for ${schema.name}:${row.id}`, { - error: (e as Error).message, - }); - } - } - if (updated > 0) results.push({ object: schema.name, count: updated }); - } catch (e) { - logger?.warn?.(`[org-scoping] claimOrgSeedOwnership scan failed for ${schema.name}`, { - error: (e as Error).message, - }); - } - } - - if (results.length > 0) { - const total = results.reduce((s, r) => s + r.count, 0); - logger?.info?.(`[org-scoping] handed ${total} seeded row(s) of org ${organizationId} to owner ${ownerUserId}`, { - breakdown: results, - }); - } - return results; -} diff --git a/packages/plugins/plugin-org-scoping/src/claim-orphan-org-rows.test.ts b/packages/plugins/plugin-org-scoping/src/claim-orphan-org-rows.test.ts deleted file mode 100644 index 6ab67bbc13..0000000000 --- a/packages/plugins/plugin-org-scoping/src/claim-orphan-org-rows.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect, vi } from 'vitest'; -import { claimOrphanOrgRows } from './claim-orphan-org-rows.js'; - -function makeQL(schemas: any[], rowsByObject: Record) { - const updates: { object: string; data: any; options: any }[] = []; - const ql: any = { - registry: { getAllObjects: () => schemas }, - find: vi.fn(async (object: string, query: any, _options: any) => { - const all = rowsByObject[object] ?? []; - // emulate `where: { organization_id: null }` - if (query?.where?.organization_id === null) { - return all.filter((r) => r.organization_id == null); - } - return all; - }), - update: vi.fn(async (object: string, data: any, options: any) => { - updates.push({ object, data, options }); - const row = (rowsByObject[object] ?? []).find((r) => r.id === data.id); - if (row) row.organization_id = data.organization_id; - return row; - }), - }; - return { ql, updates }; -} - -describe('claimOrphanOrgRows', () => { - it('returns [] when registry is unavailable', async () => { - const ql: any = { find: vi.fn(), update: vi.fn() }; - const result = await claimOrphanOrgRows(ql, 'org_1'); - expect(result).toEqual([]); - }); - - it('skips schemas with managedBy set', async () => { - const schemas = [ - { name: 'better_auth_user', managedBy: 'better-auth', fields: [{ name: 'organization_id' }] }, - ]; - const { ql, updates } = makeQL(schemas, { - better_auth_user: [{ id: 'u1', organization_id: null }], - }); - const result = await claimOrphanOrgRows(ql, 'org_1'); - expect(updates).toHaveLength(0); - expect(result).toEqual([]); - }); - - it('skips sys_-prefixed schemas even without managedBy', async () => { - const schemas = [ - { name: 'sys_permission_set', fields: [{ name: 'organization_id' }] }, - ]; - const { ql, updates } = makeQL(schemas, { - sys_permission_set: [{ id: 'ps1', organization_id: null }], - }); - await claimOrphanOrgRows(ql, 'org_1'); - expect(updates).toHaveLength(0); - }); - - it('skips schemas without an organization_id field', async () => { - const schemas = [{ name: 'global_setting', fields: [{ name: 'key' }, { name: 'value' }] }]; - const { ql, updates } = makeQL(schemas, { - global_setting: [{ id: 's1' }], - }); - await claimOrphanOrgRows(ql, 'org_1'); - expect(updates).toHaveLength(0); - }); - - it('updates only orphan rows and reports per-object counts', async () => { - const schemas = [ - { name: 'lead', fields: [{ name: 'organization_id' }] }, - { name: 'account', fields: [{ name: 'organization_id' }] }, - ]; - const { ql, updates } = makeQL(schemas, { - lead: [ - { id: 'l1', organization_id: null }, - { id: 'l2', organization_id: null }, - { id: 'l3', organization_id: 'org_other' }, - ], - account: [{ id: 'a1', organization_id: null }], - }); - const result = await claimOrphanOrgRows(ql, 'org_1'); - expect(updates).toHaveLength(3); - expect(updates.every((u) => u.options.context?.isSystem === true)).toBe(true); - expect(updates.every((u) => u.data.organization_id === 'org_1')).toBe(true); - expect(result).toEqual([ - { object: 'lead', count: 2 }, - { object: 'account', count: 1 }, - ]); - }); - - it('continues past rows whose update throws (e.g. user hooks)', async () => { - const schemas = [{ name: 'quote', fields: [{ name: 'organization_id' }] }]; - const ql: any = { - registry: { getAllObjects: () => schemas }, - find: vi.fn(async () => [ - { id: 'q1', organization_id: null }, - { id: 'q2', organization_id: null }, - ]), - update: vi.fn(async (_o: string, data: any) => { - if (data.id === 'q1') throw new Error('hook rejected'); - return { id: data.id }; - }), - }; - const logger = { info: vi.fn(), warn: vi.fn() }; - const result = await claimOrphanOrgRows(ql, 'org_1', { logger }); - expect(result).toEqual([{ object: 'quote', count: 1 }]); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('claim failed for quote:q1'), - expect.objectContaining({ error: 'hook rejected' }), - ); - }); - - it('is a no-op when no orphans exist', async () => { - const schemas = [{ name: 'lead', fields: [{ name: 'organization_id' }] }]; - const { ql, updates } = makeQL(schemas, { - lead: [{ id: 'l1', organization_id: 'org_other' }], - }); - const result = await claimOrphanOrgRows(ql, 'org_1'); - expect(updates).toHaveLength(0); - expect(result).toEqual([]); - }); - - it('returns [] when ql lacks find/update', async () => { - const result = await claimOrphanOrgRows({} as any, 'org_1'); - expect(result).toEqual([]); - }); -}); diff --git a/packages/plugins/plugin-org-scoping/src/claim-orphan-org-rows.ts b/packages/plugins/plugin-org-scoping/src/claim-orphan-org-rows.ts deleted file mode 100644 index 08eb36418c..0000000000 --- a/packages/plugins/plugin-org-scoping/src/claim-orphan-org-rows.ts +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * claimOrphanOrgRows — assign seed-loaded records to the first organization. - * - * Seeds (`defineSeed`) are inserted by `SeedLoaderService` using - * `{ context: { isSystem: true } }`, which intentionally bypasses - * SecurityPlugin's `organization_id` auto-fill. As a result, in - * multi-tenant mode every seed row lands with `organization_id = NULL`. - * - * That's correct for **cross-tenant metadata** — `sys_permission_set` - * rows, default roles, etc. (objects whose schema has `managedBy` set) - * — but for **business-domain seeds** (CRM `lead`, `account`, `contact`, - * …) it means the rows are invisible to anyone bound to an organization - * (the default `tenant_isolation` RLS policy - * `organization_id = current_user.organization_id` filters them out). - * - * This helper runs **once**, on first-organization creation, and - * back-fills `organization_id` on every orphaned (`organization_id IS - * NULL`) seed row of every user-defined object that declares the - * column. Result: out of the box, the freshly registered owner sees the - * shipped demo data scoped to their first org — no manual claim step. - * - * Idempotent: a no-op once an organization-tagged row exists, and - * `managedBy` schemas (`sys_*` better-auth/platform tables) are always - * skipped so cross-tenant defaults stay cross-tenant. - */ - -import type { ServiceObject } from '@objectstack/spec/data'; - -interface ClaimOptions { - logger?: { - info: (message: string, meta?: Record) => void; - warn: (message: string, meta?: Record) => void; - }; -} - -const SYSTEM_CTX = { isSystem: true }; - -function hasOrganizationField(schema: ServiceObject): boolean { - const fields: any = (schema as any)?.fields; - if (!fields) return false; - if (Array.isArray(fields)) { - return fields.some((f) => f?.name === 'organization_id'); - } - return Object.prototype.hasOwnProperty.call(fields, 'organization_id'); -} - -/** - * Assign every orphaned seed row to `organizationId`. - * - * Walks `ql.registry.getAllObjects()`, filters to schemas that - * (a) are not `managedBy` (skip sys_/auth/platform tables), - * (b) declare an `organization_id` field, - * and runs an `update(where: { organization_id: null }, patch: { - * organization_id: organizationId })` against each as `isSystem`. - * - * Returns a per-object summary `{ object, count }[]`. - */ -export async function claimOrphanOrgRows( - ql: any, - organizationId: string, - options: ClaimOptions = {}, -): Promise<{ object: string; count: number }[]> { - const logger = options.logger; - if (!ql || typeof ql.update !== 'function' || typeof ql.find !== 'function') { - return []; - } - const registry = (ql as any).registry; - if (!registry || typeof registry.getAllObjects !== 'function') { - logger?.warn?.('[org-scoping] claimOrphanOrgRows: registry unavailable'); - return []; - } - - const schemas: ServiceObject[] = registry.getAllObjects(); - const results: { object: string; count: number }[] = []; - - for (const schema of schemas) { - if (!schema?.name) continue; - if ((schema as any).managedBy) continue; - // Defense in depth: any platform-namespaced object (`sys_*`) is - // off-limits for tenant claim regardless of `managedBy`. Platform - // tables that should be tenant-scoped are inserted with an explicit - // `organization_id` by the code that owns them, so they will never - // be orphans here. - if (schema.name.startsWith('sys_')) continue; - if (!hasOrganizationField(schema)) continue; - - try { - const orphans = await ql.find( - schema.name, - { where: { organization_id: null }, limit: 10_000, fields: ['id'] }, - { context: SYSTEM_CTX }, - ); - const list: any[] = Array.isArray(orphans) - ? orphans - : Array.isArray(orphans?.records) - ? orphans.records - : []; - if (list.length === 0) continue; - - let updated = 0; - for (const row of list) { - if (!row?.id) continue; - try { - await ql.update( - schema.name, - { id: row.id, organization_id: organizationId }, - { context: SYSTEM_CTX }, - ); - updated += 1; - } catch (e) { - logger?.warn?.(`[org-scoping] claim failed for ${schema.name}:${row.id}`, { - error: (e as Error).message, - }); - } - } - if (updated > 0) { - results.push({ object: schema.name, count: updated }); - } - } catch (e) { - logger?.warn?.(`[org-scoping] claim scan failed for ${schema.name}`, { - error: (e as Error).message, - }); - } - } - - if (results.length > 0) { - const total = results.reduce((s, r) => s + r.count, 0); - logger?.info?.(`[org-scoping] claimed ${total} orphan seed row(s) for organization ${organizationId}`, { - breakdown: results, - }); - } - return results; -} diff --git a/packages/plugins/plugin-org-scoping/src/clone-org-seed-data.ts b/packages/plugins/plugin-org-scoping/src/clone-org-seed-data.ts deleted file mode 100644 index c689d5a5af..0000000000 --- a/packages/plugins/plugin-org-scoping/src/clone-org-seed-data.ts +++ /dev/null @@ -1,304 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * cloneOrgSeedData — give every newly-registered org its own copy of - * the demo seed data. - * - * Multi-tenant deployments treat each `sys_organization` as a hard - * isolation boundary. The platform-wide `claimOrphanOrgRows` hook - * (see `claim-orphan-tenant-rows.ts`) only fires for the very first - * org — every subsequent org (created explicitly by a user via - * `createOrganization`, or by an admin from the console) starts - * empty. For demo / trial-org UX (Salesforce-style "you get a - * fully populated sandbox on signup"), we want every freshly minted - * org to receive a private clone of the platform-first org's - * user-defined data. - * - * Strategy: - * 1. Pick the donor org — the very first `sys_organization`. - * 2. Walk `ql.registry.getAllObjects()` once to collect schemas - * that are user-defined (not `managedBy`, not `sys_*`) AND - * declare an `organization_id` field. - * 3. Pass A — for each donor object, find rows where - * `organization_id = donorOrgId`, generate a new id, insert a - * shallow copy under `targetOrgId`, recording an - * `oldId → newId` map keyed by object name. Lookup field values - * pointing at donor rows are left untouched in this pass; the - * remap happens in pass B so we don't depend on topological - * ordering of inserts. - * 4. Pass B — for each cloned row, walk its lookup-shaped fields - * and rewrite values that match the donor map for the field's - * `reference` object. - * - * Idempotent: skipped if the target org already has rows in any - * cloned object, or if no donor org exists, or if the target IS the - * donor (claim hook handles the donor itself). - * - * Best-effort: per-object failures are logged at `warn` and don't - * abort the rest of the clone. FK fields that reference an object - * that wasn't cloned (e.g. the lookup target lives in `sys_*`, or - * the remap key isn't present) are left as-is — broken refs are - * preferable to losing whole rows. - */ - -import type { ServiceObject } from '@objectstack/spec/data'; - -interface CloneOptions { - logger?: { - info: (message: string, meta?: Record) => void; - warn: (message: string, meta?: Record) => void; - }; -} - -interface FieldDescriptor { - name: string; - type?: string; - reference?: string; - multiple?: boolean; - unique?: boolean; -} - -const SYSTEM_CTX = { isSystem: true }; - -const SKIP_COPY_FIELDS = new Set([ - 'id', - 'created_at', - 'updated_at', - 'organization_id', -]); - -// Computed / virtual / system-managed field types — these have no -// physical column in the DB, so re-inserting them would fail with -// "table X has no column named Y". `find()` returns them in the -// projected row (formula evaluation, rollup summary), but they must -// NEVER be sent back to `insert()`. -// -// NOTE: `autonumber` IS a real string column in the SQL driver — it -// has no auto-generation in this codebase, the value comes from the -// seed file itself. Cloning it preserves the demo's "CTR-0001" / -// "QTE-0001" identifiers so users see meaningful titleFormats and -// the `externalId` upsert key keeps working on subsequent re-seeds. -const SKIP_COPY_TYPES = new Set(['formula', 'summary']); - -function fieldList(schema: ServiceObject): FieldDescriptor[] { - const fields: any = (schema as any)?.fields; - if (!fields) return []; - if (Array.isArray(fields)) { - return fields.map((f: any) => ({ - name: f?.name, - type: f?.type, - reference: f?.reference, - multiple: f?.multiple, - unique: f?.unique, - })); - } - return Object.entries(fields as Record).map(([name, f]) => ({ - name, - type: f?.type, - reference: f?.reference, - multiple: f?.multiple, - unique: f?.unique, - })); -} - -function isLookupField(f: FieldDescriptor): boolean { - return (f.type === 'lookup' || f.type === 'master_detail' || f.type === 'tree') && !!f.reference; -} - -function hasOrgField(schema: ServiceObject): boolean { - return fieldList(schema).some((f) => f.name === 'organization_id'); -} - -function shortId(): string { - // Mirror the format `nanoid(16)` used elsewhere in the codebase - // without pulling a runtime dep here. - const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-'; - let out = ''; - for (let i = 0; i < 16; i++) { - out += alphabet[Math.floor(Math.random() * alphabet.length)]; - } - return out; -} - -async function findDonorOrgId(ql: any): Promise { - try { - const res = await ql.find( - 'sys_organization', - { orderBy: { created_at: 'asc' }, limit: 1, fields: ['id'] }, - { context: SYSTEM_CTX }, - ); - const list: any[] = Array.isArray(res) ? res : Array.isArray(res?.records) ? res.records : []; - return list[0]?.id ?? null; - } catch { - return null; - } -} - -export async function cloneOrgSeedData( - ql: any, - targetOrgId: string, - options: CloneOptions = {}, -): Promise<{ object: string; count: number }[]> { - const logger = options.logger; - if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') { - return []; - } - const registry = (ql as any).registry; - if (!registry || typeof registry.getAllObjects !== 'function') { - logger?.warn?.('[org-scoping] cloneOrgSeedData: registry unavailable'); - return []; - } - - const donorOrgId = await findDonorOrgId(ql); - if (!donorOrgId) return []; - if (donorOrgId === targetOrgId) return []; - - const schemas: ServiceObject[] = registry.getAllObjects().filter( - (s: any) => s?.name && !s.managedBy && !s.name.startsWith('sys_') && hasOrgField(s), - ); - - // Pass A: clone rows shallowly, build per-object oldId → newId map. - const remap: Record> = {}; - const summary: { object: string; count: number }[] = []; - // Track inserted shadow records so pass B can rewrite their lookups - // without re-fetching from the DB. - const inserted: { object: string; newId: string; record: Record; lookups: FieldDescriptor[] }[] = []; - - for (const schema of schemas) { - const objectName = schema.name as string; - try { - // Idempotency: if target org already has any row in this object, - // assume a previous clone (or manual data) and skip — never - // double-clone. - const existing = await ql.find( - objectName, - { where: { organization_id: targetOrgId }, limit: 1, fields: ['id'] }, - { context: SYSTEM_CTX }, - ); - const existingList: any[] = Array.isArray(existing) - ? existing - : Array.isArray(existing?.records) - ? existing.records - : []; - if (existingList.length > 0) { - continue; - } - - const donorRows = await ql.find( - objectName, - { where: { organization_id: donorOrgId }, limit: 10_000 }, - { context: SYSTEM_CTX }, - ); - const rows: any[] = Array.isArray(donorRows) - ? donorRows - : Array.isArray(donorRows?.records) - ? donorRows.records - : []; - if (rows.length === 0) continue; - - const fields = fieldList(schema); - const lookups = fields.filter(isLookupField); - const uniqueFields = fields.filter((f) => f.unique && !SKIP_COPY_FIELDS.has(f.name)); - const objectRemap: Record = (remap[objectName] ??= {}); - let cloned = 0; - for (const row of rows) { - const newId = shortId(); - const data: Record = { id: newId, organization_id: targetOrgId }; - for (const f of fields) { - if (SKIP_COPY_FIELDS.has(f.name)) continue; - if (f.type && SKIP_COPY_TYPES.has(f.type)) continue; - if (row[f.name] === undefined) continue; - data[f.name] = row[f.name]; - } - // Disambiguate UNIQUE columns. Many seed schemas declare - // single-column unique indexes (e.g. `lead.email`) without - // tenant scoping — cloning the donor row verbatim would - // collide. Append a per-tenant suffix so each org gets its - // own copy. - const suffix = `+${targetOrgId.slice(-6)}`; - for (const uf of uniqueFields) { - const v = data[uf.name]; - if (typeof v !== 'string' || !v) continue; - if (uf.type === 'email' && v.includes('@')) { - const [local, domain] = v.split('@'); - data[uf.name] = `clone-${targetOrgId.slice(-6)}-${local}@${domain}`; - } else { - data[uf.name] = `${v}${suffix}`; - } - } - try { - await ql.insert(objectName, data, { context: SYSTEM_CTX }); - objectRemap[row.id] = newId; - inserted.push({ object: objectName, newId, record: data, lookups }); - cloned++; - } catch (e) { - logger?.warn?.('[org-scoping] cloneOrgSeedData: insert failed', { - object: objectName, - error: (e as Error).message, - }); - } - } - if (cloned > 0) summary.push({ object: objectName, count: cloned }); - } catch (e) { - logger?.warn?.('[org-scoping] cloneOrgSeedData: object failed', { - object: objectName, - error: (e as Error).message, - }); - } - } - - // Pass B: rewrite lookup field values using the per-object remap so - // intra-clone relationships stay intact. - // - // Cross-tenant FK hygiene: when a donor row's lookup value DOESN'T - // appear in `remap[reference]` (i.e. the donor itself had a stale - // FK pointing at another tenant's record, or the referenced object - // wasn't included in this clone), we NULL the field instead of - // leaving the orphan string in place. Otherwise every subsequent - // clone perpetuates the broken FK chain (donor → tenant A → tenant - // B → ...) and renderers display raw IDs because `find()` for the - // referenced ID returns no row in the current tenant. - for (const item of inserted) { - if (item.lookups.length === 0) continue; - const patch: Record = {}; - let dirty = false; - for (const f of item.lookups) { - const oldVal = item.record[f.name]; - if (oldVal == null) continue; - const targetMap = remap[f.reference!]; - if (Array.isArray(oldVal)) { - // For multi-value lookups: remap when possible, drop entries - // that have no remap (rather than keep an orphan string). - const next = oldVal - .map((v: any) => (typeof v === 'string' && targetMap?.[v]) || null) - .filter((v: any) => v != null); - if (next.length !== oldVal.length || next.some((v, i) => v !== oldVal[i])) { - patch[f.name] = next.length > 0 ? next : null; - dirty = true; - } - } else if (typeof oldVal === 'string') { - if (targetMap && targetMap[oldVal]) { - patch[f.name] = targetMap[oldVal]; - dirty = true; - } else { - // Unresolvable cross-tenant reference — null it out so the - // UI shows "empty" rather than a dangling ID. - patch[f.name] = null; - dirty = true; - } - } - } - if (!dirty) continue; - try { - await ql.update(item.object, { id: item.newId, ...patch }, { context: SYSTEM_CTX }); - } catch (e) { - logger?.warn?.('[org-scoping] cloneOrgSeedData: lookup remap failed', { - object: item.object, - id: item.newId, - error: (e as Error).message, - }); - } - } - - return summary; -} diff --git a/packages/plugins/plugin-org-scoping/src/index.ts b/packages/plugins/plugin-org-scoping/src/index.ts deleted file mode 100644 index 52137bcf70..0000000000 --- a/packages/plugins/plugin-org-scoping/src/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * @objectstack/plugin-org-scoping - * - * Row-level Organization isolation for ObjectStack: - * - auto-stamps `organization_id` on insert from - * `ExecutionContext.tenantId`, - * - replays seed datasets (or clones from the donor org) on every - * `sys_organization` insert, - * - bootstraps a Default Organization for the first platform admin. - * - * Pair with `@objectstack/plugin-security` to get full multi-tenant - * RBAC + RLS + Field-Level Security. Install standalone for - * single-tenant deployments — plugin-security detects this plugin's - * presence via `getService('org-scoping')` and adjusts wildcard - * tenant policy handling accordingly. - */ - -export { OrgScopingPlugin } from './org-scoping-plugin.js'; -export type { OrgScopingPluginOptions } from './org-scoping-plugin.js'; -export { claimOrphanOrgRows } from './claim-orphan-org-rows.js'; -export { claimOrgSeedOwnership } from './claim-org-seed-ownership.js'; -export { cloneOrgSeedData } from './clone-org-seed-data.js'; -export { - ensureDefaultOrganization, - type EnsureDefaultOrganizationResult, -} from './ensure-default-organization.js'; -export { - orgScopingObjects, - orgScopingPluginManifestHeader, - ORG_SCOPING_PLUGIN_ID, - ORG_SCOPING_PLUGIN_VERSION, -} from './manifest.js'; diff --git a/packages/plugins/plugin-org-scoping/src/manifest.ts b/packages/plugins/plugin-org-scoping/src/manifest.ts deleted file mode 100644 index 854234a01f..0000000000 --- a/packages/plugins/plugin-org-scoping/src/manifest.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Canonical plugin-org-scoping manifest source. - * - * Imported by `objectstack.config.ts` (compile-time) and - * `org-scoping-plugin.ts` (runtime `manifest.register`) so the two - * registration paths cannot drift. - */ - -export const ORG_SCOPING_PLUGIN_ID = 'com.objectstack.plugin-org-scoping'; -export const ORG_SCOPING_PLUGIN_VERSION = '1.0.0'; - -/** This plugin owns no `sys_*` objects — Organization itself lives in `@objectstack/platform-objects`. */ -export const orgScopingObjects = [] as const; - -/** Manifest header shared by compile-time config and runtime registration. */ -export const orgScopingPluginManifestHeader = { - id: ORG_SCOPING_PLUGIN_ID, - namespace: 'sys', - version: ORG_SCOPING_PLUGIN_VERSION, - type: 'plugin' as const, - scope: 'system' as const, - defaultDatasource: 'cloud', - name: 'Organization Scoping Plugin', - description: - 'Row-level Organization isolation: auto-stamps `organization_id` on insert from ' + - '`ExecutionContext.tenantId`, replays seed datasets per new org, and bootstraps a default ' + - 'organization for the first platform admin.', -}; diff --git a/packages/plugins/plugin-org-scoping/src/org-scoping-plugin.test.ts b/packages/plugins/plugin-org-scoping/src/org-scoping-plugin.test.ts deleted file mode 100644 index 61ca79e076..0000000000 --- a/packages/plugins/plugin-org-scoping/src/org-scoping-plugin.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect, vi } from 'vitest'; -import { OrgScopingPlugin } from './org-scoping-plugin.js'; - -function makeCtx(extraServices: Record = {}) { - const middlewares: any[] = []; - const baseSchema = { - name: 'task', - fields: { - id: { name: 'id' }, - organization_id: { name: 'organization_id' }, - owner_id: { name: 'owner_id' }, - name: { name: 'name' }, - }, - }; - const ql: any = { - registerMiddleware: (mw: any) => middlewares.push(mw), - getSchema: () => baseSchema, - find: vi.fn(async () => []), - insert: vi.fn(async () => ({ id: 'x' })), - }; - const metadata: any = { get: async () => baseSchema }; - const services: Record = { - manifest: { register: vi.fn() }, - objectql: ql, - metadata, - ...extraServices, - }; - const registered: Record = {}; - const ctx: any = { - logger: { info: vi.fn(), warn: vi.fn() }, - registerService: (name: string, svc: any) => { - registered[name] = svc; - services[name] = svc; - }, - getService: (name: string) => { - if (!(name in services)) throw new Error(`service not registered: ${name}`); - return services[name]; - }, - }; - return { ctx, ql, middlewares, registered }; -} - -describe('OrgScopingPlugin', () => { - it('has correct metadata', () => { - const plugin = new OrgScopingPlugin(); - expect(plugin.name).toBe('com.objectstack.org-scoping'); - expect(plugin.version).toBe('1.0.0'); - expect(plugin.dependencies).toContain('com.objectstack.engine.objectql'); - }); - - it('registers itself as `org-scoping` service during init', async () => { - const plugin = new OrgScopingPlugin(); - const { ctx, registered } = makeCtx(); - await plugin.init(ctx); - expect(registered['org-scoping']).toBe(plugin); - }); - - it('auto-stamps organization_id on insert from tenantId', async () => { - const plugin = new OrgScopingPlugin(); - const { ctx, middlewares } = makeCtx(); - await plugin.init(ctx); - await plugin.start(ctx); - const insertMw = middlewares[0]; - const opCtx: any = { - object: 'task', - operation: 'insert', - data: { name: 'A' }, - context: { userId: 'u1', tenantId: 'org-1' }, - }; - await insertMw(opCtx, async () => {}); - expect(opCtx.data.organization_id).toBe('org-1'); - }); - - it('does not overwrite an explicit organization_id', async () => { - const plugin = new OrgScopingPlugin(); - const { ctx, middlewares } = makeCtx(); - await plugin.init(ctx); - await plugin.start(ctx); - const opCtx: any = { - object: 'task', - operation: 'insert', - data: { name: 'A', organization_id: 'explicit-org' }, - context: { userId: 'u1', tenantId: 'org-1' }, - }; - await middlewares[0](opCtx, async () => {}); - expect(opCtx.data.organization_id).toBe('explicit-org'); - }); - - it('skips auto-stamping in system context', async () => { - const plugin = new OrgScopingPlugin(); - const { ctx, middlewares } = makeCtx(); - await plugin.init(ctx); - await plugin.start(ctx); - const opCtx: any = { - object: 'task', - operation: 'insert', - data: { name: 'A' }, - context: { isSystem: true, tenantId: 'org-1' }, - }; - await middlewares[0](opCtx, async () => {}); - expect(opCtx.data.organization_id).toBeUndefined(); - }); - - it('no-ops when tenantId is absent', async () => { - const plugin = new OrgScopingPlugin(); - const { ctx, middlewares } = makeCtx(); - await plugin.init(ctx); - await plugin.start(ctx); - const opCtx: any = { - object: 'task', - operation: 'insert', - data: { name: 'A' }, - context: { userId: 'u1' }, - }; - await middlewares[0](opCtx, async () => {}); - expect(opCtx.data.organization_id).toBeUndefined(); - }); - - it('skips when target object has no organization_id field', async () => { - const plugin = new OrgScopingPlugin(); - const { ctx, middlewares } = makeCtx(); - // Replace schema so the column does not exist. - (ctx.getService('objectql') as any).getSchema = () => ({ - name: 'task', - fields: { id: { name: 'id' }, name: { name: 'name' } }, - }); - await plugin.init(ctx); - await plugin.start(ctx); - const opCtx: any = { - object: 'task', - operation: 'insert', - data: { name: 'A' }, - context: { userId: 'u1', tenantId: 'org-1' }, - }; - await middlewares[0](opCtx, async () => {}); - expect(opCtx.data.organization_id).toBeUndefined(); - }); -}); diff --git a/packages/plugins/plugin-org-scoping/src/org-scoping-plugin.ts b/packages/plugins/plugin-org-scoping/src/org-scoping-plugin.ts deleted file mode 100644 index 3c2392664e..0000000000 --- a/packages/plugins/plugin-org-scoping/src/org-scoping-plugin.ts +++ /dev/null @@ -1,349 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { Plugin, PluginContext } from '@objectstack/core'; -import { claimOrphanOrgRows } from './claim-orphan-org-rows.js'; -import { cloneOrgSeedData } from './clone-org-seed-data.js'; -import { ensureDefaultOrganization } from './ensure-default-organization.js'; -import { - orgScopingObjects, - orgScopingPluginManifestHeader, -} from './manifest.js'; - -export interface OrgScopingPluginOptions { - /** - * Whether to auto-create a `Default Organization` (slug `default`) - * and bind the first platform admin as `owner` when they have zero - * memberships. Set to `false` for deployments that fully self-manage - * org provisioning via invitation links or a custom onboarding flow. - * - * @default true - */ - ensureDefaultOrganization?: boolean; -} - -/** - * OrgScopingPlugin - * - * Makes `sys_organization` a first-class row-level isolation boundary: - * - * 1. **insert auto-stamp** — on every authenticated `insert` whose - * target object declares `organization_id`, fill the column from - * `ExecutionContext.tenantId`. Without this, freshly-created - * rows have `organization_id = NULL` and the default - * `tenant_isolation` RLS policy hides them from the very user - * who just created them. - * - * 2. **per-org seed replay** — after `sys_organization` insert, copy - * the artifact's demo seed data into the new org. Three paths - * (in order of preference): - * a. replay registered `seed-datasets` via the kernel-level - * `seed-replayer` callable (set by AppPlugin), - * b. for the FIRST org, `claimOrphanOrgRows` adopts any - * NULL-org rows a previous inline-seed may have inserted, - * c. for SUBSEQUENT orgs, `cloneOrgSeedData` shallow-clones - * rows from the very first org (donor-pattern). - * - * 3. **default-org bootstrap** — on `kernel:ready` and after every - * `sys_user_permission_set` insert, ensure the platform admin has - * a Default Organization to operate in (idempotent on slug - * `default` + admin's existing memberships). - * - * Why split from plugin-security: - * - plugin-security is a single-tenant-aware RBAC + RLS engine; it - * should not know about Organization-specific seed flows. - * - This plugin is purely opt-in: not installing it gives a - * single-tenant deployment (no `organization_id` injection, no - * per-org seed clone, no default-org bootstrap). plugin-security - * detects its presence via `getService('org-scoping')` and adjusts - * RLS policy stripping accordingly. - * - * Naming note: "org-scoping" deliberately avoids the word "tenant" - * because in ObjectStack "tenant" already means *physical isolation* - * (one Environment = one database, per ADR-0002 and driver-turso's - * multi-tenant router). This plugin is about LOGICAL row-level - * scoping inside a single database — orthogonal to physical tenancy. - * - * Dependencies: - * - `objectql` (engine middleware host) - */ -export class OrgScopingPlugin implements Plugin { - name = 'com.objectstack.org-scoping'; - type = 'standard'; - version = '1.0.0'; - dependencies = ['com.objectstack.engine.objectql']; - - /** Per-object field-name cache; same shape as SecurityPlugin's. */ - private readonly fieldNamesCache = new Map | null>(); - - private readonly opts: Required; - - constructor(options: OrgScopingPluginOptions = {}) { - this.opts = { - ensureDefaultOrganization: options.ensureDefaultOrganization !== false, - }; - } - - async init(ctx: PluginContext): Promise { - ctx.logger.info('Initializing Org-Scoping Plugin...'); - // Service registration doubles as plugin-security's - // "multi-tenant mode is on" probe: SecurityPlugin queries - // `getService('org-scoping')` and keeps wildcard - // `current_user.organization_id` RLS policies when this returns. - ctx.registerService('org-scoping', this); - - ctx - .getService<{ register(m: any): void }>('manifest') - .register({ - ...orgScopingPluginManifestHeader, - objects: orgScopingObjects, - }); - ctx.logger.info('Org-Scoping Plugin initialized'); - } - - async start(ctx: PluginContext): Promise { - ctx.logger.info('Starting Org-Scoping Plugin...'); - - let ql: any; - let metadata: any; - try { - ql = ctx.getService('objectql'); - try { - metadata = ctx.getService('metadata'); - } catch { - metadata = undefined; - } - } catch { - ctx.logger.warn( - 'ObjectQL service not available, org-scoping middleware not registered', - ); - return; - } - if (!ql || typeof ql.registerMiddleware !== 'function') { - ctx.logger.warn( - 'ObjectQL engine does not support middleware, org-scoping middleware not registered', - ); - return; - } - - // ── Middleware A: auto-stamp `organization_id` on insert ────────── - ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { - if (opCtx.context?.isSystem) return next(); - if ( - opCtx.operation === 'insert' && - opCtx.data && - typeof opCtx.data === 'object' && - !Array.isArray(opCtx.data) && - opCtx.context?.tenantId - ) { - const fields = await this.getObjectFieldNames(metadata, opCtx.object, ql); - if (fields && fields.has('organization_id')) { - const data = opCtx.data as Record; - if (data.organization_id == null || data.organization_id === '') { - data.organization_id = opCtx.context.tenantId; - } - } - } - await next(); - }); - - // ── Middleware B: per-org seed pipeline on sys_organization insert ─ - ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { - await next(); - if ( - opCtx?.object !== 'sys_organization' || - (opCtx?.operation !== 'create' && opCtx?.operation !== 'insert') - ) { - return; - } - const newOrgId = opCtx?.result?.id ?? opCtx?.data?.id; - if (!newOrgId) return; - - const kernel: any = (ctx as any).kernel ?? ctx; - let datasets: any[] | undefined; - try { - const raw = kernel?.getService?.('seed-datasets'); - if (Array.isArray(raw) && raw.length > 0) datasets = raw; - } catch { - /* service not registered */ - } - - // Count existing orgs to pick the right fallback path. - let orgCount = 0; - try { - const allOrgs = await ql.find( - 'sys_organization', - { limit: 2, fields: ['id'] }, - { context: { isSystem: true } }, - ); - const list: any[] = Array.isArray(allOrgs) - ? allOrgs - : Array.isArray(allOrgs?.records) - ? allOrgs.records - : []; - orgCount = list.length; - } catch (e) { - ctx.logger.warn('[org-scoping] failed to count organizations', { - error: (e as Error).message, - }); - } - - // Primary path: SeedLoader replay scoped to newOrgId. - let replayed = false; - try { - const replayer: any = kernel?.getService?.('seed-replayer'); - if (typeof replayer === 'function') { - const summary = await replayer(newOrgId); - const total = (summary?.inserted ?? 0) + (summary?.updated ?? 0); - ctx.logger.info( - `[org-scoping] per-org seed replay for ${newOrgId}: +${summary?.inserted ?? 0} inserted, ${summary?.updated ?? 0} updated, ${summary?.errors?.length ?? 0} error(s)`, - { - organizationId: newOrgId, - errors: summary?.errors?.slice?.(0, 5), - }, - ); - if (total > 0) replayed = true; - } else if (datasets) { - ctx.logger.warn( - '[org-scoping] per-org seed: datasets present but no replayer registered', - { organizationId: newOrgId }, - ); - } - } catch (e) { - ctx.logger.warn( - '[org-scoping] per-org seed replay failed, falling back', - { organizationId: newOrgId, error: (e as Error).message }, - ); - } - if (replayed) return; - - // Fallback A: legacy claim for first org. - if (orgCount === 1) { - try { - const claims = await claimOrphanOrgRows(ql, newOrgId, { logger: ctx.logger }); - if (claims.length > 0) { - const total = claims.reduce((s, c) => s + c.count, 0); - ctx.logger.info( - `[org-scoping] claimed ${total} orphan seed row(s) for first organization ${newOrgId}`, - { breakdown: claims }, - ); - return; - } - } catch (e) { - ctx.logger.warn('[org-scoping] claim-orphan-org-rows failed', { - error: (e as Error).message, - }); - } - } - - // Fallback B: clone from donor org for subsequent orgs. - if (orgCount > 1) { - try { - const summary = await cloneOrgSeedData(ql, newOrgId, { logger: ctx.logger }); - if (summary.length > 0) { - const total = summary.reduce((s, c) => s + c.count, 0); - ctx.logger.info( - `[org-scoping] cloned ${total} seed row(s) for new organization ${newOrgId}`, - { breakdown: summary }, - ); - } - } catch (e) { - ctx.logger.warn('[org-scoping] clone-org-seed-data failed', { - organizationId: newOrgId, - error: (e as Error).message, - }); - } - } - }); - - // ── Default-org bootstrap on kernel:ready + on admin grant ──────── - if (this.opts.ensureDefaultOrganization) { - const runEnsure = async () => { - try { - const res = await ensureDefaultOrganization(ql, { logger: ctx.logger }); - if (res.defaultOrgCreated) { - ctx.logger.info( - `[org-scoping] created Default Organization ${res.defaultOrgId} for platform admin`, - ); - } - } catch (e) { - ctx.logger.warn?.('[org-scoping] ensureDefaultOrganization failed', { - error: (e as Error).message, - }); - } - }; - if (typeof (ctx as any).hook === 'function') { - (ctx as any).hook('kernel:ready', runEnsure); - } else { - void runEnsure(); - } - // Re-run after every admin grant — handles the "first sign-up - // promoted to platform admin" case where the kernel:ready hook - // fired before any user existed. - ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { - await next(); - if ( - opCtx?.object === 'sys_user_permission_set' && - (opCtx?.operation === 'insert' || opCtx?.operation === 'create') - ) { - await runEnsure(); - } - }); - } - - ctx.logger.info('Org-Scoping middleware registered on ObjectQL engine'); - } - - async destroy(): Promise { - // No cleanup needed - } - - /** - * Resolve the column-name set for an object (mirrors SecurityPlugin's - * loader so the two plugins behave consistently). Returns `null` if - * the schema can't be loaded — caller skips injection. - */ - private async getObjectFieldNames( - metadata: any, - objectName: string, - ql?: any, - ): Promise | null> { - if (this.fieldNamesCache.has(objectName)) { - return this.fieldNamesCache.get(objectName) ?? null; - } - const result = await this.loadObjectFieldNames(metadata, objectName, ql); - if (result) this.fieldNamesCache.set(objectName, result); - return result; - } - - private async loadObjectFieldNames( - metadata: any, - objectName: string, - ql?: any, - ): Promise | null> { - try { - let obj: any = - typeof ql?.getSchema === 'function' ? ql.getSchema(objectName) : null; - if (!obj || !obj.fields) { - obj = await metadata?.get?.('object', objectName); - } - if (!obj || !obj.fields) return null; - const set = new Set(['id']); - if (Array.isArray(obj.fields)) { - for (const f of obj.fields) { - if (f?.name) set.add(String(f.name)); - } - } else if (typeof obj.fields === 'object') { - for (const key of Object.keys(obj.fields)) { - set.add(key); - const v = (obj.fields as Record)[key]; - if (v && typeof v === 'object' && v.name) set.add(String(v.name)); - } - } else { - return null; - } - return set; - } catch { - return null; - } - } -} diff --git a/packages/plugins/plugin-org-scoping/tsconfig.json b/packages/plugins/plugin-org-scoping/tsconfig.json deleted file mode 100644 index f6a1e8bad5..0000000000 --- a/packages/plugins/plugin-org-scoping/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "types": [ - "node" - ] - }, - "include": [ - "src/**/*" - ], - "exclude": [ - "dist", - "node_modules", - "**/*.test.ts" - ] -} diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index 3dd0e1a355..ff004ff53e 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -14,7 +14,7 @@ * If a platform admin already exists, this is a no-op forever. * * The "create a Default Organization for the freshly-promoted admin" - * behavior moved to `@objectstack/plugin-org-scoping` (see + * behavior moved to `@objectstack/organizations` (see * `ensureDefaultOrganization`). Install that plugin to get * multi-tenant bootstrap. */ diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 17c6fc9f9f..ce8924b5ba 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -114,7 +114,7 @@ export interface SecurityPluginOptions { * without permission checks (same as current behavior). * * **Multi-tenant Organization scoping is provided by the separate - * `@objectstack/plugin-org-scoping` package** (auto-stamps + * `@objectstack/organizations` package** (auto-stamps * `organization_id` on insert, per-org seed replay, default-org * bootstrap). When that plugin is installed, SecurityPlugin detects * it via `getService('org-scoping')` and keeps the wildcard @@ -696,7 +696,7 @@ export class SecurityPlugin implements Plugin { // the very user who just created it. // // `organization_id` auto-injection has moved to - // `@objectstack/plugin-org-scoping`. Install that plugin for + // `@objectstack/organizations`. Install that plugin for // multi-tenant deployments. if ( opCtx.operation === 'insert' && @@ -1066,7 +1066,7 @@ export class SecurityPlugin implements Plugin { } // Per-organization seed data replay on `sys_organization` insert - // moved to `@objectstack/plugin-org-scoping` (along with + // moved to `@objectstack/organizations` (along with // `claimOrphanOrgRows` / `cloneOrgSeedData`). Install that // plugin for multi-tenant deployments. } @@ -1589,7 +1589,7 @@ export class SecurityPlugin implements Plugin { // net. Either way it's pure overhead. Substring match is // sufficient: every wildcard tenant policy in the default // permission sets uses exactly this token. Install - // `@objectstack/plugin-org-scoping` to enable the + // `@objectstack/organizations` to enable the // multi-tenant behavior. if ( !this.orgScopingEnabled && diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 416ad71a74..9fa4d30000 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -28,7 +28,6 @@ "@objectstack/objectql": "workspace:*", "@objectstack/observability": "workspace:*", "@objectstack/plugin-auth": "workspace:*", - "@objectstack/plugin-org-scoping": "workspace:*", "@objectstack/plugin-security": "workspace:*", "@objectstack/rest": "workspace:*", "@objectstack/service-cluster": "workspace:*", diff --git a/packages/verify/package.json b/packages/verify/package.json index 2e8a676206..1a751dac9c 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -24,7 +24,6 @@ "@objectstack/objectql": "workspace:*", "@objectstack/plugin-auth": "workspace:*", "@objectstack/plugin-hono-server": "workspace:*", - "@objectstack/plugin-org-scoping": "workspace:*", "@objectstack/plugin-security": "workspace:*", "@objectstack/plugin-sharing": "workspace:*", "@objectstack/rest": "workspace:*", diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index 2221feb328..a0cc72ff8f 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -74,7 +74,7 @@ export interface BootOptions { */ security?: SecurityPlugin; /** - * Boot multi-tenant: register `@objectstack/plugin-org-scoping` BEFORE the + * Boot multi-tenant: register enterprise `@objectstack/organizations` plugin BEFORE the * SecurityPlugin so the wildcard `organization_id` RLS policies that ship in * the default permission sets actually apply (SecurityPlugin probes the * `org-scoping` service once at start and otherwise STRIPS them — see @@ -125,7 +125,18 @@ export async function bootStack( // Service plugins `objectstack dev` auto-loads for an app of this shape. await kernel.use(new SettingsServicePlugin()); await kernel.use(new AnalyticsServicePlugin()); - await kernel.use(new AuthPlugin({ secret: opts.authSecret ?? DEFAULT_AUTH_SECRET })); + // `autoDefaultOrganization: false` (ADR-0081 D1): the harness proves the two + // ENDS of the isolation spectrum — pure single-tenant (no org, no scoping) + // and, via `opts.multiTenant`, full multi-org (the enterprise plugin owns + // the org bootstrap). AuthPlugin's single-org default-org bootstrap is a + // product onboarding convenience for `objectstack dev`/`serve`; letting it + // run here would mint a Default Organization + bind the dev admin as owner, + // giving every "single-tenant" fixture an active org — which turns on + // org-scoped RLS and reparents seeded rows, silently breaking the pure + // single-tenant baseline these dogfood proofs assert (ADR-0057 identity + // create, ADR-0062 federation, ADR-0086 two-doors). The bootstrap itself is + // covered by plugin-auth unit tests + browser E2E. + await kernel.use(new AuthPlugin({ secret: opts.authSecret ?? DEFAULT_AUTH_SECRET, autoDefaultOrganization: false })); // ADR-0062 — datasource connection service (registers 'datasource-connection'), // mirroring `objectstack dev`/serve. Without it, AppPlugin's declared-datasource @@ -147,13 +158,26 @@ export async function bootStack( } } - // Multi-tenant: org-scoping MUST register BEFORE SecurityPlugin — the latter - // probes the `org-scoping` service exactly once at start and caches it, then - // keeps (vs strips) the wildcard `organization_id` RLS policies accordingly. - // Mirrors the CLI's ordering for `OS_MULTI_ORG_ENABLED`. + // Multi-org: the enterprise OrganizationsPlugin (`@objectstack/organizations`, + // ADR-0081 D2) MUST register BEFORE SecurityPlugin — the latter probes the + // `org-scoping` service (the historical name the enterprise plugin keeps + // registering) exactly once at start and caches it, then keeps (vs strips) + // the wildcard `organization_id` RLS policies accordingly. Mirrors the CLI's + // ordering for `OS_MULTI_ORG_ENABLED`. `multiTenant` is an explicit opt-in, + // so a missing package is a hard, actionable error — not a silent + // single-org downgrade that would flip the fixture's RLS posture. if (opts.multiTenant) { - const { OrgScopingPlugin } = await import('@objectstack/plugin-org-scoping'); - await kernel.use(new OrgScopingPlugin()); + const organizationsPkg = '@objectstack/organizations'; + let mod: any; + try { + mod = await import(/* webpackIgnore: true */ organizationsPkg); + } catch (e) { + throw new Error( + 'verify: multiTenant=true requires the enterprise @objectstack/organizations package (migrated from plugin-org-scoping, ADR-0081 D2). ' + + `Install/link it in this workspace to run multi-org fixtures. (${(e as Error).message})`, + ); + } + await kernel.use(new mod.OrganizationsPlugin()); } // Automation service — opt-in. Registered before bootstrap so its start() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06415e74aa..5d4be2dc4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -385,9 +385,6 @@ importers: '@objectstack/plugin-hono-server': specifier: workspace:* version: link:../plugins/plugin-hono-server - '@objectstack/plugin-org-scoping': - specifier: workspace:* - version: link:../plugins/plugin-org-scoping '@objectstack/plugin-reports': specifier: workspace:* version: link:../plugins/plugin-reports @@ -1350,9 +1347,6 @@ importers: '@objectstack/plugin-hono-server': specifier: workspace:^ version: link:../plugin-hono-server - '@objectstack/plugin-org-scoping': - specifier: workspace:^ - version: link:../plugin-org-scoping '@objectstack/plugin-security': specifier: workspace:^ version: link:../plugin-security @@ -1441,28 +1435,6 @@ importers: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) - packages/plugins/plugin-org-scoping: - dependencies: - '@objectstack/core': - specifier: workspace:* - version: link:../../core - '@objectstack/platform-objects': - specifier: workspace:* - version: link:../../platform-objects - '@objectstack/spec': - specifier: workspace:* - version: link:../../spec - devDependencies: - '@types/node': - specifier: ^26.1.0 - version: 26.1.0 - typescript: - specifier: ^6.0.3 - version: 6.0.3 - vitest: - specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(msw@2.14.6(@types/node@26.1.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)) - packages/plugins/plugin-reports: dependencies: '@objectstack/core': @@ -1629,9 +1601,6 @@ importers: '@objectstack/plugin-auth': specifier: workspace:* version: link:../plugins/plugin-auth - '@objectstack/plugin-org-scoping': - specifier: workspace:* - version: link:../plugins/plugin-org-scoping '@objectstack/plugin-security': specifier: workspace:* version: link:../plugins/plugin-security @@ -2152,9 +2121,6 @@ importers: '@objectstack/plugin-hono-server': specifier: workspace:* version: link:../plugins/plugin-hono-server - '@objectstack/plugin-org-scoping': - specifier: workspace:* - version: link:../plugins/plugin-org-scoping '@objectstack/plugin-security': specifier: workspace:* version: link:../plugins/plugin-security