Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion APPBASE_VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v2.1.0
v3.0.0
30 changes: 24 additions & 6 deletions docs/admin/realms.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ isn't seeded there). See
[Concepts: Control Plane / Data Plane](../concepts/control-plane) for
the full three-layer defence.

The Control-Plane flag is **computed from the slug**: the realm whose
slug equals `system` is the CP. It's not a togglable field — there's
exactly one CP per deployment, fixed at deployment time.
The Control-Plane flag is a **stored, transferable** field. The `system`
realm is stamped as the CP at first boot, but the role can be moved to any
active realm (see [Transferring the control plane](#transferring-the-control-plane)).
There is always exactly one CP per deployment.

The system realm's default domains are `system.localhost`,
`localhost`, `127.0.0.1` — anything resolving to those lands on the
Expand All @@ -46,7 +47,7 @@ system realm.
| Display Name | UI label |
| Description | Optional |
| Domains | List of hostnames that route to this realm |
| IsControlPlane | Read-only flag, derived from `Slug == "system"`. |
| IsControlPlane | Stored flag — exactly one realm holds it. Moved via the transfer action, not edited inline. |
| IsActive | Disabled realms reject login attempts |

## Permissions
Expand Down Expand Up @@ -114,8 +115,25 @@ issued for the same recipient and the previous one is revoked.
## Editing a realm

Most fields are live-editable; the **slug is immutable** (it's baked
into the database name). The Control-Plane flag isn't editable — it's
computed from the slug.
into the database name). The Control-Plane flag isn't a checkbox — it
moves via the dedicated transfer action (below).

## Transferring the control plane

To hand cross-realm administration to another realm, open the **target**
realm (the one that should become the CP) in the admin UI and click
**Make this realm the control plane** (a danger action shown in edit mode for
active, non-CP realms). After you confirm:

- the target realm's `realm:admin` users gain the realm-management surface;
- **this** host stops being the control plane — `/api/admin/realms` 404s here
and the realm grid disappears. Continue administration on the target realm's
domain.

Make sure the target realm already has a `realm:admin` user before
transferring, or recover one afterwards via the
[Recovery CLI](../operate/recovery-cli) (`control-plane transfer` /
`bootstrap-admin`).

## Deactivating vs. deleting

Expand Down
83 changes: 59 additions & 24 deletions docs/concepts/control-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,33 +21,48 @@ It doesn't belong on a tenant. A tenant should not even be able to

## Model

Exactly **one** realm per deployment is the Control Plane —
**structurally**, not as a separately-stored flag.
`Realm.IsControlPlane` is computed:
Exactly **one** realm per deployment is the Control Plane — the realm that
carries the **stored** `Realm.IsControlPlane` flag:

```csharp
public bool IsControlPlane => Slug == RealmSlugRules.SystemSlug;
// SystemSlug = "system"
public bool IsControlPlane { get; set; } // stored, transferable
```

Three structural facts make this an "exactly one" guarantee without
any runtime validation:

1. The slug `"system"` is in `RealmSlugRules.ReservedSlugs` — no
`CreateRealm` call can claim it.
2. The system realm is seeded once at first boot
(`EnsureSystemRealmExistsAsync`).
3. `Slug` is immutable after creation — the realm document carries
the slug for life.

So no `Update` can promote a tenant realm to Control Plane, no `Create`
can spawn a second one, no flag can be flipped off. The Control Plane
is wherever the slug is — and that's always exactly the system realm.

`RealmProvisioningService` does still block deactivating or deleting
the system realm — losing the Control Plane would lock the deployment
out of cross-realm administration — but those are the only two
remaining guards.
The bootstrap (`system`) realm is stamped with the flag at first boot
(`EnsureSystemRealmExistsAsync`), but the slug is only the default anchor
*name* — it no longer determines control-plane status. The flag is
**transferable** to any active realm, so a deployment that starts
single-tenant can later hand cross-realm administration to a different realm
and let the original system realm become an equal, deletable peer.

### Authority = realm:admin in the flag-holding realm

There is deliberately **no** `controlplane:admin` permission. Cross-realm
authority is the ordinary `realm:admin` permission *within whichever realm
holds the flag*. That removes a privilege-escalation vector: a delegable
cross-tenant permission could be self-granted by a tenant admin through
normal role assignment, whereas a flag that only a control-plane-gated
operation (or the operator CLI) can move cannot. As a consequence,
transferring the flag hands cross-realm administration to the target realm's
existing `realm:admin` users with no permission migration. (The transfer
also re-seeds the `control-plane` app catalog into the target realm so
*scoped* `control-plane:realm:*` roles can be granted there too.)

### The "exactly one" invariant

It is enforced defensively, not by a DB constraint:

- `TransferControlPlaneAsync` clears the flag on every other holder in the
same transaction — self-healing an accidental multi-holder state down to
exactly the target.
- At boot, `EnsureSystemRealmExistsAsync` adopts the flag onto the system
realm **only when no realm currently holds it**. This is the load-bearing
guard that makes a transfer durable across reboots — without it every boot
would steal the flag back to `system`.

`RealmProvisioningService` still blocks deactivating or deleting the realm
that currently holds the flag — losing it would lock the deployment out of
cross-realm administration.

::: tip Naming
The permission namespace is `control-plane:*`, deliberately decoupled
Expand Down Expand Up @@ -120,6 +135,26 @@ a tenant DB can't grant `control-plane:realm:write` because the
`PermissionService` validates against the tenant's own resource
registry — and that registry doesn't list the `control-plane` app.

## Transferring the control plane

The flag moves via two paths, both of which clear every other holder in one
transaction:

- **In-app:** `POST /api/admin/realms/{slug}/transfer-control-plane` — POST to
the realm that should *become* the control plane, from the current
control-plane host (the route group's `RequireControlPlaneFilter` enforces
the latter). Gated by `control-plane:realm:write`.
- **Operator break-glass:** `recover control-plane transfer <slug>` (and
`recover control-plane list` to see the current holder) — for when the
control-plane realm has no usable admin. See
[Recovery CLI](../operate/recovery-cli).

After a transfer the **old** host 404s `/api/admin/realms` (its realm is no
longer the control plane) and the **new** host's `realm:admin` users gain the
surface. Plan the move so the target realm already has at least one
`realm:admin`, otherwise the new control plane is management-empty until you
recover one via the CLI.

## Hostname routing — DB is source of truth

The system realm is seeded with the localhost-style domains
Expand Down Expand Up @@ -236,7 +271,7 @@ The SPA reads `IsControlPlane: bool` from the anonymous
|---|---|---|
| Routing gate | `ControlPlaneGateMiddlewareTests` | `Modgud.Tests.Unit/Api/Middleware/` |
| Endpoint filter | `RealmsEndpointsTests.RequireControlPlaneFilterTests` | `Modgud.Tests.Unit/Api/Features/Admin/` |
| End-to-end | `ControlPlaneSeparationTests` (tenant→404, CP→OK, exactly-one-CP invariant on create + promote + demote, app-info IsControlPlane) | `Modgud.Api.Tests/Security/` |
| End-to-end | `ControlPlaneSeparationTests` (tenant→404, CP→OK, deactivate/delete-CP blocked, app-info IsControlPlane) + `ControlPlaneTransferTests` (flag move + clear-others, missing/inactive-target guards, boot durability guard, gate-follows-the-flag) | `Modgud.Api.Tests/Security/` |
| Realm-cache resolution | `RealmCacheLookupTests` | `Modgud.Tests.Unit/Realms/` |

A regression in any one layer is caught by the layer's tests; a
Expand Down
29 changes: 16 additions & 13 deletions docs/concepts/realms.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,16 @@ graph TD
| Database | Contents |
|---|---|
| `<master-db>` (Master) | Schema `realms.mt_tenant_databases` (tenant registry) + schema `global` (Realm documents) |
| `<master-db>_system` | System realm data (users, groups, ...) — physically the same DB as the master |
| `<master-db>_system` | System realm data (users, groups, ...) — its own physical DB, like every other realm |
| `<master-db>_<slug>` | A separate physical DB per additional realm |

::: info System realm and master DB
The system realm intentionally points at the master DB. That way a
single-realm installation needs only one DB. Multi-realm installations
add separate tenant DBs for the other realms without the system realm
needing to move away from the master.
::: info Master DB vs. system realm
The master DB is pure control-plane infrastructure — the tenant registry
(`realms.mt_tenant_databases`) + the global Realm store (schema `global`) +
Wolverine durability. It is **not** a tenant. Every realm, including the
bootstrap `system` realm, lives in its own `<master-db>_<slug>` database.
That makes the system realm an equal, deletable peer so the
[control plane](./control-plane.md) can be transferred off it.
:::

### Tenant resolution in code
Expand Down Expand Up @@ -112,12 +114,12 @@ of the master DB.

On first start:

1. **Create the master DB** (raw SQL, because Marten cannot
`CREATE DATABASE` on an active connection)
1. **Create the master DB and the `<master-db>_system` DB** (raw SQL, because
Marten cannot `CREATE DATABASE` on an active connection)
2. **Apply the Marten schema** → `realms.mt_tenant_databases` is created
3. **Register the system tenant** → `tenancy.AddDatabaseRecordAsync("system", masterCs)`
4. **Apply the Marten schema again** → per-tenant tables for system
5. **Seed the system realm document** in `IGlobalStore`, flagged `IsControlPlane = true`
3. **Register the system tenant** → `tenancy.AddDatabaseRecordAsync("system", systemCs)` (pointing at `<master-db>_system`, not the master DB)
4. **Apply the Marten schema again** → per-tenant tables for the system realm, in its own DB
5. **Seed the system realm document** in `IGlobalStore`, stamped `IsControlPlane = true`
6. **Seed default OAuth scopes + the Internal login provider**
7. **Seed the `modgud` and `control-plane` apps** into the system tenant DB
8. **Warm `RealmCache`**
Expand All @@ -137,7 +139,6 @@ POST /api/admin/realms
"Slug": "acme",
"DisplayName": "Acme Corp",
"Domains": ["acme.example.com"],
"IsControlPlane": false,
"InitialAdmin": {
"UserName": "max",
"Email": "max@acme.com"
Expand All @@ -147,7 +148,9 @@ POST /api/admin/realms

Backend:

1. Validates `slug` (regex, no reserved word) and the exactly-one-CP invariant.
1. Validates `slug` (regex, no reserved word). New realms are never the
control plane — the flag defaults to false and there is no create-time
switch; the role only moves via [transfer](./control-plane.md#transferring-the-control-plane).
2. `CREATE DATABASE <master-db>_acme` (raw SQL).
3. `tenancy.AddDatabaseRecordAsync("acme", connStringForAcme)`.
4. `Storage.ApplyAllConfiguredChangesToDatabaseAsync()`.
Expand Down
17 changes: 12 additions & 5 deletions docs/operate/backend-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,13 +184,18 @@ Plus two pipeline hooks:
`Program.cs` runs an explicit bootstrap path at startup
(before `app.Run()`):

1. **Create master DB** (raw SQL, because Marten can't do this while
the connection hangs on a missing DB)
1. **Create the master DB and the `<master-db>_system` DB** (raw SQL, because
Marten can't `CREATE DATABASE` on its own connection)
2. **Apply Marten schema** (`Storage.ApplyAllConfiguredChangesToDatabaseAsync`)
→ `realms.mt_tenant_databases` is created
3. **Register system tenant** (`tenancy.AddDatabaseRecordAsync("system", masterCs)`)
4. **Apply Marten schema again** → per-tenant tables for the system tenant
5. **Seed system realm document** (`EnsureSystemRealmExistsAsync`)
3. **Register system tenant** (`tenancy.AddDatabaseRecordAsync("system", systemCs)`)
pointing at its own `<master-db>_system` DB — the master DB is pure
control-plane infra (registry + global Realm store + Wolverine durability)
and holds no tenant content
4. **Apply Marten schema again** → per-tenant tables for the system realm, in
its own DB
5. **Seed system realm document** (`EnsureSystemRealmExistsAsync`), stamped as
the control plane
6. **Seed default OAuth scopes + internal login provider**
(`OAuthRealmSeeder.SeedAsync`)
7. **Warm up RealmCache**
Expand All @@ -209,6 +214,8 @@ dotnet Modgud.Api.dll recover reset-2fa <username>
dotnet Modgud.Api.dll recover set-email <username> <email>
dotnet Modgud.Api.dll recover magic-link <username>
dotnet Modgud.Api.dll recover rebuild-projections
dotnet Modgud.Api.dll recover control-plane list
dotnet Modgud.Api.dll recover adopt-tenant <slug> <name> [domain]
```

Helps with lockouts: all 2FA lost, no admin left, projection
Expand Down
25 changes: 16 additions & 9 deletions docs/operate/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,14 @@ password-protected PFX from elsewhere:
:::

::: info Database naming
`DbSettings.ConnectionString` points at the master DB — pick any name
you like. When additional realms are created, modgud appends
`_<slug>` to that name for each tenant DB (e.g. for a master DB called
`auth`: `auth_acme`, `auth_finance`).
`DbSettings.ConnectionString` points at the master DB — pick any name you like.
The master DB holds only control-plane infrastructure (the tenant registry +
the global Realm store + Wolverine durability); it is **not** a tenant. Every
realm lives in its own `<master-db>_<slug>` DB, including the bootstrap system
realm (`<master-db>_system`, created at first boot). So for a master DB called
`auth` you get `auth_system`, `auth_acme`, `auth_finance`. Back up the master DB
**and** every `auth_<slug>` DB (system included — that's where system-realm
users and keys live).
:::

## Docker image
Expand Down Expand Up @@ -374,11 +378,14 @@ correct tenant DB.

On first start (or after every image update):

1. Master DB is created if missing (`CREATE DATABASE`)
2. Marten schema is applied (idempotent)
3. System tenant is registered in `realms.mt_tenant_databases`
4. Marten schema is applied again (per-tenant tables for the system tenant)
5. System realm document is seeded
1. The master DB **and** the `<master-db>_system` DB are created if missing
(`CREATE DATABASE`)
2. Marten schema is applied (idempotent) → the tenant registry table
3. System tenant is registered in `realms.mt_tenant_databases`, pointing at its
own `<master-db>_system` DB (the master DB is pure control-plane infra)
4. Marten schema is applied again (per-tenant tables for the system realm, in
its own DB)
5. System realm document is seeded (stamped as the control plane)
6. Default scopes + internal LoginProvider are seeded
7. RealmCache is warmed up

Expand Down
Loading
Loading