Realm Endpoints
Realm management is only callable from the Control-Plane realm (the realm flagged IsControlPlane = true, which is the system realm). On any other host the endpoints return 404 — not 403, because the existence of the realm-management surface must not be leaked to tenant realms. See Concepts: Control Plane for the full three-layer defence.
Endpoints in Modgud.Api/Features/Admin/RealmsEndpoints.cs.
| Method | Path | Permission |
|---|---|---|
GET | /api/admin/realms | realm:read |
GET | /api/admin/realms/{slug} | realm:read |
POST | /api/admin/realms | realm:write |
PATCH | /api/admin/realms/{slug} | realm:write |
DELETE | /api/admin/realms/{slug} | realm:write (soft-delete = deactivate) |
POST | /api/admin/realms/{slug}/resend-bootstrap-invite | realm:write |
Permission context
These permissions live in the control-plane App's catalog (seeded only on the Control-Plane realm). The same string realm:read in the modgud App's catalog would be a different permission. The realm:admin realm-wide bypass grants all of them; see Permissions & gating.
Create a realm
POST requires an InitialAdmin payload. A realm without a recipient on file would have no admin path; the endpoint refuses to create one. The Control-Plane flag is computed from the slug (system is the CP realm); you cannot send it.
POST /api/admin/realms HTTP/1.1
Host: auth.example.com
Content-Type: application/json
{
"Slug": "acme",
"DisplayName": "Acme Corp",
"Description": "Acme Corporation Identity",
"Domains": ["acme.example.com"],
"InitialAdmin": {
"UserName": "max",
"Email": "max@acme.com",
"Firstname": "Max",
"Lastname": "Mustermann"
}
}What happens
- Slug validation: regex
^[a-z][a-z0-9-]{1,61}[a-z0-9]$, no reserved word (system,health,swagger,api,connect, …) InitialAdminvalidation:UserNameandEmailare required;FirstnameandLastnameare optional.- Create PostgreSQL DB (raw SQL):
CREATE DATABASE <master-db>_acme - Register in Marten tenancy:
tenancy.AddDatabaseRecordAsync("acme", connStringForAcme) - Apply Marten schema (tables, indexes, functions)
OAuthRealmSeeder.SeedAsyncseeds the 6 default scopes (openid,email,profile,roles,offline_access,permissions) and the built-in Internal login provider.AppRealmSeeder.SeedAsync: themodgudApp is registered in the new tenant DB. Thecontrol-planeApp is only seeded for the system realm — tenant realms physically cannot grantrealm:read/realm:write(the App that owns those catalog entries doesn't exist in their tenant DB).- Realm document persisted in
IGlobalStore(master DB, schemaglobal). RealmCache.Invalidate()— the next request loads it fresh.- Bootstrap-invite issued atomically into the new tenant DB. The recipient's SHA-256-hashed token is stored as
PendingAdminInvite; the plaintext is embedded in the magic-link URL emailed toInitialAdmin.Email.
Response (201 Created)
{
"Realm": {
"Id": "0c12…",
"Slug": "acme",
"DisplayName": "Acme Corp",
"Description": "Acme Corporation Identity",
"Domains": ["acme.example.com"],
"IsControlPlane": false,
"IsActive": true,
"NeedsSetup": false,
"CreatedAt": "2026-05-05T10:00:00Z"
},
"InitialAdminInvite": {
"UserName": "max",
"Email": "max@acme.com",
"ExpiresAt": "2026-05-12T10:00:00Z",
"MagicLinkUrl": "https://acme.example.com/bootstrap?token=…"
}
}IsControlPlane is read-only — it appears in responses but is never accepted in requests. MagicLinkUrl is returned only here, only this once — capture it if SMTP delivery isn't reliable in the issuing environment. To re-issue use the resend endpoint.
The recipient consumes the token at POST /api/account/bootstrap-admin on the new realm's host (see Auth API).
Resend a bootstrap-invite
POST /api/admin/realms/acme/resend-bootstrap-invite HTTP/1.1
Host: auth.example.comRe-uses the recipient identity (UserName + Email + Firstname + Lastname) from the most recent prior invite — no body needed. The previous invite is revoked (UsedAt set), a fresh 7-day token is issued, the email is sent again, and the new MagicLinkUrl is returned in the response (same shape as InitialAdminInvite above).
Returns 404 Realm.NoPriorInvite if no invite was ever issued (e.g. a realm whose first admin was created via the recovery CLI in direct mode).
Edit a realm
PATCH /api/admin/realms/acme HTTP/1.1
Content-Type: application/json
{
"DisplayName": "Acme Corporation",
"Description": "Updated",
"Domains": ["acme.example.com", "auth.acme.com"],
"IsActive": true
}Slug is immutable. The patchable fields are exactly the four shown above — IsControlPlane is not accepted in PATCH either.
Deactivate a realm
PATCH /api/admin/realms/acme
{ "IsActive": false }RealmCache filters on IsActive = true — all requests to the realm domain land at 404. Data is preserved.
The Control-Plane realm cannot be deactivated (Realm.CannotDeactivateControlPlane).
Hard-delete a realm
Not implemented
Current state: only soft-delete (deactivation). The tenant DB is not dropped. Roadmap: the Wolverine durability agent has to be shut down cleanly, the tenant removed from mt_tenant_databases, all sessions invalidated, and finally the DB dropped.
Realm data model
src/dotnet/Modgud.Domain/Realms/Realm.cs:
public class Realm
{
public Guid Id { get; set; }
public string Slug { get; set; } // = TenantId, immutable
public string DisplayName { get; set; }
public string? Description { get; set; }
public string[] Domains { get; set; } // Host-header matches
public bool IsControlPlane { get; set; } // computed from Slug == "system"
public bool IsActive { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
}Lives in IGlobalStore (master DB, schema global) — not in the tenant store, because that would create a chicken-and-egg problem.