FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

feat(provisioning): full realm-manifest coverage — clients, app settings, login providers, positions by windischb · Pull Request #210 · cocoar-dev/modgud · GitHub

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension .cs  (10) .md  (1) All 2 file types selected
Viewed files
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Unified
Split
Hide whitespace
Diff view
Unified
Split
Hide whitespace
43 changes: 31 additions & 12 deletions docs/admin/realm-provisioning.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ A manifest is one object with a required `Realm` plus optional entity lists.
- Permissions are addressed as **`resource:action`** (e.g. `invoice:read`).
- Groups list **`Members`** (user keys) and **`Roles`** (role keys). Group
membership is the *only* way users get roles.
- Login providers are keyed by their **`Slug`** (the one in the provider's
callback URLs); positions list **`Grants`** as user keys.

```jsonc
{
Expand All @@ -93,9 +95,10 @@ A manifest is one object with a required `Realm` plus optional entity lists.
"Domains": ["acme.example.com"],
"InitialAdmin": { "UserName": "admin", "Email": "admin@acme.example.com" }
},
"Settings": { /* optional realm-settings patch (self-reg, native grants, …) */ },
"Settings": { /* optional realm-settings patch (self-reg, sessions, native grants, …) */ },
"Apps": [ { "Slug": "acme", "DisplayName": "Acme",
"Permissions": [ { "Resource": "invoice", "Action": "read" } ] } ],
"Permissions": [ { "Resource": "invoice", "Action": "read" } ],
"Settings": { /* optional per-App override: Origin (host routing), branding, … */ } } ],
"Apis": [ { "Name": "acme-api", "App": "acme",
"Permissions": [ { "Resource": "invoice", "Action": "read" } ] } ],
"Scopes": [ { "Name": "invoice.read", "App": "acme", "Resources": ["acme-api"] } ],
Expand All @@ -107,10 +110,23 @@ A manifest is one object with a required `Realm` plus optional entity lists.
"Roles": [ { "Key": "acme-admin", "Name": "acme-admin", "App": "acme",
"Permissions": [ { "Resource": "invoice", "Action": "read" } ] } ],
"Users": [ { "Key": "alice", "Email": "alice@acme.example.com", "UserName": "alice" } ],
"Groups": [ { "Name": "Admins", "Members": ["alice"], "Roles": ["acme-admin"] } ]
"Groups": [ { "Name": "Admins", "Members": ["alice"], "Roles": ["acme-admin"] } ],
"LoginProviders": [ { "Slug": "corp-idp", "Flavor": "GenericOidc", "DisplayName": "Corp IdP",
"ClientId": "modgud", "ClientSecret": "<from the upstream IdP>",
"FlavorData": { "MetadataUri": "https://idp.example.com/.well-known/openid-configuration" } } ],
"Positions": [ { "AccountName": "gate.porter", "Grants": ["alice"],
"TerminalPolicy": { "Enabled": true,
"AllowedActivationProofs": ["personal-passkey"],
"AllowedDeviceBindings": ["dpop"],
"StaffingSessionLifetimeMinutes": 60,
"MaximumStaffingSessionLifetimeMinutes": 480 } } ]
}
```

Positions require the `PositionTerminals` feature flag; terminal **slots** (device
enrollments and their one-time-secret clients) are credential material, not config —
provision them through the position/terminal admin APIs after import.

See the [schema](#discover-the-schema) for every field and its meaning.

## Quickstart
Expand Down Expand Up @@ -156,19 +172,22 @@ clients keep their secret across `apply`.
Add **`?prune=true`** to make it a full sync: after the merge, entities in the realm
that are *absent* from the manifest are deleted (in dependency order). To prevent a
manifest from locking a realm out, prune **never deletes** the system app, auto-seeded
standard scopes, service-account-linked clients, or anything conferring `realm:admin`
(a realm-admin role, any current admin user, or an admin-conferring group).
standard scopes, service-account-linked and terminal-managed clients, the built-in
Internal login provider, or anything conferring `realm:admin` (a realm-admin role, any
current admin user, or an admin-conferring group).

## Export

`GET /{slug}/export` returns the realm as a manifest — the inverse of import. It is
**structure-only**: it never emits client secrets or password hashes (those are
one-way), and it omits auto-seeded standard scopes / system apps / SA-linked clients.
This is deliberate — it is *not* a backup (a real backup needs the whole tenant
database). Its purpose is **get-config → edit → re-apply**: export a realm, add a user
password or tweak a setting, and `POST` it back to `/{slug}/apply`. Because confidential
clients regenerate a secret on import and users can be created passwordless, a
structure-only manifest still re-applies into a fully working realm.
**structure-only**: it never emits client secrets, login-provider secrets, or password
hashes (those are one-way or encrypted), and it omits auto-seeded standard scopes /
system apps / the built-in Internal login provider / SA-linked and terminal-managed
clients / terminal slots. This is deliberate — it is *not* a backup (a real backup
needs the whole tenant database). Its purpose is **get-config → edit → re-apply**:
export a realm, add a user password or a provider secret, tweak a setting, and `POST`
it back to `/{slug}/apply`. Because confidential clients regenerate a secret on import
and users can be created passwordless, a structure-only manifest still re-applies into
a fully working realm.

## Per-realm self-service

Expand Down
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Modgud.Authorization.Roles;
using Modgud.Domain.OAuth.Apis;
using Modgud.Domain.OAuth.Applications;
using Modgud.Domain.OAuth.Common;
using Modgud.Domain.OAuth.Scopes;
using Modgud.Infrastructure.Persistence.Tenancy;
using Modgud.Infrastructure.Realms;
Expand Down Expand Up @@ -323,6 +324,66 @@ await InTenantAsync(factory, slug, async sp =>
});
}

[Fact]
public async Task Import_and_update_apply_the_client_access_token_type()
{
await using var host = await Fixture.CreateIsolatedHostAsync();
var factory = host.Factory;
var ct = TestContext.Current.CancellationToken;
var applier = factory.Services.GetRequiredService<RealmManifestApplier>();

const string slug = "tokentype";
RealmManifestClient Client(string? accessTokenType) => new()
{
ClientId = "tt-web",
ClientType = "confidential",
RedirectUris = ["https://tt.test/cb"],
Scopes = ["openid"],
AllowedGrantTypes = ["authorization_code", "refresh_token"],
AccessTokenType = accessTokenType,
};
var manifest = new RealmManifest
{
Realm = new CreateRealmDto
{
Slug = slug,
DisplayName = slug,
Domains = [$"{slug}.localhost"],
InitialAdmin = new InitialAdminDto { UserName = "admin", Email = $"admin@{slug}.test" },
},
Clients = [Client("Jwt")],
};
Assert.False((await applier.ImportNewRealmAsync(manifest, ct)).IsError);

async Task<AccessTokenType> GetTokenTypeAsync()
{
var tokenType = default(AccessTokenType);
await InTenantAsync(factory, slug, async sp =>
{
tokenType = (await sp.GetRequiredService<OAuthAdminService>()
.GetClientsAsync(new PaginationRequest { PageSize = 200 }, ct))
.Items.Single(c => c.ClientId == "tt-web").AccessTokenType;
});
return tokenType;
}

// Import applied the manifest value instead of silently falling back to Reference.
Assert.Equal(AccessTokenType.Jwt, await GetTokenTypeAsync());

// Apply with the field OMITTED: no change (same patch semantics as the bool flags).
Assert.False((await applier.UpdateRealmAsync(manifest with { Clients = [Client(null)] }, ct: ct)).IsError);
Assert.Equal(AccessTokenType.Jwt, await GetTokenTypeAsync());

// Apply with an explicit 'Reference': the merge flips it back.
Assert.False((await applier.UpdateRealmAsync(manifest with { Clients = [Client("Reference")] }, ct: ct)).IsError);
Assert.Equal(AccessTokenType.Reference, await GetTokenTypeAsync());

// An invalid value is a contextual validation error, not a silent default.
var invalid = await applier.UpdateRealmAsync(manifest with { Clients = [Client("Bogus")] }, ct: ct);
Assert.True(invalid.IsError);
Assert.Equal("Manifest.InvalidEnum", invalid.FirstError.Code);
}

[Fact]
public async Task Update_rejects_a_slug_that_does_not_exist()
{
Expand Down
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ public async Task Export_is_structure_only_and_round_trips_with_apply_and_passwo
RedirectUris = ["https://ex.test/cb"],
Scopes = ["openid"],
AllowedGrantTypes = ["authorization_code", "refresh_token"],
AccessTokenType = "Jwt",
RequireDpop = true,
AccessTokenLifetime = 600,
Apps = ["ex-app"],
},
],
Expand All @@ -68,6 +71,9 @@ public async Task Export_is_structure_only_and_round_trips_with_apply_and_passwo
Assert.Null(exClient.ClientSecret);
Assert.Contains("openid", exClient.Scopes);
Assert.Contains("ex-app", exClient.Apps);
Assert.Equal("Jwt", exClient.AccessTokenType); // token format round-trips through export
Assert.Equal(true, exClient.RequireDpop);
Assert.Equal(600, exClient.AccessTokenLifetime);
var exUser = Assert.Single(m.Users, u => u.UserName == "bob");
Assert.Null(exUser.Password);

Expand All @@ -80,6 +86,9 @@ public async Task Export_is_structure_only_and_round_trips_with_apply_and_passwo
Assert.NotNull(m.Settings);
Assert.Equal("Optional", m.Settings!.RegistrationFields!.Username); // shipped default
Assert.Null(m.Settings.SelfRegistration!.CaptchaSecret); // write-only — never exported
Assert.NotNull(m.Settings.BrowserSessions); // session policies export too
Assert.NotNull(m.Settings.ClientSessions);
Assert.NotNull(m.Settings.PositionSecurity);

// ── Re-apply the UNEDITED export = idempotent ──────────────────────────
Assert.False((await applier.UpdateRealmAsync(m, ct: ct)).IsError);
Expand Down
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Modgud.Application.DTOs.Realms;
using Modgud.Application.Services;
using Modgud.Authorization.Apps;
using Modgud.Domain.OAuth.Common;
using Modgud.Infrastructure.Persistence.Tenancy;

namespace Modgud.Api.Tests.ColdStart;
Expand Down Expand Up @@ -49,8 +50,16 @@ await InTenantAsync(factory, slugA, async sp =>
RedirectUris = ["https://parity.test/cb"],
Scopes = ["openid"],
AllowedGrantTypes = ["authorization_code", "refresh_token"],
AllowedCorsOrigins = ["https://parity.test"],
Enabled = true,
RequireConsent = false,
AccessTokenType = AccessTokenType.Jwt,
RequirePushedAuthorizationRequests = true,
RequireDpop = true,
AccessTokenLifetime = 300,
Claims = [new OAuthClientClaimDto { Type = "tenant", Value = "parity" }],
ClientClaimsPrefix = "client_",
AlwaysSendClientClaims = true,
AppIds = [new ShortGuid(appId).ToString()],
}, ct);
Assert.False(created.IsError, created.IsError ? created.FirstError.Description : string.Empty);
Expand All @@ -69,6 +78,14 @@ await InTenantAsync(factory, slugA, async sp =>
RedirectUris = ["https://parity.test/cb"],
Scopes = ["openid"],
AllowedGrantTypes = ["authorization_code", "refresh_token"],
AllowedCorsOrigins = ["https://parity.test"],
AccessTokenType = "Jwt",
RequirePushedAuthorizationRequests = true,
RequireDpop = true,
AccessTokenLifetime = 300,
Claims = [new RealmManifestClientClaim("tenant", "parity")],
ClientClaimsPrefix = "client_",
AlwaysSendClientClaims = true,
Apps = ["parity-app"],
},
],
Expand All @@ -90,8 +107,17 @@ private sealed record ClientShape(
string PostLogoutRedirectUris,
string AllowedGrantTypes,
string Permissions,
string CorsOrigins,
bool Enabled,
bool RequireConsent,
AccessTokenType AccessTokenType,
bool RequirePushedAuthorizationRequests,
bool RequireDpop,
bool RequireDpopNonce,
int? AccessTokenLifetime,
string Claims,
string? ClientClaimsPrefix,
bool AlwaysSendClientClaims,
string AppSlugs);

private static async Task<ClientShape> GetClientShapeAsync(
Expand Down Expand Up @@ -120,8 +146,17 @@ await InTenantAsync(factory, slug, async sp =>
Join(client.PostLogoutRedirectUris),
Join(client.AllowedGrantTypes),
Join(client.Permissions),
Join(client.AllowedCorsOrigins),
client.Enabled,
client.RequireConsent,
client.AccessTokenType,
client.RequirePushedAuthorizationRequests,
client.RequireDpop,
client.RequireDpopNonce,
client.AccessTokenLifetime,
Join(client.Claims.Select(c => $"{c.Type}={c.Value}")),
client.ClientClaimsPrefix,
client.AlwaysSendClientClaims,
Join(slugs));
});
return shape;
Expand Down
Loading
Loading

Back | FazBrowse Home | New Git URL