Table of Contents

Class TeamManagementService<TMember>

Namespace
Tharga.Team
Assembly
Tharga.Team.dll

Delegates to Tharga.Team.ITeamService for all operations, enforcing team:read on the reads.

Mutations are enforced downstream, by AuthorizationTeamServiceDecorator over Tharga.Team.ITeamService. Reads are enforced here, because that decorator deliberately does not gate reads: the claims pipeline reads team data while building the principal, so a gate there would be circular and break sign-in.

The [RequireScope] attributes on ITeamManagementService are documentation. They would be enforced by ScopeProxy<T> if this were registered through AddTeamService, and it is not — so nothing derives from them at runtime. Do not add a read to this class and assume the attribute covers it.

Generic methods (GetTeamsAsync, DeleteTeamAsync, RenameTeamAsync) call non-generic internal versions since the proxy resolves the member type from the team data.

public class TeamManagementService<TMember> : ITeamManagementService, ITeamLifecycleService, ITeamDirectoryService, ITeamOversightService, ITeamInvitationService where TMember : class, ITeamMember

Type Parameters

TMember
Inheritance
TeamManagementService<TMember>
Implements
Inherited Members

Constructors

TeamManagementService(ITeamService)

public TeamManagementService(ITeamService inner)

Parameters

inner ITeamService

TeamManagementService(ITeamService, IUserService, IScopeRegistry)

Preferred by the container when scopes are configured, so GetTeamsAsync<T>() can filter per team. Falls back to the single-argument constructor when no IScopeRegistry is registered — an app not using scopes must not start refusing reads.

public TeamManagementService(ITeamService inner, IUserService userService, IScopeRegistry scopeRegistry)

Parameters

inner ITeamService
userService IUserService
scopeRegistry IScopeRegistry

TeamManagementService(ITeamService, IUserService, IScopeRegistry, ITeamPrincipalAccessor, ITenantRoleService, IOptions<ConsentOptions>, IOptions<InvitationOptions>)

The full form, used by the Blazor registration. Without principalAccessor the read gate can see membership but not consent, and without tenantRoleService it can see access levels but not per-team custom roles.

public TeamManagementService(ITeamService inner, IUserService userService, IScopeRegistry scopeRegistry, ITeamPrincipalAccessor principalAccessor, ITenantRoleService tenantRoleService, IOptions<ConsentOptions> consentOptions, IOptions<InvitationOptions> invitationOptions = null)

Parameters

inner ITeamService
userService IUserService
scopeRegistry IScopeRegistry
principalAccessor ITeamPrincipalAccessor
tenantRoleService ITenantRoleService
consentOptions IOptions<ConsentOptions>
invitationOptions IOptions<InvitationOptions>

Remarks

Every added dependency is optional, and each one only widens what a caller may read. A host that supplies none gets the membership-and-access-level answer this service has always given, so an upgrade cannot start refusing a read that used to succeed.

Methods

AddMemberAsync(string, InviteUserModel)

public Task AddMemberAsync(string teamKey, InviteUserModel model)

Parameters

teamKey string
model InviteUserModel

Returns

Task

ClearTeamIconAsync(string)

public Task ClearTeamIconAsync(string teamKey)

Parameters

teamKey string

Returns

Task

CreateTeamAsync(string)

Creates a team owned by the caller. Requires authentication and the host's AllowTeamCreation — no scope, since the caller cannot hold one for a team that does not yet exist.

public Task<ITeam> CreateTeamAsync(string name = null)

Parameters

name string

Returns

Task<ITeam>

DeleteTeamAsync(string)

Delete a team. Requires being its Owner (with AllowTeamCreation), or the teams:delete system scope.

public Task DeleteTeamAsync(string teamKey)

Parameters

teamKey string

Returns

Task

Remarks

From 3.20.3, team:manage on the team is no longer sufficient. That scope is registered at Administrator, so it admitted any administrator — while the UI had always offered Delete to the Owner alone. The service now agrees with the button.

The attribute below is the team-bound half of the signature and does not state the whole rule; the Owner check lives in AuthorizationTeamServiceDecorator, because no scope can express it — every registered scope is granted to Administrator as well.

The delete is recoverable by default; PurgeTeamAsync<TMember>(string) is the irreversible one and needs teams:purge.

ExtendInvitationAsync(string, string)

Gives an outstanding invitation a fresh lifetime, keeping its code.

public Task ExtendInvitationAsync(string teamKey, string inviteKey)

Parameters

teamKey string
inviteKey string

Returns

Task

Remarks

The point is what it does not do: mint a new code. A link that has already been mailed keeps working, so extending an invitation costs the recipient nothing and needs no second message. That is only possible because the expiry lives on the invitation record rather than being derived from its creation time — see ExpiresAt.

An operation rather than a settable expiry, so it can be authorized and audited as one fact.

The new expiry is now plus the configured Lifetime. Where no lifetime is configured invitations do not expire, and extending clears any expiry the invitation was carrying.

GetAllTeamsAsync()

public IAsyncEnumerable<ITeam> GetAllTeamsAsync()

Returns

IAsyncEnumerable<ITeam>

Remarks

Enforced downstream on Read by AuthorizationTeamServiceDecorator, like the mutations — unlike the team-bound reads below, which the decorator deliberately does not gate.

GetAllTeamsAsync<T>()

public IAsyncEnumerable<ITeam<T>> GetAllTeamsAsync<T>() where T : ITeamMember

Returns

IAsyncEnumerable<ITeam<T>>

Type Parameters

T

Remarks

Enforced downstream on Read by AuthorizationTeamServiceDecorator, like the mutations — unlike the team-bound reads below, which the decorator deliberately does not gate.

GetInvitationAsync(string)

The invitation this code names, or null.

public Task<TeamInvitation> GetInvitationAsync(string inviteCode)

Parameters

inviteCode string

Returns

Task<TeamInvitation>

Remarks

Reads through _inner deliberately: the invitee holds no scope on this team, so the gated read would refuse them. The invite code is the check, and only the invitation it names is returned — not the roster the old pattern exposed.

GetMembersAsync(string)

The team's members. Requires team:read on that team.

public IAsyncEnumerable<ITeamMember> GetMembersAsync(string teamKey)

Parameters

teamKey string

Returns

IAsyncEnumerable<ITeamMember>

GetTeamAsync<T>(string)

One team and its members. Requires team:read on that team.

public Task<ITeam<T>> GetTeamAsync<T>(string teamKey) where T : ITeamMember

Parameters

teamKey string

Returns

Task<ITeam<T>>

Type Parameters

T

GetTeamByKeyAsync(string)

Team metadata without the roster. Requires team:read on that team.

public Task<ITeam> GetTeamByKeyAsync(string teamKey)

Parameters

teamKey string

Returns

Task<ITeam>

GetTeamCustomRolesAsync(string)

The team's runtime-defined custom roles. Requires team:read on that team.

public Task<IReadOnlyList<TenantRoleDefinition>> GetTeamCustomRolesAsync(string teamKey)

Parameters

teamKey string

Returns

Task<IReadOnlyList<TenantRoleDefinition>>

Remarks

A read of team detail, so it is gated like the others — not like its write sibling SetTeamCustomRolesAsync(string, IReadOnlyList<TenantRoleDefinition>), which needs team:manage. Seeing which roles a team defines is part of seeing the team.

GetTeamMemberAsync(string, string)

One active member of a team. Requires team:read on that team.

public Task<ITeamMember> GetTeamMemberAsync(string teamKey, string userKey)

Parameters

teamKey string
userKey string

Returns

Task<ITeamMember>

Remarks

Whether an invited or rejected member comes back here is up to the host's store, so do not rely on either answer. This resolves through the store's "teams this user belongs to" query. The MongoDB store filters that query on State == MembershipState.Member, which makes a pending invitee indistinguishable from somebody who was never in the team; a store written differently may well return them.

So treat a non-null result as "has some membership" and null as "cannot act as a member" — never as a reliable answer to which state they are in. Anything that must tell the states apart — a message explaining a refusal, a roster count, an admin grid — has to use GetMembersAsync(string), which reads the team directly and is the only portable way to see every state.

Not hypothetical: suspending a member shipped with this bug, refusing an invitee with "is not a member of team", which was both untrue and unhelpful.

GetTeamsAsync()

The same teams without their rosters, for a caller that does not know the host's member type.

public IAsyncEnumerable<ITeam> GetTeamsAsync()

Returns

IAsyncEnumerable<ITeam>

Remarks

Filtered identically — it runs the generic overload and drops the rosters, rather than reproducing the per-team scope recomputation. Two copies of that rule would be two chances for it to drift.

GetTeamsAsync<T>()

The caller's own teams, filtered to those where their membership grants team:read.

public IAsyncEnumerable<ITeam<T>> GetTeamsAsync<T>() where T : ITeamMember

Returns

IAsyncEnumerable<ITeam<T>>

Type Parameters

T

Remarks

This one cannot carry [RequireScope]: it names no team, and ScopeProxy takes the team from the first argument. A principal also only ever holds scope claims for the selected team, so there is nothing in the claims to check the others against.

So the scopes are resolved per team, by the same Tharga.Team.TeamGrantResolver that decides RequireTeamReadAsync(string) — through the overload that takes the member already loaded on the team, so this stays one query rather than one per team. A team whose membership does not grant team:read is omitted rather than returned without its roster: the scope covers "team details and members" together, and a half-visible team would be a third state nothing else in the model has.

Consent needs no branch here. This lists the caller's own teams, and a team reached through consent is by definition one they are not a member of.

IsSuspendedAsync(string)

Whether the caller's own membership in this team is suspended. A suspended member still sees the team here and in the selector — they simply hold no scopes in it.

public Task<bool> IsSuspendedAsync(string teamKey)

Parameters

teamKey string

Returns

Task<bool>

Remarks

Ungated on purpose, and it has to be. A suspended member holds no team scopes at all, so any scope-checked read would refuse them — and then nothing could tell them why their team stopped working. This asks only about the caller's own membership, which is not somebody else's information to protect.

Suspension is invisible to GetTeamsAsync()'s team:read filter by design: that filter recomputes scopes from access level, roles and overrides, none of which suspension touches. The team therefore stays listed with no special case, which is exactly the intent.

LeaveTeamAsync(string)

Leaves the team. Refuses the Owner, who must transfer ownership first, and the last administrator of a team with no owner.

public Task LeaveTeamAsync(string teamKey)

Parameters

teamKey string

Returns

Task

Remarks

The one mutation on this interface, and it belongs here rather than on ITeamManagementService. That interface is gated: ScopeProxy throws on an unattributed method, so every operation there must name a scope. Leaving can name none — member:manage, which used to authorize it as a self-removal, is registered at Administrator and so is held by nobody who most needs to leave, and a suspended member holds no scope whatsoever.

So it sits beside IsSuspendedAsync(string), ungated for the same reason: both concern only the caller's own membership, which is not somebody else's information to protect. The check is the signature — there is no user key to point at anyone else. The refusals are enforced by the service, not merely hidden in the UI.

Removing another member remains ITeamManagementService.RemoveMemberAsync and still requires member:manage.

A default interface method, so a host that substitutes or decorates this facet — which TryAdd registration exists to allow — keeps compiling. It throws rather than no-opping: reporting success for a departure that did not happen leaves someone believing they are out of a team they are still in.

RemoveMemberAsync(string, string)

public Task RemoveMemberAsync(string teamKey, string userKey)

Parameters

teamKey string
userKey string

Returns

Task

RenameTeamAsync(string, string)

public Task RenameTeamAsync(string teamKey, string name)

Parameters

teamKey string
name string

Returns

Task

SetInvitationResponseAsync(string, string, string, bool)

public Task SetInvitationResponseAsync(string teamKey, string userKey, string inviteCode, bool accept)

Parameters

teamKey string
userKey string
inviteCode string
accept bool

Returns

Task

SetMemberLastSeenAsync(string)

public Task SetMemberLastSeenAsync(string teamKey)

Parameters

teamKey string

Returns

Task

SetMemberNameAsync(string, string, string)

public Task SetMemberNameAsync(string teamKey, string userKey, string name)

Parameters

teamKey string
userKey string
name string

Returns

Task

SetMemberRoleAsync(string, string, AccessLevel)

public Task SetMemberRoleAsync(string teamKey, string userKey, AccessLevel accessLevel)

Parameters

teamKey string
userKey string
accessLevel AccessLevel

Returns

Task

SetMemberScopeOverridesAsync(string, string, string[])

public Task SetMemberScopeOverridesAsync(string teamKey, string userKey, string[] scopeOverrides)

Parameters

teamKey string
userKey string
scopeOverrides string[]

Returns

Task

SetMemberSuspendedAsync(string, string, bool)

Suspends a member's access to the team, or restores it. The member keeps their membership, access level, roles and history, and still sees the team in the selector — they are simply granted no team scopes, so every scoped operation refuses.

public Task SetMemberSuspendedAsync(string teamKey, string userKey, bool suspended)

Parameters

teamKey string
userKey string
suspended bool

Returns

Task

Remarks

Reuses MemberManage, which already authorizes RemoveMemberAsync(string, string) — strictly more destructive, so a separate grant would guard the lesser act more carefully than the greater one.

The Owner cannot be suspended, and a member cannot suspend themselves. Both are refused by the service, not merely hidden in the UI.

Distinct from IUserManagementService.SetUserDisabledAsync, which blocks a person from the whole application. This one is bounded to a single team.

SetMemberTenantRolesAsync(string, string, string[])

public Task SetMemberTenantRolesAsync(string teamKey, string userKey, string[] tenantRoles)

Parameters

teamKey string
userKey string
tenantRoles string[]

Returns

Task

SetOwnerAsync(string, string)

Makes an existing member the sole owner of the team, demoting every other owner to Administrator. Requires the SetOwner system scope. Returns the user keys of the owners demoted, empty when nothing changed.

public Task<SetOwnerResult> SetOwnerAsync(string teamKey, string newOwnerUserKey)

Parameters

teamKey string
newOwnerUserKey string

Returns

Task<SetOwnerResult>

Remarks

The scope is a system grant, unlike everything else here, 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 they already have ITeamService.TransferOwnershipAsync — admitting an in-team fallback here would let an Administrator depose the owner, which SetMemberRoleAsync exists to refuse.

Enforcement lives in AuthorizationTeamServiceDecorator; the attribute below documents the team-bound half of the signature and does not describe the whole rule.

SetTeamConsentAsync(string, string[], AccessLevel?)

What the team exposes to an oversight caller. Requires team:manage on that team.

public Task SetTeamConsentAsync(string teamKey, string[] consentedRoles, AccessLevel? accessLevel = null)

Parameters

teamKey string
consentedRoles string[]
accessLevel AccessLevel?

Returns

Task

Remarks

Consent is a team's own statement about what it exposes inbound, so it is deliberately gated by the in-team manage scope rather than by any system grant — an operator overriding it would be a much larger claim than fixing a typo in a name.

SetTeamCustomRolesAsync(string, IReadOnlyList<TenantRoleDefinition>)

Replace the team's runtime-defined custom roles. Requires team:manage on the team. Each role's scopes must be app-registered scopes (rejected otherwise, as a privilege-escalation guard). Assigning these roles to members remains a member:manage operation.

public Task SetTeamCustomRolesAsync(string teamKey, IReadOnlyList<TenantRoleDefinition> customRoles)

Parameters

teamKey string
customRoles IReadOnlyList<TenantRoleDefinition>

Returns

Task

SetTeamIconAsync(string, byte[], string)

public Task SetTeamIconAsync(string teamKey, byte[] data, string contentType)

Parameters

teamKey string
data byte[]
contentType string

Returns

Task

TransferOwnershipAsync(string, string)

public Task TransferOwnershipAsync(string teamKey, string newOwnerUserKey)

Parameters

teamKey string
newOwnerUserKey string

Returns

Task