Tharga Team — Implementation Guide
Step-by-step instructions for adding Tharga Team features to a Blazor application.
Recommended: Single-call setup
For most applications, use AddThargaTeam to register everything in one call:
using Tharga.Team.Blazor.Framework;
builder.AddThargaTeam(o =>
{
o.Blazor.Title = "My App";
o.Blazor.RegisterTeamService<MyTeamService, MyUserService>();
// Optional: scopes, roles, audit
o.ConfigureScopes = scopes => { /* ... */ };
o.ConfigureTenantRoles = roles => { /* ... */ };
o.Audit = new AuditOptions();
});
// MongoDB persistence (always separate — requires your entity types)
builder.Services.AddMongoDB(o => { /* connection config */ });
builder.Services.AddThargaTeamRepository(o =>
{
o.UseUserEntity<MyUserEntity>();
o.UseTeamEntity<MyTeamEntity, MyTeamMember>();
});
var app = builder.Build();
app.UseThargaTeam();
This replaces Steps 1–8 below. Set sub-options to null to skip features you don't need (e.g. o.Controllers = null, o.ApiKey = null).
Advanced: Step-by-step setup
Use the individual Add* methods when you need partial or custom registration. Each step is a self-contained feature that builds on previous steps. Add only what you need.
Secrets: Several steps require sensitive configuration values (client IDs, connection strings, API keys). These should never be committed to source control. Use Manage User Secrets in Visual Studio (right-click the Server project > Manage User Secrets) or run
dotnet user-secrets initfollowed bydotnet user-secrets set "Section:Key" "value"from the Server project directory.
Dependency overview
Step 1: UI Foundation (Tharga.Blazor)
│
Step 2: Authentication (Tharga.Team.Blazor)
│
├── Step 3: API Controllers & Swagger (Tharga.Team.Service)
│
├── Step 4: Team Management (Tharga.Team.Blazor + Tharga.Team.MongoDB)
│ │
│ ├── Step 5: API Key Authentication (Tharga.Team.Service)
│ │
│ ├── Step 6: Scopes (Tharga.Team + Tharga.Team.Service)
│ │ │
│ │ └── Step 7: Tenant Roles (Tharga.Team)
│ │
│ └── Step 8: Audit Logging (Tharga.Team.Service)
Step 1: UI Foundation
Adds Radzen-based UI components: buttons, breadcrumbs, error boundary, loading indicators, and layout primitives.
Packages
dotnet add package Tharga.Blazor
Program.cs (Server)
using Tharga.Blazor.Framework;
builder.Services.AddRadzenComponents();
builder.Services.AddRadzenCookieThemeService(o =>
o.StorageKeyName = "ThemeStorageName");
builder.Services.AddThargaBlazor(o => o.Title = "My App");
AddThargaBlazor registers BreadCrumbService, BlazoredLocalStorage, and BlazorOptions. It also supports binding from appsettings.json:
{
"Tharga": {
"Blazor": {
"Title": "My App"
}
}
}
builder.Services.AddThargaBlazor(configuration: builder.Configuration);
Code configuration takes precedence over appsettings.json.
Program.cs (Client — if using WebAssembly)
builder.Services.AddRadzenComponents();
builder.Services.AddThargaBlazor();
_Imports.razor (both projects)
@using Radzen
@using Radzen.Blazor
@using Tharga.Blazor
@using Tharga.Blazor.Framework
@using Tharga.Blazor.Framework.Buttons
@using Tharga.Blazor.Features.BreadCrumbs
App.razor
Add to <head>:
<RadzenTheme Theme="material" />
Add to <body>:
<script src="@Assets["_content/Tharga.Blazor/tharga.blazor.js"]"></script>
<script src="_content/Radzen.Blazor/Radzen.Blazor.js"></script>
What becomes available
| Component | Description |
|---|---|
<ActionButton> |
Button with built-in busy state and error handling |
<CancelButton> |
Cancel button |
<CopyButton> |
Copy-to-clipboard button |
<StandardButton> |
General purpose button |
<BreadCrumbs> |
Breadcrumb navigation (registered by AddThargaBlazor) |
<Title> |
Page title (reads from BlazorOptions.Title) |
<CustomErrorBoundary> |
Error boundary with correlation ID |
<ExpandableCard> |
Collapsible card |
<Loading> |
Loading indicator — use instead of hardcoded "Loading..." text |
<DateTimeView> |
Formatted date/time display |
<TimeSpanView> |
Formatted time span display |
Layout
Replace the default Bootstrap layout with Radzen layout components: RadzenLayout, RadzenHeader, RadzenSidebar, RadzenBody, RadzenFooter, RadzenPanelMenu.
Verification
The app should render with Radzen styling. Buttons and layout components should work without errors.
Step 2: Authentication
Adds Azure AD (CIAM) authentication with Cookie + OIDC, login/logout endpoints, and auth UI components.
Requires: Step 1
Packages
dotnet add package Tharga.Team.Blazor
Microsoft.Identity.Webis included transitively — no need to add it separately.
Configuration
Add an AzureAd section to appsettings.json. Values are environment-specific:
{
"AzureAd": {
"Authority": "",
"ClientId": "",
"TenantId": "",
"CallbackPath": ""
}
}
- Authority — varies by identity provider (e.g. CIAM:
https://<tenant>.ciamlogin.com/<domain>, standard Entra ID:https://login.microsoftonline.com/<tenant-id>/v2.0) - ClientId — from the Azure app registration
- TenantId — from the Azure app registration
- CallbackPath — varies by setup (e.g.
/signin-oidc,/authentication/login-callback)
Secrets:
ClientIdandTenantIdmay be considered sensitive depending on your environment. Put them in Manage User Secrets if you prefer not to commit them.
Redirect URIs on the app registration
Two entries are needed, and they are different kinds. Both live on the app registration, not in
appsettings.json:
| Entry | Value | Why |
|---|---|---|
| Redirect URI | your CallbackPath, e.g. https://your-host/signin-oidc |
Where the provider returns after sign-in |
| Front-channel logout / post-logout redirect URI | https://your-host/signout-callback-oidc |
Where the provider returns after sign-out |
/signout-callback-oidc is the OpenID Connect handler's SignedOutCallbackPath default; if you changed it,
register what you changed it to.
If the post-logout entry is missing, nothing throws. The provider still signs the user out — the security-relevant half happens — but it shows its own "you have signed out" page instead of returning to your application. A dead end rather than an exception, which is why it is easy to miss and worth setting up before you first test sign-out.
Program.cs
using Tharga.Team.Blazor.Features.Authentication;
// Service registration
builder.AddThargaAuth();
// After builder.Build()
app.UseThargaAuth();
Options
builder.AddThargaAuth(o =>
{
o.LoginPath = "/sign-in"; // default: "/login"
o.LogoutPath = "/sign-out"; // default: "/logout"
o.ValidateConfiguration = false; // default: true — throws at startup if AzureAd section is missing
o.FederatedSignOut = false; // default: true — see below, this one is a security setting
o.PromptForAccount = true; // default: false — ask which account to sign in with
});
Where signing in returns
/login returns to the site root, unless it is given returnUrl, a path on this site:
/login?returnUrl=%2Fprofile%2Fmail signs in and lands on /profile/mail. Since 4.2. That is what the
unsubscribe page links to with SignInPath = "/login".
Only a local path is honoured, so the endpoint is no open redirect: it must start with a single /. A scheme
(https://…), a second slash (//host), a backslash after the slash (/\host) or a control character anywhere
— which a browser drops, turning /<tab>/host into //host — returns to the root as before.
Signing out ends the provider session
FederatedSignOut is on by default and should stay on. Signing out clears the local cookie and sends
the browser to the provider's end-session endpoint, so the session that would otherwise sign the next visitor
straight back in is gone.
Turning it off restores a sign-out that clears the local cookie only. The application then renders as signed out while the provider session stays alive, and the next sign-in silently returns the same user with no prompt — on a shared machine, a usable session left behind a Logout button. The escape hatch exists for a host that cannot register the post-logout redirect URI yet and would rather make that trade knowingly.
Changed in 3.19. Before that release this was the only behaviour, and it was not a setting. Sign-out built the provider's end-session redirect and then overwrote it with a local one, so the provider session always survived. If you are upgrading, register the post-logout redirect URI above before you deploy — that is the one action this release asks of you.
Choosing an account
PromptForAccount is off by default, because it is not what fixes sign-out. With federated sign-out on,
signing out ends the provider session and the next sign-in prompts anyway. This option only matters when the
browser holds a live single-sign-on session from somewhere else entirely — and turning it on costs every user
a click on every sign-in.
Turn it on where signing in as a different person is routine rather than exceptional: a shared machine, or support staff who hold more than one account.
Which claim identifies a user
Every user record is stored under one value read from the signed-in principal — IUser.Identity. By default
it is the first non-empty claim of NameIdentifier, sub, oid, nameid, uid; with OpenID Connect and
inbound claim mapping (the Microsoft.Identity.Web default), NameIdentifier is filled from sub.
On Microsoft Entra that is the wrong claim to key on. Entra issues sub pairwise: it is unique to the
combination of user and application, in workforce tenants and External ID (CIAM) alike. Two consequences
follow, and neither produces an error:
- Replacing the app registration re-keys every user. The next sign-in finds no record under the new
sub, so a new user is created, the old record and its team memberships are orphaned, and the person appears to have lost every team. - Two applications in one tenant can never share user records, because the same person arrives with a
different
subin each.
oid is the user's object id in the tenant and is the same for every application. Configure it:
builder.AddThargaTeam(o =>
{
o.UserIdentityClaimTypes = [DirectoryClaimTypes.ObjectId];
});
| Mapped or not | oid and its mapped form http://schemas.microsoft.com/identity/claims/objectidentifier are treated as one claim, so either name works with inbound claim mapping on or off. |
| Order | Several types may be listed; the first one present wins, e.g. [DirectoryClaimTypes.ObjectId, ClaimTypes.NameIdentifier] for a host that also signs in non-Entra users. |
| No fallback | Only the listed types are read. A principal carrying none of them resolves to no user, rather than silently to a second identity for the same person. |
| Where it applies | The user record lookup and creation, the cached user, the team claims built from it, CallerUserIdentity on every audit entry (sign-in and first sign-in included), support-case authorship and read state, and TeamMcpContext.UserId. Display-only strings such as CallerIdentity are unchanged. |
| Unset | Exactly the previous behaviour, everywhere. |
Switching an existing host re-keys its users. Records already stored under
subare not found underoid. Before enabling the option, rewriteIdentityon each user record to that user'soid— theDirectoryIdfield, where the user entity declares it, already holds it for anyone who signed in since it was introduced. Audit entries and support cases keep the subject they were written with.
A user service that implements its own lookup — deriving from UserServiceBase and overriding
GetUserAsync — must look users up by ResolveUserIdentity(principal) rather than reading a claim itself, or
it will keep keying on the old claim while the rest of the toolkit uses the new one. UserServiceRepositoryBase
already does, and passes the resolved value to CreateUserEntityAsync.
_Imports.razor
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Tharga.Team.Blazor.Features.Authentication
@using Tharga.Team.Blazor.Framework
Note:
Tharga.Team.Blazor.Frameworkprovides theRolesclass used inAuthorizeView(e.g.Roles.Developer,Roles.TeamMember).
What becomes available
| Component | Namespace | Description |
|---|---|---|
<LoginDisplay /> |
Tharga.Team.Blazor.Features.Authentication |
Profile menu with Gravatar when authenticated, login button when not. Navigates to /login, /logout, and the profile/team pages — at o.Blazor.ProfilePath and o.Blazor.TeamPath if you mounted them somewhere other than /profile and /team. The Team item can be restricted to specific roles via TeamMenuRoles. |
<UserProfileView /> |
Tharga.Team.Blazor.Features.User |
The signed-in user's avatar, name and email, with inline editing of their own name, plus authentication claims in an expandable card. When access simulation is enabled it also renders <AccessSimulationCard /> between the two — set ShowAccessCard="false" to place that yourself. |
<AccessSimulationCard /> |
Tharga.Team.Blazor.Features.Simulation |
Expandable card offering demo mode and view as another user, plus the way out while either is active. Rendered by UserProfileView by default; draws nothing unless simulation is enabled and the caller can simulate. See access simulation |
<AccessSimulationIndicator /> |
Tharga.Team.Blazor.Features.Simulation |
Optional compact Access reduced badge with a return button, for the header beside <TeamSelector />. Renders nothing unless a simulation is active, and nothing during demo mode. See access simulation |
Usage
Add <LoginDisplay /> to NavMenu.razor header.
By default the Team item in the profile menu is shown to every authenticated user (whenever a team service is registered). To restrict it to specific roles, set TeamMenuRoles — the item is then shown only to users in at least one of those roles, and hidden for everyone else:
<LoginDisplay TeamMenuRoles="@(new[] { "Administrator", "Developer" })" />
Leaving TeamMenuRoles unset keeps the original behavior (visible to all authenticated users). This gates the menu link only — protect the /team page itself with [Authorize(Roles = ...)] as well.
Create a profile page:
@page "/profile"
@using Tharga.Team.Blazor.Features.User
@attribute [Authorize]
<UserProfileView />
Localizing menu strings
The profile menu (LoginDisplay) and TeamSelector strings — User, Team, Logout, Login, Create Team, Loading… — resolve through IThargaTextProvider. By default they return English; register your own provider to translate them, e.g. by bridging to your app's content/localization system. Each string is a TextKey that bundles a stable key with its English fallback, and the keys live in TeamMenuText:
public sealed class MyMenuText(IContentService content) : IThargaTextProvider
{
// Return a translation for the key, or fall back to the bundled English default.
public Task<string> GetAsync(TextKey key) => content.GetOrDefaultAsync(key.Key, key.Default);
}
// Register it through the platform options (same pattern as AddClaimsEnricher):
builder.AddThargaTeam(o => o.Blazor.AddTextProvider<MyMenuText>());
Without a provider the English defaults are used, so this is non-breaking for existing apps.
Finding every key you can override
ThargaTextKeys.All is the complete set the toolkit can render, each entry carrying its stable key and
English default. Enumerate it to generate a translation table, seed a content system, or assert in your own
tests that you have covered everything:
foreach (var key in ThargaTextKeys.All)
Console.WriteLine($"{key.Key} {key.Default}");
It is discovered by reflection over the toolkit's catalogues, so a key added in a later version appears
without you doing anything — and a test of your own over ThargaTextKeys.All will tell you when one arrives
that you have not translated yet.
Coverage — what a provider actually reaches today
Every surface Tharga/Team#204 names is now fully routed. Registering a provider still does not translate the whole UI, and the honest list matters more than a reassuring one: a component not below is still rendering English.
Fully routed — every string resolves through your provider: LoginDisplay, TeamSelector,
TeamComponent, AuditLogView, UsersView and both of its tabs (UsersListView,
TeamsListView), DirectoryOnlyUsersView, DeleteUserDialog, InviteUserDialog,
TeamInviteView, UserIconDialog, TeamIconDialog, TeamDialog, AssignOwnerDialog,
SuspendedTeamNotice, AccessSimulationCard, AccessSimulationBar, AccessSimulationDialog,
AccessSimulationIndicator, RoleEditor, ScopeOverrideEditor, and, as of 4.1, ApiKeyView,
SystemApiKeyView and ApiKeyRevealDialog (team.apiKeys.*, team.systemApiKeys.*,
team.apiKeyReveal.*; the system view reuses the team view's keys wherever the two say the same thing).
UsersViewnow means the whole page. It is a wrapper around a tab strip, and an earlier version of this note said it "resolves every string it renders" whileUsersListViewandTeamsListViewheld 80 literals between them — true of the wrapper, misleading about the page. Both tabs are migrated as of 3.13, so the claim is now true of what you actually see.
Still literal, largest first: ScopeView 14, UserProfileView 13, TenantRoleManager 11 — 38 strings
across 3 components.
Access simulation is now translatable end to end. The "View as another user" dialog was the last part still in English; it resolves through
team.simulation.dialog.*from this release.
None of those are on the surfaces #204 names. They are scope, profile and role surfaces, which that issue does not cover.
Plurals, and what a translator can and cannot do
Sentences that vary with a count are one key per form, each holding the whole sentence — for example
team.deleteUser.ownsOneTeam and team.deleteUser.ownsManyTeams. Nothing is assembled at runtime from a
head and a tail, because those reorder in translation and often change agreement elsewhere in the sentence.
Both forms of a pair take the same arguments in the same order, even where one does not use all of them, so a template can move a placeholder freely.
The limitation is deliberate: a language with more than two plural categories (Polish, Russian, Arabic) cannot be expressed today. English and Swedish both have two. Since no key is a fragment, adding a count-aware overload later would not invalidate any translation written now.
A build-time ratchet holds every one of those numbers: it fails if a count grows, fails if a count shrinks without the record being updated, and fails if a component carrying literal text is missing from the list altogether. So this table cannot quietly drift out of date, and "not listed" cannot come to mean "nothing to do".
Note the counts include dialog titles, notifications and confirmation prompts built in each component's C# — most of what a user actually reads — not only markup labels.
Messages that name something
A message with a value in it is a template with positional placeholders, resolved through
TextSet.Format:
// key default: "Email sent to {0}"
_text.Format(TeamText.EmailSent, recipient);
Placeholders are positional rather than interpolated because a translated sentence often needs its parts in
a different order — a translator can move {0} and {1}, and cannot touch a C# interpolated string at all.
A malformed template — one naming a placeholder that does not exist — falls back to the English default rather than throwing. Templates can come from your content system, so they are untrusted input on a render path.
Keys are whole strings, not substitutable nouns
There is deliberately no Text["Team"] = "Organisation" knob. Composing sentences from a noun token
produces broken translations: Swedish suffixes the definite article, so "medlem i ett team" becomes
"teamet" — a form no substitution reaches — and word order moves besides. Each string is keyed whole, which
is what lets a translation differ structurally from the English rather than only lexically.
Adding your own profile-menu items
Register extra entries on the LoginDisplay profile menu. They render after the built-in items and above
Logout, in registration order:
builder.AddThargaTeam(o =>
{
o.Blazor.AddMenuItem("help", "myapp.menu.help", "Help", "/help");
o.Blazor.AddMenuItem("receipt_long", "myapp.menu.audit", "Audit log", "/audit",
requiredScope: AuditScopes.Read);
});
The label is a key plus an English default, not a string — so it resolves through the same
IThargaTextProvider as the built-in entries. A host that registered a provider gets its own menu items
translated with no further work, in every language the provider covers.
requiredScope and requiredRole are optional; set both and both must hold. A scope is satisfied by either a
team scope or a system scope, so a cross-team administrator is not hidden from a link merely because the grant
arrived by the other provenance.
These gates control rendering, not access. They hide a link the caller cannot use — the page behind it must still gate itself. A hidden menu item is a courtesy, never a protection.
Version notes
UseThargaAuth()requires >= 2.0.1-pre.1 for correct async login behavior. Version 2.0.0 usedResults.Challenge(synchronous) which caused DNS errors with some Azure AD configurations.
Verification
The login button should appear. Clicking it redirects to Azure AD. After login, the profile menu shows with the user's Gravatar.
Step 3: API Controllers & Swagger
Adds MVC controller support with OpenAPI documentation and Swagger UI.
Requires: Step 2
Packages
dotnet add package Tharga.Team.Service
Program.cs
// Service registration
builder.Services.AddThargaControllers();
// After builder.Build()
app.UseThargaControllers();
Options
builder.Services.AddThargaControllers(o =>
{
o.SwaggerTitle = "My API v1"; // default: "API v1"
o.SwaggerRoutePrefix = "api-docs"; // default: "swagger"
});
Which credentials reach the API
AuthenticationSchemes lists the schemes Tharga's own controllers accept. It defaults to the API-key
scheme alone, which is what an API caller normally presents:
using Microsoft.AspNetCore.Authentication.Cookies;
builder.Services.AddThargaControllers(o =>
{
o.AuthenticationSchemes.Add(CookieAuthenticationDefaults.AuthenticationScheme); // also allow a signed-in user
});
Naming the schemes up front matters more than it looks. A policy that names no scheme authenticates
against the application's default scheme — in a Blazor host that is OIDC, so an unauthenticated API call
is answered with a 302 to a login page instead of a 401. An agent or script following that redirect
receives HTML and a 200. This is the same trap as Tharga/Mcp#18,
which is why both surfaces now configure schemes explicitly.
Customizing the OpenAPI document (.NET 10+)
AddThargaControllers owns the OpenAPI document and registers the API-key security scheme on it. To add your own IOpenApiDocumentTransformer / IOpenApiOperationTransformer — for example, per-scope operation filtering so the generated spec only exposes operations the caller is authorized for — use the ConfigureOpenApi hook rather than calling AddOpenApi("v1", …) directly:
builder.Services.AddThargaControllers(o =>
o.ConfigureOpenApi(api => api.AddDocumentTransformer<ScopeFilteringDocumentTransformer>()));
The callback receives the same OpenApiOptions Tharga configures, so your transformers apply to the document Tharga already manages. Multiple ConfigureOpenApi calls compose in registration order. Using this hook (instead of a separate AddOpenApi("v1", …) call) keeps composition explicit and avoids the .NET 10 OpenAPI source generator emitting an interceptor into your project. On .NET 9 the document is Swashbuckle-based and this hook is not present.
What becomes available
- MVC controller routing
- OpenAPI endpoint with API key security scheme
- Swagger UI at
/<SwaggerRoutePrefix> - API key header convention (
X-API-KEY) GET /api/audit— the audit log over REST (see below)
Reading the audit log over REST
AddThargaControllers ships one controller of its own, so audit data is reachable from a script or an
agent rather than only from the Blazor view.
The caller never names the team
There is no teamKey parameter. Which team a call is about comes from the credential:
| Credential | Team | Reads |
|---|---|---|
| Team API key | the key itself — it can be nothing else | that team |
| System API key, no header | none to imply | system audit: every team, narrowed by filters |
System API key + X-Team-Key |
the header | that team, if the team has consented |
A parameter beside a team-bound credential is two sources of truth for one question. They can disagree, and an API shaped to allow that is wrong even when the disagreement is refused — which it is. A team key presenting a header for a different team is refused rather than ignored: the request has said two incompatible things, and answering as though one of them were absent leaves the caller believing they got what they asked for.
GET /api/audit?from=2026-01-01&take=100
X-API-KEY: <team key>
GET /api/audit?from=2026-01-01&take=100
X-API-KEY: <system key>
X-Team-Key: ABC123
| Parameter | Meaning |
|---|---|
team |
A filter, not an authorization input. Narrows a system-audit read to one team; refused if it contradicts the team the caller is already bound to |
from, to |
Time window |
feature, action |
Filter by what was done |
success |
true/false |
skip, take |
Paging; take is capped at 500 |
Authorization is on the services, not on any surface
Reading audit goes through two registered services, and no surface authorizes anything itself:
| Service | Registered as | Requires |
|---|---|---|
IAuditReadService.QueryAsync(teamKey, query) |
team service | audit:read on that team |
IAuditOversightService.QueryAllAsync(query) |
system service | a system audit:read grant |
Both carry [RequireScope(AuditScopes.Read)] and are enforced by ScopeProxy, so the REST endpoint, the
Blazor view and the MCP resource give the same answer to the same caller because they ask the same
code — not because three implementations were tested into agreement.
They previously did not. The UI and REST called
AuditAccess.CanRead; the MCP resource gated on a host-configurable role instead. The same API key was admitted at one door and refused at another. A static helper shared by the surfaces was not enough: it still had to be called, and one surface simply did not.
The team-bound service cannot reach past the team it names, so a team grant never reaches system-wide audit is a property of the shape rather than a check somebody remembered to write.
| Caller | Result |
|---|---|
| No credential | 401 |
Team key holding audit:read |
200 for its own team |
Team key presenting X-Team-Key for another team |
403 — a contradiction, not a preference |
System key without a system audit:read grant |
403 |
System key holding system audit:read |
200, across every team |
| System key naming a team that has not consented | 403, indistinguishable from naming one that does not exist |
A system grant reads one named team by filtering the oversight read. ScopeProxy's team check does not
accept a system grant unless the method opts in with AllowSystemGrant (see Team services and system
services) — that provenance split is deliberate, so an in-team scope can never be spent cross-team — so the
two are separate calls, and the REST endpoint tries both on the caller's behalf.
audit:read is registered at AccessLevel.Administrator, so a Viewer- or User-level caller is refused
even for its own team.
The audit action is hidden, not shown-then-refused
<UsersView ShowAuditLogButton="true" /> and <TeamComponent ShowAuditLogButton="true" /> offer the
per-row audit action only to a caller who can actually read audit — the system grant for the users list,
which spans every team, and the team grant for a team's own members.
The flag is the host's permission to offer the feature; it is not evidence that this caller may use it. A control that appears and then refuses is a defect this codebase has fixed twice, and the second time it was in the users list while the team surface already had it right.
X-Team-Key is a platform mechanism, not an audit one
The header is resolved before any endpoint runs, and a resolved team is added to the caller's claims.
So every [RequireScope] check works unchanged — including in your own controllers, which need no
knowledge that the header exists. MCP resolves the same header through the same code, so a system key
gets the same answer whichever surface it arrives on.
Naming a team replaces the caller's team claims; it never adds a second team. A signed-in person already carries the claims of the team they selected, and naming a different team they belong to is re-selection: those claims (team key, access level, member key, team scopes and team roles) are removed and the named team's are added. Claims that are not about a team — the subject, app roles, system scopes — stay. Naming the team the caller is already on changes nothing. A team scope is only ever read from the claims issued for that same team.
Configure the name once, on TeamContextOptions.TeamKeyHeader. It is deliberately not also an MCP
option: two places to configure one name is one place for the surfaces to disagree.
Consent reaches audit, and stops where the level does
A caller who is not a member of a team reaches it if the team consented to a role they hold — at
exactly the consented level, never above it. Since audit:read sits at Administrator:
| Consented level | Reaches the team | Reads its audit |
|---|---|---|
| None | ❌ | ❌ |
| Viewer | ✅ | ❌ |
| User | ✅ | ❌ |
| Administrator | ✅ | ✅ |
Consent is therefore necessary but not sufficient: a team can grant cross-team access and grant no audit access by the same act.
A person and a key are answered differently, because they are different things. A user naming a team is resolved by membership first, then by the consent their roles carry. A key holds no roles at all, so for a key the question is whether the team consented — and the consented level is the grant.
Worth being deliberate about: a team that enables consent so its support staff can help thereby admits system keys at that same level. Consent is a single statement about who may enter, and it does not distinguish people from machines.
Denials are 403, never 404 — a 404 would confirm whether a team exists to a caller not allowed to
know that.
Reading a team is decided the same way as changing one
ITeamManagementService's reads — GetTeamAsync, GetTeamByKeyAsync, GetMembersAsync,
GetTeamMemberAsync, GetTeamCustomRolesAsync — require team:read on the team named in the call, and
resolve it exactly as the mutations beside them do: membership if the caller has it, the team's consent if
they do not. So all three of these reach a team's roster, and for the same reason they may change it:
| Caller | Reaches the team because |
|---|---|
A member at Viewer or above |
team:read sits at Viewer, so every level above inherits it |
A member at Custom holding a tenant role that grants team:read |
the role's scopes count, per-team custom roles included |
| A non-member holding a role the team consented to | the consented level carries team:read |
A suspended member reaches nothing, whichever of those would otherwise apply. Suspension is not a reduction in level — it withdraws the grant.
Changed in 3.18. Before that release these reads were resolved from the caller's member row alone, so consent, tenant roles and suspension were all invisible to them. Three consequences, and they did not all point the same way: a consent-based operator was refused a read they were entitled to — visibly, since an invite would be created and the operation would still report "Access denied" — a
Custommember's tenant role did not grant the read it named, and a suspended member kept full read access. If you worked around the first by granting the operator membership, that workaround can now go.
GetTeamsAsync filters the caller's own teams by the same rule, so a team it lists is a team the
single-team reads will return, and one it omits is one they refuse.
Usage
Create controllers as usual:
[ApiController]
[Route("api/[controller]")]
public class MyController : ControllerBase
{
[HttpGet]
public IActionResult Get() => Ok("Hello");
}
Verification
Navigate to /swagger — the Swagger UI should load with your controllers listed.
Step 4: Team Management
Adds multi-tenant team management with MongoDB persistence, team selection, member management, and claims augmentation.
Requires: Step 2, and a MongoDB database (via Tharga.MongoDB)
Packages
dotnet add package Tharga.Team.MongoDB
Tharga.Teamis included transitively viaTharga.Team.Blazor. You also needTharga.MongoDB.Blazorconfigured separately — see Tharga.MongoDB docs.
Configuration
Add a MongoDB connection string to appsettings.json:
{
"ConnectionStrings": {
"Default": ""
}
}
Secrets: The connection string contains credentials. Put it in Manage User Secrets.
Program.cs
// Service registration
builder.Services.AddThargaTeamBlazor(o =>
{
o.Title = "My App";
o.AutoCreateFirstTeam = true; // default: false — auto-creates a team for first-time users
o.CreateTeamPath = "/get-started"; // default: null — built-in "Create team" entry points navigate here instead of the bare create (see "Overriding the Create team action")
o.ProfilePath = "/account"; // default: null — where the profile menu's User item navigates. Set it if you mounted <UserProfileView /> anywhere but /profile
o.TeamPath = "/organisation"; // default: null — where the profile menu's Team item navigates. Set it if you mounted <TeamComponent /> anywhere but /team
o.ShowMemberRoles = false; // default: false — shows tenant role assignment in team UI
o.ShowScopeOverrides = false; // default: false — shows scope override controls in TeamComponent (team-member UI). For ApiKeyView, opt in via the [Parameter] ShowScopeOverrides on the component itself; the two flags are intentionally independent.
o.RegisterTeamService<MyTeamService, MyUserService>();
});
builder.Services.AddThargaTeamRepository(o =>
{
o.RegisterUserRepository<UserEntity>();
o.RegisterTeamRepository<TeamEntity, TeamMember>();
});
The two RegisterTeamService overloads
| Overload | Use when | Member type |
|---|---|---|
RegisterTeamService<TTeamService, TUserService>() |
The standard member is enough | Taken from the team service's own generic base |
RegisterTeamService<TTeamService, TUserService, TMember>() |
Your member carries extra properties | Exactly what you name |
The two-argument form reads the member type from the service you register — a service deriving from
TeamServiceRepositoryBase<TEntity, TMember> carries it — so you do not name it twice. An explicit
TMember always wins: that records a decision, and inference only fills a gap where none was expressed.
If it can find neither, the facets layered over the team store cannot be registered and
TeamServiceCompletenessChecksays so at startup, naming them. That happens when a service derives straight fromTeamServiceBase, which is generic in nothing — name the member type explicitly. SetThrowOnIncompleteTeamServiceto make it fatal rather than an error in the log.
Write no storage types at all
Tharga.Team.MongoDB ships a standard implementation of every piece:
| Type | Replaces |
|---|---|
DefaultTeamMember |
record TeamMember : TeamMemberBase; |
DefaultTeamEntity |
record TeamEntity : TeamEntityBase<TeamMember>; |
DefaultTeamService |
a service implementing the two factory methods |
DefaultUserEntity |
record UserEntity : EntityBase, IUser with the optional properties |
DefaultUserService |
a service implementing CreateUserEntityAsync |
DefaultUserEntity declares every optional property — DirectoryId, LastSeen and Icon — so
directory linking, activity tracking and icons all work. Those are opt-in by shape: the toolkit
persists them only when the entity has somewhere to put them, so a smaller default would leave three
documented features silently doing nothing.
Using your own entities
The defaults are a complete set to use or replace, not a base to extend. Deriving from
DefaultUserEntity compiles, but the store still reads and writes the base type, so your extra property
would not round-trip.
To add properties, declare the entity and its service — the factory has to construct your concrete type, which is the only reason those methods are abstract:
public record MyMember : TeamMemberBase { public string Department { get; init; } }
public record MyTeam : TeamEntityBase<MyMember>;
public class MyTeamService : TeamServiceRepositoryBase<MyTeam, MyMember>
{
protected override Task<MyTeam> CreateTeam(...) // one object initializer
protected override Task<MyMember> CreateTeamMember(...) // one object initializer
}
Everything else stays inherited, and every member is virtual — derive from DefaultTeamService instead
when you want the standard types but different behaviour.
ToString() is the one member you cannot override. TeamEntityBase seals it to return the team's
Name, because a UI control bound to a team falls back to ToString() for text it cannot get from a
display property — Radzen's dropdown does this for its hidden accessible input. Left synthesized, a
record puts its whole state there: the entity id, the consent access level and every property you added,
in the DOM of each page showing a team. Sealing is what makes that true for your entity rather than
only the base, since a record otherwise re-synthesizes ToString() in every declaration.
Custom collection names: If you need to change the MongoDB collection names (e.g. when sharing a database with a legacy app), set
TeamCollectionNameandUserCollectionName:builder.Services.AddThargaTeamRepository(o => { o.TeamCollectionName = "MyTeams"; // default: "Team" o.UserCollectionName = "MyUsers"; // default: "User" o.RegisterUserRepository<UserEntity>(); o.RegisterTeamRepository<TeamEntity, TeamMember>(); });
Note:
AddThargaTeamBlazor()internally callsAddThargaBlazor(), soBreadCrumbServiceandBlazoredLocalStorageare registered automatically.
Members with no access level
Since 4.0 a member with no access level has no access to their team. ITeamMember.AccessLevel is
AccessLevel?, and a null level grants nothing: no scopes, no team claims, no tenant-role or override scopes,
and no fall-through to consent. The member is refused exactly like a suspended one. The member grid shows them
as "No access level", and an administrator restores them by picking a level.
Before 4.0 the property was not nullable, and Owner is the enum's zero value. A stored member with no
AccessLevel field therefore read back as an Owner, and could not be told apart from a member deliberately
made one.
Two things produce such a document: it was written before the field existed, or a CreateTeamMember override
returned a member without copying the invited level. Since 4.0 the second one can no longer happen: the invite
fails instead (see Migrating to 4.0 below).
The toolkit tells you. When a team repository is registered, a startup check counts members with no stored level and logs a warning naming the affected teams:
3 team(s) contain members with no stored AccessLevel: 'acme', 'globex', 'initech'.
Since 4.0 those members have no access to their team until a level is set - before 4.0 they
were treated as Owner, so a team's original owner may be among them. …
It warns and never throws. The condition is pre-existing data, not a wiring mistake, and refusing to start would lock out every team rather than just the affected members. A healthy deployment costs one count query per start. Turn it off once your data is clean:
builder.Services.AddThargaTeamRepository(o =>
{
o.CheckMemberAccessLevels = false; // default: true
o.RegisterTeamRepository<TeamEntity, TeamMember>();
});
Finding and fixing the members
The check names teams, not members, because naming members means reading rosters at startup. To list the
members themselves, run this in mongosh against your database:
db.Team.aggregate([
{ $match: { "Members.AccessLevel": { $exists: false } } },
{ $project: {
Key: 1,
Members: { $filter: {
input: "$Members",
cond: { $eq: [ { $type: "$$this.AccessLevel" }, "missing" ] }
} }
} }
])
Then set an explicit level on each, here or from the member grid. Decide it deliberately — do not
bulk-assign. Before 4.0 these members acted as owners, so some of them may genuinely be owners; a blanket Viewer would silently demote whoever
really was one, and a blanket Owner preserves exactly the grant you are investigating. That is why the
toolkit reports rather than repairs.
db.Team.updateOne(
{ Key: "acme" },
{ $set: { "Members.$[m].AccessLevel": "User" } },
{ arrayFilters: [ { "m.Key": "the-member-key", "m.AccessLevel": { $exists: false } } ] }
)
Store the level by name ("User"), not as a number — every access level in this toolkit is persisted as
a string.
Fix the data before upgrading to 4.0, not after. On 3.x the unfixed members still work, as owners. On 4.0 they are refused until someone sets a level, and if a team's only owner is among them, nobody in that team can set one. Run the query above first.
Migrating to 4.0
4.0 makes an unset access level grant nothing, and removes the members 3.x kept only for compatibility. Work through the list in order:
- Members with no stored level. On 3.x, run the query in Members with no access level above and give each member an explicit level. A 3.x startup log that has no "no stored AccessLevel" warning means there is nothing to do.
AccessLevelis nullable.ITeamMember.AccessLevel,TeamMemberBase.AccessLevel,InviteUserModel.AccessLevel,TeamMemberInfo.AccessLevelandUserTeamInfo.AccessLevelareAccessLevel?. Code that compares or switches on a level now has to decide what null means. Treat it as "no access":if (member.AccessLevel is not { } level) return false;. A test fake that implementsITeamMemberchanges its property type to match.IScopeRegistry.GetScopesForAccessLevel,IScopeRegistry.GetEffectiveScopesandITenantRoleService.GetEffectiveScopesAsyncacceptAccessLevel?and return nothing for null.- An invitation must name a level.
AddMemberAsyncthrowsArgumentExceptionwhenInviteUserModel.AccessLevelis null.InviteUserDialogalways sets one. Code that builds anInviteUserModelitself sets it explicitly. CreateTeamMembermust copy the level. If an override returns a member with no level, the invite throwsInvalidOperationExceptionnaming the override, and nothing is stored. AddAccessLevel = model.AccessLevelto the member it builds.- The
Developerrole no longer oversees private API keys. Only theapikeys:overseesystem scope does. If developers relied on it, map it:o.ConfigureSystemRoles = roles => roles.Map("Developer", SystemApiKeyScopes.Oversee);. ITeamEmailSenderhas one member,SendInviteAsync(TeamInviteMail mail). Delete the four-string overload from your sender. A sender that implemented only that overload moves its body into the new one;mail.RecipientEmail,mail.RecipientName,mail.InviteLinkandmail.TeamNamecarry the same values.- The Tharga.Platform aliases are gone.
ThargaPlatformOptions,ThargaPlatformRegistration,builder.AddThargaPlatform(...)andapp.UseThargaPlatform()were obsolete forwarders kept from the rename. UseThargaTeamOptions,builder.AddThargaTeam(...)andapp.UseThargaTeam(). Also removed:services.AddThargaTeam()in the coreTharga.Teampackage, which never registered anything. Callbuilder.AddThargaTeam()fromTharga.Team.Blazorinstead.
Implementing the required types
You need to create entity and service types that extend the base classes:
Entities
public record UserEntity : EntityBase, IUser
{
public required string Key { get; init; }
public required string Identity { get; init; }
public required string EMail { get; init; }
public string? Name { get; init; } // populate from 'name' claim for display names
}
public record TeamEntity : TeamEntityBase<TeamMember>;
public record TeamMember : TeamMemberBase;
Store enums by name
If your entity has an enum property, declare how it is stored:
[BsonRepresentation(BsonType.String)]
public MyEnum Kind { get; init; }
The MongoDB driver's default for an enum is Int32, so leaving the attribute off selects the ordinal
without looking like a choice. An ordinal is correct only while the enum's declaration order never changes
— insert or reorder a member and every document already written silently means something else, with no
error and nothing to notice.
Storing the name costs nothing and removes the whole class of problem. A test in Tharga.Team.MongoDB
sweeps every persisted entity and fails if one is missing the attribute, so the toolkit's own entities
cannot drift; the same rule is worth applying to yours.
UserService
public class MyUserService : UserServiceRepositoryBase<UserEntity>
{
public MyUserService(AuthenticationStateProvider asp, IUserRepository<UserEntity> repo)
: base(asp, repo) { }
protected override Task<UserEntity> CreateUserEntityAsync(ClaimsPrincipal principal, string identity)
{
var email = principal.FindFirst(ClaimTypes.Email)?.Value
?? principal.FindFirst("preferred_username")?.Value
?? "unknown";
var name = principal.FindFirst("name")?.Value;
return Task.FromResult(new UserEntity
{
Key = Guid.NewGuid().ToString("N")[..10].ToUpperInvariant(),
Identity = identity,
EMail = email,
Name = name
});
}
}
Tip: Populate
IUser.Namefrom thenameclaim — it's used for default team names and member display names. If not set, the display name is derived from the email (e.g.john.doe@example.combecomesJohn Doe).
TeamService
public class MyTeamService : TeamServiceRepositoryBase<TeamEntity, TeamMember>
{
public MyTeamService(IUserService us, ITeamRepository<TeamEntity, TeamMember> repo, IMongoDbServiceFactory msf)
: base(us, repo, msf) { }
protected override Task<TeamEntity> CreateTeam(string teamKey, string name, IUser user, string displayName = null)
{
return Task.FromResult(new TeamEntity
{
Key = teamKey,
Name = name,
Members =
[
new TeamMember
{
Key = user.Key,
Name = displayName, // resolved from IUser.Name or email
AccessLevel = AccessLevel.Owner,
State = MembershipState.Member
}
]
});
}
protected override Task<TeamMember> CreateTeamMember(InviteUserModel model)
{
// Invitation and State are auto-generated by the base class if not set.
// You only need to set them here if you want custom behavior.
return Task.FromResult(new TeamMember
{
Key = null, // assigned when the user accepts the invite
Name = model.Name,
AccessLevel = model.AccessLevel // required: since 4.0 the invite fails without it
});
}
}
Auto-generated fields: When
CreateTeamMemberreturns a member withoutInvitation, the base class auto-generates it using the model's email, a new invite token, and the current timestamp — plus an expiry when an invitation lifetime is configured. Similarly,Statedefaults toMembershipState.Invitedif not set. You can still set these explicitly if you need custom behavior.
Writing your own store? Write each member change as an update of that member alone. Tharga.Team.MongoDB sends one update per write: a positional update of the member's fields, or a
$pullto remove one. Reading the team and writing its member list back loses one of two overlapping edits to the same team, and the lost edit can be a removal or a suspension. This applies to a replacementITeamRepositoryand to the member methods a host overrides onTeamServiceBase(changed in 4.1, Tharga/Team#338).The owner rules are part of the write. Three member changes check a rule that two overlapping calls could each pass on a stale read: demoting a member who has just become the owner, removing them, and two transfers or two administrators leaving at once. Tharga.Team.MongoDB therefore puts the rule in the update's filter, and a transfer is one update that promotes and demotes together. A store of your own gets the same protection by overriding
SetTeamMemberRoleUnlessOwnerAsync,RemoveTeamMemberGuardedAsyncandTransferTeamOwnershipAsynconTeamServiceBase(orSetMemberRoleUnlessOwnerAsync,RemoveMemberGuardedAsyncandTransferOwnershipAsyncon a replacementITeamRepository), returning false when the store refused. Left alone they do what the store did before, so nothing breaks, but the overlap remains possible. A refused call fails with the same message as the existing check (changed after 4.2, Tharga/Team#342).
_Imports.razor
@using Tharga.Team.Blazor.Features.Team
What becomes available
| Component | Description |
|---|---|
<TeamSelector /> |
Dropdown to switch between teams. Shows the team's name instead when there is exactly one and it is selected, and the picker with a Select a team placeholder whenever teams are visible but none is chosen. ShowCreateTeamLink="false" hides the teamless "Create team" link (presentation only — AllowTeamCreation is what governs creation). Gains a search box once there are FilterThreshold teams (default 8) — below that a short list is read faster than it is typed into. AllowFiltering forces it either way |
<TeamComponent /> |
Full team management (create, rename, delete, members). Cards while the list is short, a sortable/filterable/paged grid once it is not — the same threshold the selector uses. See Finding a team |
<TeamInviteView /> |
Pending invitation view. Works standalone on its own route — see Where invitations are redeemed |
<UsersView /> |
Admin user list: last seen, directory verification, user deletion, and a directory-only tab when a directory service is registered. Highlights your own row, shows record keys with copy, and cross-links users to teams. Its Teams tab shows owner, last used, invited-count split and an empty-team badge, and offers deleting any team to a holder of the teams:delete system scope, which no consent option grants. Opt-in ShowAuditLogButton adds a per-row audit log. Viewing and acting require the users:manage system scope — enforced in the service layer (see User management & directory) |
<ApiKeyView /> |
API key management (requires Step 5). Shows Created and Last used columns per key, and a Tags column (chips for keys in ChipTagKeys, plus an (i) tooltip of all tags). Opt-in [Parameter] flags: ShowAuditLogButton, ShowScopeOverrides (Scopes column + create-card multi-select + Edit-Scopes dialog per row), ChipTagKeys |
<AuditLogView /> |
Audit log viewer (requires Step 8). PinnedFilter scopes it and hides the pinned controls; InitialFilter sets opening values the reader can change, including ExcludedScopes for hiding access-trace noise behind a Show hidden toggle |
Roles.TeamMember |
Role claim added to authenticated team members |
Roles.Developer |
Role for developer-only UI sections |
The TeamClaimsAuthenticationStateProvider automatically augments the authentication state with team claims (TeamKey, AccessLevel, scopes) based on the selected team.
Note: Team management works without scopes or tenant roles. The
ShowMemberRolesandShowScopeOverridesoptions only take effect when the corresponding registries are registered (Step 6 and Step 7). Without them, the team UI shows access levels only — which is sufficient for many applications.
Reacting to the selected team
Inject ITeamStateService to read the current team, or to be told when it changes.
@inject ITeamStateService TeamStateService
@code {
private ITeam _team;
protected override async Task OnInitializedAsync()
{
// The handler takes the team from the args. Resolve once, here -- never from the handler.
TeamStateService.SelectedTeamChangedEvent += async (_, e) =>
{
_team = e.SelectedTeam;
await InvokeAsync(StateHasChanged);
};
_team = await TeamStateService.GetSelectedTeamAsync();
}
}
| Member | What it does | Cost |
|---|---|---|
GetSelectedTeamAsync() |
Resolves the selection: checks the held team is still visible, reads the remembered team from browser local storage, falls back to one of your memberships, may create your first team (AutoCreateFirstTeam), may force a page refresh so the team's claims apply, and raises SelectedTeamChangedEvent if the selection changed |
A JS interop round trip, sometimes a navigation. Not free to call in a loop |
TryGetSelectedTeam(out var team) |
Reads the selection already resolved on this circuit. No interop, no event, no resolution | Free |
SelectedTeamChangedEvent |
Raised when the selection changes — a different team, or the same team renamed. The args carry the team | — |
SetSelectedTeamAsync(team) |
Changes the selection, remembers it across visits, and refreshes so the new claims apply | A navigation |
Do not call GetSelectedTeamAsync from a SelectedTeamChangedEvent handler. The name reads like a
getter, but it resolves — which is the very work the handler is reacting to — and e.SelectedTeam already
holds the answer. The event is raised only on a real change, so the loop this used to create is gone; reading
from the args is still both cheaper and clearer about what the handler is for.
TryGetSelectedTeam returns false when nothing has been resolved yet on this circuit. Treat that as "ask
GetSelectedTeamAsync", not as "no team is selected" — the two are different states and only the resolve
can tell them apart.
Claims Enrichment
Team, role, access level, and scope claims are automatically enriched on the ClaimsPrincipal when a team is selected. Tharga.Team provides two enrichment paths:
| Path | How it works | Hosting models |
|---|---|---|
| Server-side (default) | IClaimsTransformation reads the selected_team_id cookie during the HTTP pipeline |
Blazor Server, SSR, Hybrid |
| Client-side | AuthenticationStateProvider decorator reads from LocalStorage via JS interop |
Standalone WASM only |
The server-side path is always registered — no configuration needed. It adds:
team_id— selected team key (TeamClaimTypes.SelectedTeamKey). Records the selection, and is added whether or not that team grants the caller anythingTeamKey— team key claim (TeamClaimTypes.TeamKey). The access anchor — added only once access to the selected team resolves, so it is absent when a team is selected but consented nothingRole: TeamMember— membership roleRole: Team{AccessLevel}— access level role (e.g.TeamOwner,TeamAdministrator)AccessLevel— raw access level value- Scope claims — all effective scopes for the member's access level, roles, and overrides
SkipAuthStateDecoration (default: true)
This setting controls whether the client-side enrichment path is also registered:
true(default) — Only server-side enrichment. Works for Blazor Server, SSR, and Hybrid apps. No JS interop is used. This is the recommended setting for most applications.false— Additionally registers a client-sideAuthenticationStateProviderdecorator that enriches claims via LocalStorage/JS interop. Only needed for standalone Blazor WebAssembly apps that have no server-side HTTP pipeline.
Warning: Setting
SkipAuthStateDecoration = falseon a Server/SSR app will cause a blank page (silent deadlock from JS interop during prerendering).
Unproven: the
falsepath has never been verified against a real standalone WebAssembly app — there is no WASM sample in the repository. If you are the first to use it, expect to validate it yourself, and please report what you find. Automatic hosting-model detection was investigated so this option could disappear entirely, but four separate approaches all produced the same silent SSR hang, and the work was dropped: the evidence pointed at theAuthenticationStateProviderdecoration pattern itself rather than at when it gets applied.
Which setting do I need?
| App type | Setting |
|---|---|
| Blazor Server | true (default) — no config needed |
| Blazor Server with SSR | true (default) — no config needed |
| Blazor Hybrid (Server + WASM) | true (default) — server enriches claims for all render modes |
| Standalone Blazor WASM | false — needs client-side enrichment |
Custom Claims Enricher
If you need to inject custom claims (e.g. global roles from a database) before team member lookup and consent evaluation, implement ITeamClaimsEnricher and register it:
public class MyClaimsEnricher : ITeamClaimsEnricher
{
private readonly IMyUserDatabase _db;
public MyClaimsEnricher(IMyUserDatabase db) => _db = db;
public async Task EnrichAsync(ClaimsIdentity identity)
{
var roles = await _db.GetGlobalRolesAsync(identity.Name);
foreach (var role in roles)
{
if (!identity.HasClaim(c => c.Type == ClaimTypes.Role && c.Value == role))
identity.AddClaim(new Claim(ClaimTypes.Role, role));
}
}
}
Register via options:
builder.Services.AddThargaTeamBlazor(o =>
{
o.AddClaimsEnricher<MyClaimsEnricher>();
// ...
});
Or via AddThargaTeam:
builder.AddThargaTeam(o =>
{
o.Blazor.AddClaimsEnricher<MyClaimsEnricher>();
});
The enricher runs once per request inside TeamServerClaimsTransformation, before member lookup and consent evaluation. It supports full dependency injection (constructor injection). Duplicate claims are automatically prevented.
When team claims refresh.
TeamServerClaimsTransformationis anIClaimsTransformation, so it runs during HTTP authentication — a page load or the establishment of a Blazor Server circuit — not on every interaction within a live circuit. Team claims are therefore re-evaluated on page load and on team switch (switching teams forces a full reload). To keep a live circuit from acting on frozen claims — a removed member, a lowered access level, or a revoked consent — Tharga.Team also revalidates team claims periodically (see Team-claim revalidation below), refreshing them in place without signing the user out.
Use cases:
- Assign global roles (e.g.
Developer,SystemAdministrator) based on user identity - Add custom claims from external systems before team consent is evaluated
- Enrich the principal with application-specific metadata
Team-claim revalidation
Because the claims transformation runs only at HTTP authentication, team membership, access level, tenant-role scopes, and consent-derived access would otherwise stay frozen for the life of a Blazor Server circuit — so a removed member, a downgraded access level, or a revoked consent would keep their old access until a full reload. This affects service-layer authorization too, not just the UI: BlazorTeamPrincipalAccessor falls back to the circuit's authentication state when there is no HttpContext, so [RequireScope], [RequireAccessLevel], and the ITeamService authorization decorator all read the frozen claims in-circuit.
To close this window, Tharga.Team revalidates team claims on an interval for the life of each Blazor Server circuit. On each tick the caller's team claims are recomputed for their selected team; if they changed, the principal is refreshed in place — the caller is not signed out, their team access is simply brought up to date (including downgrades and removal). The net guarantee: team access is stale for at most one interval instead of "until reload". Team-independent system scopes (granted by app roles) and app roles themselves are preserved; a transient recompute error fails open (current claims kept, retried next interval).
builder.AddThargaTeam(o =>
{
// Default: enabled, 30-minute interval.
o.Blazor.ClaimRevalidation.Interval = TimeSpan.FromMinutes(5); // narrow the window
// o.Blazor.ClaimRevalidation.Enabled = false; // or turn it off entirely
});
| Option | Default | Effect |
|---|---|---|
ClaimRevalidation.Enabled |
true |
Revalidate team claims for the life of a Server circuit. false reverts to pre-3.5 behaviour (claims refresh only on reload / team switch). |
ClaimRevalidation.Interval |
30 minutes | How often claims are re-evaluated. Shorter narrows the staleness window at the cost of one lightweight membership read per active circuit per interval. |
Server/SSR only. Revalidation is wired when
SkipAuthStateDecorationistrue(the default, and the signal for a server-hosted app); a standalone WASM client has no server circuit to revalidate this way. A team switch still refreshes claims immediately via a full reload regardless of this setting.
Verification
After login, the team selector should appear. Creating a team should persist to MongoDB. Switching teams should update the claims.
Step 5: API Key Authentication
Adds API key authentication so external clients can call your API using X-API-KEY headers.
Requires: Step 3, Step 4
Program.cs
Extend the existing AddThargaTeamBlazor call to register the API key service, and add API key authentication:
builder.Services.AddThargaTeamBlazor(o =>
{
// ... existing team config ...
o.RegisterApiKeyAdministrationService<MyApiKeyService>();
});
builder.Services.AddThargaApiKeys();
// Chain onto the existing authentication registration:
builder.Services.AddAuthentication()
.AddThargaApiKeyAuthentication();
Options
.AddThargaApiKeyAuthentication(o =>
{
o.AdvancedMode = false; // default: false — simple mode auto-generates keys
o.AutoKeyCount = 2; // default: 2 — number of auto-generated keys in simple mode
o.AutoLockKeys = false; // default: false — auto-lock keys after creation
o.MaxExpiryDays = 365; // default: 365 — maximum key expiry in days (null = no cap)
o.LastUsedThrottle = TimeSpan.FromMinutes(1); // default: 1 min — min interval between "last used" timestamp writes per key (TimeSpan.Zero = stamp every request)
o.VerificationCacheDuration = TimeSpan.FromMinutes(10); // default: 10 min — a repeat request skips PBKDF2; the key is still read every request, so changes apply at once (TimeSpan.Zero = verify every request)
o.MinKeyLength = 32; // default: 32 — alphanumeric chars in the key secret; fixed length unless MaxKeyLength is set (floor 24 ≈143-bit; team + system keys)
o.MaxKeyLength = null; // default: null — when set, the length is random in [MinKeyLength, MaxKeyLength] per key instead of fixed
});
What becomes available
- API key authentication handler (validates
X-API-KEY, orAuthorization: Bearer) - Three authorization policies — see the table below
- API key management UI via
<ApiKeyView />(from Step 4) - Constants in
ApiKeyConstants.HeaderName,ApiKeyConstants.PolicyName
Which policy to use
The first two are disjoint, not a hierarchy. SystemApiKeyPolicy is not "ApiKeyPolicy plus more".
| Policy | Team key | System key |
|---|---|---|
ApiKeyConstants.PolicyName (ApiKeyPolicy) |
✅ | ❌ |
ApiKeyConstants.SystemPolicyName (SystemApiKeyPolicy) |
❌ | ✅ |
ApiKeyConstants.AnyKeyPolicyName (AnyApiKeyPolicy) |
✅ | ✅ |
Warning
Requiring both admits nothing. ASP.NET Core combines policies when several are named, so
RequireAuthorization(PolicyName, SystemPolicyName) demands a key that is both at once — which no key
is. Use AnyKeyPolicyName for an endpoint both kinds should reach.
MCP endpoints need none of these — UseThargaMcp() builds its own policy that admits both kinds,
provided mcp.AddTeam() has contributed the API-key scheme. Naming a policy there narrows the endpoint
rather than securing it.
_Imports.razor (if referencing constants)
@using Tharga.Team.Service
System-set tags
API keys can carry system-set tags — a key-value list (IReadOnlyList<Tag>, record Tag(string Key, string Value)) set by backend code at creation. Tags are backend-only: there's a tags parameter on CreateKeyAsync, no mutation API, and no input in the ApiKeyView create card — so an operator can't add or re-point them from the UI.
await apiKeyManagementService.CreateKeyAsync(
teamKey, "Firewall opener", AccessLevel.Custom,
scopeOverrides: new[] { "firewall:open" },
tags: new[] { new Tag("Type", "firewall"), new Tag("firewall.groupId", "ABC123") });
- Surfaced as claims. Each tag becomes a
tag.{Key}claim on the authenticated principal (TeamClaimTypes.TagPrefix = "tag.") — no DB round-trip to read a key's binding. Because it's a list, a key may carry the same key twice (e.g.Type=firewall+Type=PIM), producing twotag.Typeclaims; read them withuser.FindAll("tag.Type"). - Displayed read-only.
ApiKeyViewshows all tags in an(i)tooltip; passChipTagKeysto render selected keys as chips (e.g.ChipTagKeys="@(new[] { "Type" })"). - Legacy data. Pre-tags keys stored an empty
Tagsdocument; reads tolerate this automatically (it deserializes as no tags). To purge the legacy field, callIApiKeyRepository.CleanLegacyTagsAsync()once (server-side, safe to repeat).
Lifecycle hook (capturing the private token)
The private API token is shown once at creation and is otherwise unrecoverable — it's never persisted, logged, or exposed programmatically. If a host needs to capture and re-deliver a key (e.g. minting a scoped key to hand out repeatedly), register an IApiKeyLifecycleHandler. It receives the token at the moment it exists — on create and recycle/regenerate — plus a tokenless delete signal so the host can purge its own copy.
public class MyApiKeyHandler(ISecretProtector protector, IMyKeyStore store) : IApiKeyLifecycleHandler
{
public async Task OnApiKeyLifecycleAsync(ApiKeyLifecycleContext ctx)
{
switch (ctx.Reason)
{
case ApiKeyLifecycleReason.Created:
case ApiKeyLifecycleReason.Recycled:
await store.SaveAsync(ctx.ApiKeyId, protector.Protect(ctx.PrivateToken), ctx.TeamKey, ctx.Tags);
break;
case ApiKeyLifecycleReason.Deleted:
await store.RemoveAsync(ctx.ApiKeyId);
break;
}
}
}
// register it inside AddThargaTeam:
builder.AddThargaTeam(o =>
{
// ...
o.AddApiKeyLifecycleHandler<MyApiKeyHandler>(); // may be called multiple times
});
- What you get —
ApiKeyLifecycleContext:Reason,ApiKeyId(the stable public id),PrivateToken(non-null on Created/Recycled, null on Deleted),TeamKey(null for system keys),IsSystemKey,Name,Tags. Applies to both team and system keys. - Error policy — if the handler throws, the originating
CreateKey/RefreshKey/DeleteKeythrows too (capture failures are not swallowed). Note this does not roll back: a thrown create still leaves the key in storage and a thrown recycle has already rotated the secret — treat a failure as "operation failed" and reconcile (re-recycle, or delete the orphan). - Scope — fires only on explicit create/recycle/delete. Simple-mode auto-generated keys (created lazily by
GetKeysAsync) and lock/scope/role edits do not fire it. - Security — the token is handed only to in-process handlers you registered; it is still never persisted or logged by the platform. You own whatever you capture (encrypt it at rest).
- Multiple handlers can be registered; all are invoked.
Personal (owner-scoped) keys
By default every team key is visible to all team admins and any Owner can recycle/lock/delete it, and every system key to everyone who manages system keys. For keys that gate personal data, create a personal key — bound to the person who created it, hidden from others, and mutable only by its owner. In code these are owner-scoped ("private") keys: PrivateKeyScope, ownerScoped, IApiKey.OwnerMemberKey. Both team keys and system keys can be personal; system keys since 4.1.
Which kinds people can create
Each key kind has its own option, both defaulting to ApiKeyOwnership.Both:
o.ApiKey.TeamKeys = ApiKeyOwnership.Both; // default: the create card offers a Personal checkbox
o.ApiKey.SystemKeys = ApiKeyOwnership.Shared; // e.g. keep every system key shared
Both(default) — the create card shows a Personal checkbox; unticked, the key is shared.Shared— no checkbox, and a request for a personal key (ownerScoped: true/personal: true) throwsInvalidOperationExceptionrather than quietly creating a shared key its creator meant to keep to themselves.Personal— no checkbox, and every key created through the management services is personal, whatever the caller asked for.
The option is enforced by IApiKeyManagementService and ISystemApiKeyManagementService, not only by the views. It governs creation only: existing keys are not changed when it changes, and stay listed and manageable. Simple mode's auto-created team keys have no creator, so they are always shared.
Creating, seeing and managing personal keys
Create — from the UI with the Personal checkbox, or in code:
- team:
IApiKeyManagementService.CreateKeyAsync(..., ownerScoped: true), or server-sideIApiKeyAdministrationService.CreateKeyAsync(..., ownerMemberKey: currentMember.Key); - system:
ISystemApiKeyManagementService.CreateSystemKeyAsync(..., personal: true), or server-sideIApiKeyAdministrationService.CreatePersonalSystemKeyAsync(name, scopes, ownerUserKey).
The management services force the owner to the caller — a caller can't create a key owned by someone else. A system key's owner is the user's key, which is the same value as their member key in any team; a caller authenticated by an API key is not a person and can't own one.
- team:
Visibility / mutation are enforced in the management services from the authenticated principal:
- Owner sees and manages their own personal keys.
- Oversight — a caller holding the
apikeys:overseesystem scope (SystemApiKeyScopes.Oversee) sees and manages all (audit/incident escape), team and system alike. Map it to a role like any other system scope:o.ConfigureSystemRoles = roles => roles.Map("Administrator", SystemApiKeyScopes.Oversee);. A team scope with the same name does not count. Since 4.0 theDeveloperrole grants nothing here by itself; map the scope to it if developers need oversight. - Privileged access levels (Administrator/Owner) can see personal team keys only when the host opts in — and remain view-only (they cannot recycle/lock/delete others').
View parameters —
ShowPrivateKeyson bothApiKeyViewandSystemApiKeyView:Mine(default) lists the caller's own personal keys,Alleverything the caller is entitled to see,Nonenone. Any value butNonealso shows the Personal checkbox when the option allows both kinds.ApiKeyViewaddsAllowPrivilegedAccess(default false; only meaningful withAll). The actual visibility is always intersected with the caller's identity server-side, so the parameters can never reveal a key the caller isn't entitled to. The list marks a personal key with a Personal badge.Service defaults —
GetKeysAsyncandGetSystemKeysAsynclist shared keys only unless asked for personal ones, as before personal keys existed.A custom key store keeps compiling:
CreatePersonalSystemKeyAsynchas a default implementation that throwsNotSupportedException. Implement it, and keepOwnerMemberKeyacrossRefreshSystemKeyAsync, to support personal system keys.
Changed in 4.1 (Tharga/Team#314): ShowPrivateKeys defaulted to None, which hid the option, so a page that set nothing could not create a personal key at all. A page that relied on that now shows the checkbox; set ShowPrivateKeys="PrivateKeyScope.None" to hide it on that page, or o.ApiKey.TeamKeys = ApiKeyOwnership.Shared to turn personal team keys off for the site. The UI said "Private (only me)"; it now says "Personal".
A personal key ends with its owner
A private key acts on behalf of its owner, so on every request the authentication handler checks the owner as well as the key:
- Refused when the owner is no longer a member of the team (removed, left, or never accepted the invitation), is suspended, or their user is disabled or deleted. Each refusal is audited as an authentication failure with its reason. Lifting the suspension or re-enabling the user brings the key back.
- Fail closed — if the owner cannot be looked up (the store faults, or no team/user service is registered), the key is refused.
- Never more than the owner — the key's effective scopes are its own scopes intersected with what the owner holds now (role, tenant roles, scope overrides), and its
AccessLevelclaim is the less privileged of the key's and the owner's. Demoting the owner narrows the key on the next request. ApiKeyOwnerclaim (TeamClaimTypes.ApiKeyOwner) carries the owner's user key, so an API can tell who a request acts for. It is deliberately not aMemberKeyclaim: the key is not itself a member.
A personal system key is checked the same way, as far as a system key can be: it is refused when its owner's user is deleted or disabled, refused when the owner can't be looked up, and carries the ApiKeyOwner claim. What can't be checked is the owner's system role — system scopes come from application roles on the sign-in token, which no store holds — so the key keeps the scopes it was created with after its owner loses the role, until someone disables or deletes it. A shared system key has the same property.
Shared keys are unaffected and never trigger an owner lookup.
Cleanup when the owner leaves
When someone stops being a member — RemoveMemberAsync, LeaveTeamAsync, or RemoveUserFromAllTeamsAsync (user deletion) — their private keys in that team are deleted by default. To keep them for audit instead, disable them:
o.ApiKey.OwnerRemovedAction = PrivateKeyOwnerRemovedAction.Disable;
Deleting a user also cleans up their personal system keys, following the same setting.
Cleanup goes through IApiKeyAdministrationService, so it covers a custom key store and notifies registered IApiKeyLifecycleHandlers. It runs after the removal succeeds and never fails it; authentication is the guarantee, cleanup is housekeeping.
The hook is public: implement ITeamMemberRemovalParticipant and register it with services.TryAddEnumerable(ServiceDescriptor.Transient<ITeamMemberRemovalParticipant, MyCleanup>()) to clean up your own per-member data the same way.
Verification
Create an API key via the UI, then call your API with X-API-KEY: <key> header. The request should authenticate successfully.
Step 6: Scopes
Adds fine-grained permission scopes that control access to service methods. Scopes are resolved per team member based on their access level, tenant roles, and scope overrides.
Requires: Step 4
Program.cs
using Tharga.Team;
using Tharga.Team.Service;
// Define scopes with default minimum access levels
builder.Services.AddThargaScopes(scopes =>
{
scopes.Register("feature:read", AccessLevel.Viewer);
scopes.Register("feature:write", AccessLevel.User);
scopes.Register("feature:manage", AccessLevel.Administrator);
});
// Register services with scope enforcement — as a team service or a system service
builder.Services.AddTeamService<IMyService, MyService>();
builder.Services.AddSystemService<IMyAdminService, MyAdminService>();
Naming a scope
Scope names follow a grammar, and it is worth following for your own scopes too — the audit log reads it.
{feature}:{action} team:read, apikey:manage, audit:read, mcp:discover
{feature}:{reach}:{action} support:unassigned:read, support:all:read
| Part | Is | Notes |
|---|---|---|
| feature | The area acted on | Becomes AuditEntry.Feature, which the audit log renders as a row of filter buttons and as the category axis of two charts. Keep the set small |
| reach | The population the grant covers | Present only where that population is not the one the scope is held against |
| action | What may be done | Becomes AuditEntry.Action, a drop-down filter. Free to grow |
Most scopes need no reach segment. A team scope is held against a team, and that team is its reach —
support:read means "any case in the team you hold this on". A system scope has no team to imply one, so
where it covers a specific population it says which: support:unassigned:read reaches the cases no team
owns, support:all:read reaches every team's. Every reach-bearing scope today is a system scope.
Reach goes in the middle, not the front. AuditEntry.ParseScope splits on the first colon and
nowhere else, so support:all:read is audited as feature support, action all:read — it files under
the support feature with everything else support does. Writing it as support-all:read instead would make
a second feature: a second button on every audit filter bar, a second bar on the charts, and a key that no
longer matches a support:* notification route.
Note
Two built-in scopes express reach differently. teams:read pluralises the feature to mean "every
team", against team:read's one, and apikey:system-manage qualifies the action instead. Both predate
the grammar and are staying as they are — a renamed scope is invisible to the compiler and silently
authorizes nothing. Follow the grammar above for new scopes rather than either of those.
Which team service to inject
A component, controller or MCP provider should inject one of these — never ITeamService.
Important
Behaviour change in 3.10. team:read is now enforced on every first-level team read. A caller
lacking it is refused where it previously succeeded.
Almost certainly a no-op for you. The scope is registered at AccessLevel.Viewer, so every
ordinary member already holds it. It bites AccessLevel.Custom — least-privilege machine keys
carrying only their explicit grants — which until now read team metadata, the full roster with access
levels and states, and API-key metadata regardless. Grant team:read to any Custom key that should
keep reading team data.
An application with no IScopeRegistry registered is unaffected: it does not use scopes, and enforcing
would refuse reads it never gated.
| Inject | For | Checked by |
|---|---|---|
ITeamManagementService |
One team: its details, roster, members, and every mutation | team:read on reads, team:manage / member:manage on mutations |
ITeamDirectoryService |
The caller's own teams, leaving one, and their own access to a team | Recomputed per team from that membership — teams not granting team:read are omitted. LeaveTeamAsync carries no scope at all; see Leaving a team. GetMyAccessAsync(teamKey) returns the caller's own CallerTeamAccess (level, and whether it is membership or consent), or null — ungated, like IsSuspendedAsync, because it concerns only the caller |
ITeamOversightService |
Every team, regardless of membership | teams:read system scope. Discovery only |
ITeamInvitationService |
Resolving an invite code | The code itself. An invitee holds no scope for the team they are joining |
ITeamLifecycleService |
Creating a team | Authenticated caller plus AllowTeamCreation |
ITeamService is the contract you implement, not the one you inject
It is the host's own storage seam and is deliberately unchecked — framework code reads through it while constructing the very claims that would authorize the read, so gating it would be circular and break sign-in.
A first-level surface injecting it bypasses authorization entirely. That is not hypothetical: it is how
team:readcame to be registered, documented, granted — and checked by nothing.
Three categories, not two, and only the first is marked by an attribute:
| Category | Marked by | Rule |
|---|---|---|
| Gated | [RequireScope] on every method |
All-or-nothing. The interface is wholly team-bound or wholly system-wide, so one registration is true of every method |
| Filtered | nothing — stated in XML docs | A first-level read naming no team, so it cannot be gated. Recomputes the caller's scopes per item and omits what they may not see |
| Internal | [EditorBrowsable(Never)] + XML docs |
The contract a host implements. Never inject from a component, controller or MCP provider |
This is enforced, not just documented. InternalServiceInjectionTests fails if any component or MCP
provider in the toolkit takes a dependency on a type marked [EditorBrowsable(Never)], reading both
constructor parameters and [Inject] properties. Internal services are discovered by the attribute rather
than a list, so marking a new contract internal enrols it automatically.
InternalServiceHolderGuardTests covers what that guard cannot read: classes that are not components or
providers, and code that fetches an internal service from a service provider instead of injecting it. The few
classes that hold ITeamService on purpose (the team selector's state, the simulation state and the code that builds
claims) are listed there, with the members each may call, so a new call fails until someone writes down why it is
safe.
The guard cannot see your assembly. It scans the toolkit's own components and providers. If you write components against these services, add the equivalent test over your assembly — the rule is the same, and a host component injecting
ITeamServiceis exactly as unchecked as a toolkit one would be. Reflect over yourIComponenttypes, collect constructor parameters and[Inject]properties, and assert none is markedEditorBrowsableState.Never.
An entry point's check need not be a scope. An invitation is authorized by its invite code, because the invitee is not yet a member and holds nothing. The rule is that a first-level call is checked, not that it is checked by a scope.
Where invitations are redeemed
Redeeming an invitation and administering a team are different capabilities with different audiences. Put them on different routes, or the gate on one becomes the gate on the other.
TeamComponent renders <TeamInviteView> at the top of its own markup, which is why /team happens to
be where invitations are redeemed by default. That is convenience, not a requirement:
TeamInviteView is standalone and route-agnostic. It reads the invite code from the current URL —
any route — and falls back to browser storage, so the code survives a redirect through login.
@page "/invitation"
@attribute [Authorize]
<TeamInviteView TMember="MyMember" ShowEmptyMessage="true" />
Then point generated links at it:
o.Blazor.InvitePath = "/invitation";
What the view says (from 3.23.1, Tharga/Team#285). Only an open invitation offers to join; each other
outcome has its own sentence and a text key of its own in TeamInviteViewText:
| Outcome | Text key | Shown |
|---|---|---|
| No code presented | team.invite.noInvitations |
Only with ShowEmptyMessage="true" |
| Code does not resolve — unknown, superseded by a re-invite, withdrawn, or already answered | team.invite.invalidLink |
Always |
| Caller is already a member of the team | team.invite.alreadyMember ({0} = team name) |
Always |
| Invitation has expired | team.invite.expired ({0} = team name) |
Always |
The last three show even where ShowEmptyMessage is off, and to a visitor who has not signed in: a code
means the visitor followed a link, so saying nothing would look like a broken link. The invalid-link wording
is deliberately vague, because the service does not tell an unknown code from a withdrawn one to someone who
only holds the link.
InvitePath covers the invitation email and the "Copy invitation link" action alike — both go
through one builder. That matters: a host can rewrite what its own ITeamEmailSender sends, but nothing
can rewrite what an administrator copies to the clipboard. Unset, links point at /team as before.
[Authorize]and nothing more. Gating the redemption route any further reproduces the problem it exists to solve. If you restrict/teamto staff roles — reasonable when teams come from a registration flow — and invitations land there, the one page that redeems an invite is closed to exactly the people who need it, and it fails silently from every angle: the inviter sees a normal link, the invitee sees a "not found" that reads like an expired invitation, and nothing surfaces server-side because the request never reaches the invite handling (Tharga/Team#191).
The route name is yours; /invitation above is only an example, and worth checking against whatever your
own stack already serves.
Where answering an invitation leaves the invitee
Accepting selects the team that was joined and navigates to the site root. Declining navigates to the same place and changes no selection. Both are a forced load, because the answer changes the caller's teams and the new claims only apply on a fresh one.
The root is the default because it is already where UseThargaAuth returns people after signing in. Where
that is not somewhere to land — a marketing page, or an invitation page that is one step of a longer flow —
say where instead:
o.Blazor.HomePath = "/start";
HomePath takes the route in whatever shape you write it: /start, start and /start/ are the same
thing, and unset means the application root. It is deliberately not InvitePath: one is where an invitee
arrives from their inbox, the other is where a member belongs once they have joined.
Changed in 3.22. Accepting used to reload the invitation page and leave the selection untouched. Because the reload re-runs the selection resolver, which honours the remembered team first, an invitee who already belonged to a team kept the old one — the team they had just accepted was discarded — and they were left looking at an invitation page with nothing to show (Tharga/Team#287). A fresh account appeared to work, because with no remembered team the resolver fell through to the only membership there was.
ITeamService.SelectTeamEventno longer fires when an invitation is accepted. The screen that accepts it now makes the selection itself, awaited, and navigates once. The event was raised from the service and bridged by anasync voidhandler that could not be awaited, so the selection it wrote raced the screen's own navigation and the reload it performed of its own was a second navigation for one click. If you subscribe to that event to react to a new membership, move the reaction toTeamsListChangedEvent, which still fires on both answers.CreateTeamAsyncstill raisesSelectTeamEvent: there, nobody else is present to choose.
What an invitation link looks like
https://your-host/invitation?tic=84Fb6G_8BbXE
A twelve-character opaque token and nothing else. The store resolves it to its team.
Changed in 3.20. Links used to carry
?TeamInviteCode=holding base64 of{"TeamKey":…,"Code":…}. Base64 is an encoding, not encryption, so the team key was readable by anything the link passed through — a mail relay, a helpdesk ticket, a forwarded message. The team key is no longer in the link at all.Links already sent keep working. The old parameter and payload are still accepted, so invitations sitting unopened in an inbox are unaffected. Only newly generated links use the short form.
Fixed in 3.21.1 — upgrade if you are on 3.20.0 through 3.21.0. In those versions the short form never resolved in a host registered through
AddThargaTeamBlazororAddThargaTeam: every link minted there opened on "no invitation", whatever the store held (Tharga/Team#272). Nothing needs re-sending — the same links resolve once the host is on 3.21.1.
A host with its own team store must be able to look an invitation up without its team. That is one
method — TeamServiceBase.GetTeamKeyByInviteKeyInternalAsync — and it is not optional: the short form is the
only one the toolkit generates, so without it every new invitation link fails. Links that name their team
are legacy — still accepted, no longer produced. The MongoDB store implements it, backed by an index on the
invitation code.
From 3.23.1 this is reported at startup (Tharga/Team#286). TeamServiceCompletenessCheck logs an error naming
the member when your service does not override it — fatal under o.Blazor.ThrowOnIncompleteTeamService — and
with Tharga.Team.MongoDB, a replacement ITeamRepository that leaves GetByInviteKeyAsync at its default is
logged the same way.
A host that wraps ITeamService in a decorator of its own must implement GetTeamKeyByInviteKeyAsync and
forward it. It is a default interface member returning null, so a decorator that leaves it out compiles,
and every short link resolved through it comes back as "no invitation". The same is true of every default
member on the interface — forward all of them. The toolkit's own decorators are held to this by
DecoratorDefaultMemberTests; a host writing its own should carry the equivalent test.
Invitations that expire
Off by default — invitations have never expired, and applying a lifetime on upgrade would silently invalidate every one already outstanding.
builder.Services.Configure<InvitationOptions>(o => o.Lifetime = TimeSpan.FromDays(14));
With a lifetime set, an invitation older than it is refused at acceptance and reported as expired rather than as an invalid code — the distinction between "ask for a new link" and retrying something that will never work. A code that is malformed or unknown still resolves to nothing, indistinguishably, because that answer goes to someone who guessed.
Extending keeps the same code:
await teamManagementService.ExtendInvitationAsync(teamKey, inviteKey);
That is the point of it. Someone who has already mailed a link can give it another fortnight without the recipient's link dying, because the expiry lives on the invitation record rather than being derived from when it was created. Re-inviting an address that already has an outstanding invitation does the same thing — it renews rather than issuing a second live code for one seat, and applies any changed access level or name.
Three things a host with its own store should know:
- Expiry is enforced without an override.
TeamServiceBasefinds the invitation on the roster throughGetMembersAsync, so a store that exposes its members needs nothing more. Up to 3.23.0 the default found nothing, which read as "never expires", and an expired invitation was accepted by any path that skipped the invitation screen. With a lifetime set, accepting a code the roster does not hold is now refused. - Extending needs
TeamServiceBase.SetTeamMemberInvitationExpiryAsync. Unlike the lookup above this one throws rather than no-opping: a store that silently discarded an extension would report success for an invitation that stays expired, and the operator would find out from the person who could not accept it. With Tharga.Team.MongoDB, the same holds one layer down:ITeamRepository.SetInvitationExpiryAsyncthrows by default, and a replacement repository that leaves it out is logged as an error at startup when a lifetime is set. - Forward
InvitationOptionsfrom your own service's constructor, the same asITeamCache. Left unforwarded, the base receives defaults and invitations never expire however the host configured them.
Guessing an invite code is slowed, and shows up in the audit log
Resolving a code has to be an oracle: a hit returns the team's name, because the screen has to say which
team you were invited to. So a correct guess announces itself — and because invitations do not expire
unless you configure a Lifetime, the set of live codes only grows.
Repeated failed resolves from one source are therefore delayed, on a curve, and the failure that first
crosses the threshold is recorded as AuditEventType.RateLimit — which the log view has always been able
to render and nothing raised until now.
builder.Services.Configure<InvitationOptions>(o =>
{
o.ThrottleFailureThreshold = 5; // failures before delay starts
o.ThrottleWindow = TimeSpan.FromMinutes(5);
o.MaxThrottleDelay = TimeSpan.FromSeconds(2); // TimeSpan.Zero to audit without delaying
});
Defaults are generous rather than off: a real invitee fails once or twice — a mistyped link, a code already
accepted — and never reaches five. Set ThrottleFailureThreshold to 0 to turn it off entirely.
Four properties worth knowing, because each is a deliberate limit rather than an omission:
- It delays, and never refuses. An invitee retrying a link, or an office behind one address accepting invitations the same morning, must not be locked out. A throttled resolve still answers.
- It is not a substitute for entropy. Against a distributed source a per-source delay barely moves a weak code. Token length should never be reduced on the strength of this.
- The count is per process. Spread across instances an attacker gets one budget per instance. Shared state would need a third host-implemented port, which is not worth a new obligation for a threat nobody has reported; the delay still slows every connection an attacker holds.
- A Blazor circuit has no client address to read, so callers whose address cannot be determined share one bucket. That is affordable exactly because the consequence is a delay rather than a refusal.
Only the first crossing is audited. Auditing every failure past the threshold would bury the signal in the noise it exists to report — and the entry deliberately carries no invite code, since a failed code is one somebody guessed and the log should not hand candidate codes to everyone who can read it.
Sending the invitation email
ITeamEmailSender has one member, SendInviteAsync(TeamInviteMail), so configuring email here is a decision about
invitation delivery — not about handing the toolkit your mail pipeline. (The only other mail the toolkit sends
is support mail, configured separately in Tharga.Team.Support. Both obey the same rules outside production;
see Mail outside production below. Receiving mail into a team's own mailbox is in
Email, team mailboxes & the postmaster.)
Three-way choice, and it works identically on the facade and granular paths:
// 1. Your own sender — wins over SMTP, which is then ignored.
o.AddEmailService<MyEmailSender>(); // any ITeamEmailSender
// 2. Or SMTP: the settings here, the sender from Tharga.Team.Email.
o.Email = new EmailOptions
{
SmtpHost = "smtp.example.com",
FromAddress = "hello@example.com", // an address a reply reaches
// FromName falls back to the application Title when unset.
};
builder.Services.AddThargaEmailInvitations();
// 3. Or neither — no email is sent, and no sender is registered.
On the granular path set these on AddThargaTeamBlazor; on the facade set them on AddThargaTeam (or on its
o.Blazor section — the facade's own Email wins if both are set).
Important
Changed in 3.29: o.Email alone no longer registers a sender. The SMTP sender moved to Tharga.Team.Email
(MailKit, one SMTP stack with support mail), so Tharga.Team.Service carries no mail code; install the package
and call AddThargaEmailInvitations(). With settings and no sender, startup logs a warning saying so.
With no sender registered, invitations are not sent and nothing reports it.
InviteUserDialogandTeamComponentresolve the sender withGetService, so they degrade to "Copy invitation link" and an administrator delivers it by hand. That is deliberate — it is what option 3 means — but it looks the same as forgetting to configure email. If invitations seem to vanish, check whether a sender is registered at all.Before 3.10,
AddThargaTeamBlazorcould not register a sender at all, so a granular host hit this state without having chosen it (Tharga/Team#176).
A fourth way, since 4.2: mail to users. With AddThargaUserMail registered,
invitations are sent through its outbox instead of option 2 — from UserMailOptions.Mail, in one immediate
attempt, recorded in the invited person's mail history and marked bounced when they come back. Option 1, a
sender of your own, still wins.
Mail outside production
Outside production, the toolkit mails only your own people (Tharga/Team#290). The same build deployed to a test environment would otherwise invite real customers while someone tries something out. Every mail the toolkit sends — invitations and support mail alike — goes through one policy:
| Environment | Recipient | What happens |
|---|---|---|
| Production | anyone | Sent unchanged |
| Not production | in an allowed domain | Sent unchanged |
| Not production | anyone else, override address set | Sent to the override address instead; the subject says who it was for: [Staging -> customer@example.org] You've been invited… |
| Not production | anyone else, no override address | Not sent, and logged |
"Email": {
"Override": {
"Address": "test-inbox@example.com",
"AllowedDomains": [ "example.com", "example.se" ]
}
}
Or in code: builder.Services.Configure<OutboundMailPolicyOptions>(o => o.Address = "test-inbox@example.com");
- Production is
IHostEnvironment.IsProduction(). A host that configures nothing gets the safe behaviour: outside production, nothing reaches a customer. - Allowed domains match exactly, case-insensitively.
example.comdoes not admitmail.example.com; list it as well if wanted. - Allowed domains may also be one value, separated by commas or semicolons —
Email__Override__AllowedDomains=example.com,example.se, the shape a pipeline variable or app setting writes. Before 3.29 that bound to no domain at all, so every recipient was redirected. - A value like
$(MailOverride)— an undefined pipeline variable arriving as its own name — counts as unset. - At startup, outside production, the log states the policy in force, and warns when everything is being withheld.
- A withheld invitation still exists; its link can be copied from the team page. A withheld support reply is recorded as not delivered. A withheld mail to a user is recorded as failed with the reason, and an invitation sent as mail to users then says it could not be sent.
Important
Upgrading changes what a test environment sends. A staging or test deployment that mailed everyone before
this release mails only allowed domains and the override address after it — and with neither configured,
nothing. Set Email:Override before upgrading if that environment should keep receiving mail.
Your own sender can apply the same rules. The toolkit cannot enforce the policy in code it does not own, but
it registers IOutboundMailPolicy for a custom ITeamEmailSender as well. Inject it and send according to its
decision:
public class MyEmailSender(IOutboundMailPolicy policy, IMyMailQueue queue, ITeamLanguages languages) : ITeamEmailSender
{
// The mail carries the team key, so per-team choices need no ambient state.
public async Task SendInviteAsync(TeamInviteMail mail)
{
var decision = policy.Decide(mail.RecipientEmail, $"Join {mail.TeamName}");
if (!decision.ShouldSend) return;
var language = await languages.GetAsync(mail.TeamKey);
await queue.EnqueueAsync(decision.Recipient, decision.Subject, mail.InviteLink, language);
}
}
Use mail.TeamKey when the mail depends on the team (Tharga/Team#289). Language, branding and sender
address are per-team choices. A display name is neither unique nor stable, and the selected team is right only
while every invitation is raised from a page. The four-string overload that preceded it (before 3.24) was
removed in 4.0; see Migrating to 4.0.
A host that sends mail outside the toolkit altogether can call services.AddOutboundMailPolicy() itself to get
the same policy, configuration and startup report.
Team services and system services
Every scope-enforced service is registered as exactly one of two kinds. The choice is made once, at registration, and applies to every method — including ones added later, which is the point: there is no per-method annotation to forget.
AddTeamService |
AddSystemService |
|
|---|---|---|
| Every method | takes the team it acts on as its first parameter, named teamKey |
takes no teamKey |
| The scope must be held | for the team named in that call (Scope claim) |
as a system grant (SystemScope claim) |
| A team must be selected | yes | no |
Registration validates the interface against its declared kind in both directions and throws at
startup otherwise. Rejecting a team service whose method names no team is the obvious half; rejecting a
system service that does take a teamKey is what stops a mixed interface being registered as a system
service to escape the team check.
An interface must therefore be wholly one kind. Split it if it is not — that is why
ISystemApiKeyManagementService exists separately from IApiKeyManagementService.
A system-granted operator acting on one team — AllowSystemGrant
Some operations act on one named team but belong to staff, not to the team: a feature switch per organisation, say, that the team's own Administrator must not be able to grant themselves. The scope has to stay a system scope for that reason, yet the method is team-bound, so neither kind fits on its own. Opt the method in:
public interface IFeatureSettingsService
{
[RequireScope("features:read")]
Task<FeatureSet> GetAsync(string teamKey);
[RequireScope("features:manage", AllowSystemGrant = true)]
Task UpdateAsync(string teamKey, FeatureSet features);
}
builder.Services.AddTeamService<IFeatureSettingsService, FeatureSettingsService>();
On that method the check passes if the caller holds features:manage for the named team (as without
the flag) or holds it as a system grant — and the system-granted caller need not be a member of,
or have selected, that team. A system grant of a different scope, or a team-level grant of this one for
another team, is still refused. The default is false, so every existing method behaves exactly as before.
A call admitted this way is recorded as such: the ambient TeamAccess.Current is a team decision for the
named team with a Reason naming the system grant, and the audit entry carries the named team as
TeamKey and authorization.via = system-grant in its metadata (AuditMetadataKeys.AuthorizedVia), so
the team's own audit log shows staff acting on it.
AllowSystemGrant on a method of a service registered with AddSystemService is rejected at
registration: a system service is already authorized by the system grant alone, so the flag could only
mislead a reader into thinking the method is team-bound.
Service implementation
Decorate service methods with the required scope:
public class MyService : IMyService
{
[RequireScope("feature:read")]
public Task<Data> GetAsync(string teamKey) { ... }
[RequireScope("feature:write")]
public Task SaveAsync(string teamKey, Data data) { ... }
}
ScopeProxy<T> checks the scope before calling the method and throws UnauthorizedAccessException
otherwise. On a team service it checks the scope against teamKey as passed in the call, not merely
against whichever team the caller happens to have selected — so holding feature:write for one team does
not authorize writing to another.
The attribute alone enforces nothing. Enforcement comes from the registration installing the proxy. A service registered with a plain
AddScopedcarries its[RequireScope]attributes as documentation and is not checked at all.
Works in interactive Blazor Server too. The proxy resolves the caller via
ITeamPrincipalAccessor. The default implementation readsIHttpContextAccessor(controllers/API).AddThargaTeam/AddThargaTeamBlazorautomatically swap in a circuit-aware accessor that usesHttpContextwhen present and falls back toAuthenticationStateProviderotherwise — so a single[RequireScope]/[RequireAccessLevel]enforces both your API and interactive Blazor callers (noHttpContextis needed in a circuit). To plug in a different principal source, register your ownITeamPrincipalAccessor.
How scopes are resolved
- Access level — Owner and Administrator get all scopes. User gets scopes at User or Viewer level. Viewer gets only Viewer-level scopes.
Customgets no base scopes (and is exempt from the Owner/Administrator "all scopes" rule). - Tenant roles — Additional scopes granted by assigned roles (see Step 7).
- Scope overrides — Per-member overrides set in the team management UI (when
ShowScopeOverrides = true).
AccessLevel.Custom— least-privilege keys/members. UseCustomwhen a principal should carry only its explicitly assigned roles and scope overrides, with nothing inherited from the access-level tier — e.g. a machine API key minted with a single scope. Its effective scopes are exactlyroles ∪ scopeOverrides. Set it explicitly: a key created without an access level still defaults to a non-Customlevel.Customis surfaced in theApiKeyViewcreate card; it is intentionally hidden from the team-member pickers until member scope/role editing lands (#76).
Hiding an access level from the pickers
Every scope you register at Administrator makes Viewer and User less distinguishable, and if all of
them sit there the two levels resolve to exactly the same set. Offering both is then a choice with nothing
behind it that every team administrator has to reason about.
o.Blazor.HiddenAccessLevels = [AccessLevel.Viewer];
That level stops being offered by the invite dialog, the member editor, the API-key level picker, the
consent picker, and the access-level selector in ScopeView. The one exception is ScopeView: a level the
signed-in member holds stays offered there, so a page explaining their own access never hides the level that
grants it. Default is empty — unset behaves exactly as before.
Hidden is not invalid. This governs what a person can choose, and nothing else. Members already on the level keep working, keep their scopes, and keep rendering their badge; if you sync members from another system that still produces the level, they arrive and function normally. Nothing about the model, the claims or the display changes.
| Level | Effect of hiding it |
|---|---|
Viewer, User |
Removed from every picker. The common case |
Custom |
Only the API-key picker offers it; hiding it removes the least-privilege machine-key option |
Administrator |
Allowed, but read the note below |
Owner |
Throws at registration |
Hiding Owner is refused because no picker offers it in the first place — ownership moves through
TransferOwnershipAsync and SetOwnerAsync, never by choosing a level. Accepting the setting would leave
you believing a restriction was in force when nothing had changed, so it fails at startup instead.
Hiding Administrator is allowed but consequential. It is a coherent model — the Owner still manages the
team — but management can then only be delegated by handing over ownership. Note the domain still produces
Administrators regardless: both ownership operations demote a displaced owner to that level, and those
members keep working. That is the hidden-is-not-invalid rule doing its job rather than a bug.
A configuration that empties a picker is refused, naming the surface it emptied. An invite dialog with nothing to choose is broken, not configured.
Grant-only scopes — keeping one out of the Owner/Administrator grant
Rule 1 above has a consequence worth stating plainly: every scope registered with ConfigureScopes reaches
Owner and Administrator, unconditionally. GetScopesForAccessLevel returns the whole registry for those two
levels, GetEffectiveScopes unions roles and overrides in, and there is no deny list. So a team
administrator holds every registered scope by virtue of being a team administrator.
That is usually what you want. It is not what you want for a scope that should be held only as a recorded decision — a scope reaching regulated or classified records, say, where "an administrator gets it automatically" is the wrong default.
Register it grant-only (3.14+):
o.ConfigureScopes = scopes =>
{
scopes.RegisterGrantOnly("case:read", "Read secrecy-classified case records.");
};
o.ConfigureTenantRoles = roles =>
{
roles.Register("CaseOfficer", ["case:read"], "May read case records.");
};
Then use it exactly as any other scope:
[RequireScope("case:read")]
Task<Case> GetCaseAsync(string teamKey, string caseKey);
What grant-only exempts
A grant-only scope is excluded from every path that grants a scope automatically or from inside the tenant — three of them, not one:
| Path | Behaviour | Why it has to be covered |
|---|---|---|
| Access-level grants | GetScopesForAccessLevel returns it for no level, Owner and Administrator included |
The headline rule |
| Tenant-defined custom roles | SetTeamCustomRolesAsync rejects it, and <TenantRoleManager /> does not offer it |
Defining custom roles is authorized by DynamicTenantRoleOptions.ManageScope — team:manage by default — which every administrator holds |
| Scope-override pickers | <TeamComponent ShowScopeOverrides="true" /> and <ApiKeyView ShowScopeOverrides="true" /> do not offer it |
Editing overrides is authorized by team:member:manage, which every administrator also holds |
The last two matter more than they look. Exempting only the access level would leave a team administrator able to define a role containing the scope, or tick it in the override picker, and grant it to themselves — so the scope would be no better protected than before, while reading as protected.
What still grants it, and this is the point rather than a gap:
- A code-registered tenant role (
ConfigureTenantRoles). This is the intended grant path. - An explicit
ScopeOverridesvalue set programmatically. Nothing validates overrides against the registry, so your own code can still assign one; it is the picker that does not offer it.
A member already holding a grant-only scope still sees it in the override picker, checked and disabled, so the effective set on screen stays truthful. An existing override remains removable — removal is de-escalation.
What you keep by registering it
Registering the scope rather than hiding it is what buys:
- A catalogue entry with its description, shown in
<ScopeView />marked with a lock and granted by no access level. - Typo safety. A misspelled scope on a role definition grants nothing and matches nothing, which for a
security scope is indistinguishable from one nobody needed.
UnregisteredRoleScopeChecklogs a warning at startup naming any role scope absent from the registry.
Do not reach for
AccessLevel.Customhere. Registering a scope atCustomlooks like the natural way to say "no level grants this", and it does the opposite. The levels areOwner=0, Administrator=1, User=2, Viewer=3, Custom=4; Owner and Administrator receive all registered scopes regardless ofDefaultMinimumLevel, and the fall-through filter isDefaultMinimumLevel >= accessLevel, soCustom=4satisfies User and Viewer as well. A scope registered that way is granted to every level. TheCustomguard inGetScopesForAccessLevelapplies to the principal's level, not the scope's.
One limitation
The toolkit's own scopes cannot be made grant-only. team:read, team:manage, team:member:manage,
apikey:manage, audit:read and simulation:use are registered by AddThargaTeamBlazor before your
ConfigureScopes runs, and Register throws on a duplicate name, so there is no way to relevel them. If you
need one of those held only by explicit grant, please open an issue describing the case.
The pre-3.14 alternative
Before RegisterGrantOnly existed, the same authorization outcome was reached by not registering the scope
at all and naming it only on a code-registered role:
// Works on any version. Nothing in the enforcement path consults the scope registry.
o.ConfigureTenantRoles = roles => roles.Register("CaseOfficer", ["case:read"]);
This still works — TenantRoleRegistry stores scope names without validating them, GetEffectiveScopes
unions role scopes in, ScopeProxy checks the claim, and GetScopesForAccessLevel cannot return a scope
that was never registered. On 3.14+ prefer RegisterGrantOnly: the authorization is the same, and you keep
the catalogue entry, the description and the typo warning. The startup check will warn about a role scope
left unregistered this way, which is the nudge to move it across.
Built-in scopes
| Scope | Kind | Source | Gates |
|---|---|---|---|
team:read |
team | TeamScopes.Read |
View team details & members |
team:manage |
team | TeamScopes.Manage |
Rename, transfer ownership, consent, custom roles, team icon. Not delete — that is an Owner act from 3.20.3 |
member:manage |
team | TeamScopes.MemberManage |
Invite/remove members, suspend/restore a member, change access level/roles/scope-overrides, edit display names |
teams:delete |
system | SystemTeamScopes.Delete |
Delete any team (cross-team), and restore one that was soft-deleted |
teams:purge |
system | SystemTeamScopes.Purge |
Permanently remove a soft-deleted team and destroy its stored data. Irreversible |
teams:read |
system | SystemTeamScopes.Read |
See every team (cross-team discovery) |
teams:manage |
system | SystemTeamScopes.Manage |
Rename and set the icon of any team — not consent or custom roles |
apikey:manage |
team | ApiKeyScopes.Manage |
Create/refresh/lock/disable/delete API keys |
Consent is configured once, in the core package
ConsentOptions lives in Tharga.Team, not in the Blazor package, and is registered as a single
instance every surface reads:
builder.AddThargaTeam(o =>
{
o.Blazor.Consent.Roles = ["Support"]; // roles a team may consent to
o.Blazor.Consent.AccessLevel = AccessLevel.User; // level consent grants by default
});
It is authorization, not presentation: it decides what a caller may do in a team they do not belong to. More than one surface answers that question — the Blazor circuit, and an MCP call naming a team — and while the type lived in the Blazor package the MCP side could not reach it and briefly carried its own copy of the default level. The same caller could then reach the same team at two different levels depending on which door they came through. Two tests pin the single instance and its assembly.
teams:manage stops short of consent
The system scope covers rename and icon only. In-team team:manage also covers consent and
custom roles, and those stay in-team: consent is a team's own statement about what it exposes
inbound, and custom roles decide what a member may do. Both are authorization; a name and an icon are
presentation. An operator fixing a typo across teams is a much smaller claim than one overriding what a
team exposes.
Nothing in the type system can express "these two members of that scope but not those two", so the erosion would be a one-line change that reads like consistency. Two tests assert the refusals instead.
Team-operation authorization
Team mutations are enforced in the service layer (AuthorizationTeamServiceDecorator over ITeamService), so the same rules protect the Blazor circuit and any consumer's REST controller that calls the service — the toolkit ships no controllers of its own.
| Operation | Allowed when |
|---|---|
| Create | authenticated and AllowTeamCreation (no scope — self-service) |
| Delete | (Owner of the team and AllowTeamCreation) or teams:delete. From 3.20.3 team:manage alone no longer suffices — see below |
| Restore | Same as Delete — restoring undoes it and is strictly less destructive |
| Purge | teams:purge only. No team-level or AllowTeamCreation path: destroying a team's data is not something a tenant should reach by holding team:manage |
| Rename / Consent | team:manage on the team |
| Member invite/remove/role/overrides/display-name | member:manage on the team |
| Leave | No scope. The operation names no user but the caller |
| Transfer ownership | Owner only |
Leaving a team
Any member can leave a team they belong to. The Owner cannot — they transfer ownership first — and
neither can the last administrator of a team that has no owner. Both are refused by the service, not
merely hidden in the UI. A suspended administrator does not count as cover: suspension withdraws every
scope, so they cannot be the one left holding member:manage. The same guards apply to an administrator
removing themselves through RemoveMemberAsync.
@inject ITeamDirectoryService TeamDirectory
await TeamDirectory.LeaveTeamAsync(teamKey);
It is the one team operation authorized by no scope, and the signature is why. LeaveTeamAsync takes
no user key, so there is nothing in it to point at anybody else — a caller can only remove themselves.
Removing another member is still ITeamManagementService.RemoveMemberAsync and still requires
member:manage.
Note
Before 3.20.2 leaving was RemoveMemberAsync(teamKey, yourOwnKey), and an ordinary member could not
do it. member:manage is registered at Administrator, so User and Viewer never held it: the
Leave button offered itself and the service then refused with UnauthorizedAccessException. No scope
could have fixed it either — a suspended member holds none at all, and suspension is precisely the
state someone most wants out of. Letting them go strands nothing, because the Owner cannot be
suspended.
Leaving is audited as leave-team, separate from remove-member, so the log says whether somebody
left or was removed.
Deleting a team is an Owner act
From 3.20.3, deleting a team from inside it requires being its Owner. Holding team:manage is no
longer enough, and neither is any other scope.
No scope could express this rule, which is why the check is not one. Owner and Administrator are both
granted every registered scope, so team:manage cannot distinguish them — and so the service admitted any
administrator, while TeamComponent had always offered the button to nobody but the Owner. The gate now
agrees with the button, and it is the button that was right.
The Owner is also the member who cannot leave without first transferring the team on, which makes them the one person whose departure and whose deletion are the same decision.
| Caller | Can delete their own team |
|---|---|
Owner, AllowTeamCreation enabled |
Yes |
Owner, AllowTeamCreation disabled |
No |
| Administrator, whatever scopes they hold | No — changed in 3.20.3 |
Holder of the teams:delete system scope |
Yes, on any team, member or not, and regardless of AllowTeamCreation |
Nothing about the operator path changes. teams:delete is a system grant for support and dev tooling;
it never depended on membership and is checked first. If an administrator in your product genuinely needs
to delete teams, that grant is the supported way to give it to them — o.ConfigureSystemRoles or a system
API key — rather than a team-level scope.
Note
Restore follows delete, as it always has: it undoes deletion, so it is authorized by the same rule
and is likewise Owner-or-teams:delete. In practice a soft-deleted team is invisible to its own
members, so restoring is nearly always an operator act.
Deleting a team: soft delete, restore and purge
From 3.13.1 deleting a team is recoverable by default. DeleteTeamAsync marks the team deleted and
hides it from every read; the record survives until it is purged.
o.TeamDeleteMode = TeamDeleteMode.Soft; // default
o.TeamDeleteMode = TeamDeleteMode.Hard; // pre-3.13.1 behaviour: remove and destroy in one step
Nothing in the API changed and a deleted team still disappears from every list, lookup, team selector and MCP resource — and stops granting access, because the membership and consent reads that issue claims exclude it too. The observable difference is that the document survives, and that deleting no longer needs the privilege to destroy stored data.
| Operation | Scope | Reversible | Needs the storage privilege |
|---|---|---|---|
| Delete | teams:delete |
Yes — restore | No |
| Restore | teams:delete |
— | No |
| Purge | teams:purge |
No | Yes |
Purge is the only operation that destroys data, and it requires the privilege to do so. The MongoDB adapter drops the team's database, and in a per-team-database deployment (
DatabasePart= team key) that needs a database user permitted to rundropDatabase. MongoDB Atlas'sreadWriteAnyDatabasedoes not include it — you needdbAdminAnyDatabase,atlasAdmin, or a custom role, and because the database name is generated per team the grant has to cover any database matching the pattern.A deployment that never purges can withhold both
teams:purgeand that database grant entirely and still delete teams normally. That is the point of the split (Tharga/Team#224).When the privilege is missing, purge fails with a
TeamStorageExceptionnaming what the deployment has to grant, rather than a driver stack trace. The team record is already gone at that point — the ordering is deliberate, so a partial failure leaves an inert orphaned database rather than a live team pointing at deleted data.
A soft-deleted team keeps its key until it is purged, so a new team can never be created on it. In a per-team-database deployment that would otherwise point the new team at the deleted team's data.
If your store predates this, nothing changes: a TeamServiceBase that does not override
SupportsSoftDelete reports false, and its deletes stay exactly as irreversible as they were. Implement
SoftDeleteTeamAsync and RestoreTeamAsync to opt in.
Team scopes (team:*, member:manage) authorize only the caller's own team — the TeamKey claim must match the team being acted on, so an admin of one team can't act on another. TeamComponent mirrors this in the UI: because the scope is issued for the selected team only, the Rename and Delete buttons appear on the selected team and not on the other teams you belong to (Delete additionally requires that you own it). Select a team to manage it. Leave is the exception, because it carries no scope: it is offered on every team you are a member of and does not need the team selected first. teams:delete is a system scope (toolkit-defined) that authorizes deleting any team regardless of membership and regardless of AllowTeamCreation — grant it to your support/dev tooling via o.ConfigureSystemRoles (e.g. map Developer → teams:delete) or to a system API key. Setting AllowTeamCreation = false disables the self-service create and in-team delete paths but never blocks teams:delete.
Finding a team when there are many of them
Both team surfaces were built for a handful of teams. Past that they change shape, at the same threshold — 8 by default, because both turn on the same fact: whether the list can still be taken in at a glance.
The selector gains a search box:
<TeamSelector /> @* search appears at 8 teams *@
<TeamSelector FilterThreshold="3" /> @* …or wherever you want it *@
<TeamSelector AllowFiltering="true" /> @* …or always *@
The team list switches from expandable cards to a grid with sorting, filtering and paging:
<TeamComponent TMember="MyMember" /> @* cards, then a grid at 8 *@
<TeamComponent TMember="MyMember" TeamLayout="TeamListLayout.Grid" /> @* always a grid *@
<TeamComponent TMember="MyMember" TeamLayout="TeamListLayout.Cards" /> @* always cards *@
Cards suit a handful — the expand affordance is obvious, and a grid of three rows looks like an administrative report of nothing much. Past the threshold that reverses: cards cannot be sorted, filtered or paged, and a page of stacked accordions is not a list.
| Parameter | Default | What it does |
|---|---|---|
TeamLayout |
Auto |
Cards, Grid, or let the threshold decide |
TeamFilterThreshold |
8 | Teams needed before the list becomes a grid, and before its filter shows |
AllowTeamSorting |
true |
Sorting on the team name |
AllowTeamFiltering |
threshold | Forces the name filter on or off |
AllowTeamPaging |
auto | Pages only when there is more than one page |
TeamPageSize |
10 | Teams per page |
TeamPageSizeOptionsValues |
10 / 25 / 50 | Page sizes offered |
The grid shows Team (sortable, filterable, the default sort), Your access, Consent, Members
as 3 (+2) where two are still invited, and the team actions. The selected team's row is marked and opens
on load.
Your access states the level you actually hold — the same answer selecting the team gives:
| Tag | Means |
|---|---|
| Filled level (e.g. Owner) | You hold it as a member |
| Outlined level (e.g. Administrator) | You are not a member; the team consented to a role you hold |
| No access | Neither — or you are a suspended member |
When the list contains a team you have no access to, a Hide teams I have no access to switch appears above it. It only hides rows, is remembered per browser, and does not change the layout.
Only the team name sorts and filters. Every other column is derived — your level comes from your membership or the team's consent — and a sort control that silently does nothing is worse than no control.
Paging is a rendering fix, not a loading one. The team list still fetches every team with its members up front, so paging changes how much is drawn rather than how much is fetched. A paged, member-less read would be a service-contract change and is not part of this.
Alternative: Access level enforcement
For simpler cases where scopes are overkill, use access level enforcement instead:
builder.Services.AddScopedWithAccessLevel<IMyService, MyService>();
[RequireAccessLevel(AccessLevel.Administrator)]
public Task DeleteAsync(string id) { ... }
A
Customprincipal is the lowest tier and fails every[RequireAccessLevel]gate (includingViewer). Authorize such principals with scope-based checks ([RequireScope]) rather than access-level enforcement.
Verification
Call a scope-protected method as a Viewer when it requires User level — it should be denied. Elevate the member's access level and retry — it should succeed.
Step 7: Tenant Roles
Adds named roles that bundle scopes together, making it easier to manage permissions for team members.
Requires: Step 6
Program.cs
builder.Services.AddThargaTenantRoles(roles =>
{
roles.Register("Editor", new[] { "feature:read", "feature:write" });
roles.Register("Auditor", new[] { "feature:read", "audit:read" });
});
Team UI
Role assignment is a component parameter, not a global option. Set ShowRoles="true" on <TeamComponent>
(and on <ApiKeyView> to assign roles to keys):
<TeamComponent TMember="MyMember" ShowRoles="true" ShowScopeOverrides="true" />
ShowRoles offers the Roles picker only when there is a role to pick: a team with no code-registered roles
and no custom roles gets no Roles column in <TeamComponent> and no Roles picker in <ApiKeyView>, rather
than a control that can only be set to nothing. Every role and scope picker — here, in <ApiKeyView>,
<SystemApiKeyView>, <TenantRoleManager> and the access-simulation dialog — has a case-insensitive search
box, so a long scope list can be narrowed by typing.
How it works
When a team member is assigned the "Editor" role, they automatically receive the feature:read and feature:write scopes in addition to their access-level scopes. Roles are combined — a member with both "Editor" and "Auditor" gets all scopes from both. Members/keys store the role names; the scopes are resolved live from the registry (change a role's scopes and it applies to all assignees).
Hiding roles per team
By default the role editor offers every registered role for every team. If a role is feature-gated — only meaningful for teams that have the feature enabled — register an ITenantRoleVisibilityProvider to hide it where the feature is off:
public sealed class FeatureGatedRoleVisibility : ITenantRoleVisibilityProvider
{
public Task<bool> IsRoleVisibleAsync(string teamKey, string roleName, CancellationToken ct = default)
=> _features.IsRoleEnabledForTeamAsync(teamKey, roleName, ct);
}
builder.Services.AddSingleton<ITenantRoleVisibilityProvider, FeatureGatedRoleVisibility>();
<TeamComponent> consults the provider per team before building each row's role list. This is display-only: a role already assigned to a member is preserved (never pruned) and still grants its scopes at runtime even while hidden — it simply isn't offered as a new choice, and reappears in the editor if the feature is re-enabled. The default provider shows all roles, so the hook is opt-in and non-breaking. <ApiKeyView> applies the same provider to a team API key's role picker.
Hiding scopes per team
The scope counterpart is ITeamScopeVisibilityProvider. Register one to hide the scopes of a feature that is switched off for a team from the scope pickers — member scope overrides in <TeamComponent>, team API keys in <ApiKeyView> and custom roles in <TenantRoleManager> — and from the access page, <ScopeView>:
public sealed class FeatureGatedScopeVisibility : ITeamScopeVisibilityProvider
{
public Task<bool> IsScopeVisibleAsync(string teamKey, string scope, CancellationToken ct = default)
=> _features.IsScopeEnabledForTeamAsync(teamKey, scope, ct);
}
builder.Services.AddSingleton<ITeamScopeVisibilityProvider, FeatureGatedScopeVisibility>();
A hidden scope is not offered, and is left out of the inherited and held scopes the pickers and scope tooltips show. It is display-only in the same way as a hidden role: an override a member or key already holds, or a scope a custom role already grants, is kept when the picker saves, so re-enabling the feature restores it. Refusing the scope at runtime while the feature is off is the application's decision, made where the feature is enforced. AddThargaScopes registers a default provider that shows every scope.
<ScopeView> leaves a hidden scope's row out of the table, and out of everything derived from it: the legends, and the role bar, which drops a role whose every scope is hidden (it grants nothing that applies to the team). That is the opposite of how it treats role visibility, which it deliberately ignores: a hidden role still grants its scopes, so omitting it would understate access, whereas a hidden scope belongs to a feature that does not apply to the team. The page is only right if the application also refuses the scope at runtime while the feature is off; a host that hides a scope it still honours makes the access page understate what a member holds. <TenantRoleManager> applies the scope provider to the scopes a custom role is built from, but not ITenantRoleVisibilityProvider to the roles it lists: those are the team's own definitions, and hiding one would leave the team unable to edit or delete it. The role provider is for the pickers that assign roles (<TeamComponent>, <ApiKeyView>).
Added in 4.1 (Tharga/Team#318, #331, #334): before, <TenantRoleManager> offered and <ScopeView> listed every registered scope.
Grouping scopes by area
A host with several product areas can show its scopes by area instead of as one alphabetical list. Define the groups once, in the order they should appear, and name a group when registering a scope:
o.ConfigureScopes = scopes =>
{
scopes.DefineGroup("platform", "Platform");
scopes.DefineGroup("diary", "Diary");
scopes.Register("team:read", AccessLevel.Viewer, "Read the team.", group: "platform");
scopes.Register("case:read", AccessLevel.Viewer, "Read cases.", group: "diary");
scopes.Register("record:write", AccessLevel.User, "Write records.", group: "diary");
scopes.RegisterGrantOnly("record:erase", "Erase records.", group: "diary");
};
<ScopeView> shows one table per group under its heading, in the order you defined them, with scopes that name no group last under an "Other" heading. <TenantRoleManager> and the scope-override picker (member editor and <ApiKeyView>) list scopes in the same order and tag each with its group, since a dropdown has no group headings. A group whose scopes are all hidden for the team (see above) shows no heading.
- Display only. A group has no bearing on who holds a scope or how it is checked.
- Localised headings. The display name passed to
DefineGroupis the English default; override it through yourIThargaTextProviderunder the keyteam.scopeGroup.<key>(fordiary:team.scopeGroup.diary). The "Other" heading isteam.scopeView.ungrouped. - An undefined group. A scope naming a group that was never defined is still shown, under its key and after the defined groups, so a typo is visible instead of lost.
- Not configured. With no groups and no
group:arguments nothing changes: one list, no headings.
Added after 4.2 (Tharga/Team#347).
Dynamic (runtime-defined) tenant roles
The roles registered above via AddThargaTenantRoles are code roles — global and fixed at deploy time. To let a team administrator define their own roles per team at runtime (e.g. org-specific Registrar / Case officer / Reader / Archivist), enable dynamic tenant roles and add the management component:
builder.AddThargaTeam(o =>
{
o.ConfigureScopes = s => { s.Register("case:read", AccessLevel.Custom); s.Register("case:write", AccessLevel.Custom); };
o.EnableDynamicRoles = true;
// o.DynamicRoleManageScope = "access:manage"; // optional — scope for custom-role CRUD (default team:manage)
});
@* a team:manage-gated admin page *@
<TenantRoleManager />
- Per-team storage — custom roles are stored on the team document and edited via
ITeamManagementService.SetTeamCustomRolesAsync, which requiresteam:manageon the team by default; seto.DynamicRoleManageScopeto require a different scope (e.g.access:manage) instead, honoured by both the service layer andTenantRoleManager. Assigning a role to a member is still amember:manageoperation. - No privilege escalation — the manager only offers scopes registered via
o.ConfigureScopes, and the server rejects any unregistered scope, duplicate names, or names that collide with a code role. - Resolved like code roles — when enabled, a member assigned a custom role receives that role's scopes as claims (server, WASM, and API-key paths), and custom roles appear alongside code roles in the role pickers of
<TeamComponent>(honouring the visibility provider above) and<ApiKeyView ShowRoles="true">, so a custom role can be assigned to a team API key. - Cached — enabling dynamic roles puts a team's custom roles on the claims path, which runs on every authenticating request. They are cached through
ITeamCache;SetTeamCustomRolesAsyncdrops the entry, so editing through the service layer or<TenantRoleManager />takes effect immediately. A host that writes custom roles straight to its own store, aroundSetTeamCustomRolesAsync, keeps serving the previous roles. If you run more than one instance, read "Claims-path caching" below — the built-in cache is process-local. - Off by default —
EnableDynamicRoles = falseleaves behaviour unchanged (code roles only).
Verification
Assign a role to a team member, then verify they can access methods protected by the role's scopes.
Step 7a: Claims-path caching (required reading for multi-instance)
The server claims transformation runs on every authenticating HTTP request and performs three lookups —
the caller, their membership in the selected team, and that team's custom roles. All three go through
ITeamCache, and the built-in InMemoryTeamCache is registered automatically. On a single instance
there is nothing to configure.
Warning
InMemoryTeamCache is correct for one instance only. Entries are process-local, so a change made
through one instance never reaches the others. Until that instance restarts, it keeps issuing the old
claims — including keeping a suspended member's scopes and a disabled user's session alive.
Periodic claim revalidation does not correct it: it recomputes through the same cache, reads the same
stale entry, and concludes nothing changed.
Replacing it
Register any implementation backed by a store every instance can see. The toolkit uses TryAdd, so yours
wins:
builder.Services.AddSingleton<ITeamCache, RedisTeamCache>(); // before AddThargaTeam
builder.AddThargaTeam(o => { ... });
Then forward it from your own service constructors. Since 3.10.8 this cannot be missed silently: the toolkit fails at startup when a custom cache is registered that your services never received, naming the types and the fix. It compares the cache each service actually holds against the registered one, so a host that registered nothing custom never sees it:
public class TeamService : TeamServiceRepositoryBase<TeamEntity, TeamMember>
{
public TeamService(IUserService userService, ITeamRepository<TeamEntity, TeamMember> repository,
IMongoDbServiceFactory factory, IIconStore iconStore = null, ITeamCache cache = null)
: base(userService, repository, factory, iconStore, cache) { }
}
public class UserService : UserServiceRepositoryBase<UserEntity>
{
public UserService(AuthenticationStateProvider auth, IUserRepository<UserEntity> repository,
ILogger<UserServiceBase> logger = null, IIconStore iconStore = null, ITeamCache cache = null)
: base(auth, repository, logger, iconStore, cache) { }
}
Implementing one
| Requirement | Why |
|---|---|
Return CachedValue<T>.Miss for "no entry" — not a null value |
null users and memberships are cached deliberately: a non-member is remembered as not being one. Collapsing the two sends every non-member request to the store. |
| Serialize your own entity types | IUser and ITeamMember are interfaces your entities implement. Only you know the concrete types — which is why this is an adapter you own. |
| Prefer a miss over an exception | An uncached read is slow; a throwing one breaks sign-in. Returning Miss from every read is valid and simply disables caching. |
| Keep a companion index for the by-user removals | RemoveUserByKeyAsync and RemoveMembersForUserAsync are not keyed the way their entries are. |
Not cached
The team document is deliberately never cached: it carries the member roster, and the paths that suspend a member, remove one, assign an owner or transfer ownership read it precisely because they need current state to decide access. The consent-teams query on the non-member consent path is also uncached.
Step 7b: Managing roles & scopes (reference)
A principal's effective scopes are the union of four sources:
| Source | Applies to | Configured via |
|---|---|---|
| Access level → scopes | team members, team API keys | o.ConfigureScopes (scope's default min level); AccessLevel.Custom grants no base scopes |
| Tenant roles → scopes | team members, team API keys | o.ConfigureTenantRoles (role → scopes) |
| Scope overrides (explicit) | team members, team API keys | per-principal, edited in the UI |
| System scopes (global, flat) | system API keys, and users via role mapping | o.ConfigureSystemScopes; o.ConfigureSystemRoles (app role → system scopes) |
Service methods gate uniformly with [RequireScope("…")] regardless of whether the caller is a team member, a team key, a system key, or a privileged user — but the claim carries where the grant came from, and the two are not interchangeable:
| Granted by | Claim type | Authorizes |
|---|---|---|
| Access level, tenant roles, scope overrides — the first three rows above | TeamClaimTypes.Scope |
the caller's selected team only |
A system API key, or an app role via ConfigureSystemRoles |
TeamClaimTypes.SystemScope |
system-wide, no team needed |
A scope name may legitimately appear in both. audit:read is registered at AccessLevel.Administrator and commonly mapped to a system role: the team grant opens that team's audit log, the system grant opens the cross-team view. Because the claim types differ, a team administrator can no longer satisfy a system-wide check — which, while both were emitted as Scope, meant any team administrator could read every team's audit log.
Changed in 3.10. Code reading
TeamClaimTypes.Scopedirectly to detect a system grant must readTeamClaimTypes.SystemScopeinstead. Anything gating through[RequireScope],TeamScopeGateor the authorization decorators needs no change.
Tip: drop the
<ScopeView />component (Tharga.Team.Blazor) on a page to explore the configured team scopes interactively. Pick an access level and roles and the scopes a member would have light up while the rest grey out; it defaults to the signed-in member's own access level, roles, and overrides (overrides are highlighted). It builds itself fromIScopeRegistryand the roles in force for the selected team — the team's own custom roles merged with the code-registered ones — so it always matches the running configuration, including roles a team defined at runtime through<TenantRoleManager />. PassRolesto supply that list yourself. When the signed-in user holds any system scopes, a separate System scopes table appears listing them (it's hidden entirely when they hold none; setShowSystemScopes="false"to disable it) — so you can tell at a glance which of your scopes are team vs system.
What happens to a team's credentials and data when it is deleted
An API key stops working the moment its team is deleted. Authentication resolves the key's team and
refuses the key if it no longer exists, recording the refusal in the audit log as "API key for a deleted
team". This applies to the ordinary soft delete (teams:delete) as well as to a purge — before 3.15.x a
deleted team's keys kept authenticating and kept carrying that team's scope claims.
- System keys are unaffected. They carry no team, so there is nothing to check.
- Restoring a team restores its keys. The check reads the team's current state rather than latching.
- A store fault does not refuse keys. A team lookup that throws is not evidence the team is gone, and refusing every key on a transient fault would be worse than the gap it closes. Only a lookup that successfully returns "no such team" refuses.
Purging a team destroys the data the toolkit holds for it — API keys, team icons and support cases. Purge
drops the host's per-team database, which does not reach the toolkit's own shared collections, so each
store contributes an ITeamPurgeParticipant that removes its own records.
Participants run before the team record is deleted. If one fails the purge aborts with the team still present, so it is visible and can be purged again; the alternative would leave data nothing can find.
The upgrade does not repair history. Teams purged on an earlier version have already left their API keys, icons and cases behind. This release stops it happening again; it does not clean up what is already orphaned. Those keys no longer authenticate — their team is gone — but the records remain until removed.
Soft delete deliberately does not cascade. A soft-deleted team can be restored, and a restore that brought back a team with no keys, no icons and no history would be a restore in name only.
Adding your own participant
A host storing its own per-team data can join the cascade:
public sealed class InvoicePurgeParticipant(IInvoiceStore store) : ITeamPurgeParticipant
{
public string Name => "invoices";
public Task<int> PurgeTeamDataAsync(string teamKey, CancellationToken cancellationToken = default)
=> store.DeleteForTeamAsync(teamKey, cancellationToken);
}
services.AddTransient<ITeamPurgeParticipant, InvoicePurgeParticipant>();
Make it safe to run twice — a purge that failed part-way is retried, so a participant that has already run should remove nothing and report zero rather than failing.
Built-in system scopes
The toolkit auto-registers these; grant them through ConfigureSystemRoles or a system API key.
Registering one of these yourself is safe.
SystemScopeRegistry.Registerskips a name already present, so a host declaring a scope the library also declares is a no-op rather than an error, in either order. The first registration's description wins, so the catalogue text you see depends on which ran first — register nothing you do not need if you care which wording appears.This changed in 3.14.1. Before it,
Registerthrew on a duplicate, and because 3.14.0 started registeringsimulation:demo— a scope hosts had previously been obliged to register themselves — every such host failed at startup withSystem scope '…' is already registered.(#237). Team scopes are unchanged and still throw:ScopeDefinitionalso carries an access level and a grant-only flag, which two registrations can genuinely disagree about, so there a duplicate is a real conflict rather than a repetition.
| Scope | Authorizes |
|---|---|
teams:read |
Enumerating any team, regardless of membership. Discovery only — selecting a team still yields only what it consented to |
teams:delete |
Deleting any team, regardless of membership or AllowTeamCreation; also restoring a soft-deleted one |
teams:purge |
Permanently removing a soft-deleted team and destroying its stored data |
teams:set-owner |
Making any existing member the sole owner of any team, demoting every other owner. Works whatever the current owner count — none, one, or several |
simulation:demo |
Entering demo mode — dropping your own system scopes and application roles. The run-as half is the separate team scope simulation:use |
users:manage |
User administration: the admin lists, verify, rename, delete |
teams:set-owner has no in-team fallback, unlike teams:delete which accepts either a system grant or
team:manage on the team — and for two reasons rather than one. On an ownerless team no in-team caller can
exist. On a team that has an owner, the in-team caller who should move ownership is the owner, and
TransferOwnershipAsync is already their path; an in-team fallback would let an Administrator depose the
owner, which SetMemberRoleAsync exists to refuse. See
Choosing who owns a team.
Renamed in 3.14. This scope was
teams:assign-ownerin 3.9.0–3.13.0, where it authorized only the ownerless-repair case. The name changed with the capability rather than being widened in place, so a host that granted the old string does not silently acquire the ability to depose owners. Remap it — the old name now authorizes nothing, so anyone who still holds it is refused at the point of use, with nothing to say why.
System scopes & privileged users
System scopes are global capabilities (no access-level hierarchy):
o.ConfigureSystemScopes = s =>
{
s.Register("system:teams:read", "Read any team's data (cross-tenant).");
s.Register("system:metrics:read", "Read infrastructure metrics.");
};
// Map app/global roles to system scopes so privileged USERS gain them (team-independent).
o.ConfigureSystemRoles = r =>
{
r.Map("Developer", "system:teams:read", "system:metrics:read", "apikey:manage", "audit:read");
};
- System API keys are minted with an explicit system-scope list (
SystemApiKeyViewpicker readsConfigureSystemScopes). - Users with a mapped app role (e.g.
Developer) receive the mapped scopes as claims viaTeamServerClaimsTransformation— even with no team selected. Mapapikey:manage/audit:readto a role to grant that role cross-team key/audit management. - Map external IdP role claims to internal role names with an
ITeamClaimsEnricher(runs first), e.g.Dev → Developer.
ConfigureSystemScopesis not an isolation boundary for every scope.users:manageandteams:deleteare auto-registered by the framework — the admin surfaces need them grantable — so leaving them out ofConfigureSystemScopesdoes not prevent a system API key from being granted them. Withhold them by not granting them to the key, not by omitting them from the registry. Any comment or test asserting the opposite is relying on behaviour that does not hold.
Consent (cross-team access)
A team can consent to grant a global role access to its data, at a chosen access level:
o.Blazor.Consent.Roles = ["Developer"]; // which roles a team may consent to
o.Blazor.Consent.ShowToggle = true; // show the consent picker in TeamComponent
o.Blazor.Consent.AccessLevel = AccessLevel.Viewer; // default level when the consent doesn't carry one
The team admin picks the access level when consenting (Viewer/User/Administrator); a consented user gains that team's scopes at that level. The granted level is team.ConsentAccessLevel ?? Consent.AccessLevel.
When ShowToggle is on, an administrator of the selected team can change the consent from its card,
under the label Consent access for Developer (naming the configured roles). Callers consent concerns — a
holder of a consent role, or of teams:read — see the same label with a read-only picker on every
other team, once the card is opened. Everyone else sees no consent information: it does not affect them,
and a bare "No access" on a team's header read as their own access rather than as a setting.
Changing consent requires team:manage held as a member (3.23). Previously the scope was accepted
however it arrived, including through consent itself — so a caller consented in at Administrator could
change the consent that admitted them. With time-bounded consent that means extending their own window or
removing its expiry, which is why it changed. A team API key naming its own team still counts as direct; a
system key reaching the team through consent does not. If a host relied on a consented caller changing
consent, have a member do it.
Consent can be time-bound, and is always read through one function:
ConsentInForce TeamConsent.Resolve(ITeam team, DateTime utcNow);
It returns the stored consent while unexpired and the previous consent afterwards. Expiry therefore
needs no background job — it takes effect at the next claims issuance or revalidation, bounded by
ClaimRevalidation.Interval like every other access change. An architecture test fails the build if
anything reads ConsentAccessLevel or ConsentedRoles directly instead. A manager setting consent by hand
clears any expiry, making the consent standing.
Time-bound consent is what an approved access request grants — see
Team access requests for the request/approve flow, the <TeamNotificationMenu />
bell, and the audit actions.
Cross-team visibility for oversight roles
Support and administration roles often need to see the whole estate. The teams:read system scope
grants exactly that — discovery, and nothing else:
- Discovery is global. A caller holding
teams:readsees every team inTeamComponent,TeamSelectorand the developerUsersView— both the Teams tab and the per-user team counts and membership lists on the Users tab. - Access stays per-team and consent-governed. Selecting a team they are not a member of grants only the scopes that team has consented to. A team that consented to nothing yields no access — the team is visible, its data is not.
- The selector says which is which. For a caller holding
teams:reador a consent role, the open list tags each team reached through consent with its level (short and outlined, e.g. Admin) and each team they cannot reach with No access; teams they belong to carry no tag. Beside the selected team, the access-level badge appears only when that access is not membership. The list is as wide as its widest team name.
Grant it either explicitly, or with the opt-in convenience flag:
o.ConfigureSystemRoles = roles => roles.Map("Developer", SystemTeamScopes.Read);
// or, to reuse the consent role list:
o.Blazor.Consent.Roles = ["Developer"];
o.Blazor.Consent.GrantTeamsRead = true; // default false
GrantTeamsRead is off by default on purpose. Consent.Roles means "roles a team may grant access to"
— a per-team, inbound opt-in. Turning that into a global enumeration privilege automatically would widen
access for existing hosts on upgrade, so it must be opted into. The flag composes with any
ConfigureSystemRoles mapping for the same role rather than conflicting with it.
Before granting it: your team service must be able to list every team. Oversight mode lists teams from
ITeamService.GetAllTeamsAsync instead of from the caller's memberships.
On the built-in store (
TeamServiceRepositoryBase, orDefaultTeamService) there is nothing to do.A service deriving
TeamServiceBasedirectly must overrideGetAllTeamsInternalAsyncto enumerate its store — every team, regardless of membership:protected override async IAsyncEnumerable<ITeam> GetAllTeamsInternalAsync() { await foreach (var team in _myStore.GetAllTeamsAsync()) yield return team; }Without it, the call throws
NotSupportedExceptionnaming the member and the scope. Startup reports it first: as soon as a system role mapsteams:read,GrantTeamsReadis on, or the scope is registered for system API keys,AddThargaTeamBlazorlogs an error naming your service. Seto.Blazor.ThrowOnIncompleteTeamService = trueto fail the boot instead.
The symptom on older releases was quieter and misleading: the default returned no teams, so every user
holding the scope — team Owners included — was told "You are not member of a team", with nothing in the
log. It looks like a data problem and is not. If you see it, check this override before the database. The
full list of TeamServiceBase members with defaults is in the Tharga.Team README, under
TeamServiceBase: what you must override.
What a teams:read holder sees. Each team carries a consent badge — No access (red), Partial
access (yellow, Viewer/User) or Full access (green, Administrator) — preceded by a Not a member
badge on teams they don't belong to, so the qualifier is read before the level it qualifies. The TeamSelector shows the same state as a tinted dot.
Selecting a team you don't belong to. An oversight caller can select any team they can see, and that choice is remembered across visits like any other — returning to the site re-selects it. Selection on its own carries no access: the claims transformation still grants only what that team has consented to, to a role the caller holds. No consent, or a role the team hasn't consented to, means no team scopes at all.
Acting on such a team throws UnauthorizedAccessException with "Access denied for the selected team
'<key>'." — distinct from "No team selected.", which means no team is chosen at all. The two are
separate claims: TeamClaimTypes.TeamKey is the access anchor, emitted only once access resolves, while
TeamClaimTypes.SelectedTeamKey (team_id) records the selection regardless of access. A guard that reads
only the anchor cannot tell the two states apart and will report a selected-but-inaccessible team as
"No team selected."
The distinction that matters is chosen versus defaulted. A team the caller picked is restored; a team they never picked is never selected for them. When there is no current or remembered selection, the fallback always comes from the caller's own memberships — so a support user with no memberships and no prior choice lands on no team, rather than inside whichever tenant happens to sort first. A remembered team that is no longer visible (deleted, consent revoked, scope removed) falls back the same way.
With teams visible and none selected, TeamSelector shows the picker with a Select a team placeholder
— the estate is offered, not entered. This is the ordinary first visit for a support or operations account:
they can see every team and belong to none, so there is nothing to default to and the choice stays theirs.
Override the placeholder through TeamSelectorText.SelectTeam (team.selector.selectTeam) like any other
string.
Before 3.10.10 this state rendered nothing at all — an empty top bar with no way to reach a team (Tharga/Team#214). If you built a wrapper component to cover it, you can drop it. Note that the two blank states meant opposite things and were indistinguishable on screen: a Create team link meant
teams:readwas not reaching the caller, while a completely empty bar meant it was. If you spent time re-checking a scope mapping that turned out to be correct, this is why.
Team enumeration is deliberately not audited — it is a read with no side effect. Mutations performed inside a team are audited as usual.
Overriding the "Create team" action
By default the teamless Create team link (TeamSelector) navigates to /team and the Create new Team button (TeamComponent) calls CreateTeamAsync() directly. Both entry points respect AllowTeamCreation — setting it to false hides the link and the button, and blocks the programmatic create API at the service layer. (The TeamSelector link previously ignored the option and was offered even when creation was disabled, so following it reached an operation the service then refused.) To route team creation through your own onboarding flow instead of disabling it, use one of two override points, evaluated callback → path → built-in:
// 1. Global, declarative — both entry points navigate to your page (which runs the wizard + CreateTeamAsync):
o.CreateTeamPath = "/get-started";
@* 2. Per component, imperative — handle in place (e.g. a dialog); takes precedence over CreateTeamPath: *@
<TeamSelector CreateTeamRequested="LaunchOnboardingAsync" />
<TeamComponent TMember="MyMember" CreateTeamRequested="LaunchOnboardingAsync" />
Both default to unset, so behavior is unchanged unless you opt in. The override applies to the built-in UI entry points only — teams created programmatically or via AutoCreateFirstTeam are unaffected.
Where the profile menu navigates
LoginDisplay's two built-in items — User and Team — navigate to /profile and /team. The toolkit
ships <UserProfileView /> and <TeamComponent /> as components and lets you mount them at any route, so
if you mounted either anywhere else, say where it went:
o.ProfilePath = "/account"; // default: null — keeps /profile
o.TeamPath = "/organisation"; // default: null — keeps /team
Both default to unset, so an existing host needs neither. They are independent: the two pages are mounted separately, so moving one says nothing about the other.
Menu items you supply yourself were never affected — they carry their own Href and are matched before
the built-ins. This is only about the two the toolkit renders.
This matters beyond a stray link if you use access simulation. With
ShowBanner="false"onAccessSimulationBar, the access card on the profile page is the only way out of a reduced session — so the profile route has to actually resolve.
Component parameter reference
| Component | Parameters |
|---|---|
<TeamSelector> |
CreateTeamRequested (intercept the teamless "Create team" link) |
<UserProfileView> |
ShowAccessCard (default true — renders <AccessSimulationCard /> between the profile details and Claims) |
<AccessSimulationBar> |
Text (overrides the resolved team.simulation.bar.viewAs, "View as…"), ShowEntryPoint (true), ShowBanner (true — off means the profile card is the only way out) |
<AccessSimulationIndicator> |
none — renders only while a run-as simulation is active |
<TeamNotificationMenu> |
none — optional top-bar bell; the badge counts access requests awaiting the caller's decision |
<TeamAccessRequestButton> |
TeamKey (required), ButtonStyle, Size, Changed — places Request access / Withdraw anywhere; renders nothing for a caller who cannot ask |
<TeamComponent> |
ShowScopeTooltip (default true), ShowScopeOverrides, ShowRoles, CreateTeamRequested (intercept the "Create new Team" button), plus the member-row hooks below |
<ApiKeyView> |
ShowScopeTooltip (true), ShowScopeOverrides, ShowRoles, ShowLastUsed (true), ShowExpiryDatePicker, ShowTags (bool?, null=auto), ChipTagKeys, ShowAuditLogButton, ShowPrivateKeys (Mine), AllowPrivilegedAccess |
<SystemApiKeyView> |
ShowScopeTooltip (true), ShowScopeOverrides (true), ShowLastUsed (true), ShowExpiryDatePicker, ShowAuditLogButton, ShowPrivateKeys (Mine) |
Extending the member grid
A host that adds a field to its member type can put the UI for it on the member row itself, rather than in a second grid beside the first:
<TeamComponent TMember="TeamMember" MemberActionInvoked="@OnMemberAction">
<MemberColumns>
<RadzenDataGridColumn TItem="TeamMember" Title="Project" Property="Project" />
</MemberColumns>
<MemberActionItems>
<RadzenSplitButtonItem Text="Set project" Value="set-project" Icon="folder" />
</MemberActionItems>
</TeamComponent>
| Parameter | Kind | Renders |
|---|---|---|
MemberColumns |
RenderFragment of RadzenDataGridColumn |
appended after the built-in columns, before the action column |
MemberActionItems |
RenderFragment of RadzenSplitButtonItem |
inside the member's action menu |
MemberActionInvoked |
EventCallback<MemberRowAction<TMember>> |
raised when one of your items is clicked, carrying your member type |
MemberActionsTemplate |
RenderFragment<TMember> |
beside the built-in action control |
Raw columns rather than a per-row template, so an added column carries its own header, width and sorting.
Five action values are the component's own — copy-invite, remove-member, member-audit,
suspend-member, restore-member. An item using one of those is handled as that built-in rather than
forwarded, so pick something else; matching is exact, so a prefix or a different casing is yours.
Your action appears even where the built-ins do not. On a row the caller can take no built-in action on —
their own, or the owner's — the menu still renders for your items alone. This mirrors
TeamsListView/UsersListView, which have had TeamActionsTemplate / TeamActionItems /
TeamActionInvoked since 3.7.x; TeamComponent was the surface still missing them.
Access to manage keys is gated on apikey:manage; the audit log on audit:read. (The former per-component
CrossTeamRoles / RequiredScopes parameters were removed — grant cross-team access via the role→system-scope
mapping instead.)
Disabling a key, and why refresh does not undo it
SetKeyDisabledAsync stops a key authenticating while keeping its name, scopes, roles, tags and audit
trail — the reversible alternative to deleting it. The refusal is recorded as an authentication
failure, because a disabled key still gets used: by a scheduled job nobody remembers, or by whoever
the disabling was aimed at, and those attempts are the point of the trail.
Refreshing a disabled key leaves it disabled. A refresh mints a new secret; it is not a decision to trust the key again — and the usual reason to refresh is the same suspected leak that prompted the disable. Re-enabling is always explicit.
Disabled is shown as a badge, distinct from expiry's red text. Two red things in one grid is how an operator concludes a contained key merely lapsed.
Step 8: Audit Logging
Adds audit logging for service calls, authorization events, and data changes. Logs can be stored in the application logger, MongoDB, or both.
Requires: Step 4
Program.cs
builder.Services.AddThargaAuditLogging();
⚠️ Most common gotcha:
StorageModedefaults toLoggeronly. TheAuditLogViewcomponent reads from MongoDB, so with the default it stays empty — entries only go toILogger. To populate the UI you must opt into Mongo storage:builder.Services.AddThargaAuditLogging(o => o.StorageMode = AuditStorageMode.MongoDB); // or keep both: AuditStorageMode.Logger | AuditStorageMode.MongoDB
AuditStorageModeis a[Flags]enum, so the values combine. MongoDB storage requires MongoDB configured (Step 4).
Options
builder.Services.AddThargaAuditLogging(o =>
{
o.StorageMode = AuditStorageMode.Logger | AuditStorageMode.MongoDB; // default: Logger only — see gotcha above
o.CallerFilter = AuditCallerFilter.Api | AuditCallerFilter.Web; // default: Api | Web ([Flags])
o.EventFilter = AuditEventFilter.All; // default: All ([Flags])
o.ExcludedActions = new[] { "read", "list" }; // default: empty — skip noisy read operations
o.ExcludedEndpoints = Array.Empty<string>(); // default: empty
o.RetentionDays = 90; // default: 90 — null (or <= 0) = keep forever (no TTL)
o.BatchSize = 100; // default: 100 — MongoDB background-writer batch size
o.FlushIntervalSeconds = 5; // default: 5 — MongoDB background-writer flush interval
});
| Option | Default | Notes |
|---|---|---|
StorageMode |
Logger |
[Flags]: Logger, MongoDB, or both. Set MongoDB to populate AuditLogView. |
CallerFilter |
Api \| Web |
[Flags] — which caller sources to record. |
EventFilter |
All |
[Flags] — which event types to record. |
ExcludedActions |
empty | Action names to skip (e.g. "read", "list"). |
ExcludedEndpoints |
empty | Endpoints to skip (e.g. "/health"). |
RetentionDays |
90 |
int? mapped to a MongoDB TTL index (Timestamp_TTL). null or <= 0 = keep forever (no TTL index). |
BatchSize |
100 |
Background MongoDB writer batch size. |
FlushIntervalSeconds |
5 |
Background MongoDB writer flush interval. |
Retention / TTL.
RetentionDayscreates a MongoDB TTL index that auto-deletes entries older than the given age. Set it tonull(or any value<= 0) to keep entries indefinitely — no TTL index is created. LeavingRetentionDaysunset keeps the default of 90; setting it tonullexplicitly means forever.A changed value reaches an existing database. When the host starts with MongoDB audit storage, the
Timestamp_TTLindex onAuditLogis made to match the setting: a different expiry is changed in place (collMod, no rebuild), "forever" drops the index, and a finite value with no index creates it. Each change is logged at Information level; a start where nothing differs writes nothing. If the reconciliation fails (for example the database is unreachable) a warning is logged and the host keeps starting. A host that turned index assurance off (AssureIndex = AssureIndexMode.DisabledinAddMongoDB) manages its own indexes, so the toolkit leaves the index alone.Upgrading from a version before this fix. Those versions turned an explicit
nullinto 90 days, so an application that setnullto keep its audit log has been deleting entries after 90 days. On the first start of the fixed version the 90-day index is dropped and entries are kept from then on. Entries already deleted are gone.
What gets audited
Mutations flow through auditing decorators: team-service operations (create/rename/delete team,
invite/remove member, set consent, …), API-key management (create/recycle/lock/delete, for team
and system keys), and user administration (directory verify, bulk verify, user delete — see
User management & directory). Read operations pass through unaudited; use
ExcludedActions to drop any others you consider noise.
What becomes available
| Component | Description |
|---|---|
<AuditLogView /> |
Audit log viewer with charts and filtering |
CompositeAuditLogger |
Write your own audit entries. Applies the caller/event filters and fans out to every configured backend. Inject this, not IAuditLogger — that resolves to a single backend and bypasses the filters. |
Scoping <AuditLogView />
| Parameter | Effect |
|---|---|
TeamKey |
Scopes the view to one team and authorizes against it — a caller holding that team's audit:read is admitted. Hides the Team filter, since there is nothing to choose. |
PinnedFilter |
Forces one or more dimensions (team, caller, feature, action, key) and hides their controls. Outranks TeamKey when both name a team. |
RestrictCallerType |
Limits entries to one caller type (user or API key). |
Naming no team makes the read system-wide, which requires a system grant (audit:read via
o.ConfigureSystemRoles, or a system API key) rather than membership of any one team.
@* One team, authorized by that team's audit:read *@
<AuditLogView TeamKey="@teamKey" />
@* Every team the caller can reach; needs a system grant *@
<AuditLogView />
Before 3.10,
TeamKeyscoped the query but not the access decision, so passing it alone was refused even for a team Owner holdingaudit:read, and hosts worked around it by also passing aPinnedFilternaming the same team. That workaround is no longer needed (Tharga/Team#175).
Audit entry fields
Each audit entry captures: timestamp, correlation ID, event type, feature/action, caller identity, team key, access level, scope check results, duration, and a Metadata dictionary.
Who an entry is attributed to
Three fields describe the actor, and they answer different questions:
| Field | Holds | Match |
|---|---|---|
CallerIdentity |
A display string, resolved name → preferred_username → subject → name |
Substring — its content depends on which claims your IdP emits |
CallerUserIdentity |
The acting user's authentication subject, or null — NameIdentifier, or the claim UserIdentityClaimTypes names |
Exact — the subject or nothing, never a fallback |
CallerKeyId |
The API key's id, or null | Exact |
Use CallerUserIdentity to correlate rows to one person; CallerIdentity is for reading.
CallerType and CallerSource say what kind of actor it was:
| Situation | CallerType |
CallerSource |
|---|---|---|
| API-key request | ApiKey |
Api |
| Cookie / federated sign-in | User |
Web |
| Authenticated, unrecognised scheme | User |
Unknown |
| Anonymous request | Unknown |
Unknown |
| Declared background actor | System |
Background |
| No principal, no declared actor | Unknown |
Unknown |
Changed: a caller with no
HttpContextused to be recorded asUserwith a null identity — a row claiming a person did what a background job did. It now recordsUnknown, or the declared actor below. If you report onCallerType == User, those rows have moved.
Auditing background work
Code outside a request — a hosted service, a message handler, a scheduled job — has no principal to attribute. Declare one for the duration of the work:
public class ClaimedJobWorker(
IAuditContextAccessor auditContext,
IAuditEntryFactory auditEntryFactory,
IAuditLogger auditLogger)
{
public async Task RunAsync(Job job, CancellationToken cancellationToken)
{
using var _ = auditContext.Push(new AuditActor(
Identity: "archive-worker",
CorrelationId: job.Id)); // groups every entry this job writes
auditLogger.Log(auditEntryFactory.Create("job", "claim", teamKey: job.TeamKey));
}
}
Build entries with IAuditEntryFactory, not by hand. IAuditLogger.Log takes a pre-built entry and
does not consult the ambient actor, so an AuditEntry you construct yourself will not carry the actor
however carefully you scoped it. The factory resolves the caller the same way the built-in decorators do
— HTTP principal if there is one, declared actor if not.
Supply teamKey explicitly for background work: there is no selected team to infer it from.
Entries written inside the scope carry that identity, AuditCallerType.System and
AuditCallerSource.Background. Three things worth knowing:
- The scope is
AsyncLocal, so it survivesawaitand flows into nested calls without being passed along. Nested scopes restore the outer actor on dispose rather than clearing it. - An authenticated caller always wins. A scope left open on a pooled thread cannot relabel a real user's action as the system's. An anonymous request does not win — a job triggered through an unauthenticated endpoint still knows what it is.
- Set
CorrelationIdper unit of work. A job is not a request, so there is no ambient trace to group its entries; without a declared id each one falls back to its own and the grouping cannot be reconstructed afterwards.
IAuditContextAccessor is registered by AddThargaAuditLogging(), regardless of storage mode.
Correlation inside a request
Entries written during a request share the request's correlation id, so a domain entry and the per-call
access traces around it can be pulled back together. The value is Activity.Current.TraceId — the same
trace id your telemetry carries, mapped to a Guid by value rather than hashed — so an audit row can be
matched against the request's spans in Application Insights or any other tool reading W3C trace context.
Nothing to configure: it works wherever Activity is flowing, which is the default for an ASP.NET Core
request. Outside one (and with no declared actor) each entry still gets its own id.
Changed in 3.21. This previously derived from
HttpContext.TraceIdentifierparsed as aGuid. That identifier is{ConnectionId}:{RequestNumber:X8}and never parses as one, so the fallback fired on every request and every entry carried a distinct id — one consumer measured 417 entries with 417 ids. If you group or report onCorrelationId, entries written before and after this release will not correlate with each other; entries written before it were never correlated with anything.
Classifying your own entries
IAuditEntryFactory.Create has an overload taking an AuditEventType. Use it for anything that changed
something:
auditLogger.Log(auditEntryFactory.Create(
AuditEventType.DataChange, "case", "CaseClosed", teamKey: team.Key, metadata: metadata));
Why it matters for reading, not for storage. The enforcement proxies record their per-call access
traces as ServiceCall, and so does the overload without an event type — so an entry you write without one
cannot be told apart from them by the log view's Event filter. On a busy tenant the traces outnumber
the domain entries several times over, which makes that filter the difference between a readable log and a
list of everything that was permitted.
The classification changes nothing about how the entry is stored, retained or authorized, and it is not a
claim that anything was checked — ScopeChecked is what says a check happened, and the factory never sets
it.
Deciding per method what gets audited
Every scope- or access-level-checked call is recorded. On a busy tenant that is most of the log: opening a dialog that loads five catalogs writes five entries describing nothing a person did, and reading the audit log is itself a read, so the log fills with the act of reading it.
Neither ExcludedActions nor EventFilter can fix that. ExcludedActions is a name blacklist — every quiet
read has to be named, and the next one somebody adds is audited until a human remembers. EventFilter works
on event types, and every proxy-written entry is a ServiceCall. What separates a read worth recording from
a read that is noise is intent, and only the declaration site knows it.
So it is declared there:
[RequireScope(CaseScopes.Read)] // whatever the host defaults to
Task<Case> GetAsync(string teamKey, ObjectId id);
[RequireScope(CaseScopes.Read, Audit = AuditMode.Access)] // recorded, because this one must be
Task<Case> GetForDisclosureAsync(string teamKey, ObjectId id);
[RequireScope(CaseScopes.Manage, Audit = AuditMode.Change)] // recorded as a data change
Task<Case> UpdateAsync(string teamKey, ObjectId id, UpdateCaseRequest request);
[RequireAccessLevel] takes the same parameter and behaves identically — a method's audit rule should not
depend on which kind of guard happens to be on it.
AuditMode |
Effect |
|---|---|
Default |
Defer to the host. What an unannotated method gets. |
None |
Not recorded. |
Access |
Recorded as AuditEventType.ServiceCall. |
Change |
Recorded as AuditEventType.DataChange. |
The host sets the default, and it stays Access — so nothing changes for an application that configures
nothing. To make silence the default and record by exception:
o.Audit = new AuditOptions { DefaultAuditMode = AuditMode.None };
That combination is what a host under privacy rules needs. Where broad read logging is itself a hazard — Swedish meddelarskydd, for instance, where audit logs must not become a way to work out who asked for something — nothing should be recorded unless a method asks for it, so that adding a method cannot silently start recording who read what. Making that the shipped default instead would have ended the access trace of every existing application with no compile error and nothing in a diff to notice.
Two properties worth relying on:
- A refusal is always recorded.
AuditMode.Nonesuppresses the call, not the denial. Saying "do not record who read this" is not saying "do not record who was refused" — those are different records, and refusals are low-volume by nature. - The annotation decides what is written; the configuration still decides what is kept.
CallerFilter,EventFilterandExcludedActionsapply afterwards exactly as before. That also gives a method that must be recorded an escape hatch without a special rule: mark itChangeand it records aDataChange, which survives a host filter that drops service calls.
What a row is about: the Operation column
The grid's Operation column shows the scope that was checked, or — for an entry your application wrote,
where none was — that entry's own feature and action, rendered muted with a tooltip saying so. Before 3.21
this column showed ScopeChecked alone, so a domain entry carrying the object identity rendered as a blank
cell beside a fully-labelled access trace: the important row was the anonymous one.
It is one column rather than a Feature and an Action column because the proxies already store the same fact
twice — ScopeProxy writes the feature, the action and the scope those two compose, so separate columns
would print case:manage | case | manage on the row type that dominates the log.
Opening the log on a useful view
<AuditLogView /> takes an InitialFilter alongside the existing PinnedFilter, and the two are
opposites:
PinnedFilter |
InitialFilter |
|
|---|---|---|
| The control | hidden | visible |
| The reader | cannot change it | can change it |
| For | scoping a view to one key, team or person | opening on what is usually wanted |
<AuditLogView InitialFilter="@(new AuditInitialFilter { ExcludedScopes = ["audit:read"] })" />
That opens the log with its own readers hidden — commonly the largest single source of noise, since a page refresh writes several — and renders a Show hidden toggle that brings them back. Entries that checked no scope are unaffected, so hiding a scope never hides the entries your application wrote.
AuditInitialFilter also carries EventTypes, Features and Actions. Two rules worth knowing:
- A pin wins where both name a dimension. An opening value is one the reader can change, so a value that survived alongside a pin would be a way to widen the view past the scope the pin exists to impose.
- Opening values never narrow the filter options. A category hidden on open still appears in its control — otherwise the reader could not discover what was held back, and the default would be a pin wearing a different name.
Reading failures in the log view
The OK column of <AuditLogView /> shows a green check for a successful entry. For a failed entry it
shows a red icon plus a short failure code — the audit equivalent of a response code, taken from the
entry's EventType (ScopeDenial, AccessLevelDenial, AuthFailure, RateLimit), or Error for an
entry that failed by throwing. Hovering the status shows a detail tooltip with the code, the scope that was
checked and its result (when the failure was an authorization denial), and the ErrorMessage reason. The
reason is also present in the CSV and JSON exports (the ErrorMessage column/field).
Operation metadata (what changed)
Built-in operations record what changed in AuditEntry.Metadata, not just that they happened — so the
log answers "renamed to what, from what?" without a second lookup. Keys are defined on AuditMetadataKeys;
before/after pairs are captured where the previous value is needed to read the entry:
| Operation | Metadata keys |
|---|---|
| Create team | team.name |
| Rename team | team.name.old, team.name.new |
| Delete team | team.name (unrecoverable afterward, so captured) |
| Invite member | member.email |
| Remove member | member.key |
| Change role | member.key, member.accesslevel.old, member.accesslevel.new |
| Set tenant roles / scope overrides | member.key, member.tenantroles / member.scopeoverrides |
| Set display name | member.key, member.name.old, member.name.new ("" = cleared override) |
| Set consent | consent.accesslevel.old, consent.accesslevel.new ("none" = cleared), consent.roles |
| Set custom roles | customroles.names |
| Transfer ownership | team.newowner.key |
Every team member change (invite, remove, leave, suspend, restore, role, tenant roles, scope overrides, display name, invitation response, invitation extension, ownership transfer) also records team.name, read when the change is made, so a notification can name the team. If the team cannot be read the name is omitted and the entry still carries the key.
Capturing a "before" value is best-effort: if the read fails it is omitted rather than recorded as a
misleading blank, and it never fails the operation. Metadata shows as an expandable detail row in
<AuditLogView />, in the CSV export (a JSON-encoded Metadata column) and JSON export, and — since the
3.2.x line — in LoggerAuditLogger output.
One gap: a caller changing consent on a team they are not a member of doesn't get the
consent.accesslevel.oldvalue (the "before" read is scoped to the caller's own teams). The new value and roles are still recorded.
Adding your own metadata
Register an IAuditEnricher to attach host-defined metadata (a request id, a ticket number, anything from
IHttpContextAccessor) to every entry the toolkit writes:
public sealed class RequestIdAuditEnricher(IHttpContextAccessor http) : IAuditEnricher
{
public void Enrich(AuditEntry entry, IDictionary<string, string> metadata)
{
var requestId = http.HttpContext?.TraceIdentifier;
if (requestId != null) metadata["request.id"] = requestId;
}
}
builder.Services.AddThargaAuditEnricher<RequestIdAuditEnricher>();
Enrichers run for every entry that passes the audit filters, in registration order. The merge is
add-only: an enricher augments the record but cannot overwrite a key the toolkit set (nor one an
earlier enricher set), so the authoritative "who did what" can't be forged. An enricher is resolved as a
singleton — read per-request state through IHttpContextAccessor, not a scoped dependency — and one
that throws is logged and skipped, so enrichment can never fail the operation being audited.
Event types
| Type | When logged |
|---|---|
ServiceCall |
Any proxied service method call |
AuthSuccess |
Successful authentication |
AuthFailure |
Failed authentication |
ScopeDenial |
Scope check denied |
DataChange |
Data modification |
RateLimit |
Rate limit hit |
Verification
Perform some actions, then view the audit log via <AuditLogView />. Entries should appear with correct caller identity and scope information.
Quick reference: Registration order in Program.cs
using Microsoft.AspNetCore.Authentication;
using Tharga.Team;
using Tharga.Team.Blazor.Features.Authentication;
using Tharga.Team.Blazor.Framework;
using Tharga.Team.Service;
// Step 1: Radzen + Blazor foundation
builder.Services.AddRadzenComponents();
builder.Services.AddThargaBlazor(o => o.Title = "My App");
// Step 2: Authentication
builder.AddThargaAuth();
// Step 3: Controllers
builder.Services.AddThargaControllers();
// Step 4: Team management
builder.Services.AddThargaTeamBlazor(o =>
{
o.Title = "My App";
o.RegisterTeamService<MyTeamService, MyUserService>();
o.RegisterApiKeyAdministrationService<MyApiKeyService>(); // Step 5
o.ShowMemberRoles = true; // Step 7
o.ShowScopeOverrides = true; // Step 6
});
builder.Services.AddThargaTeamRepository(o =>
{
o.RegisterUserRepository<UserEntity>();
o.RegisterTeamRepository<TeamEntity, TeamMember>();
});
// Step 5: API key auth
builder.Services.AddThargaApiKeys();
builder.Services.AddAuthentication()
.AddThargaApiKeyAuthentication();
// Step 6: Scopes
builder.Services.AddThargaScopes(scopes =>
{
scopes.Register("feature:read", AccessLevel.Viewer);
scopes.Register("feature:write", AccessLevel.User);
});
builder.Services.AddScopedWithScopes<IMyService, MyService>();
// Step 7: Tenant roles
builder.Services.AddThargaTenantRoles(roles =>
{
roles.Register("Editor", new[] { "feature:read", "feature:write" });
});
// Step 8: Audit
builder.Services.AddThargaAuditLogging();
var app = builder.Build();
// Step 2: Auth endpoints
app.UseThargaAuth();
// Step 3: Controllers
app.UseThargaControllers();
Quick reference: _Imports.razor
@* Step 1: UI Foundation *@
@using Radzen
@using Radzen.Blazor
@using Tharga.Blazor
@using Tharga.Blazor.Framework
@using Tharga.Blazor.Framework.Buttons
@using Tharga.Blazor.Features.BreadCrumbs
@* Step 2: Authentication *@
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Tharga.Team.Blazor.Features.Authentication
@using Tharga.Team.Blazor.Framework
@* Step 4: Team management *@
@using Tharga.Team.Blazor.Features.Team
Package summary
| Package | Added in | Purpose |
|---|---|---|
Tharga.Blazor |
Step 1 | Generic UI components (Radzen, buttons, breadcrumbs) |
Tharga.Team.Blazor |
Step 2 | Authentication, team UI, claims augmentation |
Tharga.Team.Service |
Step 3 | API controllers, API key auth, scopes, audit |
Tharga.Team.MongoDB |
Step 4 | MongoDB persistence for teams and users |
Tharga.Team.Entra |
(optional) | Microsoft Entra ID user directory — verify / list / delete via Graph (guide) |
Tharga.Team.Images |
(optional) | Auto-square and downscale uploaded team/user icons via SkiaSharp (guide) |
Tharga.Team |
(transitive) | Domain models, authorization primitives |