feat(domains): reserve the platform namespace + one workspace per domain
ci / changes (push) Successful in 4s
ci / tc_portal (push) Has been skipped
ci / build_operator (push) Has been skipped
ci / test_platform_api (push) Successful in 34s
ci / tc_booking (push) Has been skipped
ci / tc_operator (push) Has been skipped
ci / tc_website (push) Has been skipped
ci / tc_platform_api (push) Successful in 23s
ci / build_portal (push) Has been skipped
ci / build_booking (push) Has been skipped
ci / build_platform_api (push) Successful in 18s
ci / deploy (push) Successful in 41s

dezky.eu doubles as the platform's infrastructure domain AND the company's
own employee mail domain (added to the dezky tenant via the normal Domains
flow). Guard rails in DomainsService.add:
- a domain already used by ANY other workspace is rejected — Stalwart's
  idempotent ensureDomain would otherwise silently share one mail domain
  (and its mailboxes) between tenants
- the PLATFORM_TENANT_DOMAIN apex is claimable only by the dezky tenant;
  everything under it (per-tenant service domains, auth/api/mail/* infra
  hosts) is reserved outright

Set PLATFORM_TENANT_DOMAIN=dezky.eu in the prod ConfigMap (was unset, so
prod service domains would have been {slug}.dezky.local) and align the
seeded dezky tenant's display domain with the environment.
This commit is contained in:
Ronni Baslund
2026-06-10 20:15:46 +02:00
parent 4907d0a856
commit a43a172449
4 changed files with 112 additions and 1 deletions
@@ -14,6 +14,10 @@ data:
STALWART_API_URL: "http://10.42.0.1:8080"
STALWART_ADMIN_USER: "admin"
STALWART_PROVISIONING_ENABLED: "true"
# Base for per-tenant service mail domains ({slug}.dezky.eu) AND the
# reserved namespace for customer domains: only the dezky tenant may claim
# the apex; nothing under it can be added as a customer domain.
PLATFORM_TENANT_DOMAIN: "dezky.eu"
# JWT validation for portal/operator-issued access tokens. Public Authentik
# URLs on purpose: the token `iss` claim is the public URL, and the pod can
# hairpin to it through the node's public IP.
@@ -0,0 +1,82 @@
// Guard tests for DomainsService.add(): one workspace per domain platform-wide,
// and the platform's own namespace (PLATFORM_TENANT_DOMAIN) is reserved — the
// apex only for the dezky tenant, everything under it for nobody. The service
// is constructed directly with minimal mocks; Stalwart is "not configured" so
// the happy path skips provisioning.
import { ConflictException } from '@nestjs/common'
import { Types } from 'mongoose'
import { DomainsService, type TenantRef } from './domains.service.js'
const ACTOR = { kind: 'user', id: 'test', label: 'test' } as unknown as Parameters<
DomainsService['add']
>[2]
function makeService(opts: { takenByOtherTenant?: boolean } = {}) {
const created: Record<string, unknown>[] = []
const domainModel = {
findOne: () => ({ exec: async () => null }),
exists: async () => (opts.takenByOtherTenant ? { _id: new Types.ObjectId() } : null),
countDocuments: async () => 0,
create: async (doc: Record<string, unknown>) => {
created.push(doc)
// runChecks mutates and saves the doc; give it the bits it touches.
return {
...doc,
records: doc.records ?? [],
ownershipVerified: false,
markModified: () => {},
save: async () => {},
}
},
}
const tenantModel = { updateOne: () => ({ exec: async () => ({}) }) }
const userModel = { countDocuments: () => ({ exec: async () => 0 }) }
const stalwart = { configured: false }
const dns = { check: async () => ({ observed: [], status: 'pending' }) }
const audit = { record: async () => {} }
const svc = new DomainsService(
domainModel as never,
tenantModel as never,
userModel as never,
stalwart as never,
dns as never,
audit as never,
)
return { svc, created }
}
const tenant = (slug: string): TenantRef => ({ _id: new Types.ObjectId(), slug })
const BASE = process.env.PLATFORM_TENANT_DOMAIN || 'dezky.local'
describe('DomainsService.add guards', () => {
it('rejects a domain already used by another workspace', async () => {
const { svc } = makeService({ takenByOtherTenant: true })
await expect(svc.add(tenant('acme'), 'example.com', ACTOR)).rejects.toThrow(
ConflictException,
)
await expect(svc.add(tenant('acme'), 'example.com', ACTOR)).rejects.toThrow(
/another workspace/,
)
})
it('rejects the platform apex for any tenant other than dezky', async () => {
const { svc } = makeService()
await expect(svc.add(tenant('acme'), 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/)
})
it('allows the dezky tenant to claim the platform apex', async () => {
const { svc, created } = makeService()
const view = await svc.add(tenant('dezky'), BASE, ACTOR)
expect(view.domain).toBe(BASE)
expect(created).toHaveLength(1)
})
})
@@ -69,6 +69,29 @@ export class DomainsService {
const existing = await this.domainModel.findOne({ tenantId: tenant._id, domain }).exec()
if (existing) throw new ConflictException(`Domain "${domain}" is already added`)
// One workspace per domain, platform-wide. Stalwart's ensureDomain is
// idempotent, so without this check a second tenant adding the same name
// would silently SHARE the first tenant's mail domain (and its mailboxes).
// Service-level check only — the unique index stays per-tenant — which
// leaves a tiny concurrent-add race we accept at this scale.
const takenElsewhere = await this.domainModel.exists({ domain, tenantId: { $ne: tenant._id } })
if (takenElsewhere) {
throw new ConflictException(`Domain "${domain}" is already in use by another workspace`)
}
// 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 (slug "dezky") 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()
if (domain === platformBase && tenant.slug !== 'dezky') {
throw new ConflictException(`Domain "${domain}" is reserved`)
}
if (domain.endsWith(`.${platformBase}`)) {
throw new ConflictException(`Domains under "${platformBase}" are reserved`)
}
const isPrimary = (await this.domainModel.countDocuments({ tenantId: tenant._id })) === 0
const verificationToken = `dezky-verify=${randomBytes(16).toString('hex')}`
@@ -81,7 +81,9 @@ export class SeedService implements OnApplicationBootstrap {
name: 'Dezky',
status: 'active',
plan: 'enterprise',
domains: ['dezky.local'],
// 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'],
billingInfo: { companyName: 'Dezky', country: 'DK' },
},
},