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

License, Quota, and Policy

License gating, quota enforcement, and onboarding authorisation are three orthogonal concerns in the EDK that converge on the same place: the first lines of RegisterTenantServiceCommand. A registration call is admitted only when the license allows it, the quota is available, and the onboarding policy says the channel is acceptable. Each layer is replaceable through Metro replaces, and each has a documented fail-closed default.

License

License is an abstract data class. EDK only defines the structural fields onboarding policies need to gate registration:

data class License(
val licenseId: String,
val licensee: String,
val tier: String,
val validFrom: Instant,
val validUntil: Instant,
val limits: LicenseLimits,
val features: Set<String>,
)

Concrete license formats (signed JSON token, signed JWT, signed archive) live in higher layers (VDX, or a customer-specific overlay). The LicenseService SPI exposes only the parsed snapshot to the rest of the EDK.

Limits

LicenseLimits caps the deployment's tenant footprint:

data class LicenseLimits(
val maxRootTenants: Int,
val maxTotalTenants: Int,
val maxHierarchyDepth: Int,
val subtenantsAllowed: Boolean,
val maxInstancesPerTenantByService: Map<TenantPublicEndpointServiceType, Int> = emptyMap(),
)
  • maxRootTenants caps the number of customer (non-system) root tenants.
  • maxTotalTenants caps the total number of customer (non-system) tenants across the tree.
  • maxHierarchyDepth caps how deep the tenant tree can go (a root is depth 1, its child is depth 2).
  • subtenantsAllowed is a hard switch: when false, every registration with parentTenantId != null is refused. Useful for tiers that are root-only.
  • maxInstancesPerTenantByService caps the per-service-type instance rows per tenant. Slug-addressed instance rows (oauth2.servers.<slug>.*) live inside one tenant; the cap is the per-tenant slug count per service type. Absent entry: no cap for that service type. Empty map: no caps at all (matches UNBOUNDED). Enterprise-container deployments declare relevant entries via their per-image LicenseService overlay.

Features

LicenseFeatures is the set of standard capability flags onboarding interprets:

object LicenseFeatures {
const val SUBTENANTS = "subtenants"
const val CUSTOM_DOMAINS = "custom-domains"
const val SELF_SIGNUP = "self-signup"
const val FEDERATION = "federation"
}

The set is an open string set. EDK ships the four standard flags; VDX and customer-specific overlays mint additional features by string equality without changing EDK. The production policy interprets each flag against the registration args:

  • subtenants enables non-null parentTenantId. Without it, subtenantsAllowed is forced false regardless of the limit.
  • custom-domains enables TenantDomainKind.CUSTOM_DOMAIN rows. Without it, only platform subdomains are allowed.
  • self-signup enables the public signup endpoint family. Without it, /api/v1/tenants/signup/... returns 503.
  • federation enables OwnerInput.Federated and OwnerInput.Hybrid registrations. Without it, only OwnerInput.Local is accepted.

The Unbounded Default

Pure-EDK builds bind UnboundedLicenseService, which returns:

LicenseLimits.UNBOUNDED  // all limits at Int.MAX_VALUE, subtenantsAllowed = true
features = setOf(SUBTENANTS, CUSTOM_DOMAINS, SELF_SIGNUP, FEDERATION)

This is the right default for development and for non-commercial EDK use. Production commercial deployments overlay a real LicenseService impl that reads the actual signed license and returns the snapshot the customer actually paid for.

Quota

The TenantQuotaService runs the count-based checks against the license snapshot. The service exposes:

interface TenantQuotaService {
suspend fun ensureRootTenantQuotaAvailable(): IdkResult<Unit, IdkError>
suspend fun ensureTotalTenantQuotaAvailable(): IdkResult<Unit, IdkError>
suspend fun ensureSubtenantQuotaAvailable(parentTenantId: String): IdkResult<Unit, IdkError>
suspend fun ensureHierarchyDepth(parentTenantId: String?): IdkResult<Unit, IdkError>
}

RegisterTenantServiceCommand calls these in order before any side effect. The default impl reads the current tenant count from TenantRoutingRepository, compares against the snapshot, and returns an error if the quota is exceeded. Two concurrent registrations racing for the last available slot are serialised at the unique-index level: the loser sees a 409.

TenantServiceInstancePolicy is the per-service-type variant. The per-protocol admin commands (creating an OID4VCI issuer instance, a verifier instance, an AS instance for a tenant) consult the policy before persisting a new instance row.

Onboarding Policy

TenantOnboardingPolicy is the authorisation chokepoint for tenant registration. RegisterTenantServiceCommand.validate() calls assertAllowed as the first step, before any DB query, parent existence check, or slug-uniqueness probe. Returning an Err short-circuits the entire registration: no row is created, no audit event is emitted, and no information about the deployment's existing tenant inventory leaks to an unauthorised caller. The intent is to prevent an unauthenticated probe from using the registration endpoint to enumerate slugs.

interface TenantOnboardingPolicy {
suspend fun assertAllowed(
args: RegisterTenantArgs,
channel: OnboardingChannel?,
): IdkResult<Unit, IdkError>
}

The policy receives the typed registration arguments and the OnboardingChannel populated on the session-scoped OnboardingChannelHolder by the calling adapter. The three channel variants and their typical policy interpretation:

  • Operator. An authenticated operator. Carries the session tenant id and the roles extracted from the validated JWT. The production policy checks:
    • Is sessionTenantId the application tenant? (Platform admin acts on any tenant.)
    • Is sessionTenantId the parent of the registering subtenant? (Parent admin acts on children.)
    • Is the role set sufficient (platform-admin, tenant-admin, depending on what is being created)?
    • Are the license features and limits satisfied?
  • SelfServiceSignup. The materialisation of a confirmed signup request. The policy loads the row from tenant_signup_request and validates:
    • Status is CONFIRMED.
    • args.email matches the row's email.
    • args.slug matches the row's slug.
    • args.parentTenantId matches the row's parentTenantId.
    • The row's signup approval (if required) was actually granted.
    • License features (self-signup, subtenants for non-null parents) are present.
  • Bootstrap. The one-shot first-tenant claim. The policy accepts unconditionally because the durable gate IS the gate.

null channel (no caller has populated the holder for this session): the policy rejects unconditionally. A misconfigured deployment that forgets to populate the channel cannot accept anonymous registration.

Fail-Closed Default

FailClosedTenantOnboardingPolicy is the EDK default. It rejects every channel except Bootstrap. A pure-EDK build whose deployment forgets to bind a real policy fails closed: registrations through the admin REST and through self-service signup are refused, only the durable-gated bootstrap goes through.

The production replacement is shipped as a sibling module (the VDX platform supplies one; a pure-EDK commercial deployment can bind its own). It interprets each channel arm against role, parent-tenant scope, license features, and signup state. The replacement is a @ContributesBinding(replaces = FailClosedTenantOnboardingPolicy::class) against the policy interface, so the binding is by-type and a deployment that includes the production module automatically gets the production policy.

Signup Policy

Self-service signup has its own policy stack on top of the onboarding policy, because the signup-request lifecycle (request, resend, confirm, approve, reject) is much richer than the single-shot assertAllowed check on the registration path.

TenantSignupPolicy

The high-level signup policy decides whether a signup request is admissible at all, and whether it should hit the approval queue:

interface TenantSignupPolicy {
suspend fun rootSignupEnabled(): Boolean
suspend fun rootSignupRequiresApproval(): Boolean
suspend fun childSignupEnabled(parentTenantId: String): Boolean
suspend fun childSignupRequiresApproval(parentTenantId: String): Boolean

suspend fun rateLimit(scope: SignupScope, email: String, ipHash: String?): RateLimitDecision
suspend fun tokenTtl(scope: SignupScope): Duration
}

The default impl reads the tenant.signup.platform.* and tenant.signup.children.* properties from the standard config chain. A real deployment can replace the binding with a richer impl that, say, reads per-parent-tenant overrides for childSignupRequiresApproval.

SignupChallengeVerifier

The bot-challenge verifier sits in front of the public signup endpoint to keep public bots out. The interface is intentionally generic:

interface SignupChallengeVerifier {
suspend fun verify(challengeProof: String, requestContext: SignupRequestContext): IdkResult<Unit, IdkError>
}

Production impls integrate with reCAPTCHA, Cloudflare Turnstile, hCaptcha, or whatever the deployment standardises on. The EDK does not ship a default; an undefined verifier rejects every public signup request (the fail-closed behaviour matches the policy chain).

SignupAuthorizationPolicy

The signup-side authorisation policy decides who can approve / reject pending signups:

interface SignupAuthorizationPolicy {
suspend fun canApprove(signupRequest: TenantSignupRequest, principal: SessionPrincipal): Boolean
suspend fun canReject(signupRequest: TenantSignupRequest, principal: SessionPrincipal): Boolean
suspend fun canListPending(parentTenantId: String?, principal: SessionPrincipal): Boolean
}

The default platform-admin / parent-admin pattern: platform admins can act on any pending row, parent tenant admins can act on rows where parentTenantId matches their tenant.

SignupTokenHasher

Signup verification tokens are stored hashed. The plaintext is given to the Email Service at issue time and forgotten by the EDK; only the hash persists. Hash equality is constant-time.

interface SignupTokenHasher {
fun hash(plainToken: String): String
fun matches(plainToken: String, storedHash: String): Boolean // constant-time
}

Sha256SignupTokenHasher is the default. A deployment can replace the binding with a different hash function if their security posture requires it.

Tenant View Policy

TenantOnboardingViewPolicy decides what the read endpoints under /api/v1/application/onboarding/availability reveal to which principals. The default lets platform admins see everything and lets tenant admins see only their own tenant's perspective; the policy is replaceable for deployments with finer scope rules.

Putting It Together

A typical production deployment binds:

SPIDefaultProduction binding
LicenseServiceUnboundedLicenseServiceCustomer-specific or VDX-supplied real impl
TenantQuotaServiceEDK default reading from TenantRoutingRepositorySame; customer rarely overlays
TenantOnboardingPolicyFailClosedTenantOnboardingPolicyVDX overlay or pure-EDK commercial overlay
TenantSignupPolicyConfig-driven defaultSame; tenants may overlay with parent-scoped richer policy
SignupChallengeVerifierFail-closed (no verifier binding rejects every request)Turnstile, reCAPTCHA, or equivalent
SignupAuthorizationPolicyPlatform / parent admin defaultSame in most deployments
SignupTokenHasherSha256SignupTokenHasherSame in most deployments
TenantOnboardingViewPolicyPlatform / parent admin defaultSame in most deployments

The defaults are chosen so a pure-EDK build either works correctly (the read-only TenantQuotaService, the SHA-256 hasher) or fails closed (the onboarding policy, the challenge verifier). The expectation is that a production deployment explicitly binds the parts it wants to enable.