Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 9 additions & 2 deletions docs/concepts/apps-and-resource-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,15 @@ is the organisational clamp.**

Every artefact below the realm sits on one of these axes. App-scoped
artefacts (`PermissionRole.AppId`, `OAuthScope.AppId`,
`OAuthApi.AppId`) reach back up to the App; `Group.BoundTo` is the
activation switch ("is this group active in app X?").
`OAuthApi.AppId`) reach back up to the App. `Group.BoundTo` assigns a
group to one or more Apps: its effective members belong to those Apps'
Principal scopes, and its roles are active there.

This also supports pure assignment groups. A group with `BoundTo =
["acme"]` and no roles grants no permission, but its effective members
still belong to Acme's Principal scope. Modgud therefore needs no second
per-user or per-position App-assignment list. The `"*"` binding assigns
the group to every App in the realm.

## The App's second facet: a login experience (ADR-0011)

Expand Down
70 changes: 68 additions & 2 deletions docs/integrate/management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,15 @@ Use this flow when no person is present:

1. Create a role containing only the required Modgud permissions. For the
Position reads, grant `position:read`. Terminal provisioning needs both
`position:write` and `oauth-client:write`.
`position:write` and `oauth-client:write`. Reading an Application's complete
Principal scope needs `app-scope:read`.
2. Create or choose a group, attach the role, and add the Service Account as a
member.
3. On that Service Account, issue a credential and allow the
`modgud.management` scope. Modgud pins the resulting OAuth client to
`client_credentials` and to that Service Account.
`client_credentials` and to that Service Account. Assign the OAuth client to
each Application whose scope it may read; scope reads never treat an empty
`AppIds` list as permission to read every Application.
4. Store the one-time client secret in the consumer's secret store.

Request a token with the standard OAuth wire format:
Expand Down Expand Up @@ -137,6 +140,68 @@ or inline-creating a Service Account additionally needs
`service-account:write`. This prevents a client administrator from minting a
credential for a more privileged machine identity.

## Read an Application's Principal scope

`GET /api/app/{appId}/scope` returns one consistent full read of the Principals
assigned to an Application. It requires `app-scope:read` and accepts the App id
as a Guid or ShortGuid. A bearer caller may only target Applications listed in
its OAuth client's `AppIds`; an empty assignment grants no scope access. A
cookie-authenticated administrator with the same permission may read any
Application in the realm.

There is no separate scope configuration. Modgud derives the result from the
existing group graph:

1. Every active group whose `BoundTo` contains the App slug or `*` is a scope
root.
2. Each root, its nested groups, and every transitive active member belong to
the scope.
3. All Principal kinds use the same rule: Person, Position, Service Account,
and Group.

A group may deliberately have no roles. It then grants no permission while
still assigning its members to the selected Application scopes. The admin UI
marks such a group as **No permissions**.

The response includes an opaque `scopeVersion`, the contributing root groups,
and the typed Principal records:

```json
{
"appId": "<short-guid>",
"appSlug": "alert-hub",
"scopeVersion": "v1-<opaque-hash>",
"rootGroups": [
{
"id": "<short-guid>",
"name": "AlertHub principals",
"hasPermissions": false
}
],
"principals": [
{
"id": "<short-guid>",
"type": "person",
"displayName": "AP | Alice Person",
"isActive": true,
"isScopeRoot": false,
"accountName": "alice",
"firstname": "Alice",
"lastname": "Person",
"acronym": "AP",
"email": "alice@example.com"
}
]
}
```

`scopeVersion` versions the **definition**, not every member. Adding or removing
an App binding, changing the nested-group structure, or changing an automatic
membership predicate changes it and tells a consumer to perform a new full
read. Ordinary direct membership/profile changes leave it stable; the resumable
change stream can therefore represent those as individual changes. Consumers
must treat the version as opaque and compare it for equality only.

## Delegated-person setup

Use this flow when the consumer should act with the permissions of a signed-in
Expand Down Expand Up @@ -183,6 +248,7 @@ scheme does not turn every cookie-only admin route into a remote API.
|---|---|---|---|
| `GET` | `/api/position` | `position:read` | `PositionTerminals` enabled |
| `GET` | `/api/position/{id}` | `position:read` | `PositionTerminals` enabled |
| `GET` | `/api/app/{id}/scope` | `app-scope:read` | Full `BoundTo`-derived Principal snapshot |
| `POST` | `/api/admin/oauth/clients` | `oauth-client:write` | `position:write` for terminal provisioning; `service-account:write` for an SA link |

Direct Position creation, mutation, deletion, grants, terminal enrollment, and
Expand Down
229 changes: 229 additions & 0 deletions src/dotnet/Modgud.Api.Tests/Authorization/ApplicationScopeApiTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using BuildingBlocks.Helper;
using Microsoft.Extensions.DependencyInjection;
using Modgud.Api.Tests.Infrastructure;
using Modgud.Application.DTOs.OAuth;
using Modgud.Application.Services;
using Modgud.Authorization.Apps;
using Modgud.Authorization.Events;
using Modgud.Authorization.Principals;
using Modgud.Domain.OAuth.Common;
using Modgud.Domain.OAuth.Management;

namespace Modgud.Api.Tests.Authorization;

[Collection(IntegrationTestCollection.Name)]
public class ApplicationScopeApiTests : IntegrationTestBase
{
public ApplicationScopeApiTests(SharedPostgresFixture fixture) : base(fixture) { }

private sealed record ScopeRoot(string Id, string Name, bool HasPermissions);
private sealed record ScopePrincipal(string Id, string Type, string DisplayName, bool IsScopeRoot);
private sealed record ScopeResponse(
string AppId,
string AppSlug,
string ScopeVersion,
List<ScopeRoot> RootGroups,
List<ScopePrincipal> Principals);

[Fact]
public async Task Full_read_uses_bound_groups_and_keeps_version_stable_for_membership_changes()
{
var ct = TestContext.Current.CancellationToken;
var appId = Guid.NewGuid();
var positionId = Guid.NewGuid();
var serviceAccount = new ServiceAccount
{
Id = Guid.NewGuid(),
AccountName = "scope-sync",
Purpose = "Scope API test",
};
var nestedId = Guid.NewGuid();
var rootId = Guid.NewGuid();

await using (var session = GetTenantedDocumentSession())
{
session.Events.StartStream<App>(appId, new AppCreatedEvent(
appId, "scope-test", "Scope test", null, [], IsSystem: false));
session.Events.StartStream<PositionPrincipal>(positionId, new PositionPrincipalCreatedEvent(
positionId, "gate", "Gate", true, PositionTerminalPolicy.Disabled));
session.Store(serviceAccount);
session.Events.StartStream<Group>(nestedId, new GroupCreatedEvent(
nestedId, "Nested", null,
[positionId, serviceAccount.Id], [], BoundTo: []));
session.Events.StartStream<Group>(rootId, new GroupCreatedEvent(
rootId, "Scope only", null,
[DefaultUser!.Id, nestedId], [], BoundTo: ["scope-test"]));
await session.SaveChangesAsync(ct);
}

var path = $"/api/app/{new ShortGuid(appId)}/scope";
var firstResponse = await Client.GetAsync(path, ct);
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
var first = await firstResponse.Content.ReadFromJsonAsync<ScopeResponse>(JsonOptions, ct);

Assert.NotNull(first);
Assert.Equal("scope-test", first.AppSlug);
var ownRoot = Assert.Single(
first.RootGroups,
root => root.Id == new ShortGuid(rootId).ToString());
Assert.False(ownRoot.HasPermissions);

var expectedPrincipals = new Dictionary<Guid, string>
{
[rootId] = "group",
[nestedId] = "group",
[DefaultUser!.Id] = "person",
[positionId] = "position",
[serviceAccount.Id] = "service-account",
};
foreach (var (principalId, principalType) in expectedPrincipals)
{
var principal = Assert.Single(
first.Principals,
candidate => candidate.Id == new ShortGuid(principalId).ToString());
Assert.Equal(principalType, principal.Type);
}

Assert.True(Assert.Single(
first.Principals,
principal => principal.Id == new ShortGuid(rootId).ToString()).IsScopeRoot);

var secondServiceAccount = new ServiceAccount
{
Id = Guid.NewGuid(),
AccountName = "scope-sync-2",
};
await using (var session = GetTenantedDocumentSession())
{
session.Store(secondServiceAccount);
session.Events.Append(rootId, new GroupUpdatedEvent(
rootId, "Scope only", null,
[DefaultUser!.Id, nestedId, secondServiceAccount.Id], [],
BoundTo: ["scope-test"]));
await session.SaveChangesAsync(ct);
}

var second = await Client.GetFromJsonAsync<ScopeResponse>(path, JsonOptions, ct);
Assert.NotNull(second);
Assert.Equal(first.ScopeVersion, second.ScopeVersion);
Assert.Contains(second.Principals, p => p.Id == new ShortGuid(secondServiceAccount.Id).ToString());

var secondRootId = Guid.NewGuid();
await using (var session = GetTenantedDocumentSession())
{
session.Events.StartStream<Group>(secondRootId, new GroupCreatedEvent(
secondRootId, "Second root", null, [], [], BoundTo: ["scope-test"]));
await session.SaveChangesAsync(ct);
}

var third = await Client.GetFromJsonAsync<ScopeResponse>(path, JsonOptions, ct);
Assert.NotNull(third);
Assert.NotEqual(second.ScopeVersion, third.ScopeVersion);
}

[Fact]
public async Task Bearer_client_can_read_only_its_assigned_application_scope()
{
var ct = TestContext.Current.CancellationToken;
var ownAppId = Guid.NewGuid();
var foreignAppId = Guid.NewGuid();
var serviceAccount = new ServiceAccount
{
Id = Guid.NewGuid(),
AccountName = $"scope-reader-{Guid.NewGuid():N}",
};

await using (var session = GetTenantedDocumentSession())
{
session.Events.StartStream<App>(ownAppId, new AppCreatedEvent(
ownAppId, $"scope-own-{Guid.NewGuid():N}", "Own App", null, [], IsSystem: false));
session.Events.StartStream<App>(foreignAppId, new AppCreatedEvent(
foreignAppId, $"scope-foreign-{Guid.NewGuid():N}", "Foreign App", null, [], IsSystem: false));
session.Store(serviceAccount);
await session.SaveChangesAsync(ct);
}

var role = await Factory.CreateTestRoleAsync(
$"AppScopeReader_{Guid.NewGuid():N}", [("app-scope", "read")]);
await Factory.CreateTestGroupAsync(
$"AppScopeReaders_{Guid.NewGuid():N}", [serviceAccount.Id], [role.Id]);

var clientId = $"app-scope-reader-{Guid.NewGuid():N}";
await CreateManagementClientAsync(clientId, serviceAccount.Id, ownAppId);
var token = await IssueManagementTokenAsync(clientId);

using var own = await SendScopeGetAsync(token, ownAppId);
Assert.Equal(HttpStatusCode.OK, own.StatusCode);

using var foreign = await SendScopeGetAsync(token, foreignAppId);
var body = await foreign.Content.ReadAsStringAsync(ct);
Assert.Equal(HttpStatusCode.Forbidden, foreign.StatusCode);
Assert.Contains("Management.ClientAppMismatch", body);
}

private async Task CreateManagementClientAsync(
string clientId,
Guid serviceAccountId,
Guid appId)
{
using var scope = Factory.Services.CreateScope();
var oauth = scope.ServiceProvider.GetRequiredService<OAuthAdminService>();
var result = await oauth.CreateClientAsync(new CreateOAuthClientDto
{
ClientId = clientId,
ClientSecret = $"{clientId}-secret",
ClientType = OAuthClientTypes.Confidential,
ConsentType = OAuthConsentTypes.Implicit,
DisplayName = clientId,
RedirectUris = [],
PostLogoutRedirectUris = [],
Scopes = [ModgudManagementApi.Scope],
AllowedGrantTypes = ["client_credentials"],
RequireConsent = false,
AccessTokenType = AccessTokenType.Jwt,
AppIds = [new ShortGuid(appId).ToString()],
LinkedServiceAccountId = new ShortGuid(serviceAccountId).ToString(),
}, TestContext.Current.CancellationToken);
if (result.IsError)
{
throw new InvalidOperationException(
$"CreateClientAsync failed: {string.Join(", ", result.Errors.Select(error => $"{error.Code}: {error.Description}"))}");
}
}

private async Task<string> IssueManagementTokenAsync(string clientId)
{
var form = new List<KeyValuePair<string, string>>
{
new("grant_type", "client_credentials"),
new("client_id", clientId),
new("client_secret", $"{clientId}-secret"),
new("scope", ModgudManagementApi.Scope),
new("resource", ModgudManagementApi.Audience),
};
using var tokenClient = Factory.CreateClient();
using var response = await tokenClient.PostAsync(
"/connect/token",
new FormUrlEncodedContent(form),
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.IsSuccessStatusCode,
$"client_credentials failed ({(int)response.StatusCode}): {body}");
using var document = JsonDocument.Parse(body);
return document.RootElement.GetProperty("access_token").GetString()!;
}

private async Task<HttpResponseMessage> SendScopeGetAsync(string token, Guid appId)
{
using var request = new HttpRequestMessage(
HttpMethod.Get,
$"/api/app/{new ShortGuid(appId)}/scope");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var client = Factory.CreateClient();
return await client.SendAsync(request, TestContext.Current.CancellationToken);
}
}
Loading
Loading