Table of Contents

Interface ITeamManagementService

Namespace
Tharga.Team
Assembly
Tharga.Team.dll

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

teamKey string
model InviteUserModel

Returns

Task

ClearTeamIconAsync(string)

[RequireScope("team:manage")]
Task ClearTeamIconAsync(string teamKey)

Parameters

teamKey string

Returns

Task

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

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.

[RequireScope("member:manage")]
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.

GetMembersAsync(string)

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

[RequireScope("team:read")]
IAsyncEnumerable<ITeamMember> GetMembersAsync(string teamKey)

Parameters

teamKey string

Returns

IAsyncEnumerable<ITeamMember>

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

teamKey string

Returns

Task<ITeam<TMember>>

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

teamKey string

Returns

Task<ITeam>

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

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.

[RequireScope("team:read")]
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.

RemoveMemberAsync(string, string)

[RequireScope("member:manage")]
Task RemoveMemberAsync(string teamKey, string userKey)

Parameters

teamKey string
userKey string

Returns

Task

RenameTeamAsync(string, string)

[RequireScope("team:manage")]
Task RenameTeamAsync(string teamKey, string name)

Parameters

teamKey string
name string

Returns

Task

SetInvitationResponseAsync(string, string, string, bool)

[RequireScope("team:read")]
Task SetInvitationResponseAsync(string teamKey, string userKey, string inviteCode, bool accept)

Parameters

teamKey string
userKey string
inviteCode string
accept bool

Returns

Task

SetMemberLastSeenAsync(string)

[RequireScope("team:read")]
Task SetMemberLastSeenAsync(string teamKey)

Parameters

teamKey string

Returns

Task

SetMemberNameAsync(string, string, string)

[RequireScope("member:manage")]
Task SetMemberNameAsync(string teamKey, string userKey, string name)

Parameters

teamKey string
userKey string
name string

Returns

Task

SetMemberRoleAsync(string, string, AccessLevel)

[RequireScope("member:manage")]
Task SetMemberRoleAsync(string teamKey, string userKey, AccessLevel accessLevel)

Parameters

teamKey string
userKey string
accessLevel AccessLevel

Returns

Task

SetMemberScopeOverridesAsync(string, string, string[])

[RequireScope("member:manage")]
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.

[RequireScope("member:manage")]
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[])

[RequireScope("member:manage")]
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.

[RequireScope("teams:set-owner")]
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.

[RequireScope("team:manage")]
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.

[RequireScope("team:manage")]
Task SetTeamCustomRolesAsync(string teamKey, IReadOnlyList<TenantRoleDefinition> customRoles)

Parameters

teamKey string
customRoles IReadOnlyList<TenantRoleDefinition>

Returns

Task

SetTeamIconAsync(string, byte[], string)

[RequireScope("team:manage")]
Task SetTeamIconAsync(string teamKey, byte[] data, string contentType)

Parameters

teamKey string
data byte[]
contentType string

Returns

Task

TransferOwnershipAsync(string, string)

[RequireScope("team:manage")]
Task TransferOwnershipAsync(string teamKey, string newOwnerUserKey)

Parameters

teamKey string
newOwnerUserKey string

Returns

Task