Skip to content
ThargaPublic

Latest commit

 

History

1,608 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Tharga Team

A suite of NuGet packages for building multi-tenant Blazor applications with team management, authorization, and API infrastructure.

Packages

Package Description WASM-safe
Tharga.Team Domain models, authorization primitives, service abstractions Yes
Tharga.Blazor Generic Blazor UI components (buttons, breadcrumbs, etc.) Yes
Tharga.Team.Blazor Team management Blazor components Yes
Tharga.Team.MongoDB MongoDB persistence for teams and users No
Tharga.Team.Service Server-side API key auth, Swagger, audit logging No
Tharga.Team.Entra Microsoft Entra ID user directory (verify / list / delete users via Graph) No
Tharga.Team.Images Automatic squaring and downscaling of uploaded team/user icons (SkiaSharp) No
Tharga.Team.Email E-mail — IMAP reading, SMTP sending and MIME (MailKit), team mailboxes, and mail to users: categories, unsubscribe links, a persisted queue, staff messages and automatic notices No
Tharga.Team.Support Support module — Slack notifications for audited events, and support cases on the site, by mail and in Slack No
Tharga.Team.Support.Anthropic Lets a team connect the support assistant to its own Neurolito or Anthropic key, over the Anthropic Messages API (Anthropic SDK) No
Tharga.Team.Mcp MCP (Model Context Protocol) bridge — auth, scopes, audit for MCP tools. Renamed from Tharga.Platform.Mcp, which is deprecated and frozen at 3.5.x No

Dependency graph

Tharga.Team ── plain .NET, no external dependencies
├── Tharga.Blazor ── generic Blazor UI components
│   └── Tharga.Team.Blazor ── team management UI
│       └── + Tharga.Team.Service
├── Tharga.Team.MongoDB ── persistence layer
│   └── + Tharga.MongoDB
├── Tharga.Team.Service ── server-side API + auth
│   └── + Tharga.MongoDB, ASP.NET Core, HtmlSanitizer
├── Tharga.Team.Entra ── Entra ID user directory (optional)
│   └── + Azure.Identity, Microsoft Graph REST
├── Tharga.Team.Images ── icon squaring + downscaling (optional)
│   └── + SkiaSharp
├── Tharga.Team.Email ── e-mail transport, team mailboxes, mail to users (optional)
│   └── + Tharga.Team.Service, MailKit, DnsClient, AngleSharp
├── Tharga.Team.Support ── support module, Slack notifications (optional)
│   └── + Tharga.Team.Service, Tharga.Team.Email
└── Tharga.Team.Support.Anthropic ── a team's own AI provider for the assistant (optional)
    └── + Tharga.Team.Support, Anthropic

Quick Start

Install the packages:

dotnet add package Tharga.Team.Blazor
dotnet add package Tharga.Team.Service
dotnet add package Tharga.Team.MongoDB

Register everything in Program.cs:

// One call to set up auth, Blazor, controllers, API keys
builder.AddThargaTeam(o =>
{
    o.Blazor.Title = "My App";
    o.Blazor.RegisterTeamService<MyTeamService, MyUserService>();
    // or, writing no storage types of your own:
    // o.Blazor.RegisterTeamService<DefaultTeamService, DefaultUserService>();
});

// MongoDB persistence (requires consumer-specific 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();

Add to appsettings.json:

{
  "AzureAd": {
    "Authority": "https://your-tenant.ciamlogin.com/your-tenant-id",
    "ClientId": "your-client-id",
    "TenantId": "your-tenant-id",
    "CallbackPath": "/signin-oidc"
  }
}

Optional features (pass via ThargaTeamOptions):

builder.AddThargaTeam(o =>
{
    // Fine-grained scopes
    o.ConfigureScopes = scopes =>
    {
        scopes.Register("orders:read", AccessLevel.Viewer);
        scopes.Register("orders:write", AccessLevel.Administrator);

        // Grant-only: reaches no access level, not even Owner or Administrator, and cannot be added to a
        // tenant-defined custom role or a scope override. It is held solely through a role you register
        // in code, so holding it stays an explicit decision. Registering it keeps its catalogue entry,
        // description and typo-safety, which the older "just leave it unregistered" trick gives up.
        scopes.RegisterGrantOnly("case:read", "Read secrecy-classified case records.");
    };

    // Named roles that bundle scopes
    o.ConfigureTenantRoles = roles =>
    {
        roles.Register("Editor", new[] { "orders:read", "orders:write" });
        roles.Register("CaseOfficer", new[] { "case:read" });
    };

    // Hide an access level from the invite, member, API-key and consent pickers. Hidden is not invalid --
    // members already on the level keep working and keep their badge.
    // o.Blazor.HiddenAccessLevels = [AccessLevel.Viewer];

    // Optional: let team admins define their own custom roles at runtime (assignable to members and
    // API keys via <TenantRoleManager /> and <ApiKeyView ShowRoles="true" />, and explained by
    // <ScopeView />, which credits them alongside the code-registered ones).
    // o.EnableDynamicRoles = true;
    // o.DynamicRoleManageScope = "access:manage"; // scope for custom-role CRUD (default team:manage)

    // Audit logging (StorageMode defaults to Logger only — set MongoDB to populate AuditLogView)
    o.Audit = new AuditOptions { StorageMode = AuditStorageMode.MongoDB };

    // Capture an API key's private token on create/recycle (e.g. to re-deliver a minted key)
    o.AddApiKeyLifecycleHandler<MyApiKeyHandler>();
});

API-key behaviour (auto-lock, expiry, and the random secret length via MinKeyLength/MaxKeyLength) is configured on o.ApiKey. See the Tharga.Team.Service README and the Implementation Guide.

User administration & Entra directory

The user store tracks per-user last seen (opt-in: declare LastSeen/DirectoryId on your user entity), and IUserManagementService provides audited administration: verify users against Microsoft Entra ID, list users that exist only in Entra, disable users, and delete them — from the app and (explicit opt-in) from Entra. Everything cross-user — including viewing the admin lists and enumerating users via IUserService — requires the users:manage system scope, enforced in the service layer:

// dotnet add package Tharga.Team.Entra
builder.Services.AddThargaEntraUserDirectory(builder.Configuration);   // AzureAd section + ClientSecret

builder.AddThargaTeam(o =>
{
    o.ConfigureSystemRoles = roles => roles.Map("Developer", SystemUserScopes.Manage);
});

The <UsersView /> admin component picks it all up automatically. Its two tabs show each record's key with a copy control, the signed-in user's own row highlighted, and — on the Teams tab — owner, last used, a pending-invitation split, avatars and an empty-team badge. Two host opt-ins: grant the teams:delete system scope through o.ConfigureSystemRoles to offer team deletion, and set <UsersView ShowAuditLogButton="true" /> for a per-row audit-history dialog. With a directory that can read sign-in identities (Entra), the Users tab also shows how each user signs in, with provider names you map through o.Blazor.SignInMethods. See User management & directory.

Which claim identifies a user

By default a user is found by the first non-empty claim of NameIdentifier, sub, oid, nameid, uid. With OpenID Connect sign-in that is sub. On Microsoft Entra — workforce and External ID alike — sub is pairwise: a different value for the same person in every application. Replace the app registration and every user gets a new key, so their user record and team memberships are orphaned and duplicates are created; and two applications in one tenant can never share user records.

Key users on the tenant-wide object id instead:

builder.AddThargaTeam(o =>
{
    o.UserIdentityClaimTypes = [DirectoryClaimTypes.ObjectId];   // "oid"
});
  • oid matches both the raw claim and the mapped http://schemas.microsoft.com/identity/claims/objectidentifier, so it works whether or not inbound claim mapping is on.
  • It applies everywhere a user is identified: the stored user record, the team claims built from it, the audit trail's CallerUserIdentity, support-case authorship and the MCP user id.
  • Listed types are tried in order with no fallback to the default chain — a principal with none of them resolves to no user rather than to a second record for the same person.
  • Turning it on for a host that already has users re-keys them. Existing records are stored under the old value; migrate Identity on the user collection to the oid first, or everyone gets a new record on their next sign-in.

Leave it unset and nothing changes. See Which claim identifies a user.

Keeping name and email current from the sign-in

A user's Name and EMail are stored once, on the first sign-in. To have them follow the sign-in for providers you trust — one that verifies who the person is, say — override ShouldRefreshProfile on your user service:

protected override bool ShouldRefreshProfile(ClaimsPrincipal claimsPrincipal)
    => claimsPrincipal.FindFirst("http://schemas.microsoft.com/identity/claims/identityprovider")?.Value
        ?.StartsWith("https://id.example.com/", StringComparison.OrdinalIgnoreCase) == true;

Only a non-empty value that differs is written, a name guessed from the email address never replaces a real one, and a failure never fails the sign-in. Each refresh is audited as auth/user-profile-refreshed. Off by default. A custom IUserRepository must implement the new SetEMailAsync. See Keeping name and email current from trusted sign-ins.

Extending the member grid

A host that adds a field to its member type can surface it on the member row itself rather than in a second grid beside the first — <TeamComponent> takes an extra column, extra menu items, and a callback carrying your member type:

<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>

Your action appears even on rows where the built-in ones do not — the caller's own, or the owner's. Five action values are the component's own (copy-invite, remove-member, member-audit, suspend-member, restore-member); pick anything else. TeamsListView and UsersListView have had the same trio since 3.7.x. See the Implementation guide.

Team & user icons

Teams and users get real icons/avatars via two pluggable seams — storage (IIconStore, default MongoDB, no extra package) and sourcing (IIconSource: stored icon → custom → Gravatar → default → initials). Team icons need no entity change; add Icon to your user entity to enable user icons. A team:manage holder sets a team icon (upload or URL) from the team component; users upload their own from the profile page (an alternative to Gravatar), and admins (users:manage) can set a user's icon. Behavior is configurable and runtime-adjustable via o.IconSettings (Gravatar on/off + style, a default image, upload toggles). Add the optional Tharga.Team.Images package to auto-downscale oversized uploads (256 px) instead of rejecting them:

builder.Services.AddThargaImageProcessing();   // optional: auto-square + downscale via SkiaSharp

Those settings are also changeable from inside the application. Drop <IconSettingsView /> on a page and a holder of the system users:manage grant gets the switches, the style picker with a live preview of every Gravatar style, and the default-image URL:

<IconSettingsView />

Settings are site-level and persisted (IIconSettingsStore, default MongoDB, replaceable), so they survive a restart; the check lives in IIconSettingsService rather than the component, and every change is audited with the values stored. A host that never saves keeps exactly what it configured at startup. The instance handling a save applies it at once, and others re-read on o.Blazor.IconSettingsRefreshInterval (default 15 minutes).

See Team & user icons.

Live claim revalidation

Team claims (membership, access level, tenant-role scopes, consent access) are enriched at HTTP authentication, so in a long-lived Blazor Server circuit they would otherwise stay frozen until a reload — a removed member, a downgraded access level, or a revoked consent would keep their old access, in the service-layer checks as well as the UI. Tharga.Team revalidates them on an interval and refreshes the principal in place, so team access is stale for at most one interval. A change in team access never signs anybody out — the session is still valid, only the access moved. The one exception is a disabled user, who is signed out on the same interval. On by default (30 min); tune or disable it:

builder.AddThargaTeam(o =>
{
    o.Blazor.ClaimRevalidation.Interval = TimeSpan.FromMinutes(5); // narrow the window
    // o.Blazor.ClaimRevalidation.Enabled = false;                 // or turn it off
});

See Team-claim revalidation.

Naming a team on an MCP call

MCP derived everything from the caller's TeamKey claim, so a call could only address the team the caller was already anchored to — and a system key is anchored to none. Send the team key in a header instead:

POST /mcp
Authorization: Bearer <api key>
X-Team-Key: acme-corp

Naming a team grants the caller's membership scopes in it, or the team's consented level if they hold a global role it consented to, and refuses otherwise — including for a suspended member. Selection replaces rather than accumulates: scopes are recomputed for the named team and the principal's own scope claims, which describe a different team, are never consulted. So naming a team can only narrow what a caller may do.

Per call rather than per session, because ModelContextProtocol 2.0.0 is stateless — over HTTP, per-request is per-call. The rule comes from the same resolver the Blazor claims builder uses, so a caller reaches a team at the same level either way. See Tharga.Team.Mcp.

Suspending instead of deleting

Deletion is final: it drops the record, the memberships, the scopes and the trail. Three reversible alternatives cover the ordinary cases — someone on leave, a suspected compromise, a paused integration — each bounded differently:

To stop Call Scope Reach
A person signing in at all IUserManagementService.SetUserDisabledAsync users:manage The whole application
A person working in one team ITeamManagementService.SetMemberSuspendedAsync member:manage That team only
An API key IApiKeyManagementService.SetKeyDisabledAsync apikey:manage That key

All three record when and by whom, audit both directions under distinct actions, and give back everything on the way out. Nobody can disable or suspend themselves, and a team's Owner cannot be suspended.

A disabled user is refused at sign-in and evicted from a live session within the revalidation interval. A suspended member keeps their membership and still sees the team in the selector — they simply hold no scopes in it, so every scoped operation refuses. Seeing the team is the point: a membership that silently vanishes is indistinguishable from removal. Drop <SuspendedTeamNotice /> into your layout to explain it to them. They can still leave: that is the one team operation carrying no scope, so holding none does not trap somebody in a team.

A disabled key stops authenticating, and the refusal is recorded as an authentication failure — those attempts are the point of the trail. Refreshing it does not re-enable it: a refresh mints a new secret, it is not a decision to trust the key again.

To persist any of it, declare the properties on your entity (DisabledAt/DisabledBy on the user, SuspendedAt/SuspendedBy on the member) and implement the matching hook. Both are opt-in by shape, as Icon and LastSeen are. See Suspending instead of deleting.

Asking a team for access

Consent lets a team grant a global role access to its data. From 3.23 the person who needs that access can ask for it, for a stated length of time, and the team's managers approve or deny:

Who may ask Anyone holding a configured consent role who is not a member of the team. No new scope
What they ask for Viewer, User or Administrator — never Owner — for 1 hour, 8 hours, 1 day, 1 week, 30 days, or with no end
Who decides A member holding team:manage. Never the requester
What approval grants The team's consent, at that level, until the window closes — so every holder of the consent roles reaches the team, which the approval screen states plainly
On expiry The consent the team had before the approval, or none if it had none

The requester and manager UI sit inside <TeamComponent>. Add the optional <TeamNotificationMenu /> to your top bar for a bell whose badge counts the requests waiting on this caller.

To ask from somewhere of your own, drop in <TeamAccessRequestButton TeamKey="@team.Key" /> — it shows Request access, Withdraw or nothing at all depending on the caller, so it can be placed unconditionally. Or call ITeamAccessRequestService directly; the rules live there rather than in any UI.

Expiry needs nothing to run. Every consent read resolves through TeamConsent.Resolve, which returns the previous consent once the window has passed, so it takes effect on the next claim revalidation like any other access change.

⚠️ Changing consent now requires team:manage held as a member. It previously accepted the scope however it arrived — including through consent — which with a time limit would have let a grant remove its own expiry. See Team access requests.

Invitations

An invitation link carries a short opaque token and nothing else:

https://your-host/invitation?tic=84Fb6G_8BbXE

Changed in 3.20. Links used to carry base64 of {"TeamKey":…,"Code":…} — an encoding, not encryption, so the team key was readable by any mail relay, helpdesk ticket or forwarded message the link passed through. It is no longer in the link at all. Links already sent keep working; only new ones use the short form.

Fixed in 3.21.1. From 3.20.0 through 3.21.0 the short form never resolved in a host registered through AddThargaTeamBlazor or AddThargaTeam — every new link opened on "no invitation" (#272). Upgrade; the links already sent resolve once the host is on the fixed version. If you wrap ITeamService in a decorator of your own, implement and forward GetTeamKeyByInviteKeyAsync — and every other default member — or the same thing happens through yours.

Invitations can expire, off by default because they never used to:

builder.Services.Configure<InvitationOptions>(o => o.Lifetime = TimeSpan.FromDays(14));

An expired invitation is refused at acceptance and reported as expired rather than as an invalid code — the difference between asking for a new link and retrying something that will never work.

Repeated failed resolves from one source are delayed, and the first failure past the threshold is recorded as AuditEventType.RateLimit, so an attempt to guess codes is slowed and visible rather than silent. It delays and never refuses, so an invitee retrying a link is never locked out. Defaults are generous — five failures in five minutes, then a curve capped at two seconds — and configurable on InvitationOptions (ThrottleFailureThreshold, ThrottleWindow, MaxThrottleDelay); set the threshold to 0 to turn it off. It is a second layer, not a replacement for code entropy. See the implementation guide.

Extending one keeps its code, which is the point: someone who has already mailed a link can give it more time without the recipient's link dying. Re-inviting the same address does the same thing rather than issuing a second live code for one seat.

await teamManagementService.ExtendInvitationAsync(teamKey, inviteKey);

A host with its own team store implements one method to resolve a token without its team, and one to move an expiry. Skip the first and links naming their team still work; see the implementation guide.

Accepting selects the team that was joined, and leaves for the site root. Answering an invitation is the end of that page's business, so both answers navigate away rather than reloading the invitation page with nothing left to show. Point them somewhere else when your root is a landing page, or when the invitation page is one step of a longer flow:

o.Blazor.HomePath = "/start";

Changed in 3.22. Accepting used to reload the invitation page and leave the selection alone, so an invitee who already belonged to a team stayed on the old one — the team they had just chosen was discarded by the reload (#287). Two consequences if you have built on the old behaviour: set HomePath if the invitation page needs to stay on screen, and note that ITeamService.SelectTeamEvent no longer fires when an invitation is accepted, because the screen that accepted it now makes the selection itself. Creating a team still raises it.

With mail to users registered, the invitation mail goes through it (4.2): recorded, sent in one immediate attempt from its address so the inviter is told whether it went, shown in the invited person's mail history and marked bounced when it comes back. A sender of your own is kept.

Support cases, and an assistant to answer them

A support case lives on the site, and can project onto Slack (where support watches) and email (where the customer writes). A case raised on the site is complete and trackable with no channel configured at all.

A case can also be answered by an assistant — and the toolkit does not choose which one. It consumes IChatClient from Microsoft.Extensions.AI and ships no vendor SDK, so Ollama, llama.cpp, a self-hosted model, OpenAI and Anthropic are all one registration in your host:

builder.Services.AddSingleton<IChatClient>(_ => new OllamaApiClient(uri, "llama3")
    .AsBuilder().UseFunctionInvocation().Build());

Register none and there is no assistant: the choice is never offered and a person answers every case, exactly as before. To give each team its own model, or none, register an ISupportChatClientResolver instead; the assistant is then offered only to a team that has one (GetAssistantReadinessAsync). The customer picks when raising a case and can ask for a person at any point, keeping the same transcript. An assistant answer takes the case out of support's queue and a customer reply puts it back, so a dissatisfied customer is always back in front of a human.

The assistant reads through the same scope-checked services as any caller — it sees what the person it is answering sees, and nothing more — and it answers rather than acts: closing, assigning and reopening stay with people. Add tools over your own product by implementing ISupportAssistantTools.

Help beside every page. <SupportPanel /> is a right-hand sidebar in your layout where a member talks to the assistant or to support, in the same case the support page shows. The assistant's answer appears as the model writes it, and either side sees flashing dots while the other is writing.

What the assistant is doing, and whether support is there (4.4). While it works the assistant says "Working 2" for each round of service calls, and under every answer it keeps what it took: the calls, with what each was asked for, and the tokens the model reported. It searches the member's own cases, links to them, and records what it was asked and could not do, for the people who build it. Support staff are Online, Away or Hidden on the desk, members see whether support is online, and a message nobody answers within a minute is told we will get back to them.

Support by mail is complete both ways (4.2). Several mailboxes feed one support and a case is answered from the one it came in through; a sender whose DKIM-verified address belongs to one account in one team lands in that team, anything less waits unassigned with suggested teams; files and formatting survive both ways through one sanitizer; and every answer says whether it was delivered, with a bounce marking it and a button to send it again.

Full article: Support cases.

Mail to users

Tharga.Team.Email mails the people who use your product, in categories each user turns on or off on their profile, with an unsubscribe link and the one-click headers on every mail they could have turned off, through a persisted queue that whichever instance claims a mail sends:

builder.Services.AddThargaUserMail(o =>
{
    o.Mail.Smtp.Host = "smtp.example.com";
    o.Mail.FromAddress = "hello@example.com";      // an address a reply reaches; there is no default
    o.BaseUrl = "https://app.example.com";
    o.AddCategory(new UserMailCategory { Key = "news", Name = "Product news" });
});

app.MapThargaMailUnsubscribe();                    // allow it anonymously in a deny-by-default host

On top of it: messages your staff write to chosen users of chosen teams (AddThargaUserMessages, the messages:send system scope), automatic notices raised from server code and delivered at once or in a daily summary, chosen per user over each team's default (AddThargaUserNotices), a mail history per user, mail marked bounced when it comes back, and invitations recorded and sent through the same outbox. And mail to contacts — people who are not users, listed from your own records — in categories of its own, with an opt-out per address that holds for good (AddThargaContactMessages, the contacts:send system scope).

Full articles: Mail to users, Messages to users, Messages to contacts, Automatic notices.

Reading the audit log

Two kinds of entry share the log: the ones your application writes for things people did, and the per-call access traces the scope proxy writes for every [RequireScope] invocation. On a busy tenant the traces outnumber the rest several times over, and the audit log's own readers are usually the loudest of them.

Classify what you write, so the Event filter can tell the two apart:

auditLogger.Log(auditEntryFactory.Create(
    AuditEventType.DataChange, "case", "CaseClosed", teamKey: team.Key, metadata: metadata));

Decide per method what is recorded at all, since most of the volume is the enforcement proxies tracing every scope-checked call:

[RequireScope(CaseScopes.Read)]                              // the host's default
[RequireScope(CaseScopes.Read, Audit = AuditMode.Access)]    // recorded, because this one must be
[RequireScope(CaseScopes.Manage, Audit = AuditMode.Change)]  // recorded as a data change

[RequireAccessLevel] takes the same parameter. The host default stays Access, so nothing changes unless you ask — set o.Audit = new AuditOptions { DefaultAuditMode = AuditMode.None } to make silence the default and record by exception. A refusal is recorded whatever the mode says, and the existing CallerFilter/EventFilter/ExcludedActions still apply to whatever is written.

Open the view on what a reader is looking for, without taking the choice away from them:

<AuditLogView InitialFilter="@(new AuditInitialFilter { ExcludedScopes = ["audit:read"] })" />

InitialFilter sets opening values the reader can change — as against PinnedFilter, which hides its controls and locks the query. Hiding a scope never hides the entries your application wrote, since those check no scope, and a Show hidden toggle brings the rest back.

The grid's Operation column shows the scope that was checked, or the entry's own feature and action where none was, so an entry you wrote is identifiable without expanding the row.

Two corrections in 3.21 that change what a log contains. A refused team call was recorded as an allowed one — denials were classified by matching an exception message that only the system path produces — and access-level denials were discarded entirely, because that event type had no filter mapping. Both now record correctly, so refusals appear in reporting where they previously did not.

Changed in 3.21. Entries in one request now share a correlation id taken from Activity.Current.TraceId. It was previously derived from HttpContext.TraceIdentifier parsed as a Guid, which never succeeds, so every entry carried its own id and nothing grouped. See the implementation guide.

Advanced Usage

Individual Add* methods remain available for partial/custom setups. See the Implementation Guide for step-by-step instructions.

Links

Releases

Packages

Contributors

Languages