Tenants
A tenant is one customer of the platform: a slug, a name, the hosts it is served
at, and every row in every other table belongs to exactly one. That makes this
module the control plane rather than a business module: the kernel resolves a
request’s host through it before the request is about anything, and the periodic
jobs walk its list. It promises that an installation is whole or absent — a
tenant, its first host and the roles the auth module seeds are one transaction —
and that a suspended tenant’s hosts read from outside as no site at all. It
imports no other module; the modules above it reach it through hooks. The module
is modules/tenant; this page states nothing that
tree does not.
The map
features/tenant in the recordWhat it promises
Entity |
|
Events |
|
Permissions |
|
Service |
|
Routes and screens
| Method | Path | Does | Authorization | Publishes |
|---|---|---|---|---|
|
|
List the tenants |
|
— |
|
|
Create a tenant |
|
|
|
|
Read a tenant |
|
— |
|
|
Give a tenant another host |
|
|
|
|
Give a tenant its first administrator |
|
|
|
|
Suspend a tenant |
|
|
-
GET/POST /api/v1/tenant/tenantsandGET /api/v1/tenant/tenants/{id}— written by hand ininternal/handler.gorather than mounted from arest.Spec, because a tenant carries notenant_idfor the generic repository to stamp. There is noPATCHand noDELETE. Each handler opens a system transaction of its own,db.RunSystemoverdb.Detached, with the tokenapi.SystemToken()handsRegisterRoutes; the write commits whether or not the response reaches the caller. -
POST /api/v1/tenant/tenants/{id}/suspend— stops the tenant being served and drops its hosts from the resolution cache withapi.InvalidateHost;POST /api/v1/tenant/tenants/{id}/hosts— another host,primaryfalse unless asked;POST /api/v1/tenant/tenants/{id}/invite— the first administrator, mounted only whenDeps.Inviteis wired. Every route declareshttpx.OperatorPermission(contracts.PermissionTenantManage); the operation IDs aretenant-tenant-<verb>. -
Screens: none generated — no
rest.Spec, so nohttpx.Resourceand no entry in the catalog the native shell reads. The navigation entry "Tenants" at/admin/tenant/tenantsis shown to a holder oftenant:managein the operator’s tenant; the page there is the admin module’s hand-written switcher (modules/admin/internal/pages.go), every tenant with its status and hosts, behind the same operator permission. Hand-written pages in this module: none.
Authorization
| Authorization | Who passes | Routes |
|---|---|---|
|
the operator’s tenant only |
|
Events, jobs and subscriptions
| Event | Published by | Handled by |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
-
Publishes: the three events above, each declared on the operation that publishes it so the boot gate can check it; the invite route declares
user.invited. -
Jobs: none, written out as a decision: the module has no periodic work of its own; it is what the other modules' periodic work walks, through
contracts.Active. -
Subscriptions: none. A module above it is notified inside the create transaction through
Deps.OnCreateinstead, because atenant.createdsubscriber runs when the worker gets to it, after the administrator the same transaction created has tried to sign in.
What it needs
|
Interface |
Supplied by |
|
|
its own contracts; |
|
|
its own contracts; |
Configuration: none.
Who uses it
-
The kernel, through main:
Modulereturns the service beside its manifest, main hands it toapp.Options.Tenantsashttpx.TenantLoader(ByHost), andcontracts.Activeover it is thejobs.TenantListerthatjobs.PerTenantwalks. -
task, billing and audit (
Deps.Tenants: jobs.TenantLister), and the catalog modules — theActivelister, for their per-tenant sweeps; auth takes it for its hourly sweep. -
auth and notification (
Deps.Hosts, notification’scontracts.HostLookup) — main’stenantHostsadapter overService.Hosts, so a mailed link is built on the tenant’s primary host. -
admin (
Deps.Tenants: contracts.Service) — the switcher’s list, under the token its ownRoutesis handed. -
apps/platformkit(bootstrap,start), the catalog’sapps/catalog-checkand the clients'apps/flagshipcalltenant.Bootstrap(apps/platformkit/bootstrap.go) for the first tenant of an installation; it refuses when one exists and is the only writer ofOperator. -
The admin reaches it through
contracts/, not throughhttpx.Resource; the native shell does not reach it at all.
Verification
-
go test ./modules/tenant/…—TestFakeConformsandTestServiceConformsrun one conformance suite against the fake and the real service, the latter on a real cross-tenant transaction that reads the outbox, so an idempotent command is held to publishing nothing the second time; the internal tests prove the hook runs in the create transaction and a failing hook leaves no tenant, a tenant transaction sees only its own row and cannot write another’s, andBootstraprefuses a second installation (internal/service_test.go). -
go test ./apps/platformkit/…—TestAnEmptyDatabaseBecomesAWorkingInstallationcreates a second tenant as the operator’s administrator, is refused with 403AUTH_NOT_OPERATORat another tenant’s host — with the wildcard and with a role namingtenant:manage— and invites a first administrator who chooses a password from a link on their own host;TestTheWorkerRoleSweepsEveryTenantwalks two tenants throughcontracts.Active(apps/platformkit/app_test.go). -
No browser journey:
make e2ebootstraps one tenant and opens neither the switcher nor a control-plane route. -
Not proven:
suspendandhostssucceeding over HTTP, only their refusal is; the cache invalidation on suspend;handler.gohas no test file of its own.