diff --git a/infrastructure/production/fleet/apps/platform-api-config.yaml b/infrastructure/production/fleet/apps/platform-api-config.yaml index ef7a954..59f466f 100644 --- a/infrastructure/production/fleet/apps/platform-api-config.yaml +++ b/infrastructure/production/fleet/apps/platform-api-config.yaml @@ -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. diff --git a/services/platform-api/src/domains/domains.service.guards.spec.ts b/services/platform-api/src/domains/domains.service.guards.spec.ts new file mode 100644 index 0000000..79e5aa6 --- /dev/null +++ b/services/platform-api/src/domains/domains.service.guards.spec.ts @@ -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[] = [] + const domainModel = { + findOne: () => ({ exec: async () => null }), + exists: async () => (opts.takenByOtherTenant ? { _id: new Types.ObjectId() } : null), + countDocuments: async () => 0, + create: async (doc: Record) => { + 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) + }) +}) diff --git a/services/platform-api/src/domains/domains.service.ts b/services/platform-api/src/domains/domains.service.ts index 5084396..03c5a6f 100644 --- a/services/platform-api/src/domains/domains.service.ts +++ b/services/platform-api/src/domains/domains.service.ts @@ -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')}` diff --git a/services/platform-api/src/seed/seed.service.ts b/services/platform-api/src/seed/seed.service.ts index ce6b3ce..3f71f83 100644 --- a/services/platform-api/src/seed/seed.service.ts +++ b/services/platform-api/src/seed/seed.service.ts @@ -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' }, }, },