Class TeamManagementService<TMember>
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
innerITeamService
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
innerITeamServiceuserServiceIUserServicescopeRegistryIScopeRegistry
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
innerITeamServiceuserServiceIUserServicescopeRegistryIScopeRegistryprincipalAccessorITeamPrincipalAccessortenantRoleServiceITenantRoleServiceconsentOptionsIOptions<ConsentOptions>invitationOptionsIOptions<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
teamKeystringmodelInviteUserModel
Returns
ClearTeamIconAsync(string)
public Task ClearTeamIconAsync(string teamKey)
Parameters
teamKeystring
Returns
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
namestring
Returns
DeleteTeamAsync(string)
Delete a team. Requires being its Owner (with AllowTeamCreation), or the
teams:delete system scope.
public Task DeleteTeamAsync(string teamKey)
Parameters
teamKeystring
Returns
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
Returns
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
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
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
inviteCodestring
Returns
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
teamKeystring
Returns
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
teamKeystring
Returns
Type Parameters
T
GetTeamByKeyAsync(string)
Team metadata without the roster. Requires team:read on that team.
public Task<ITeam> GetTeamByKeyAsync(string teamKey)
Parameters
teamKeystring
Returns
GetTeamCustomRolesAsync(string)
The team's runtime-defined custom roles. Requires team:read on that team.
public Task<IReadOnlyList<TenantRoleDefinition>> GetTeamCustomRolesAsync(string teamKey)
Parameters
teamKeystring
Returns
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
Returns
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
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
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
teamKeystring
Returns
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
teamKeystring
Returns
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
Returns
RenameTeamAsync(string, string)
public Task RenameTeamAsync(string teamKey, string name)
Parameters
Returns
SetInvitationResponseAsync(string, string, string, bool)
public Task SetInvitationResponseAsync(string teamKey, string userKey, string inviteCode, bool accept)
Parameters
Returns
SetMemberLastSeenAsync(string)
public Task SetMemberLastSeenAsync(string teamKey)
Parameters
teamKeystring
Returns
SetMemberNameAsync(string, string, string)
public Task SetMemberNameAsync(string teamKey, string userKey, string name)
Parameters
Returns
SetMemberRoleAsync(string, string, AccessLevel)
public Task SetMemberRoleAsync(string teamKey, string userKey, AccessLevel accessLevel)
Parameters
teamKeystringuserKeystringaccessLevelAccessLevel
Returns
SetMemberScopeOverridesAsync(string, string, string[])
public Task SetMemberScopeOverridesAsync(string teamKey, string userKey, string[] scopeOverrides)
Parameters
Returns
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
Returns
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
Returns
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
Returns
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
teamKeystringconsentedRolesstring[]accessLevelAccessLevel?
Returns
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
teamKeystringcustomRolesIReadOnlyList<TenantRoleDefinition>
Returns
SetTeamIconAsync(string, byte[], string)
public Task SetTeamIconAsync(string teamKey, byte[] data, string contentType)
Parameters
Returns
TransferOwnershipAsync(string, string)
public Task TransferOwnershipAsync(string teamKey, string newOwnerUserKey)