Public Access
- Pull hydrated every historical change entry with the item's current ciphertext under that entry's old revision. The AAD binds the revision, so a client opened it at the wrong version, failed, and parked its own pending edit as undecryptable. Entries an item has since moved past are left out; the later entry carries the same payload under the right number, and a fresh device no longer downloads an item once per edit it ever had. - Deleting a team's last vault archived the team whoever deleted it, so an admin could archive it out from under its owner. Only the owner's delete archives it now, and TeamService.ArchiveAsync checks. - UserStatus was checked nowhere on the request path. ActiveAccountRequirement on both policies answers 403 account-not-active for a suspended or deprovisioned account. - The hourly last-seen update moves xmin under the tracked user, and enrolment then saved that user against the stale token: a 500 for anybody enrolling more than an hour after first signing in. The entity is reloaded after the update.
561 lines
19 KiB
C#
561 lines
19 KiB
C#
using DodoSSH.Api.Authorization;
|
|
using DodoSSH.Api.Setup;
|
|
using DodoSSH.Contracts;
|
|
using FastEndpoints;
|
|
using Microsoft.AspNetCore.Http.HttpResults;
|
|
|
|
namespace DodoSSH.Api.Features.Teams;
|
|
|
|
/// <summary>Creates a team, with the caller as its owner.</summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
internal sealed class CreateTeamEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
|
: Endpoint<CreateTeamRequest, Results<Ok<TeamSummary>, ProblemHttpResult>>
|
|
{
|
|
/// <inheritdoc />
|
|
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"));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task<Results<Ok<TeamSummary>, 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Lists the teams the caller belongs to.</summary>
|
|
internal sealed class ListTeamsEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
|
: EndpointWithoutRequest<Ok<IReadOnlyList<TeamSummary>>>
|
|
{
|
|
/// <inheritdoc />
|
|
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"));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task<Ok<IReadOnlyList<TeamSummary>>> ExecuteAsync(CancellationToken ct)
|
|
{
|
|
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
|
|
|
return TypedResults.Ok(await teams.ListAsync(user, ct).ConfigureAwait(false));
|
|
}
|
|
}
|
|
|
|
/// <summary>Renames a team, or changes its description.</summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
internal sealed class UpdateTeamEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
|
: Endpoint<UpdateTeamRequest, Results<Ok<TeamSummary>, NotFound, ProblemHttpResult>>
|
|
{
|
|
/// <inheritdoc />
|
|
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"));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task<Results<Ok<TeamSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
|
|
UpdateTeamRequest req,
|
|
CancellationToken ct)
|
|
{
|
|
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
|
var teamId = Route<Guid>("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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Archives a team.</summary>
|
|
/// <remarks>
|
|
/// Owner-only, and refused while the team owns vaults. See <c>TeamService.ArchiveAsync</c> for why
|
|
/// the refusal is the end of that road rather than a step on it.
|
|
/// </remarks>
|
|
internal sealed class ArchiveTeamEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
|
: EndpointWithoutRequest<Results<NoContent, NotFound, ProblemHttpResult>>
|
|
{
|
|
/// <inheritdoc />
|
|
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"));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
|
|
CancellationToken ct)
|
|
{
|
|
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
|
var teamId = Route<Guid>("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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Hands a team's ownership to another member.</summary>
|
|
internal sealed class TransferTeamOwnershipEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
|
: Endpoint<TransferTeamOwnershipRequest, Results<NoContent, NotFound, ProblemHttpResult>>
|
|
{
|
|
/// <inheritdoc />
|
|
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"));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
|
|
TransferTeamOwnershipRequest req,
|
|
CancellationToken ct)
|
|
{
|
|
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
|
var teamId = Route<Guid>("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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Lists a team's members.</summary>
|
|
internal sealed class ListTeamMembersEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
|
: EndpointWithoutRequest<Results<Ok<IReadOnlyList<TeamMemberSummary>>, NotFound>>
|
|
{
|
|
/// <inheritdoc />
|
|
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"));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task<Results<Ok<IReadOnlyList<TeamMemberSummary>>, NotFound>> ExecuteAsync(
|
|
CancellationToken ct)
|
|
{
|
|
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
|
var teamId = Route<Guid>("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));
|
|
}
|
|
}
|
|
|
|
/// <summary>Adds a member to a team.</summary>
|
|
internal sealed class AddTeamMemberEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
|
: Endpoint<AddTeamMemberRequest, Results<Ok<TeamMemberSummary>, NotFound, ProblemHttpResult>>
|
|
{
|
|
/// <inheritdoc />
|
|
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"));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task<Results<Ok<TeamMemberSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
|
|
AddTeamMemberRequest req,
|
|
CancellationToken ct)
|
|
{
|
|
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
|
var teamId = Route<Guid>("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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Changes a member's role.</summary>
|
|
internal sealed class ChangeTeamMemberRoleEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
|
: Endpoint<ChangeTeamMemberRoleRequest, Results<Ok<TeamMemberSummary>, NotFound, ProblemHttpResult>>
|
|
{
|
|
/// <inheritdoc />
|
|
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"));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task<Results<Ok<TeamMemberSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
|
|
ChangeTeamMemberRoleRequest req,
|
|
CancellationToken ct)
|
|
{
|
|
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
|
var teamId = Route<Guid>("teamId");
|
|
var memberId = Route<Guid>("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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes a member from a team.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
internal sealed class RemoveTeamMemberEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
|
: EndpointWithoutRequest<Results<NoContent, NotFound, ProblemHttpResult>>
|
|
{
|
|
/// <inheritdoc />
|
|
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"));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
|
|
CancellationToken ct)
|
|
{
|
|
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
|
var teamId = Route<Guid>("teamId");
|
|
var memberId = Route<Guid>("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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Creates a vault owned by a team.</summary>
|
|
internal sealed class CreateTeamVaultEndpoint(
|
|
ICurrentUserContext currentUser,
|
|
TeamService teams,
|
|
VaultGrantService grants)
|
|
: Endpoint<CreateTeamVaultRequest, Results<Ok<VaultSummary>, NotFound, ProblemHttpResult>>
|
|
{
|
|
/// <inheritdoc />
|
|
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"));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task<Results<Ok<VaultSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
|
|
CreateTeamVaultRequest req,
|
|
CancellationToken ct)
|
|
{
|
|
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
|
var teamId = Route<Guid>("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);
|
|
}
|
|
}
|
|
}
|