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.
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 ─────────────────────────────────────────────────────
const dangerAction = ref<'suspend' | 'resume' | 'delete' | 'purge' | null>(null)
const dangerBusy = ref(false)
@@ -145,6 +164,7 @@ async function reconcile() {
<template #actions>
<Badge :tone="STATUS_TONE[tenant.status]" dot>{{ tenant.status }}</Badge>
<Badge tone="neutral">{{ tenant.plan }}</Badge>
<Badge v-if="tenant.isPlatformTenant" tone="warn">platform tenant</Badge>
<UiButton variant="secondary" @click="inviteAdminOpen = true">
<template #leading><UiIcon name="plus" :size="13" /></template>
Invite admin
@@ -320,6 +340,21 @@ async function reconcile() {
<!-- DANGER (real) -->
<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>
<h2 class="danger">Suspend tenant</h2>
<p>
+2
View File
@@ -13,6 +13,8 @@ export interface Tenant {
plan: TenantPlan
domains: string[]
partnerId?: string
// The company's own tenant (single holder) — may claim the dezky.eu apex.
isPlatformTenant?: boolean
authentikGroupId?: string
ocisSpaceId?: string
stalwartDomain?: string
@@ -19,11 +19,10 @@ data:
STALWART_ADMIN_USER: "admin@dezky.eu"
STALWART_PROVISIONING_ENABLED: "true"
# Base for per-tenant service mail domains ({slug}.dezky.eu) AND the
# reserved namespace for customer domains: only the company's own tenant
# (PLATFORM_TENANT_SLUG) may claim the apex; nothing under it can be added
# as a customer domain.
# reserved namespace for customer domains: only the tenant flagged
# isPlatformTenant (operator-set on the tenant page) may claim the apex;
# nothing under it can be added as a customer domain.
PLATFORM_TENANT_DOMAIN: "dezky.eu"
PLATFORM_TENANT_SLUG: "dezky-aps"
# No auto-seeded tenants in production — the dezky company tenant is
# created and owned through the operator like any other.
SEED_ENABLED: "false"
@@ -21,7 +21,7 @@ spec:
annotations:
# Bump to force a rolling restart when only the ConfigMap changed —
# pods read it as env, which is only resolved at container start.
dezky.eu/config-rev: "4"
dezky.eu/config-rev: "5"
spec:
containers:
- name: platform-api
@@ -49,7 +49,7 @@ export class DomainsController {
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
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()
@@ -46,7 +46,11 @@ function makeService(opts: { takenByOtherTenant?: boolean } = {}) {
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'
@@ -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()
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 () => {
const { svc } = makeService()
await expect(svc.add(tenant('acme'), `foo.${BASE}`, ACTOR)).rejects.toThrow(/reserved/)
// Even the dezky tenant can't claim service/infra hostnames.
await expect(svc.add(tenant('dezky'), `mail.${BASE}`, ACTOR)).rejects.toThrow(/reserved/)
// Even the platform tenant can't claim service/infra hostnames.
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 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(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 {
_id: Types.ObjectId
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()
@@ -80,13 +83,13 @@ export class DomainsService {
}
// 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
// own tenant (PLATFORM_TENANT_SLUG, prod: dezky-aps) may claim it. Everything
// under it is off limits for everyone: that's where per-tenant service
// domains ({slug}.dezky.eu) and the infrastructure hosts (auth/api/app/mail/…) live.
// e.g. dezky.eu) doubles as dezky's employee mail domain — only the tenant
// carrying the isPlatformTenant flag (operator-set, single holder) may
// claim it. Everything under it is off limits for everyone: that's where
// 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 platformSlug = (process.env.PLATFORM_TENANT_SLUG || 'dezky').toLowerCase()
if (domain === platformBase && tenant.slug !== platformSlug) {
if (domain === platformBase && !tenant.isPlatformTenant) {
throw new ConflictException(`Domain "${domain}" is reserved`)
}
if (domain.endsWith(`.${platformBase}`)) {
@@ -51,6 +51,15 @@ export class Tenant {
@Prop({ type: Types.ObjectId, ref: 'Partner', index: true, sparse: true })
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)
@Prop({ index: true, sparse: true })
authentikGroupId?: string
@@ -84,6 +84,7 @@ export class SeedService implements OnApplicationBootstrap {
// Display field only — the real mail domain is added through the
// Domains flow. Follows the environment (dezky.local dev, dezky.eu prod).
domains: [process.env.PLATFORM_TENANT_DOMAIN || 'dezky.local'],
isPlatformTenant: true,
billingInfo: { companyName: 'Dezky', country: 'DK' },
},
},
@@ -1,5 +1,6 @@
import {
IsArray,
IsBoolean,
IsEnum,
IsInt,
IsMongoId,
@@ -30,4 +31,9 @@ export class UpdateTenantDto {
// Attach / move tenant under a partner (or pass null to detach).
@IsOptional() @IsMongoId()
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))) {
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))
}
@@ -229,6 +229,15 @@ export class TenantsService {
if (dto.partnerId === null) unset.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> = {}
if (Object.keys(set).length) update.$set = set
if (Object.keys(unset).length) update.$unset = unset