Table of Contents

Tharga Team — Implementation Guide

Step-by-step instructions for adding Tharga Team features to a Blazor application.

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 init followed by dotnet 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.Web is 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: ClientId and TenantId may 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 sub in 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 sub are not found under oid. Before enabling the option, rewrite Identity on each user record to that user's oid — the DirectoryId field, 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.Framework provides the Roles class used in AuthorizeView (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).

UsersView now 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" while UsersListView and TeamsListView held 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 used Results.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.

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 Custom member'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.Team is included transitively via Tharga.Team.Blazor. You also need Tharga.MongoDB.Blazor configured 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 TeamServiceCompletenessCheck says so at startup, naming them. That happens when a service derives straight from TeamServiceBase, which is generic in nothing — name the member type explicitly. Set ThrowOnIncompleteTeamService to 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 TeamCollectionName and UserCollectionName:

builder.Services.AddThargaTeamRepository(o =>
{
    o.TeamCollectionName = "MyTeams";     // default: "Team"
    o.UserCollectionName = "MyUsers";     // default: "User"
    o.RegisterUserRepository<UserEntity>();
    o.RegisterTeamRepository<TeamEntity, TeamMember>();
});

Note: AddThargaTeamBlazor() internally calls AddThargaBlazor(), so BreadCrumbService and BlazoredLocalStorage are 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:

  1. 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.
  2. AccessLevel is nullable. ITeamMember.AccessLevel, TeamMemberBase.AccessLevel, InviteUserModel.AccessLevel, TeamMemberInfo.AccessLevel and UserTeamInfo.AccessLevel are AccessLevel?. 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 implements ITeamMember changes its property type to match. IScopeRegistry.GetScopesForAccessLevel, IScopeRegistry.GetEffectiveScopes and ITenantRoleService.GetEffectiveScopesAsync accept AccessLevel? and return nothing for null.
  3. An invitation must name a level. AddMemberAsync throws ArgumentException when InviteUserModel.AccessLevel is null. InviteUserDialog always sets one. Code that builds an InviteUserModel itself sets it explicitly.
  4. CreateTeamMember must copy the level. If an override returns a member with no level, the invite throws InvalidOperationException naming the override, and nothing is stored. Add AccessLevel = model.AccessLevel to the member it builds.
  5. The Developer role no longer oversees private API keys. Only the apikeys:oversee system scope does. If developers relied on it, map it: o.ConfigureSystemRoles = roles => roles.Map("Developer", SystemApiKeyScopes.Oversee);.
  6. ITeamEmailSender has 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.InviteLink and mail.TeamName carry the same values.
  7. The Tharga.Platform aliases are gone. ThargaPlatformOptions, ThargaPlatformRegistration, builder.AddThargaPlatform(...) and app.UseThargaPlatform() were obsolete forwarders kept from the rename. Use ThargaTeamOptions, builder.AddThargaTeam(...) and app.UseThargaTeam(). Also removed: services.AddThargaTeam() in the core Tharga.Team package, which never registered anything. Call builder.AddThargaTeam() from Tharga.Team.Blazor instead.

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.Name from the name claim — 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.com becomes John 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 CreateTeamMember returns a member without Invitation, 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, State defaults to MembershipState.Invited if 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 $pull to 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 replacement ITeamRepository and to the member methods a host overrides on TeamServiceBase (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, RemoveTeamMemberGuardedAsync and TransferTeamOwnershipAsync on TeamServiceBase (or SetMemberRoleUnlessOwnerAsync, RemoveMemberGuardedAsync and TransferOwnershipAsync on a replacement ITeamRepository), 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 ShowMemberRoles and ShowScopeOverrides options 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 anything
  • TeamKey — 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 nothing
  • Role: TeamMember — membership role
  • Role: 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-side AuthenticationStateProvider decorator that enriches claims via LocalStorage/JS interop. Only needed for standalone Blazor WebAssembly apps that have no server-side HTTP pipeline.

Warning: Setting SkipAuthStateDecoration = false on a Server/SSR app will cause a blank page (silent deadlock from JS interop during prerendering).

Unproven: the false path 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 the AuthenticationStateProvider decoration 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. TeamServerClaimsTransformation is an IClaimsTransformation, 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 SkipAuthStateDecoration is true (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, or Authorization: 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 two tag.Type claims; read them with user.FindAll("tag.Type").
  • Displayed read-only. ApiKeyView shows all tags in an (i) tooltip; pass ChipTagKeys to render selected keys as chips (e.g. ChipTagKeys="@(new[] { "Type" })").
  • Legacy data. Pre-tags keys stored an empty Tags document; reads tolerate this automatically (it deserializes as no tags). To purge the legacy field, call IApiKeyRepository.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/DeleteKey throws 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) throws InvalidOperationException rather 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-side IApiKeyAdministrationService.CreateKeyAsync(..., ownerMemberKey: currentMember.Key);
    • system: ISystemApiKeyManagementService.CreateSystemKeyAsync(..., personal: true), or server-side IApiKeyAdministrationService.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.

  • 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:oversee system 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 the Developer role 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 — ShowPrivateKeys on both ApiKeyView and SystemApiKeyView: Mine (default) lists the caller's own personal keys, All everything the caller is entitled to see, None none. Any value but None also shows the Personal checkbox when the option allows both kinds. ApiKeyView adds AllowPrivilegedAccess (default false; only meaningful with All). 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 — GetKeysAsync and GetSystemKeysAsync list shared keys only unless asked for personal ones, as before personal keys existed.

  • A custom key store keeps compiling: CreatePersonalSystemKeyAsync has a default implementation that throws NotSupportedException. Implement it, and keep OwnerMemberKey across RefreshSystemKeyAsync, 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 AccessLevel claim is the less privileged of the key's and the owner's. Demoting the owner narrows the key on the next request.
  • ApiKeyOwner claim (TeamClaimTypes.ApiKeyOwner) carries the owner's user key, so an API can tell who a request acts for. It is deliberately not a MemberKey claim: 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:read came 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 ITeamService is exactly as unchecked as a toolkit one would be. Reflect over your IComponent types, collect constructor parameters and [Inject] properties, and assert none is marked EditorBrowsableState.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 /team to 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.SelectTeamEvent no 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 an async void handler 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 to TeamsListChangedEvent, which still fires on both answers. CreateTeamAsync still raises SelectTeamEvent: there, nobody else is present to choose.

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 AddThargaTeamBlazor or AddThargaTeam: 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. TeamServiceBase finds the invitation on the roster through GetMembersAsync, 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.SetInvitationExpiryAsync throws by default, and a replacement repository that leaves it out is logged as an error at startup when a lifetime is set.
  • Forward InvitationOptions from your own service's constructor, the same as ITeamCache. 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. InviteUserDialog and TeamComponent resolve the sender with GetService, 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, AddThargaTeamBlazor could 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.com does not admit mail.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 AddScoped carries 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 reads IHttpContextAccessor (controllers/API). AddThargaTeam / AddThargaTeamBlazor automatically swap in a circuit-aware accessor that uses HttpContext when present and falls back to AuthenticationStateProvider otherwise — so a single [RequireScope] / [RequireAccessLevel] enforces both your API and interactive Blazor callers (no HttpContext is needed in a circuit). To plug in a different principal source, register your own ITeamPrincipalAccessor.

How scopes are resolved

  1. Access level — Owner and Administrator get all scopes. User gets scopes at User or Viewer level. Viewer gets only Viewer-level scopes. Custom gets no base scopes (and is exempt from the Owner/Administrator "all scopes" rule).
  2. Tenant roles — Additional scopes granted by assigned roles (see Step 7).
  3. Scope overrides — Per-member overrides set in the team management UI (when ShowScopeOverrides = true).

AccessLevel.Custom — least-privilege keys/members. Use Custom when 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 exactly roles ∪ scopeOverrides. Set it explicitly: a key created without an access level still defaults to a non-Custom level. Custom is surfaced in the ApiKeyView create 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 ScopeOverrides value 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. UnregisteredRoleScopeCheck logs a warning at startup naming any role scope absent from the registry.

Do not reach for AccessLevel.Custom here. Registering a scope at Custom looks like the natural way to say "no level grants this", and it does the opposite. The levels are Owner=0, Administrator=1, User=2, Viewer=3, Custom=4; Owner and Administrator receive all registered scopes regardless of DefaultMinimumLevel, and the fall-through filter is DefaultMinimumLevel >= accessLevel, so Custom=4 satisfies User and Viewer as well. A scope registered that way is granted to every level. The Custom guard in GetScopesForAccessLevel applies 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

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.

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 run dropDatabase. MongoDB Atlas's readWriteAnyDatabase does not include it — you need dbAdminAnyDatabase, 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:purge and 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 TeamStorageException naming 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 Custom principal is the lowest tier and fails every [RequireAccessLevel] gate (including Viewer). 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 DefineGroup is the English default; override it through your IThargaTextProvider under the key team.scopeGroup.<key> (for diary: team.scopeGroup.diary). The "Other" heading is team.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 requires team:manage on the team by default; set o.DynamicRoleManageScope to require a different scope (e.g. access:manage) instead, honoured by both the service layer and TenantRoleManager. Assigning a role to a member is still a member:manage operation.
  • 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; SetTeamCustomRolesAsync drops the entry, so editing through the service layer or <TenantRoleManager /> takes effect immediately. A host that writes custom roles straight to its own store, around SetTeamCustomRolesAsync, 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 = false leaves 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.Scope directly to detect a system grant must read TeamClaimTypes.SystemScope instead. Anything gating through [RequireScope], TeamScopeGate or 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 from IScopeRegistry and 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 />. Pass Roles to 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; set ShowSystemScopes="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.Register skips 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, Register threw on a duplicate, and because 3.14.0 started registering simulation:demo — a scope hosts had previously been obliged to register themselves — every such host failed at startup with System scope '…' is already registered. (#237). Team scopes are unchanged and still throw: ScopeDefinition also 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-owner in 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 (SystemApiKeyView picker reads ConfigureSystemScopes).
  • Users with a mapped app role (e.g. Developer) receive the mapped scopes as claims via TeamServerClaimsTransformation — even with no team selected. Map apikey:manage / audit:read to 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.

ConfigureSystemScopes is not an isolation boundary for every scope. users:manage and teams:delete are auto-registered by the framework — the admin surfaces need them grantable — so leaving them out of ConfigureSystemScopes does 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.

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:read sees every team in TeamComponent, TeamSelector and the developer UsersView — 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:read or 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, or DefaultTeamService) there is nothing to do.

  • A service deriving TeamServiceBase directly must override GetAllTeamsInternalAsync to 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 NotSupportedException naming the member and the scope. Startup reports it first: as soon as a system role maps teams:read, GrantTeamsRead is on, or the scope is registered for system API keys, AddThargaTeamBlazor logs an error naming your service. Set o.Blazor.ThrowOnIncompleteTeamService = true to 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:read was 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" on AccessSimulationBar, 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: StorageMode defaults to Logger only. The AuditLogView component reads from MongoDB, so with the default it stays empty — entries only go to ILogger. 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

AuditStorageMode is 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. RetentionDays creates a MongoDB TTL index that auto-deletes entries older than the given age. Set it to null (or any value <= 0) to keep entries indefinitely — no TTL index is created. Leaving RetentionDays unset keeps the default of 90; setting it to null explicitly means forever.

A changed value reaches an existing database. When the host starts with MongoDB audit storage, the Timestamp_TTL index on AuditLog is 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.Disabled in AddMongoDB) manages its own indexes, so the toolkit leaves the index alone.

Upgrading from a version before this fix. Those versions turned an explicit null into 90 days, so an application that set null to 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, TeamKey scoped the query but not the access decision, so passing it alone was refused even for a team Owner holding audit:read, and hosts worked around it by also passing a PinnedFilter naming 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 HttpContext used to be recorded as User with a null identity — a row claiming a person did what a background job did. It now records Unknown, or the declared actor below. If you report on CallerType == 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 survives await and 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 CorrelationId per 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.TraceIdentifier parsed as a Guid. 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 on CorrelationId, 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.None suppresses 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, EventFilter and ExcludedActions apply afterwards exactly as before. That also gives a method that must be recorded an escape hatch without a special rule: mark it Change and it records a DataChange, 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.old value (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