Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions apps/web/src/api/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
adminUserListQuerySchema,
blockUserRequestSchema,
setGlobalAdminRequestSchema,
setTenantExternalOrgRequestSchema,
suspendTenantRequestSchema,
} from '@rocketpod/shared/admin'
import {
Expand All @@ -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,
Expand All @@ -31,6 +34,7 @@ import {
listAdminTenants,
listAdminUsers,
setGlobalAdmin,
setTenantExternalOrg,
setTenantSuspended,
setUserBlocked,
} from '../services/admin'
Expand All @@ -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,
Expand Down Expand Up @@ -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 => {
Expand Down
63 changes: 61 additions & 2 deletions apps/web/src/api/services/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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<AdminTenantDetail> {
Expand Down Expand Up @@ -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(
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/ui/hooks/useAdminTenants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import {
adminTenantDetailSchema,
adminTenantListItemSchema,
type SetTenantExternalOrgRequest,
type SuspendTenantRequest,
} from '@rocketpod/shared/admin'
import { sessionResponseSchema } from '@rocketpod/shared/auth'
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading