using DodoSSH.Api.Authorization; using DodoSSH.Api.Setup; using DodoSSH.Contracts; using FastEndpoints; using Microsoft.AspNetCore.Http.HttpResults; namespace DodoSSH.Api.Features.Teams; /// Creates a team, with the caller as its owner. /// /// 200 rather than 201, for the reason enrollment gives: the id is chosen by the client, so a retried /// request returns the identical team and there is no single moment of creation to point a Location /// header at. /// internal sealed class CreateTeamEndpoint(ICurrentUserContext currentUser, TeamService teams) : Endpoint, ProblemHttpResult>> { /// public override void Configure() { Post("/api/v1/teams"); // Enrolled, not merely authenticated. Somebody who has not published an identity key cannot // be wrapped a vault key, so a team they created would be one they could never share // anything into — and the flag they would hit instead is a 400 from the grant endpoint, // several steps later, about a request that was fine. Policies(Auth.EnrolledPolicy); Description(b => b .WithName("CreateTeam") .WithSummary("Creates a team, with the caller as its owner.") .WithTags("Teams")); } /// public override async Task, ProblemHttpResult>> ExecuteAsync( CreateTeamRequest req, CancellationToken ct) { var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); try { return TypedResults.Ok(await teams.CreateAsync(user, req, ct).ConfigureAwait(false)); } catch (TeamSlugTakenException exception) { return Problems.Coded( StatusCodes.Status409Conflict, ProblemCodes.TeamSlugTaken, exception.Message); } catch (TeamInvalidException exception) { return Problems.Coded( StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message); } } } /// Lists the teams the caller belongs to. internal sealed class ListTeamsEndpoint(ICurrentUserContext currentUser, TeamService teams) : EndpointWithoutRequest>> { /// public override void Configure() { Get("/api/v1/teams"); // Authenticated rather than enrolled: reading which teams you are in needs no key, and a // member who has just been added should be able to see that before they set a vault up. Policies(Auth.AuthenticatedPolicy); Description(b => b .WithName("ListTeams") .WithSummary("Lists the teams the caller belongs to.") .WithTags("Teams")); } /// public override async Task>> ExecuteAsync(CancellationToken ct) { var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); return TypedResults.Ok(await teams.ListAsync(user, ct).ConfigureAwait(false)); } } /// Renames a team, or changes its description. /// /// Admin rather than owner-only. A rename is visible to everybody and reversible by anybody who can /// perform it, which is the test that separates it from archiving and from handing the team over. /// internal sealed class UpdateTeamEndpoint(ICurrentUserContext currentUser, TeamService teams) : Endpoint, NotFound, ProblemHttpResult>> { /// public override void Configure() { // PUT, not PATCH: the body carries both fields every time, so clearing a description is // sending null rather than a distinct verb, and a repeat is the same team. Put("/api/v1/teams/{teamId:guid}"); Policies(Auth.AuthenticatedPolicy); Description(b => b .WithName("UpdateTeam") .WithSummary("Renames a team, or changes its description.") .WithTags("Teams")); } /// public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync( UpdateTeamRequest req, CancellationToken ct) { var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); var teamId = Route("teamId"); var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false); if (!access.Granted) { return TypedResults.NotFound(); } if (!access.CanAdminister) { return Problems.Coded( StatusCodes.Status403Forbidden, ProblemCodes.Forbidden, "Only an admin or the owner of this team can rename it."); } try { return TypedResults.Ok( await teams.UpdateAsync(user, access, req, ct).ConfigureAwait(false)); } catch (TeamInvalidException exception) { return Problems.Coded( StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message); } } } /// Archives a team. /// /// Owner-only, and refused while the team owns vaults. See TeamService.ArchiveAsync for why /// the refusal is the end of that road rather than a step on it. /// internal sealed class ArchiveTeamEndpoint(ICurrentUserContext currentUser, TeamService teams) : EndpointWithoutRequest> { /// public override void Configure() { Delete("/api/v1/teams/{teamId:guid}"); Policies(Auth.AuthenticatedPolicy); Description(b => b .WithName("ArchiveTeam") .WithSummary("Archives a team. Refused while it still owns vaults.") .WithTags("Teams")); } /// public override async Task> ExecuteAsync( CancellationToken ct) { var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); var teamId = Route("teamId"); var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false); if (!access.Granted) { return TypedResults.NotFound(); } // Owner, not admin. An admin the owner promoted must not be able to archive the team out // from under them — that is the boundary IsOwner exists to draw. TeamService.ArchiveAsync // checks it again, because deleting a team's last vault archives it too. if (!access.IsOwner) { return Problems.Coded( StatusCodes.Status403Forbidden, ProblemCodes.Forbidden, "Only the owner of this team can archive it."); } try { await teams.ArchiveAsync(user, access.Team!, ct).ConfigureAwait(false); return TypedResults.NoContent(); } catch (TeamNotEmptyException exception) { return Problems.Coded( StatusCodes.Status409Conflict, ProblemCodes.TeamNotEmpty, exception.Message); } } } /// Hands a team's ownership to another member. internal sealed class TransferTeamOwnershipEndpoint(ICurrentUserContext currentUser, TeamService teams) : Endpoint> { /// public override void Configure() { // POST to a singular sub-resource rather than PUT on the member's role, because it is not a // change to one membership: two rows move together and neither is meaningful alone. Post("/api/v1/teams/{teamId:guid}/owner"); Policies(Auth.AuthenticatedPolicy); Description(b => b .WithName("TransferTeamOwnership") .WithSummary("Hands ownership to another member, demoting the outgoing owner to admin.") .WithTags("Teams")); } /// public override async Task> ExecuteAsync( TransferTeamOwnershipRequest req, CancellationToken ct) { var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); var teamId = Route("teamId"); var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false); if (!access.Granted) { return TypedResults.NotFound(); } if (!access.IsOwner) { return Problems.Coded( StatusCodes.Status403Forbidden, ProblemCodes.Forbidden, "Only the owner of this team can hand it over."); } try { await teams.TransferOwnershipAsync(user, teamId, req, ct).ConfigureAwait(false); // 204. The caller knows both ids — it supplied one and is the other — and a client that // wants the new roles reads the members list, which is where roles live. return TypedResults.NoContent(); } catch (LastTeamOwnerException exception) { return Problems.Coded( StatusCodes.Status409Conflict, ProblemCodes.LastTeamOwner, exception.Message); } catch (TeamInvalidException exception) { return Problems.Coded( StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message); } } } /// Lists a team's members. internal sealed class ListTeamMembersEndpoint(ICurrentUserContext currentUser, TeamService teams) : EndpointWithoutRequest>, NotFound>> { /// public override void Configure() { Get("/api/v1/teams/{teamId:guid}/members"); Policies(Auth.AuthenticatedPolicy); Description(b => b .WithName("ListTeamMembers") .WithSummary("Lists a team's members.") .WithTags("Teams")); } /// public override async Task>, NotFound>> ExecuteAsync( CancellationToken ct) { var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); var teamId = Route("teamId"); var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false); // 404 for a team that is not there and one the caller is not in, identically. See // VaultAccessService for why the two must not be distinguishable. if (!access.Granted) { return TypedResults.NotFound(); } return TypedResults.Ok(await teams.ListMembersAsync(teamId, ct).ConfigureAwait(false)); } } /// Adds a member to a team. internal sealed class AddTeamMemberEndpoint(ICurrentUserContext currentUser, TeamService teams) : Endpoint, NotFound, ProblemHttpResult>> { /// public override void Configure() { Post("/api/v1/teams/{teamId:guid}/members"); Policies(Auth.AuthenticatedPolicy); Description(b => b .WithName("AddTeamMember") .WithSummary("Adds a member to a team.") .WithTags("Teams")); } /// public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync( AddTeamMemberRequest req, CancellationToken ct) { var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); var teamId = Route("teamId"); var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false); if (!access.Granted) { return TypedResults.NotFound(); } // 403 rather than 404 here: the team is visible to this caller, so refusing by name leaks // nothing and "you are not an admin" is a far more useful answer than "no such team". if (!access.CanAdminister) { return Problems.Coded( StatusCodes.Status403Forbidden, ProblemCodes.Forbidden, "Only an admin or the owner of this team can add members."); } try { var member = await teams.AddMemberAsync(user, teamId, req, ct).ConfigureAwait(false); return TypedResults.Ok(member); } catch (TeamInvalidException exception) { return Problems.Coded( StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message); } catch (NoSuchAccountException exception) { // Its own code so the caller can offer an invitation rather than report a failure. 404 // rather than 400: the request was well formed and named something that is not here. return Problems.Coded( StatusCodes.Status404NotFound, ProblemCodes.NoSuchAccount, exception.Message); } } } /// Changes a member's role. internal sealed class ChangeTeamMemberRoleEndpoint(ICurrentUserContext currentUser, TeamService teams) : Endpoint, NotFound, ProblemHttpResult>> { /// public override void Configure() { // PUT rather than PATCH. The body is the whole of what a role is, so this replaces it // outright and is idempotent; PATCH would promise a partial update of a single scalar. Put("/api/v1/teams/{teamId:guid}/members/{userId:guid}/role"); Policies(Auth.AuthenticatedPolicy); Description(b => b .WithName("ChangeTeamMemberRole") .WithSummary("Changes a member's role.") .WithTags("Teams")); } /// public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync( ChangeTeamMemberRoleRequest req, CancellationToken ct) { var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); var teamId = Route("teamId"); var memberId = Route("userId"); var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false); if (!access.Granted) { return TypedResults.NotFound(); } if (!access.CanAdminister) { return Problems.Coded( StatusCodes.Status403Forbidden, ProblemCodes.Forbidden, "Only an admin or the owner of this team can change roles."); } try { var member = await teams .ChangeRoleAsync(user, teamId, memberId, req, ct) .ConfigureAwait(false); return TypedResults.Ok(member); } catch (LastTeamOwnerException exception) { return Problems.Coded( StatusCodes.Status409Conflict, ProblemCodes.LastTeamOwner, exception.Message); } catch (TeamInvalidException exception) { return Problems.Coded( StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message); } } } /// /// Removes a member from a team. /// /// /// A member may remove themselves — leaving a team needs nobody's permission — but not while they /// own it. Everyone else needs to be an admin. /// internal sealed class RemoveTeamMemberEndpoint(ICurrentUserContext currentUser, TeamService teams) : EndpointWithoutRequest> { /// public override void Configure() { Delete("/api/v1/teams/{teamId:guid}/members/{userId:guid}"); Policies(Auth.AuthenticatedPolicy); Description(b => b .WithName("RemoveTeamMember") .WithSummary("Removes a member from a team, revoking their vault key grants.") .WithTags("Teams")); } /// public override async Task> ExecuteAsync( CancellationToken ct) { var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); var teamId = Route("teamId"); var memberId = Route("userId"); var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false); if (!access.Granted) { return TypedResults.NotFound(); } if (!access.CanAdminister && memberId != user.Id) { return Problems.Coded( StatusCodes.Status403Forbidden, ProblemCodes.Forbidden, "Only an admin or the owner of this team can remove other members."); } try { await teams.RemoveMemberAsync(user, teamId, memberId, ct).ConfigureAwait(false); return TypedResults.NoContent(); } catch (LastTeamOwnerException exception) { return Problems.Coded( StatusCodes.Status409Conflict, ProblemCodes.LastTeamOwner, exception.Message); } catch (TeamInvalidException exception) { return Problems.Coded( StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message); } } } /// Creates a vault owned by a team. internal sealed class CreateTeamVaultEndpoint( ICurrentUserContext currentUser, TeamService teams, VaultGrantService grants) : Endpoint, NotFound, ProblemHttpResult>> { /// public override void Configure() { Post("/api/v1/teams/{teamId:guid}/vaults"); Policies(Auth.EnrolledPolicy); Description(b => b .WithName("CreateTeamVault") .WithSummary("Creates a vault owned by a team, with the creator's key grant.") .WithTags("Teams")); } /// public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync( CreateTeamVaultRequest req, CancellationToken ct) { var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); var teamId = Route("teamId"); var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false); if (!access.Granted) { return TypedResults.NotFound(); } if (!access.CanAdminister) { return Problems.Coded( StatusCodes.Status403Forbidden, ProblemCodes.Forbidden, "Only an admin or the owner of this team can create a vault in it."); } try { var vault = await grants .CreateTeamVaultAsync(user, access.Team!, req, ct) .ConfigureAwait(false); return TypedResults.Ok(vault); } catch (VaultGrantInvalidException exception) { return Problems.Coded( StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message); } catch (TeamInvalidException exception) { return Problems.Coded( StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message); } } }