Skip to content

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.

MethodPathPermission
GET/api/admin/realmsrealm:read
GET/api/admin/realms/{slug}realm:read
POST/api/admin/realmsrealm: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-inviterealm: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.

http
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

  1. Slug validation: regex ^[a-z][a-z0-9-]{1,61}[a-z0-9]$, no reserved word (system, health, swagger, api, connect, …)
  2. InitialAdmin validation: UserName and Email are required; Firstname and Lastname are optional.
  3. Create PostgreSQL DB (raw SQL): CREATE DATABASE <master-db>_acme
  4. Register in Marten tenancy: tenancy.AddDatabaseRecordAsync("acme", connStringForAcme)
  5. Apply Marten schema (tables, indexes, functions)
  6. OAuthRealmSeeder.SeedAsync seeds the 6 default scopes (openid, email, profile, roles, offline_access, permissions) and the built-in Internal login provider.
  7. AppRealmSeeder.SeedAsync: the modgud App is registered in the new tenant DB. The control-plane App is only seeded for the system realm — tenant realms physically cannot grant realm:read/realm:write (the App that owns those catalog entries doesn't exist in their tenant DB).
  8. Realm document persisted in IGlobalStore (master DB, schema global).
  9. RealmCache.Invalidate() — the next request loads it fresh.
  10. 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 to InitialAdmin.Email.

Response (201 Created)

json
{
  "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

http
POST /api/admin/realms/acme/resend-bootstrap-invite HTTP/1.1
Host: auth.example.com

Re-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

http
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

http
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:

csharp
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.

Released under the Apache-2.0 License.