Skip to content

Commit aed4590

Browse files
dcl10claude
andauthored
Docs/keycloak setup (#51)
* Add Users group and missing roles to Keycloak seeder Adds a Users group constant and seeds it alongside Admin and Guest. Adds ManageSkills and ManageProjects to the seeded role list, which were previously missing. Maps ViewContent, ManageSkills, and ManageProjects to the Users group; all roles to Admin; ViewContent only to Guest. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add Keycloak setup doc and trim README Extracts all Keycloak configuration detail from README into a new docs/KEYCLOAK_SETUP.md covering realm, clients, roles, groups, claims, and the default-group decision. README retains a single reference link. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Rename KEYCLOAK_SETUP.md to keycloak_setup.md macOS case-insensitive filesystem required git mv to detect the rename. Updates the README link accordingly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ea6d6a6 commit aed4590

4 files changed

Lines changed: 184 additions & 64 deletions

File tree

README.md

Lines changed: 1 addition & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -117,50 +117,7 @@ dotnet run --project src/SkillMatrixLlm.Api
117117

118118
### 1 — Configure Keycloak
119119

120-
Create two clients in your Keycloak realm:
121-
122-
**`skill-matrix-llm-frontend`** — confidential client used by the Next.js backend (NextAuth)
123-
124-
| Setting | Value |
125-
| --- | --- |
126-
| Client authentication | **On** |
127-
| Authentication flow | Standard flow only |
128-
| Valid redirect URIs | `<frontend-url>` e.g., `http://localhost:3000/*` |
129-
| Web origins | `<frontend-url>` e.g., `http://localhost:3000` |
130-
131-
Add an **Audience** mapper to the frontend client so that the access tokens it receives include `skill-matrix-llm-api` in the `aud` claim. The backend validates this claim on every request — without it you will get an audience validation error.
132-
- Clients → `skill-matrix-llm-frontend` → Client scopes → `skill-matrix-llm-frontend-dedicated` → Add mapper → **Audience**
133-
- Included client audience: `skill-matrix-llm-api` — Add to access token: **On**
134-
135-
Copy the client secret from the **Credentials** tab into `AUTH_KEYCLOAK_SECRET` (`.env` locally, Key Vault in Azure).
136-
137-
---
138-
139-
**`skill-matrix-llm-api`** — confidential client representing the backend API (used as the JWT audience)
140-
141-
| Setting | Value |
142-
| --- | --- |
143-
| Client authentication | **On** |
144-
| Authentication flow | Standard flow only |
145-
146-
Copy the client secret into `Keycloak__Secret` in App Service config (or Key Vault reference).
147-
148-
---
149-
150-
**`skill-matrix-llm-public`** — public client for developer API docs via Scalar — **local development only**
151-
152-
| Setting | Value |
153-
| --- | --- |
154-
| Client authentication | **Off** (public client) |
155-
| Authentication flow | Standard flow only |
156-
| Valid redirect URIs | `http://localhost:5000/*` |
157-
| Web origins | `http://localhost:5000` |
158-
159-
No secret is required. PKCE (SHA-256) is enforced by the Scalar configuration.
160-
161-
---
162-
163-
Update Keycloak URLs in `infra/main.*.bicepparam` files and `AUTH_KEYCLOAK_ISSUER` in frontend `.env.example` and App Service config.
120+
See [docs/keycloak_setup.md](docs/keycloak_setup.md) for the full realm, client, role, and group configuration.
164121

165122
### 2 — Provision Azure Infrastructure
166123

backend/src/SkillMatrixLlm.Api/Auth/KeycloakData.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public static class Groups
2828
{
2929
public const string Admin = "Admin";
3030
public const string Guest = "Guest";
31+
public const string Users = "Users";
3132
}
3233

3334
/// <summary>

backend/src/SkillMatrixLlm.Api/Data/Seeder/KeycloakDataSeeder.cs

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,9 @@ private async Task SeedGroups()
4343

4444
var groups = new List<GroupRepresentation>
4545
{
46-
new GroupRepresentation
47-
{
48-
Name = Groups.Admin
49-
},
50-
new GroupRepresentation()
51-
{
52-
Name = Groups.Guest
53-
}
46+
new GroupRepresentation { Name = Groups.Admin },
47+
new GroupRepresentation { Name = Groups.Guest },
48+
new GroupRepresentation { Name = Groups.Users }
5449
};
5550

5651
foreach (var group in groups)
@@ -89,14 +84,10 @@ private async Task SeedRoles()
8984
{
9085
Name = Roles.ViewUsers
9186
},
92-
new RoleRepresentation
93-
{
94-
Name = Roles.SendHealthCheckEmails
95-
},
96-
new RoleRepresentation
97-
{
98-
Name = Roles.ViewContent
99-
},
87+
new RoleRepresentation { Name = Roles.SendHealthCheckEmails },
88+
new RoleRepresentation { Name = Roles.ViewContent },
89+
new RoleRepresentation { Name = Roles.ManageSkills },
90+
new RoleRepresentation { Name = Roles.ManageProjects },
10091
};
10192

10293
foreach (var role in roles)
@@ -121,10 +112,11 @@ private async Task MapRolesToGroups()
121112

122113
var adminGroup = groups.FirstOrDefault(x => x.Name == Groups.Admin);
123114
var guestGroup = groups.FirstOrDefault(x => x.Name == Groups.Guest);
115+
var usersGroup = groups.FirstOrDefault(x => x.Name == Groups.Users);
124116

125-
if (adminGroup is null || guestGroup is null)
117+
if (adminGroup is null || guestGroup is null || usersGroup is null)
126118
{
127-
throw new InvalidOperationException("Admin or Guest group not found");
119+
throw new InvalidOperationException("Admin, Guest, or Users group not found");
128120
}
129121

130122
var adminRoles = new List<string>
@@ -134,18 +126,27 @@ private async Task MapRolesToGroups()
134126
Roles.DeleteUsers,
135127
Roles.ViewUsers,
136128
Roles.SendHealthCheckEmails,
137-
Roles.ViewContent
129+
Roles.ViewContent,
130+
Roles.ManageSkills,
131+
Roles.ManageProjects
132+
};
133+
134+
var usersRoles = new List<string>
135+
{
136+
Roles.ViewContent,
137+
Roles.ManageSkills,
138+
Roles.ManageProjects
138139
};
139140

140141
var guestRoles = new List<string>
141142
{
142143
Roles.ViewContent
143144
};
144145

145-
146146
var existingRoles = await realm.Roles.GetAsync() ?? [];
147147

148148
await AssignRolesToGroup(adminGroup.Id!, existingRoles.Where(x => adminRoles.Contains(x.Name!)).ToList());
149+
await AssignRolesToGroup(usersGroup.Id!, existingRoles.Where(x => usersRoles.Contains(x.Name!)).ToList());
149150
await AssignRolesToGroup(guestGroup.Id!, existingRoles.Where(x => guestRoles.Contains(x.Name!)).ToList());
150151
}
151152

docs/keycloak_setup.md

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# Keycloak Realm Setup
2+
3+
This document describes the roles, groups, and claims you need to configure in your Keycloak realm to run this application.
4+
5+
---
6+
7+
## Realm
8+
9+
Name your realm `skill-matrix-llm` (must match `AUTH_KEYCLOAK_ISSUER` and the backend `Realm` config value).
10+
11+
---
12+
13+
## Clients
14+
15+
You need three clients:
16+
17+
---
18+
19+
### `skill-matrix-llm-frontend`
20+
21+
Confidential client used by the Next.js backend (NextAuth).
22+
23+
| Setting | Value |
24+
|---------|-------|
25+
| Client authentication | **On** |
26+
| Authentication flow | Standard flow only |
27+
| Valid redirect URIs | `<frontend-url>/*` e.g. `http://localhost:3000/*` |
28+
| Web origins | `<frontend-url>` e.g. `http://localhost:3000` |
29+
30+
Add an **Audience** mapper so that access tokens include `skill-matrix-llm-api` in the `aud` claim. The backend validates this on every request — without it you get an audience validation error.
31+
32+
- Clients → `skill-matrix-llm-frontend` → Client scopes → `skill-matrix-llm-frontend-dedicated` → Add mapper → **Audience**
33+
- Included client audience: `skill-matrix-llm-api` — Add to access token: **On**
34+
35+
Copy the client secret from the **Credentials** tab into `AUTH_KEYCLOAK_SECRET` (`.env.local` locally, Key Vault in Azure).
36+
37+
---
38+
39+
### `skill-matrix-llm-api`
40+
41+
Confidential client representing the backend API — used as the JWT audience and for Admin API machine-to-machine calls.
42+
43+
| Setting | Value |
44+
|---------|-------|
45+
| Client authentication | **On** |
46+
| Authentication flow | Standard flow only |
47+
48+
Copy the client secret into `Keycloak__Secret` in App Service config (or Key Vault reference).
49+
50+
---
51+
52+
### `skill-matrix-llm-public`
53+
54+
Public client for developer API docs via Scalar — **local development only**, do not create in production.
55+
56+
| Setting | Value |
57+
|---------|-------|
58+
| Client authentication | **Off** (public client) |
59+
| Authentication flow | Standard flow only |
60+
| Valid redirect URIs | `http://localhost:5000/*` |
61+
| Web origins | `http://localhost:5000` |
62+
63+
No secret required. PKCE (SHA-256) is enforced by the Scalar configuration.
64+
65+
---
66+
67+
After creating the clients, update `AUTH_KEYCLOAK_ISSUER` in `frontend/.env.example` and App Service config, and update the Keycloak URLs in the `infra/main.*.bicepparam` files.
68+
69+
---
70+
71+
## Roles
72+
73+
The seeder creates these as **realm roles**, which is fine — this is the only app on this realm so there is no namespace collision concern. The claims transformer reads from both `realm_access.roles` and `resource_access.{clientId}.roles` in the JWT, so either approach works.
74+
75+
### Roles to create
76+
77+
| Role name | What it grants |
78+
|-----------|----------------|
79+
| `CreateUsers` | Create new users in Keycloak via the API |
80+
| `UpdateUsers` | Update user details and assign roles to other users |
81+
| `DeleteUsers` | Delete users |
82+
| `ViewUsers` | Read the user list and individual user profiles |
83+
| `ManageSkills` | Create, rename, and delete skills in the catalogue |
84+
| `ManageProjects` | Create and manage projects; trigger LLM recommendations |
85+
86+
> **`SendHealthCheckEmails`** and **`ViewContent`** are defined in code but not enforced as distinct policies — the health check endpoint actually requires `ViewUsers`, and `ViewContent` is currently unused. You do not need to create these in Keycloak unless you plan to use them in future.
87+
88+
---
89+
90+
## Groups
91+
92+
Create the following groups in the realm:
93+
94+
| Group name | Roles assigned | Intended members |
95+
|------------|---------------|-----------------|
96+
| `Admin` | All roles | Administrators |
97+
| `Users` | `ViewContent`, `ManageProjects`, `ManageSkills` | Regular authenticated users |
98+
| `Guest` | `ViewContent` | Read-only viewers |
99+
100+
Assign the realm roles to each group using **Group Role Mappings** rather than assigning roles directly to individual users. This makes access management much easier.
101+
102+
### Default group for self-registration
103+
104+
The app uses Keycloak's own registration UI (via `prompt=create`), so new users who sign up themselves are not created through the backend — no code runs to assign them a group. Without a default group they would land in the app authenticated but with no roles, and every policy-gated endpoint would return 403.
105+
106+
Set the default group to `Users` in **Realm Settings → General → Default Groups**. Every self-registered user is then automatically placed in `Users` after verifying their email, which is the right level of access for anyone signing up.
107+
108+
> Email verification is already enabled, which is sufficient access control for this app — invite-only was considered but rejected because anyone who would be invited would get standard `Users` access anyway, so the extra admin overhead buys nothing.
109+
110+
> **Ownership enforcement — current state:**
111+
>
112+
> - **User skills:** enforced. `UsersController` runs an `IsSelfOrAdmin` check before add/update/remove skill endpoints — the request is allowed only if the caller holds the `UpdateUsers` Keycloak role (admin) or their `sub` claim resolves to the same user as the path parameter.
113+
> - **Projects:** not yet enforced. `Update`, `TransitionStatus`, `Close`, and all team management endpoints (`CreateTeam`, `AddTeamMember`, `RemoveTeamMember`, `ConfirmTeam`, `RejectTeam`) execute without any caller-vs-owner check. Any user with the `ManageProjects` Keycloak role can currently mutate any project.
114+
>
115+
> Project ownership enforcement is planned but not yet implemented.
116+
117+
---
118+
119+
## Standard claims (no configuration needed)
120+
121+
The following claims are read from the JWT by the backend but are standard OIDC claims that Keycloak includes automatically — you do not need to add custom mappers for them:
122+
123+
| Claim | Source | Used for |
124+
|-------|--------|---------|
125+
| `sub` | Always present | Keycloak user ID, stored as the primary key in the app DB |
126+
| `name` | `profile` scope | Display name shown in the UI |
127+
| `preferred_username` | `profile` scope | Fallback display name if `name` is absent |
128+
| `email` | `email` scope | Shown on profile; used when sending health-check emails |
129+
130+
Ensure the `profile` and `email` scopes are added to the `skill-matrix-llm-frontend` client (they are included by default in new Keycloak clients).
131+
132+
---
133+
134+
## Token mapper for client roles
135+
136+
Keycloak does **not** include `resource_access` in access tokens by default for all client configurations. Verify this is working:
137+
138+
1. In your realm, go to **Clients → skill-matrix-llm-api → Client scopes**.
139+
2. Open the dedicated scope (`skill-matrix-llm-api-dedicated`).
140+
3. Confirm a **"roles"** mapper of type **"User Client Role"** exists and has **"Add to access token"** enabled.
141+
142+
If it is missing, add it manually:
143+
- Mapper type: `User Client Role`
144+
- Client ID: `skill-matrix-llm-api`
145+
- Token claim name: `resource_access.${client_id}.roles`
146+
- Multivalued: on
147+
- Add to access token: on
148+
149+
---
150+
151+
## Summary checklist
152+
153+
- [ ] Realm named `skill-matrix-llm`
154+
- [ ] Client `skill-matrix-llm-frontend` created with redirect URIs and audience mapper
155+
- [ ] Client `skill-matrix-llm-api` created (confidential, client credentials enabled)
156+
- [ ] Client `skill-matrix-llm-public` created (local dev only)
157+
- [ ] Six realm roles created: `CreateUsers`, `UpdateUsers`, `DeleteUsers`, `ViewUsers`, `ManageSkills`, `ManageProjects`
158+
- [ ] Groups `Admin`, `Users`, and `Guest` created with appropriate role mappings
159+
- [ ] Default group set to `Users` (Realm Settings → General → Default Groups)
160+
- [ ] `profile` and `email` scopes on the frontend client
161+
- [ ] `resource_access` included in access tokens (verify mapper above)

0 commit comments

Comments
 (0)