feat(tenants): isPlatformTenant flag replaces PLATFORM_TENANT_SLUG
ci / changes (push) Successful in 4s
ci / tc_portal (push) Has been skipped
ci / tc_booking (push) Has been skipped
ci / tc_website (push) Has been skipped
ci / tc_platform_api (push) Successful in 22s
ci / tc_operator (push) Successful in 22s
ci / build_portal (push) Has been skipped
ci / build_booking (push) Has been skipped
ci / build_operator (push) Successful in 30s
ci / test_platform_api (push) Successful in 34s
ci / build_platform_api (push) Successful in 15s
ci / deploy (push) Successful in 42s

Identifying the company tenant by slug in env was fragile — every
purge/recreate changed the slug (or id) and the apex guard chased reality
through three config flips in one day. The identity now lives ON the
tenant document: isPlatformTenant, operator-set from the tenant page
(single holder — setting it clears the flag everywhere else), guarded so
tenant admins can't set it on themselves through the shared PATCH route.
The dezky.eu apex guard reads the flag; PLATFORM_TENANT_SLUG is gone.
Dev seed flags its seeded tenant. config-rev 5 rolls platform-api.
This commit is contained in:
Ronni Baslund
2026-06-10 21:47:27 +02:00
parent eefe1b3ec3
commit 83214eb379
12 changed files with 93 additions and 31 deletions
+35
View File
@@ -85,6 +85,25 @@ type IntegrationKey = (typeof INTEGRATIONS)[number]
// runs; reusable whenever the first invite failed or someone new takes over. // runs; reusable whenever the first invite failed or someone new takes over.
const inviteAdminOpen = ref(false) const inviteAdminOpen = ref(false)
// Platform-tenant flag toggle. Single holder — the API clears it from every
// other tenant when set. Grants exactly one thing: claiming the dezky.eu
// apex as this tenant's customer mail domain.
const flagBusy = ref(false)
const flagError = ref<string | null>(null)
async function setPlatformTenant(value: boolean) {
flagBusy.value = true
flagError.value = null
try {
await $fetch(`/api/tenants/${slug.value}`, { method: 'PATCH', body: { isPlatformTenant: value } })
await refreshTenant()
} catch (err: unknown) {
const e = err as { data?: { data?: { message?: string }; message?: string } }
flagError.value = e.data?.data?.message ?? e.data?.message ?? String(err)
} finally {
flagBusy.value = false
}
}
// ── Danger-zone state ───────────────────────────────────────────────────── // ── Danger-zone state ─────────────────────────────────────────────────────
const dangerAction = ref<'suspend' | 'resume' | 'delete' | 'purge' | null>(null) const dangerAction = ref<'suspend' | 'resume' | 'delete' | 'purge' | null>(null)
const dangerBusy = ref(false) const dangerBusy = ref(false)
@@ -145,6 +164,7 @@ async function reconcile() {
<template #actions> <template #actions>
<Badge :tone="STATUS_TONE[tenant.status]" dot>{{ tenant.status }}</Badge> <Badge :tone="STATUS_TONE[tenant.status]" dot>{{ tenant.status }}</Badge>
<Badge tone="neutral">{{ tenant.plan }}</Badge> <Badge tone="neutral">{{ tenant.plan }}</Badge>
<Badge v-if="tenant.isPlatformTenant" tone="warn">platform tenant</Badge>
<UiButton variant="secondary" @click="inviteAdminOpen = true"> <UiButton variant="secondary" @click="inviteAdminOpen = true">
<template #leading><UiIcon name="plus" :size="13" /></template> <template #leading><UiIcon name="plus" :size="13" /></template>
Invite admin Invite admin
@@ -320,6 +340,21 @@ async function reconcile() {
<!-- DANGER (real) --> <!-- DANGER (real) -->
<div v-else-if="activeTab === 'danger'" class="grid"> <div v-else-if="activeTab === 'danger'" class="grid">
<Card>
<h2>Platform tenant</h2>
<p>
Marks this as dezky's own tenant (single holder — setting it here clears it
anywhere else). Required to claim <Mono>dezky.eu</Mono> as a customer mail
domain; everything else treats it as a normal tenant.
</p>
<UiButton
:variant="tenant.isPlatformTenant ? 'secondary' : 'primary'"
:disabled="flagBusy"
@click="setPlatformTenant(!tenant.isPlatformTenant)"
>{{ tenant.isPlatformTenant ? 'Unmark platform tenant' : 'Mark as platform tenant' }}</UiButton>
<p v-if="flagError" class="danger-err">{{ flagError }}</p>
</Card>
<Card> <Card>
<h2 class="danger">Suspend tenant</h2> <h2 class="danger">Suspend tenant</h2>
<p> <p>
+2
View File
@@ -13,6 +13,8 @@ export interface Tenant {
plan: TenantPlan plan: TenantPlan
domains: string[] domains: string[]
partnerId?: string partnerId?: string
// The company's own tenant (single holder) — may claim the dezky.eu apex.
isPlatformTenant?: boolean
authentikGroupId?: string authentikGroupId?: string
ocisSpaceId?: string ocisSpaceId?: string
stalwartDomain?: string stalwartDomain?: string
@@ -19,11 +19,10 @@ data:
STALWART_ADMIN_USER: "admin@dezky.eu" STALWART_ADMIN_USER: "admin@dezky.eu"
STALWART_PROVISIONING_ENABLED: "true" STALWART_PROVISIONING_ENABLED: "true"
# Base for per-tenant service mail domains ({slug}.dezky.eu) AND the # Base for per-tenant service mail domains ({slug}.dezky.eu) AND the
# reserved namespace for customer domains: only the company's own tenant # reserved namespace for customer domains: only the tenant flagged
# (PLATFORM_TENANT_SLUG) may claim the apex; nothing under it can be added # isPlatformTenant (operator-set on the tenant page) may claim the apex;
# as a customer domain. # nothing under it can be added as a customer domain.
PLATFORM_TENANT_DOMAIN: "dezky.eu" PLATFORM_TENANT_DOMAIN: "dezky.eu"
PLATFORM_TENANT_SLUG: "dezky-aps"
# No auto-seeded tenants in production — the dezky company tenant is # No auto-seeded tenants in production — the dezky company tenant is
# created and owned through the operator like any other. # created and owned through the operator like any other.
SEED_ENABLED: "false" SEED_ENABLED: "false"
@@ -21,7 +21,7 @@ spec:
annotations: annotations:
# Bump to force a rolling restart when only the ConfigMap changed — # Bump to force a rolling restart when only the ConfigMap changed —
# pods read it as env, which is only resolved at container start. # pods read it as env, which is only resolved at container start.
dezky.eu/config-rev: "4" dezky.eu/config-rev: "5"
spec: spec:
containers: containers:
- name: platform-api - name: platform-api
@@ -49,7 +49,7 @@ export class DomainsController {
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) { if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`) throw new ForbiddenException(`No access to tenant "${slug}"`)
} }
return { _id: tenant._id, slug: tenant.slug } return { _id: tenant._id, slug: tenant.slug, isPlatformTenant: tenant.isPlatformTenant }
} }
@Get() @Get()
@@ -46,7 +46,11 @@ function makeService(opts: { takenByOtherTenant?: boolean } = {}) {
return { svc, created } return { svc, created }
} }
const tenant = (slug: string): TenantRef => ({ _id: new Types.ObjectId(), slug }) const tenant = (slug: string, isPlatformTenant = false): TenantRef => ({
_id: new Types.ObjectId(),
slug,
isPlatformTenant,
})
const BASE = process.env.PLATFORM_TENANT_DOMAIN || 'dezky.local' const BASE = process.env.PLATFORM_TENANT_DOMAIN || 'dezky.local'
@@ -61,35 +65,24 @@ describe('DomainsService.add guards', () => {
) )
}) })
it('rejects the platform apex for any tenant other than dezky', async () => { it('rejects the platform apex for any non-platform tenant', async () => {
const { svc } = makeService() const { svc } = makeService()
await expect(svc.add(tenant('acme'), BASE, ACTOR)).rejects.toThrow(/reserved/) await expect(svc.add(tenant('acme'), BASE, ACTOR)).rejects.toThrow(/reserved/)
// Even a tenant that happens to be NAMED dezky — the flag decides, not the slug.
await expect(svc.add(tenant('dezky'), BASE, ACTOR)).rejects.toThrow(/reserved/)
}) })
it('rejects anything under the platform namespace for everyone', async () => { it('rejects anything under the platform namespace for everyone', async () => {
const { svc } = makeService() const { svc } = makeService()
await expect(svc.add(tenant('acme'), `foo.${BASE}`, ACTOR)).rejects.toThrow(/reserved/) await expect(svc.add(tenant('acme'), `foo.${BASE}`, ACTOR)).rejects.toThrow(/reserved/)
// Even the dezky tenant can't claim service/infra hostnames. // Even the platform tenant can't claim service/infra hostnames.
await expect(svc.add(tenant('dezky'), `mail.${BASE}`, ACTOR)).rejects.toThrow(/reserved/) await expect(svc.add(tenant('dezky', true), `mail.${BASE}`, ACTOR)).rejects.toThrow(/reserved/)
}) })
it('allows the platform tenant to claim the platform apex', async () => { it('allows the flagged platform tenant to claim the apex, whatever its slug', async () => {
const { svc, created } = makeService() const { svc, created } = makeService()
const view = await svc.add(tenant('dezky'), BASE, ACTOR) const view = await svc.add(tenant('dezky-aps', true), BASE, ACTOR)
expect(view.domain).toBe(BASE) expect(view.domain).toBe(BASE)
expect(created).toHaveLength(1) expect(created).toHaveLength(1)
}) })
it('respects PLATFORM_TENANT_SLUG for the apex allowance', async () => {
process.env.PLATFORM_TENANT_SLUG = 'dezky-aps'
try {
const { svc, created } = makeService()
await expect(svc.add(tenant('dezky'), BASE, ACTOR)).rejects.toThrow(/reserved/)
const view = await svc.add(tenant('dezky-aps'), BASE, ACTOR)
expect(view.domain).toBe(BASE)
expect(created).toHaveLength(1)
} finally {
delete process.env.PLATFORM_TENANT_SLUG
}
})
}) })
@@ -36,6 +36,9 @@ const CHECK_KINDS: RecordKind[] = ['ownership', 'mx', 'spf', 'dkim', 'dmarc']
export interface TenantRef { export interface TenantRef {
_id: Types.ObjectId _id: Types.ObjectId
slug: string slug: string
// Set on the company's own tenant — the only one allowed to claim the
// PLATFORM_TENANT_DOMAIN apex as a customer mail domain.
isPlatformTenant?: boolean
} }
@Injectable() @Injectable()
@@ -80,13 +83,13 @@ export class DomainsService {
} }
// The platform's own namespace is reserved. The apex (PLATFORM_TENANT_DOMAIN, // The platform's own namespace is reserved. The apex (PLATFORM_TENANT_DOMAIN,
// e.g. dezky.eu) doubles as dezky's employee mail domain — only the company's // e.g. dezky.eu) doubles as dezky's employee mail domain — only the tenant
// own tenant (PLATFORM_TENANT_SLUG, prod: dezky-aps) may claim it. Everything // carrying the isPlatformTenant flag (operator-set, single holder) may
// under it is off limits for everyone: that's where per-tenant service // claim it. Everything under it is off limits for everyone: that's where
// domains ({slug}.dezky.eu) and the infrastructure hosts (auth/api/app/mail/…) live. // per-tenant service domains ({slug}.dezky.eu) and the infrastructure
// hosts (auth/api/app/mail/…) live.
const platformBase = (process.env.PLATFORM_TENANT_DOMAIN || 'dezky.local').toLowerCase() const platformBase = (process.env.PLATFORM_TENANT_DOMAIN || 'dezky.local').toLowerCase()
const platformSlug = (process.env.PLATFORM_TENANT_SLUG || 'dezky').toLowerCase() if (domain === platformBase && !tenant.isPlatformTenant) {
if (domain === platformBase && tenant.slug !== platformSlug) {
throw new ConflictException(`Domain "${domain}" is reserved`) throw new ConflictException(`Domain "${domain}" is reserved`)
} }
if (domain.endsWith(`.${platformBase}`)) { if (domain.endsWith(`.${platformBase}`)) {
@@ -51,6 +51,15 @@ export class Tenant {
@Prop({ type: Types.ObjectId, ref: 'Partner', index: true, sparse: true }) @Prop({ type: Types.ObjectId, ref: 'Partner', index: true, sparse: true })
partnerId?: Types.ObjectId partnerId?: Types.ObjectId
// Marks the company's OWN tenant (dezky itself, dogfooding the platform).
// At most one tenant carries this — TenantsService.update clears it from
// every other tenant when it's set. Grants exactly one privilege: claiming
// the PLATFORM_TENANT_DOMAIN apex (dezky.eu) as a customer mail domain.
// Flag on the document instead of a slug/id in env so the identity
// survives purge-and-recreate without a config chase.
@Prop({ default: false, index: true })
isPlatformTenant?: boolean
// External system handles — filled in by the provisioning worker (Phase 4) // External system handles — filled in by the provisioning worker (Phase 4)
@Prop({ index: true, sparse: true }) @Prop({ index: true, sparse: true })
authentikGroupId?: string authentikGroupId?: string
@@ -84,6 +84,7 @@ export class SeedService implements OnApplicationBootstrap {
// Display field only — the real mail domain is added through the // Display field only — the real mail domain is added through the
// Domains flow. Follows the environment (dezky.local dev, dezky.eu prod). // Domains flow. Follows the environment (dezky.local dev, dezky.eu prod).
domains: [process.env.PLATFORM_TENANT_DOMAIN || 'dezky.local'], domains: [process.env.PLATFORM_TENANT_DOMAIN || 'dezky.local'],
isPlatformTenant: true,
billingInfo: { companyName: 'Dezky', country: 'DK' }, billingInfo: { companyName: 'Dezky', country: 'DK' },
}, },
}, },
@@ -1,5 +1,6 @@
import { import {
IsArray, IsArray,
IsBoolean,
IsEnum, IsEnum,
IsInt, IsInt,
IsMongoId, IsMongoId,
@@ -30,4 +31,9 @@ export class UpdateTenantDto {
// Attach / move tenant under a partner (or pass null to detach). // Attach / move tenant under a partner (or pass null to detach).
@IsOptional() @IsMongoId() @IsOptional() @IsMongoId()
partnerId?: string | null partnerId?: string | null
// Operator-only (enforced in the controller): mark this as the company's
// own tenant. Setting it true clears the flag everywhere else.
@IsOptional() @IsBoolean()
isPlatformTenant?: boolean
} }
@@ -164,6 +164,11 @@ export class TenantsController {
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) { if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`) throw new ForbiddenException(`No access to tenant "${slug}"`)
} }
// The platform-tenant flag grants the dezky.eu apex — a tenant admin must
// never be able to set it on their own tenant through this shared route.
if (dto.isPlatformTenant !== undefined && !user.platformAdmin) {
throw new ForbiddenException('Only platform admins can change the platform-tenant flag')
}
return this.tenants.update(slug, dto, auditActor(user, req)) return this.tenants.update(slug, dto, auditActor(user, req))
} }
@@ -229,6 +229,15 @@ export class TenantsService {
if (dto.partnerId === null) unset.partnerId = '' if (dto.partnerId === null) unset.partnerId = ''
else set.partnerId = new Types.ObjectId(dto.partnerId) else set.partnerId = new Types.ObjectId(dto.partnerId)
} }
if (dto.isPlatformTenant !== undefined) {
set.isPlatformTenant = dto.isPlatformTenant
// Single-holder invariant: there is one company tenant at most.
if (dto.isPlatformTenant) {
await this.tenantModel
.updateMany({ slug: { $ne: slug } }, { $set: { isPlatformTenant: false } })
.exec()
}
}
const update: Record<string, unknown> = {} const update: Record<string, unknown> = {}
if (Object.keys(set).length) update.$set = set if (Object.keys(set).length) update.$set = set
if (Object.keys(unset).length) update.$unset = unset if (Object.keys(unset).length) update.$unset = unset