Interface ITeamManagementService
The scope-checked entry point for team operations — the interface a component, controller or MCP
provider should inject. Every member carries a [RequireScope] attribute enforced by
ScopeProxy, so a caller lacking the scope is refused before the operation runs.
public interface ITeamManagementService
Remarks
Tharga.Team.ITeamService is the internal path beneath this one: it is the contract a host implements, and its reads are deliberately unchecked so that framework code — building claims, revalidating a circuit — can read without needing the very scopes it is in the middle of computing. Calling it from a first-level surface bypasses authorization entirely, which is why the read methods below exist.
Methods
AddMemberAsync(string, InviteUserModel)
[RequireScope("member:manage")]
Task AddMemberAsync(string teamKey, InviteUserModel model)
Parameters
teamKeystringmodelInviteUserModel
Returns
ClearTeamIconAsync(string)
[RequireScope("team:manage")]
Task ClearTeamIconAsync(string teamKey)
Parameters
teamKeystring
Returns
DeleteTeamAsync(string)
Delete a team. Requires being its Owner (with AllowTeamCreation), or the
teams:delete system scope.
[RequireScope("team:manage")]
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.
[RequireScope("member:manage")]
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.
GetMembersAsync(string)
The team's members. Requires team:read on that team.
[RequireScope("team:read")]
IAsyncEnumerable<ITeamMember> GetMembersAsync(string teamKey)
Parameters
teamKeystring
Returns
GetTeamAsync<TMember>(string)
One team and its members. Requires team:read on that team.
[RequireScope("team:read")]
Task<ITeam<TMember>> GetTeamAsync<TMember>(string teamKey) where TMember : ITeamMember
Parameters
teamKeystring
Returns
Type Parameters
TMember
GetTeamByKeyAsync(string)
Team metadata without the roster. Requires team:read on that team.
[RequireScope("team:read")]
Task<ITeam> GetTeamByKeyAsync(string teamKey)
Parameters
teamKeystring
Returns
GetTeamCustomRolesAsync(string)
The team's runtime-defined custom roles. Requires team:read on that team.
[RequireScope("team:read")]
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.
[RequireScope("team:read")]
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.
RemoveMemberAsync(string, string)
[RequireScope("member:manage")]
Task RemoveMemberAsync(string teamKey, string userKey)
Parameters
Returns
RenameTeamAsync(string, string)
[RequireScope("team:manage")]
Task RenameTeamAsync(string teamKey, string name)
Parameters
Returns
SetInvitationResponseAsync(string, string, string, bool)
[RequireScope("team:read")]
Task SetInvitationResponseAsync(string teamKey, string userKey, string inviteCode, bool accept)
Parameters
Returns
SetMemberLastSeenAsync(string)
[RequireScope("team:read")]
Task SetMemberLastSeenAsync(string teamKey)
Parameters
teamKeystring
Returns
SetMemberNameAsync(string, string, string)
[RequireScope("member:manage")]
Task SetMemberNameAsync(string teamKey, string userKey, string name)
Parameters
Returns
SetMemberRoleAsync(string, string, AccessLevel)
[RequireScope("member:manage")]
Task SetMemberRoleAsync(string teamKey, string userKey, AccessLevel accessLevel)
Parameters
teamKeystringuserKeystringaccessLevelAccessLevel
Returns
SetMemberScopeOverridesAsync(string, string, string[])
[RequireScope("member:manage")]
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.
[RequireScope("member:manage")]
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[])
[RequireScope("member:manage")]
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.
[RequireScope("teams:set-owner")]
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.
[RequireScope("team:manage")]
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.
[RequireScope("team:manage")]
Task SetTeamCustomRolesAsync(string teamKey, IReadOnlyList<TenantRoleDefinition> customRoles)
Parameters
teamKeystringcustomRolesIReadOnlyList<TenantRoleDefinition>
Returns
SetTeamIconAsync(string, byte[], string)
[RequireScope("team:manage")]
Task SetTeamIconAsync(string teamKey, byte[] data, string contentType)
Parameters
Returns
TransferOwnershipAsync(string, string)
[RequireScope("team:manage")]
Task TransferOwnershipAsync(string teamKey, string newOwnerUserKey)