Skip to main content
Version: v0.25.0 (Latest)

Application Tenant and Bootstrap

Every EDK deployment has exactly one application tenant. It is the control-plane tenant that the platform operator signs in to to configure the deployment itself: activate the license, choose the secret backend, configure the email service and onboarding policy, and register the first real customer tenant.

The application tenant is structurally different from a customer tenant in three ways:

  • It always has a hosted Authorisation Server. Even after a real customer tenant configures its own external IdP, the application tenant keeps its hosted AS so the operator can sign in to fix a broken external-IdP setup without becoming locked out of the deployment.
  • It is the only tenant that can claim the durable bootstrap gate. Once the gate is claimed, the application tenant has registered the first real tenant and the gate is permanently closed.
  • It is materialised as a system tenant (system = true), so it is excluded from default tenant listings and from slug-based or parent-based resolution queries.

The application admin REST lives at /api/v1/application on the AS image and is the operator's surface for everything the application tenant owns.

EDK application tenant and bootstrap flow

Why a Separate Application Tenant

Production EDK deployments separate "operator running the deployment" from "tenant operating their issuer / verifier / AS". The two have different scopes (the operator can touch every tenant; the tenant can touch only their own), different authentication paths (the operator never depends on a customer's IdP; the customer can configure whatever IdP they like), and different failure modes (a misconfigured customer IdP breaks that tenant; it does not break the operator's ability to fix it).

Modelling the operator's access as a tenant in its own right is the cleanest way to keep those distinctions consistent. The operator signs in through the application tenant's hosted AS, holds a JWT bound to the application tenant, and acts on any other tenant through the standard platform-admin role rather than through a back-channel bypass.

The shape of the application tenant follows from that:

  • Hosted AS is mandatory and is provisioned at application tenant bootstrap.
  • External-IdP federation is allowed (the operator can sign in through Azure AD, Okta, or any OIDC IdP) but is never required.
  • The operator can manage license, email, secret backend, signup policy, and any customer tenant from this one signed-in session.

The Bootstrap Gate

tenant_bootstrap is a single-row Postgres table that carries the durable first-tenant gate:

tenant_bootstrap(completed_at, completed_tenant_id, completed_by)

On a fresh deployment, the row exists with completed_at = null. The gate is open. The next call to BootstrapTenantServiceCommand may claim it.

The claim is atomic. Inside a Postgres transaction the command runs SELECT ... FOR UPDATE, checks completed_at is still null, runs the full RegisterTenantServiceCommand pipeline against the bootstrap channel, and on success updates the row to (completed_at = now(), completed_tenant_id = <new tenant id>, completed_by = <principal id>). Two concurrent calls cannot both succeed: the second SELECT ... FOR UPDATE blocks until the first commits or rolls back, then sees completed_at non-null and returns a 409.

Once closed, the gate stays closed. There is no POST /api/v1/application/bootstrap/reopen endpoint, because the bootstrap is not a routine operation. Re-opening requires an explicit operator SQL action:

UPDATE tenant_bootstrap SET completed_at = NULL;

The SQL gate exists so an operator who is rebuilding a deployment from a Postgres backup can land the same first tenant without manual surgery.

BootstrapTenantServiceCommand is what produces the OnboardingChannel.Bootstrap channel. That is the only channel FailClosedTenantOnboardingPolicy accepts unconditionally; the durable gate has already proved the deployment is in its "no real tenants yet" state, so no role check applies.

GET /api/v1/application/tenant returns a TenantBootstrapStatus snapshot:

{
"completedAt": "2026-04-12T10:31:00Z",
"completedTenantId": "tenant-acme",
"completedBy": "principal-platform-admin",
"isOpen": false
}

isOpen = true means the gate is still claimable; the application admin UI uses this to surface the "register first tenant" wizard until it flips.

Application Admin REST

The application admin surface is mounted at /api/v1/application through ApplicationAdminHttpAdapter on the AS image. TenantPathPolicy.None: the singleton application tenant is implicit and the acting tenant always comes from the JWT.

The endpoint set:

GET    /api/v1/application/tenant                    # getApplicationTenant
POST /api/v1/application/tenant/bootstrap # bootstrapApplicationTenant

GET /api/v1/application/license # getApplicationLicense
PUT /api/v1/application/license # putApplicationLicense
POST /api/v1/application/license/verify # verifyApplicationLicense

GET /api/v1/application/secret-backend # getSecretBackend
PUT /api/v1/application/secret-backend # putSecretBackend

GET /api/v1/application/onboarding # getApplicationOnboarding
PUT /api/v1/application/onboarding # putApplicationOnboarding
GET /api/v1/application/onboarding/availability # getApplicationOnboardingAvailability

Tenant Endpoints

GET /api/v1/application/tenant returns the TenantBootstrapStatus plus the application tenant's id, slug, and primary domain when it exists.

POST /api/v1/application/tenant/bootstrap invokes BootstrapTenantServiceCommand with the supplied RegisterTenantArgs. The standard rules apply: slug validation, owner shape, hosted AS provisioning. The difference from POST /api/v1/tenants is that the channel populated on the holder is OnboardingChannel.Bootstrap rather than Operator, and the durable gate is checked.

License Endpoints

GET /api/v1/application/license returns the currently active license snapshot. When no license is installed (or the installed one is invalid), the response indicates which paths are blocked:

{
"license": null,
"snapshot": null,
"status": "missing",
"blocks": [
"root-tenant-registration",
"self-signup",
"subtenants"
]
}

When a license is installed:

{
"license": {
"licenseId": "lic-acme-corp-001",
"licensee": "Acme Corp",
"tier": "enterprise",
"validFrom": "2026-01-01T00:00:00Z",
"validUntil": "2027-01-01T00:00:00Z"
},
"snapshot": {
"limits": {
"maxRootTenants": 100,
"maxTotalTenants": 1000,
"maxHierarchyDepth": 5,
"subtenantsAllowed": true
},
"features": ["self-signup", "subtenants", "custom-domains", "federation"]
},
"status": "active"
}

PUT /api/v1/application/license uploads a new license. The body carries the license artifact (the format is deployment-specific: signed JSON token, signed JWT, base64 archive, depending on what the deployed LicenseService impl understands). The EDK does not enforce a license format; it only exposes the parsed License snapshot.

POST /api/v1/application/license/verify runs the signature + validity-window check against the installed license without changing it. Useful for an operator who wants to confirm the license is still valid before a high-risk operation.

The EDK default LicenseService is UnboundedLicenseService, which returns a license with infinity limits and the full feature set. Pure-EDK builds therefore have no limit gates. A production EDK + customer deployment binds a real LicenseService impl (typically through a VDX overlay) that reads the actual signed license.

Secret Backend Endpoints

GET /api/v1/application/secret-backend returns the selected secret backend:

{
"backend": "vault",
"config": {
"address": "https://vault.acme.internal",
"authMode": "kubernetes",
"namespace": "edk"
}
}

PUT /api/v1/application/secret-backend selects or reconfigures the backend. Choices match the supported SecretProvider impls: vault (HashiCorp Vault), aws-secrets-manager (AWS Secrets Manager), azure-key-vault (Azure Key Vault), kubernetes-secret-mount (k8s file mounts), or config-system-dev-only (for local dev only). The non-secret configuration (vault address, key vault URL, region, namespace) is stored in tenant_config_property for the application tenant; the credential needed to access the backend is itself a secret reference that the deployment seeds at startup.

The secret backend choice gates production onboarding: the platform admin UI does not enable invite or self-service signup journeys until a non-dev backend is selected. The intent is to prevent a deployment from accidentally storing tenant IdP secrets, SMTP passwords, or webhook signing keys in the config system.

Onboarding Endpoints

GET /api/v1/application/onboarding returns the current onboarding configuration:

{
"emailTransport": "smtp",
"emailService": {
"endpoint": "http://enterprise-email-service.edk.svc:8080",
"appId": "enterprise-email-service",
"templatesVerified": true
},
"signupPolicy": {
"rootEnabled": true,
"rootRequiresApproval": false,
"rootTokenTtlMinutes": 60,
"rootRateLimitPerEmailPerHour": 3,
"rootRateLimitPerIpPerHour": 20
},
"publicLinkBaseUrl": "https://saas.example.com"
}

PUT /api/v1/application/onboarding updates the same. Updates emit invalidation events on the shared event bus so every data-plane replica picks up the new onboarding policy without a restart.

GET /api/v1/application/onboarding/availability reports which journeys the current configuration actually enables:

{
"adminDirectRoot": true,
"adminDirectSubtenant": true,
"adminInviteRoot": true,
"adminInviteSubtenant": true,
"selfServiceRoot": true,
"selfServiceSubtenant": false,
"blockers": {
"selfServiceSubtenant": ["subtenants-license-feature-missing"]
}
}

The admin UI consumes this endpoint to decide which journey buttons to show, which to hide, and which to show with a "blocked because ..." explanation.

The Bootstrap Sequence

Running the application tenant bootstrap end-to-end on a fresh deployment:

  1. Containers come up. The AS, KMS, DID, Issuer, and Verifier all start. The AS replicas connect to Postgres and ensure their schemas. The tenant_bootstrap row exists with completed_at = null.
  2. Initial operator sign-in. A deployment-shipped bootstrap admin credential (a system-generated initial JWT, or a one-time token printed to the container logs on first startup) authenticates the operator into the application admin REST. The exact mechanism is deployment-specific; the EDK exposes the surface.
  3. License activation. Operator PUTs the license through /api/v1/application/license. The snapshot loads. GET /api/v1/application/onboarding/availability reports adminDirectRoot: true and the other journeys gated by SMTP and license features.
  4. Secret backend selection. Operator PUTs the backend through /api/v1/application/secret-backend. The deployment-supplied backend credential is verified.
  5. Optional email service setup. Operator points the deployment at the running enterprise-email-service through /api/v1/application/onboarding, runs a test-send to confirm SMTP works, and verifies the templates render.
  6. Application tenant bootstrap. Operator POSTs the first-tenant payload to /api/v1/application/tenant/bootstrap. BootstrapTenantServiceCommand claims the gate, runs RegisterTenantServiceCommand under the Bootstrap channel, and the first real tenant exists. The bootstrap status flips to closed.
  7. Subsequent tenant onboarding. Further tenants come in through POST /api/v1/tenants (admin direct or admin invite) or POST /api/v1/tenants/signup/request (self-service). The bootstrap gate is no longer involved.

When the Operator Locks Themselves Out

A few failure modes the application tenant guards against:

  • Customer external IdP misconfigured. The customer's tenant IdP setup is broken; their users cannot sign in. The operator signs in through the application tenant's hosted AS, fixes the customer's federation provider through the federation admin REST, and the customer is unblocked. The application tenant's access does not depend on any customer's IdP.
  • License expired. The license snapshot has expired. New tenant registrations are blocked. The operator signs in (the application tenant remains accessible regardless of license state) and uploads a renewed license.
  • Secret backend unreachable. The configured backend is offline. The deployment surfaces the unhealthy state through /api/v1/application/secret-backend/health. Existing operations continue against cached secrets where possible; new secret writes fail. The operator switches backends or restores connectivity.
  • Email service down. Invite and signup journeys fail-fast with a clear error. Admin direct creation still works, so the operator can bring up new tenants without waiting for the Email Service to recover.

The intent throughout is that the operator path through the application tenant remains usable in degraded states.