From bd3ec381bb4e24d12010ebe8bc2594721ce05f7c Mon Sep 17 00:00:00 2001 From: Clifton Cunningham Date: Mon, 2 Nov 2026 16:12:59 +0000 Subject: [PATCH 1/2] Admin: link a tenant to its IdP org, and delete an organisation A tenant made in the app is never matched to an issuer org by slug, so the org's first sign-in made a second tenant. A global admin now sets or clears tenants.external_org_id (409 external_org_taken when another tenant holds it), and can delete an organisation, including an ownerless one managed tenancy created. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/api/routes/admin.ts | 48 ++++- apps/web/src/api/services/admin.ts | 63 +++++- apps/web/src/ui/hooks/useAdminTenants.ts | 26 +++ apps/web/src/ui/pages/admin/TenantDetail.tsx | 183 +++++++++++++++++- apps/web/tests/api/admin.test.ts | 71 ++++++- apps/web/tests/api/auth-oidc.test.ts | 13 ++ .../tests/ui/admin-access-requests.test.tsx | 1 + docs/CONCEPTS.md | 7 + packages/shared/src/admin.ts | 13 ++ packages/shared/src/errors.ts | 2 + 10 files changed, 421 insertions(+), 6 deletions(-) diff --git a/apps/web/src/api/routes/admin.ts b/apps/web/src/api/routes/admin.ts index 0a83b09..dd6f221 100644 --- a/apps/web/src/api/routes/admin.ts +++ b/apps/web/src/api/routes/admin.ts @@ -12,6 +12,7 @@ import { adminUserListQuerySchema, blockUserRequestSchema, setGlobalAdminRequestSchema, + setTenantExternalOrgRequestSchema, suspendTenantRequestSchema, } from '@rocketpod/shared/admin' import { @@ -20,7 +21,9 @@ import { updateFeatureFlagRequestSchema, } from '@rocketpod/shared/features' import type { FeatureName } from '@rocketpod/shared/permissions' +import { deleteTenantRequestSchema } from '@rocketpod/shared/tenants' import { resolveCookieAuth } from '../middleware/auth' +import { operationLock } from '../middleware/rate-limit' import { decideAccessRequest, enterSupport, @@ -31,6 +34,7 @@ import { listAdminTenants, listAdminUsers, setGlobalAdmin, + setTenantExternalOrg, setTenantSuspended, setUserBlocked, } from '../services/admin' @@ -43,7 +47,9 @@ import { updateFeatureFlag, } from '../services/features' import { nudge, realtimeEvent } from '../services/realtime' +import { deleteTenant, getTenant } from '../services/tenants' import { + BadRequestError, ForbiddenError, NotFoundError, UnauthorizedError, @@ -93,8 +99,46 @@ adminRouter.get('/tenants', validate('query', adminTenantListQuerySchema), async }) adminRouter.get('/tenants/:id', async c => { - const { db, user } = withAuth(c) - return c.json(await getAdminTenant(db, uuidParam(c, 'id'), user.id)) + const { db, cfg, user } = withAuth(c) + return c.json(await getAdminTenant(db, cfg, uuidParam(c, 'id'), user.id)) +}) + +adminRouter.put( + '/tenants/:id/external-org', + validate('json', setTenantExternalOrgRequestSchema), + async c => { + const { db, user } = withAuth(c) + const { externalOrgId } = c.req.valid('json') + const row = await setTenantExternalOrg(db, uuidParam(c, 'id'), externalOrgId, user) + return c.json({ id: row.id, externalOrgId: row.externalOrgId }) + } +) + +/** + * Delete an organisation, as its owner could (`DELETE /api/tenant`: the cascade, then + * `tenant.purge`), typing its slug to confirm. For a tenant no owner can reach — one IdP-managed + * tenancy created, which never has an owner — this is the only way to remove it. Its activity log + * goes with it, so the deletion is logged here. + */ +adminRouter.delete('/tenants/:id', validate('json', deleteTenantRequestSchema), async c => { + const { db, cfg, logger, user, realtime } = withAuth(c) + requireMultiTenant(cfg) + const tenantId = uuidParam(c, 'id') + const tenant = await getTenant(db, tenantId) + if (c.req.valid('json').confirm !== tenant.slug) { + throw new BadRequestError( + 'Type the organisation slug to confirm deletion', + 'confirmation_mismatch' + ) + } + await operationLock(c.get('platform').locks, `tenant:delete:${tenantId}`, () => + deleteTenant(db, tenantId, c.get('platform').jobs, realtime) + ) + logger.info( + { tenantId, tenantSlug: tenant.slug, adminUserId: user.id }, + 'organisation deleted by a global admin' + ) + return c.body(null, 204) }) adminRouter.post('/tenants/:id/suspend', validate('json', suspendTenantRequestSchema), async c => { diff --git a/apps/web/src/api/services/admin.ts b/apps/web/src/api/services/admin.ts index fb78eed..3c88ba0 100644 --- a/apps/web/src/api/services/admin.ts +++ b/apps/web/src/api/services/admin.ts @@ -17,9 +17,10 @@ import type { AdminUserListItem, AdminUserListQuery, } from '@rocketpod/shared/admin' +import { ERROR_CODES } from '@rocketpod/shared/errors' import { NON_MEMBER_ROLES } from '@rocketpod/shared/tenants' import { and, asc, desc, eq, ilike, inArray, isNotNull, or, sql } from 'drizzle-orm' -import type { AppConfig } from '../../config' +import { type AppConfig, isIdpManaged } from '../../config' import type { Database } from '../../db/client' import { accessRequests, @@ -31,7 +32,7 @@ import { users, } from '../../db/schema' import { deleteUserSessions, updateSelectedTenant } from '../auth/sessions' -import { ConflictError, NotFoundError } from '../utils/core/errors' +import { ConflictError, isUniqueViolation, NotFoundError } from '../utils/core/errors' import type { Logger } from '../utils/core/logger' import { createTenantForUser } from '../utils/db/tenant-helpers' import { asCount, pageWindow } from '../utils/routes/pagination' @@ -222,6 +223,7 @@ function toAdminTenant(row: typeof tenants.$inferSelect, memberCount: number): A seedDataCreated: row.seedDataCreated, lastAccessedAt: row.lastAccessedAt, createdAt: row.createdAt, + externalOrgId: row.externalOrgId, } } @@ -251,6 +253,7 @@ export async function listAdminTenants(db: Database, query: AdminTenantListQuery export async function getAdminTenant( db: Database, + cfg: AppConfig, tenantId: string, adminUserId: string ): Promise { @@ -278,7 +281,63 @@ export async function getAdminTenant( ...toAdminTenant(row.tenant, Number(row.memberCount)), members, supportAccess: members.some(m => m.userId === adminUserId && m.role === 'support'), + idpManaged: isIdpManaged(cfg), + } +} + +/** + * Link a tenant to the OIDC issuer's org, or unlink it (null). Linking is how a tenant made in the + * app before IdP-managed tenancy (or by hand) becomes the one that org's sign-ins reconcile into; + * left unlinked, the first sign-in with that org creates a second tenant. The next sign-in adopts + * its `manual` admin and member rows; unlinking leaves the `idp` rows for the next sign-in to + * remove. 409 `external_org_taken` when another tenant holds the org. + */ +export async function setTenantExternalOrg( + db: Database, + tenantId: string, + externalOrgId: string | null, + admin: User +) { + const holder = externalOrgId + ? await db.query.tenants.findFirst({ where: eq(tenants.externalOrgId, externalOrgId) }) + : undefined + if (holder && holder.id !== tenantId) throw externalOrgTaken(holder) + const before = await db.query.tenants.findFirst({ where: eq(tenants.id, tenantId) }) + if (!before) throw new NotFoundError('Organisation not found') + if (before.externalOrgId === externalOrgId) return before + let row: typeof tenants.$inferSelect | undefined + try { + ;[row] = await db + .update(tenants) + .set({ externalOrgId }) + .where(eq(tenants.id, tenantId)) + .returning() + } catch (err) { + if (!isUniqueViolation(err) || !externalOrgId) throw err + const taken = await db.query.tenants.findFirst({ + where: eq(tenants.externalOrgId, externalOrgId), + }) + throw externalOrgTaken(taken) } + if (!row) throw new NotFoundError('Organisation not found') + await recordActivity(db, { + tenantId, + userId: admin.id, + type: externalOrgId ? 'tenant.idp_linked' : 'tenant.idp_unlinked', + subjectType: 'Tenant', + subjectId: tenantId, + metadata: { from: before.externalOrgId, to: externalOrgId }, + }) + return row +} + +function externalOrgTaken(holder: { name: string; slug: string } | undefined) { + return new ConflictError( + holder + ? `${holder.name} (@${holder.slug}) is already linked to that org; unlink it first` + : 'Another organisation is already linked to that org', + ERROR_CODES.externalOrgTaken + ) } export async function setTenantSuspended( diff --git a/apps/web/src/ui/hooks/useAdminTenants.ts b/apps/web/src/ui/hooks/useAdminTenants.ts index 72688d9..d6b6518 100644 --- a/apps/web/src/ui/hooks/useAdminTenants.ts +++ b/apps/web/src/ui/hooks/useAdminTenants.ts @@ -7,6 +7,7 @@ import { adminTenantDetailSchema, adminTenantListItemSchema, + type SetTenantExternalOrgRequest, type SuspendTenantRequest, } from '@rocketpod/shared/admin' import { sessionResponseSchema } from '@rocketpod/shared/auth' @@ -76,6 +77,31 @@ export function useSuspendTenant(id: string) { }) } +export function useAdminDeleteTenant(id: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (confirm: string) => + api.delete(`/api/admin/tenants/${id}`, { confirm }, { showErrorToast: true }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.admin.tenants.all }) + navigateTo('/admin/tenants', { replace: true }) + }, + }) +} + +export function useSetTenantExternalOrg(id: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (externalOrgId: string | null) => + api.put( + `/api/admin/tenants/${id}/external-org`, + { externalOrgId } satisfies SetTenantExternalOrgRequest, + { showErrorToast: true } + ), + onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.admin.tenants.all }), + }) +} + export function useEnterSupport(id: string) { const { applySession, selectTenant } = useAuth() return useMutation({ diff --git a/apps/web/src/ui/pages/admin/TenantDetail.tsx b/apps/web/src/ui/pages/admin/TenantDetail.tsx index 79efeb5..8f80548 100644 --- a/apps/web/src/ui/pages/admin/TenantDetail.tsx +++ b/apps/web/src/ui/pages/admin/TenantDetail.tsx @@ -1,22 +1,32 @@ /** * Admin → one organisation: members, suspend/reinstate, and "enter as support" — which * adds a real `support` membership its owners can see in Settings → People, then switches in. + * Under IdP-managed tenancy, also the issuer org it mirrors: linking a tenant made by hand is what + * stops that org's first sign-in creating a second one. And deleting it, which an owner can do from + * Settings but a tenant with no owner (any IdP-managed tenancy creates) needs an admin for. */ -import { ArrowLeftIcon, BuildingOffice2Icon } from '@heroicons/react/24/outline' +import { + ArrowLeftIcon, + BuildingOffice2Icon, + ExclamationTriangleIcon, +} from '@heroicons/react/24/outline' import { useState } from 'react' import { Link, useParams } from 'react-router-dom' import { RoleBadge } from '@/ui/components/RoleBadge' import { ConfirmModal, EmptyState, + Modal, PageHeader, SectionPanel, SectionPanelSkeleton, } from '@/ui/components/shared' import { + useAdminDeleteTenant, useAdminTenant, useEnterSupport, useLeaveSupport, + useSetTenantExternalOrg, useSuspendTenant, } from '@/ui/hooks/useAdminTenants' import { useAuth } from '@/ui/hooks/useAuth' @@ -92,6 +102,10 @@ export default function TenantDetail() { } /> + {(tenant.idpManaged || tenant.externalOrgId) && ( + + )} + {tenant.members.length === 0 ? ( @@ -130,6 +144,15 @@ export default function TenantDetail() { )} + {tenancyMode === 'multi' && ( + + )} + ) } + +/** The issuer org this tenant mirrors: shown, linked, changed or unlinked. */ +function IssuerOrgPanel({ + tenantId, + externalOrgId, +}: { + tenantId: string + externalOrgId: string | null +}) { + const link = useSetTenantExternalOrg(tenantId) + const [draft, setDraft] = useState(externalOrgId ?? '') + const [confirmUnlink, setConfirmUnlink] = useState(false) + const value = draft.trim() + const changed = value.length > 0 && value !== externalOrgId + + return ( + +

+ Sign-ins whose org claim names this id are reconciled into this organisation. Link an + organisation made here by hand before its people sign in, or their first sign-in creates a + second one. An id is linked to one organisation at a time: unlink the other first. +

+
{ + e.preventDefault() + if (changed) link.mutate(value) + }} + > + setDraft(e.target.value)} + /> + + {externalOrgId && ( + + )} +
+ setConfirmUnlink(false)} + onConfirm={() => + link.mutate(null, { + onSuccess: () => { + setConfirmUnlink(false) + setDraft('') + }, + }) + } + /> +
+ ) +} + +/** Delete the organisation for good, typing its slug to confirm. */ +function DeleteTenantPanel({ + tenantId, + name, + slug, + linked, +}: { + tenantId: string + name: string + slug: string + linked: boolean +}) { + const remove = useAdminDeleteTenant(tenantId) + const [open, setOpen] = useState(false) + const [confirm, setConfirm] = useState('') + const matches = confirm.trim() === slug + + return ( + Danger zone} + description="Deleting removes every member, invitation, key, record and stored file. There is no undo." + > +
+

Delete {name} permanently.

+ +
+ setOpen(false)} + title={`Delete ${name}?`} + actions={ + <> + + + + } + > +
+ + + This cannot be undone. Every member loses access immediately. + {linked && + ' It is linked to an identity provider org: the next sign-in with that org creates a new organisation unless you link another one to it.'} + +
+ + setConfirm(e.target.value)} + autoComplete="off" + /> +
+
+ ) +} diff --git a/apps/web/tests/api/admin.test.ts b/apps/web/tests/api/admin.test.ts index c6945ff..e23efce 100644 --- a/apps/web/tests/api/admin.test.ts +++ b/apps/web/tests/api/admin.test.ts @@ -8,7 +8,14 @@ import { describe, expect, it } from 'vitest' import { setGlobalAdmin } from '@/api/services/admin' import { ensureAccessRequest } from '@/api/services/auth' import type { Database } from '@/db/client' -import { notifications, tenants, tenantUsers, userSessions, users } from '@/db/schema' +import { + activityEvents, + notifications, + tenants, + tenantUsers, + userSessions, + users, +} from '@/db/schema' import { createTestGlobalAdmin, createTestSession, @@ -196,6 +203,68 @@ describe('tenants', () => { expect((await request('/api/members', { headers: cookie })).status).toBe(200) }) + it('links a tenant to an issuer org, refuses an org linked elsewhere, and unlinks', async () => { + const a = await admin() + const made = await createTestTenant(db) + const other = await createTestTenant(db) + const orgId = `org-${uniqueId()}` + const put = (id: string, externalOrgId: string | null) => + request( + `/api/admin/tenants/${id}/external-org`, + { method: 'PUT', headers: a.cookie }, + { json: { externalOrgId } } + ) + const linked = await put(made.id, orgId) + expect(linked.status).toBe(200) + expect(await json(linked)).toEqual({ id: made.id, externalOrgId: orgId }) + const detail = await json<{ externalOrgId: string | null; idpManaged: boolean }>( + await request(`/api/admin/tenants/${made.id}`, { headers: a.cookie }) + ) + expect(detail).toMatchObject({ externalOrgId: orgId, idpManaged: false }) + + const taken = await put(other.id, orgId) + expect(taken.status).toBe(409) + expect(await json(taken)).toMatchObject({ code: 'external_org_taken' }) + + expect((await put(made.id, null)).status).toBe(200) + expect((await put(other.id, orgId)).status).toBe(200) + const [row] = await db.select().from(tenants).where(eq(tenants.id, made.id)) + expect(row?.externalOrgId).toBeNull() + const types = await db + .select({ type: activityEvents.type }) + .from(activityEvents) + .where(eq(activityEvents.tenantId, made.id)) + expect(types.map(t => t.type).sort()).toEqual(['tenant.idp_linked', 'tenant.idp_unlinked']) + }) + + it('deletes an organisation with no owner once its slug is typed', async () => { + const a = await admin() + const tenant = await createTestTenant(db) + const del = (confirm: string) => + request( + `/api/admin/tenants/${tenant.id}`, + { method: 'DELETE', headers: a.cookie }, + { json: { confirm } } + ) + const wrong = await del('not-the-slug') + expect(wrong.status).toBe(400) + expect(await json(wrong)).toMatchObject({ code: 'confirmation_mismatch' }) + expect((await del(tenant.slug)).status).toBe(204) + expect(await db.select().from(tenants).where(eq(tenants.id, tenant.id))).toEqual([]) + expect((await del(tenant.slug)).status).toBe(404) + }) + + it('linking is for global admins only', async () => { + const { user, tenant } = await createTestTenantWithUser(db, 'owner') + const cookie = sessionCookieHeader(await createTestSession(db, user.id, tenant.id)) + const res = await request( + `/api/admin/tenants/${tenant.id}/external-org`, + { method: 'PUT', headers: cookie }, + { json: { externalOrgId: 'org-x' } } + ) + expect(res.status).toBe(403) + }) + it('support enter mints a support membership + selects it; leave removes it', async () => { const a = await admin() const { tenant } = await createTestTenantWithUser(db, 'owner') diff --git a/apps/web/tests/api/auth-oidc.test.ts b/apps/web/tests/api/auth-oidc.test.ts index 17e3b95..2de1ca0 100644 --- a/apps/web/tests/api/auth-oidc.test.ts +++ b/apps/web/tests/api/auth-oidc.test.ts @@ -11,6 +11,7 @@ import { exportJWK, generateKeyPair, type JWTPayload, SignJWT } from 'jose' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { OAUTH_STATE_COOKIE_NAME, SESSION_COOKIE_NAME } from '@/api/auth/cookies' import { resetOidcCaches } from '@/api/auth/providers/oidc' +import { setTenantExternalOrg } from '@/api/services/admin' import { accessRequests, oauthProviders, @@ -738,6 +739,18 @@ describe('OIDC_MANAGED_TENANCY', () => { expect(tenant?.slug).toBe(`${org.slug}-2`) }) + it('a tenant made by hand and linked by an admin is the one the org signs in to', async () => { + const org = newOrg() + const { user: owner, tenant: made } = await createTestTenantWithUser(db, 'owner') + await setTenantExternalOrg(db, made.id, org.id, owner) + const email = newEmail() + managedToken(email, [org]) + const userId = await sessionUserId(await signIn(managedEnv()), managedEnv()) + expect((await tenantFor(org))?.id).toBe(made.id) + expect(await membership(made.id, userId)).toMatchObject({ role: 'member', source: 'idp' }) + expect(await membership(made.id, owner.id)).toMatchObject({ role: 'owner' }) + }) + it('an org gone from the claims loses its idp membership; manual ones stay; the session is unpinned', async () => { const email = newEmail() const keep = newOrg() diff --git a/apps/web/tests/ui/admin-access-requests.test.tsx b/apps/web/tests/ui/admin-access-requests.test.tsx index 500ee5f..25168bf 100644 --- a/apps/web/tests/ui/admin-access-requests.test.tsx +++ b/apps/web/tests/ui/admin-access-requests.test.tsx @@ -31,6 +31,7 @@ const org = (id: string, name: string, slug: string) => ({ status: 'active', memberCount: 3, seedDataCreated: false, + externalOrgId: null, lastAccessedAt: null, createdAt: '2025-01-01T00:00:00Z', }) diff --git a/docs/CONCEPTS.md b/docs/CONCEPTS.md index 2121668..fe37205 100644 --- a/docs/CONCEPTS.md +++ b/docs/CONCEPTS.md @@ -70,6 +70,13 @@ suites on vitest 4, §9 — never part of the gate). the issuer, and `tenant_users.source` (`manual | idp`) says who manages a membership. The sign-in reconcile creates a tenant per org in the claims, keeps its name in step, and adds or removes the person's `idp` rows; identity events change them between sign-ins. + **A tenant made in the app is never matched to an org by slug or name**, since any user may + create a tenant and could take a client's slug first. A global admin links it instead (Admin → + the organisation → Identity provider org; `PUT /api/admin/tenants/:id/external-org`, 409 + `external_org_taken` when another tenant holds the org). Link before the org's people sign in, + or the first sign-in creates a second tenant (slug `-2`). A global admin can delete that one + (`DELETE /api/admin/tenants/:id`, the owner's delete with its slug confirmation), which frees + the org for the link. `idp_synced_at` on both tables records when the issuer last spoke about the row, so a stale event is skipped. **A tenant created this way has no owner — decided:** managed tenancy never writes `owner` (or `support`), so the last-owner guard only ever counts owners a person chose, and diff --git a/packages/shared/src/admin.ts b/packages/shared/src/admin.ts index cfbaa8f..1c2338c 100644 --- a/packages/shared/src/admin.ts +++ b/packages/shared/src/admin.ts @@ -18,6 +18,8 @@ export const adminTenantListItemSchema = z.object({ seedDataCreated: z.boolean(), lastAccessedAt: z.coerce.date().nullable(), createdAt: z.coerce.date(), + /** The OIDC issuer's org this tenant mirrors (`tenants.external_org_id`); null: none. */ + externalOrgId: z.string().nullable(), }) export type AdminTenantListItem = z.infer @@ -41,9 +43,20 @@ export const adminTenantDetailSchema = adminTenantListItemSchema.extend({ members: z.array(adminTenantMemberSchema), /** Whether the calling admin currently holds a `support` membership here. */ supportAccess: z.boolean(), + /** Whether tenants follow the OIDC issuer's orgs (`OIDC_MANAGED_TENANCY`). */ + idpManaged: z.boolean(), }) export type AdminTenantDetail = z.infer +/** + * Link a tenant to the OIDC issuer's org (or unlink it with null), so sign-ins with that org land + * in this tenant rather than creating one. An org id is linked to at most one tenant. + */ +export const setTenantExternalOrgRequestSchema = z.object({ + externalOrgId: z.string().trim().min(1).max(200).nullable(), +}) +export type SetTenantExternalOrgRequest = z.infer + export const suspendTenantRequestSchema = z.object({ suspended: z.boolean(), }) diff --git a/packages/shared/src/errors.ts b/packages/shared/src/errors.ts index aad1528..16ee441 100644 --- a/packages/shared/src/errors.ts +++ b/packages/shared/src/errors.ts @@ -72,6 +72,8 @@ export const ERROR_CODES = { * the app may neither edit nor remove it. Change it at the issuer. */ idpManaged: 'idp_managed', + /** 409: another tenant is already linked to that OIDC issuer org (`tenants.external_org_id`). */ + externalOrgTaken: 'external_org_taken', /** 416: the `Range` asked for starts past the end of the object (`Content-Range: bytes *\/n`). */ rangeNotSatisfiable: 'range_not_satisfiable', } as const From 2d03fb6e535e1be4e46f3cb024cdb254f5f1153b Mon Sep 17 00:00:00 2001 From: Clifton Cunningham Date: Mon, 2 Nov 2026 16:33:39 +0000 Subject: [PATCH 2/2] Upgrade notes: admin linking and deletion Co-Authored-By: Claude Opus 5.5 --- docs/upgrades/unreleased.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/upgrades/unreleased.md b/docs/upgrades/unreleased.md index e3b5640..3e6632b 100644 --- a/docs/upgrades/unreleased.md +++ b/docs/upgrades/unreleased.md @@ -33,6 +33,13 @@ IdP-managed tenancy: with `OIDC_MANAGED_TENANCY=true` the OIDC issuer decides wh - `auth/providers/oidc.ts`: the profile carries the verified payload (`idTokenClaims`); `oidcEndSessionUrl` adds `logout_uri` beside `post_logout_redirect_uri`. - UI: `Login.tsx` copy for `no_access`; `People.tsx` shows a "Managed" badge and no controls on `idp` rows. - Decision: a tenant managed tenancy creates has no owner (`docs/CONCEPTS.md` §1). +- Admin: a global admin links a tenant to an issuer org, or unlinks it + (`PUT /api/admin/tenants/:id/external-org`, `setTenantExternalOrg`, 409 `external_org_taken`, + activity `tenant.idp_linked` / `tenant.idp_unlinked`), since a tenant made in the app is never + matched by slug; and deletes an organisation (`DELETE /api/admin/tenants/:id`, the owner's + `deleteTenant` with its slug confirmation), the only way to remove an ownerless one. The admin + tenant DTOs carry `externalOrgId`, the detail `idpManaged`; `TenantDetail.tsx` gains the + Identity provider org panel and a danger zone. ## How to apply @@ -110,7 +117,16 @@ IdP-managed tenancy: with `OIDC_MANAGED_TENANCY=true` the OIDC issuer decides wh 18. Port the docs: `docs/CONCEPTS.md` §1 (tenants managed by the OIDC issuer) and §2 (IdP-managed tenancy, identity events, gaps), `SETUP.md` 2.3c, the config line in `CLAUDE.md` and the `identity-events.ts` line in `apps/web/src/api/routes/CLAUDE.md`. -19. To turn it on for a deployment, follow `SETUP.md` 2.3c. +19. To turn it on for a deployment, follow `SETUP.md` 2.3c. Before an org's people first sign in, + link any tenant made for it by hand (Admin → the organisation → Identity provider org). +20. Admin linking and deletion: add `externalOrgId` to `adminTenantListItemSchema`, `idpManaged` + to `adminTenantDetailSchema` and `setTenantExternalOrgRequestSchema` in + `packages/shared/src/admin.ts`, and `externalOrgTaken: 'external_org_taken'` to `ERROR_CODES`. + Copy `setTenantExternalOrg` (and `externalOrgTaken`) into `services/admin.ts`, pass `cfg` to + `getAdminTenant`, and add the `PUT …/external-org` and `DELETE /tenants/:id` routes to + `routes/admin.ts`. Copy `useSetTenantExternalOrg` and `useAdminDeleteTenant` into + `useAdminTenants.ts` and the two panels into `pages/admin/TenantDetail.tsx`. Add + `externalOrgId: null` to any admin tenant fixture in `tests/ui/`. ## Conflicts to expect @@ -129,3 +145,4 @@ IdP-managed tenancy: with `OIDC_MANAGED_TENANCY=true` the OIDC issuer decides wh 5. `POST /auth/logout` with an issuer that advertises `end_session_endpoint` returns an `endSessionUrl` carrying both `post_logout_redirect_uri` and `logout_uri`. 6. A managed sign-in for a person with one org lands in that org's tenant, `/auth/session` shows `accessRequest: null`, `PATCH /api/members/` answers 409 `idp_managed`, and their `user_sessions` row has `max_expires_at` seven days out. 7. With `IDENTITY_WEBHOOK_SECRET` set, an unsigned `POST /api/identity/events` answers 401, and a signed `membership.removed` for that person and org answers 204 and removes the membership. +8. A global admin links a hand-made tenant to an org id; the next sign-in with that org lands in it, not in a `-2` copy. `DELETE /api/admin/tenants/` with the slug answers 204.