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
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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user