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