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
@@ -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