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(),
)
maxRootTenantscaps the number of customer (non-system) root tenants.maxTotalTenantscaps the total number of customer (non-system) tenants across the tree.maxHierarchyDepthcaps how deep the tenant tree can go (a root is depth 1, its child is depth 2).subtenantsAllowedis a hard switch: when false, every registration withparentTenantId != nullis refused. Useful for tiers that are root-only.maxInstancesPerTenantByServicecaps 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 (matchesUNBOUNDED). Enterprise-container deployments declare relevant entries via their per-imageLicenseServiceoverlay.
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:
subtenantsenables non-nullparentTenantId. Without it,subtenantsAllowedis forced false regardless of the limit.custom-domainsenablesTenantDomainKind.CUSTOM_DOMAINrows. Without it, only platform subdomains are allowed.self-signupenables the public signup endpoint family. Without it,/api/v1/tenants/signup/...returns 503.federationenablesOwnerInput.FederatedandOwnerInput.Hybridregistrations. Without it, onlyOwnerInput.Localis 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
sessionTenantIdthe application tenant? (Platform admin acts on any tenant.) - Is
sessionTenantIdthe 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?
- Is
SelfServiceSignup. The materialisation of a confirmed signup request. The policy loads the row fromtenant_signup_requestand validates:- Status is
CONFIRMED. args.emailmatches the row's email.args.slugmatches the row's slug.args.parentTenantIdmatches the row'sparentTenantId.- The row's signup approval (if required) was actually granted.
- License features (
self-signup,subtenantsfor non-null parents) are present.
- Status is
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:
| SPI | Default | Production binding |
|---|---|---|
LicenseService | UnboundedLicenseService | Customer-specific or VDX-supplied real impl |
TenantQuotaService | EDK default reading from TenantRoutingRepository | Same; customer rarely overlays |
TenantOnboardingPolicy | FailClosedTenantOnboardingPolicy | VDX overlay or pure-EDK commercial overlay |
TenantSignupPolicy | Config-driven default | Same; tenants may overlay with parent-scoped richer policy |
SignupChallengeVerifier | Fail-closed (no verifier binding rejects every request) | Turnstile, reCAPTCHA, or equivalent |
SignupAuthorizationPolicy | Platform / parent admin default | Same in most deployments |
SignupTokenHasher | Sha256SignupTokenHasher | Same in most deployments |
TenantOnboardingViewPolicy | Platform / parent admin default | Same 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.