using DodoSSH.Api.Authorization; using DodoSSH.Api.Setup; using DodoSSH.Contracts; using DodoSSH.Domain.Authorization; using FastEndpoints; using Microsoft.AspNetCore.Http.HttpResults; namespace DodoSSH.Api.Features.Teams; /// Lists who can open a vault. /// /// Read, not Share. Every member who can read a vault can already see the sharing graph — the server /// stores it in plaintext and says so in docs/crypto.md §10 — so gating this on Share would hide from /// the people it is about something the operator can read either way. /// internal sealed class ListVaultGrantsEndpoint( ICurrentUserContext currentUser, IVaultAccessService vaultAccess, VaultGrantService grants) : EndpointWithoutRequest, NotFound>> { /// public override void Configure() { Get("/api/v1/vaults/{vaultId:guid}/grants"); Policies(Auth.EnrolledPolicy); Description(b => b .WithName("ListVaultGrants") .WithSummary("Lists who holds a key to this vault.") .WithTags("Vaults")); } /// public override async Task, NotFound>> ExecuteAsync( CancellationToken ct) { var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); var access = await vaultAccess .ResolveAsync(user.Id, Route("vaultId"), ct) .ConfigureAwait(false); if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read)) { return TypedResults.NotFound(); } return TypedResults.Ok(await grants.ListGrantsAsync(access.Vault!, ct).ConfigureAwait(false)); } } /// Renames a vault. /// /// /// Admin rather than Write, and the line is the one UpdateTeamEndpoint draws: a name is what /// everybody in the vault sees it called, so changing it is an administrative act rather than an edit /// to the vault's contents. A member who may add hosts to a shared vault may not rename it out from /// under the people who share it. /// /// /// Authenticated rather than Enrolled, unlike everything else about a vault here. A rename touches no /// key material and needs none — somebody added to a team before they have finished setting their own /// machine up can still be reading this screen — and requiring a published identity key would refuse /// them for a reason that has nothing to do with what they are asking. /// /// internal sealed class RenameVaultEndpoint( ICurrentUserContext currentUser, IVaultAccessService vaultAccess, VaultGrantService grants) : Endpoint, NotFound, ProblemHttpResult>> { /// public override void Configure() { // PUT, for the reason the team rename is a PUT: one field, always sent whole, and a repeat is // the same vault rather than a second edit. Put("/api/v1/vaults/{vaultId:guid}"); Policies(Auth.AuthenticatedPolicy); Description(b => b .WithName("RenameVault") .WithSummary("Renames a vault.") .WithTags("Vaults")); } /// public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync( UpdateVaultRequest req, CancellationToken ct) { var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); var access = await vaultAccess .ResolveAsync(user.Id, Route("vaultId"), ct) .ConfigureAwait(false); if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read)) { return TypedResults.NotFound(); } if (!access.Permissions.HasFlag(PermissionFlags.Admin)) { return Problems.Coded( StatusCodes.Status403Forbidden, ProblemCodes.Forbidden, "Only an admin or the owner of the team that owns this vault can rename it."); } try { return TypedResults.Ok( await grants.RenameVaultAsync(access.Vault!, req, ct).ConfigureAwait(false)); } catch (TeamInvalidException exception) { return Problems.Coded( StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message); } } } /// Deletes a vault. /// /// /// Admin, like the rename above and for a stronger version of the same reason: this takes the vault away /// from everybody in it at once, so it belongs to whoever administers the membership list rather than to /// anybody who may write to its contents. /// /// /// Authenticated rather than Enrolled, again like the rename. Deleting a vault touches no key material — /// it withdraws grants rather than wrapping anything — so requiring a published identity key would refuse /// an admin for a reason that has nothing to do with what they are asking. /// /// /// The team behind it is archived in a second call, and the two are deliberately not one /// transaction. Archiving is TeamService's, it refuses while a team owns vaults, and it can /// only tell that this one no longer does once the deletion is committed. A crash between the two leaves /// an empty team — invisible on every screen, archivable afterwards by the endpoint that exists for it, /// and harmless — which is a better failure than a vault that could not be deleted because tidying up its /// team failed. /// /// internal sealed class DeleteVaultEndpoint( ICurrentUserContext currentUser, IVaultAccessService vaultAccess, VaultGrantService grants, TeamService teams) : EndpointWithoutRequest> { /// public override void Configure() { Delete("/api/v1/vaults/{vaultId:guid}"); Policies(Auth.AuthenticatedPolicy); Description(b => b .WithName("DeleteVault") .WithSummary("Deletes a vault and withdraws every key to it.") .WithTags("Vaults")); } /// public override async Task> ExecuteAsync( CancellationToken ct) { var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); var access = await vaultAccess .ResolveAsync(user.Id, Route("vaultId"), ct) .ConfigureAwait(false); if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read)) { // Indistinguishable from a vault that never existed, and from one somebody else deleted a // moment ago — which is what makes a repeat of this call safe to send. return TypedResults.NotFound(); } if (!access.Permissions.HasFlag(PermissionFlags.Admin)) { return Problems.Coded( StatusCodes.Status403Forbidden, ProblemCodes.Forbidden, "Only an admin or the owner of the team that owns this vault can delete it."); } try { var orphaned = await grants.DeleteVaultAsync(user, access.Vault!, ct).ConfigureAwait(false); // Archived only by its owner. Archiving revokes every membership, the owner's included, and an // admin the owner promoted must not be able to do that by deleting vaults one at a time when the // archive endpoint refuses them outright. An admin's delete stands; the empty team is left for // the owner, who can archive it or make a vault in it. if (orphaned is not null && (await teams.ResolveAsync(user.Id, orphaned.Id, ct).ConfigureAwait(false)).IsOwner) { await teams.ArchiveAsync(user, orphaned, ct).ConfigureAwait(false); } return TypedResults.NoContent(); } catch (VaultGrantInvalidException exception) { return Problems.Coded( StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message); } catch (TeamNotEmptyException) { // The team gained a vault between the delete and the archive. The deletion stands and is what // was asked for; the team staying is not a failure the caller can or should act on. return TypedResults.NoContent(); } } } /// Wraps this vault's key to another member. /// /// The one call in this API whose body the server can neither produce nor check. It stores a sealed /// key and a signature over a tuple it never verifies — see docs/crypto.md §6 and §7 — which is /// exactly why sharing is a client operation with a server-side record rather than a server feature. /// internal sealed class IssueVaultGrantEndpoint( ICurrentUserContext currentUser, IVaultAccessService vaultAccess, VaultGrantService grants) : Endpoint> { /// public override void Configure() { Post("/api/v1/vaults/{vaultId:guid}/grants"); Policies(Auth.EnrolledPolicy); Description(b => b .WithName("IssueVaultGrant") .WithSummary("Records a vault key wrapped to another member.") .WithTags("Vaults")); } /// public override async Task> ExecuteAsync( IssueVaultGrantRequest req, CancellationToken ct) { var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); var access = await vaultAccess .ResolveAsync(user.Id, Route("vaultId"), ct) .ConfigureAwait(false); if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read)) { return TypedResults.NotFound(); } if (!access.Permissions.HasFlag(PermissionFlags.Share)) { return Problems.Coded( StatusCodes.Status403Forbidden, ProblemCodes.Forbidden, "You do not have permission to share this vault."); } try { await grants.IssueGrantAsync(user, access.Vault!, req, ct).ConfigureAwait(false); // 204. There is nothing to return that the caller does not already hold — it produced // the wrap — and echoing the sealed key back would put it on the wire twice for nothing. return TypedResults.NoContent(); } catch (VaultGrantInvalidException exception) { return Problems.Coded( StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message); } } } /// Moves this vault to a fresh key. /// /// Gated on Share rather than on a rotation permission of its own. Rotating decides who can read what /// is written next, which is the same question sharing and withdrawing answer, and a fourth permission /// would be a distinction nobody administering a team would be able to explain. /// internal sealed class RekeyVaultEndpoint( ICurrentUserContext currentUser, IVaultAccessService vaultAccess, VaultGrantService grants) : Endpoint, NotFound, ProblemHttpResult>> { /// public override void Configure() { Post("/api/v1/vaults/{vaultId:guid}/rekey"); Policies(Auth.EnrolledPolicy); Description(b => b .WithName("RekeyVault") .WithSummary("Advances this vault's key generation, wrapped to the caller.") .WithTags("Vaults")); } /// public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync( RekeyVaultRequest req, CancellationToken ct) { var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); var access = await vaultAccess .ResolveAsync(user.Id, Route("vaultId"), ct) .ConfigureAwait(false); if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read)) { return TypedResults.NotFound(); } if (!access.Permissions.HasFlag(PermissionFlags.Share)) { return Problems.Coded( StatusCodes.Status403Forbidden, ProblemCodes.Forbidden, "You do not have permission to share this vault, so you cannot rotate its key."); } try { var summary = await grants .RekeyAsync(user, access.Vault!, (int)access.Permissions, req, ct) .ConfigureAwait(false); return TypedResults.Ok(summary); } catch (VaultGrantInvalidException exception) { return Problems.Coded( StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message); } } } /// Withdraws a member's key to this vault. /// /// 404 for a member who holds no live grant, rather than a bland 204, for the reason device /// revocation gives: "revoked" is what the user reads, and reading it about the wrong account is /// worse than being told to look again. A caller driving towards "they cannot read this any more" /// can treat 404 as having arrived. /// internal sealed class RevokeVaultGrantEndpoint( ICurrentUserContext currentUser, IVaultAccessService vaultAccess, VaultGrantService grants) : EndpointWithoutRequest> { /// public override void Configure() { Delete("/api/v1/vaults/{vaultId:guid}/grants/{userId:guid}"); Policies(Auth.EnrolledPolicy); Description(b => b .WithName("RevokeVaultGrant") .WithSummary("Withdraws a member's key to this vault. Blocks future reads only.") .WithTags("Vaults")); } /// public override async Task> ExecuteAsync( CancellationToken ct) { var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); var access = await vaultAccess .ResolveAsync(user.Id, Route("vaultId"), ct) .ConfigureAwait(false); if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read)) { return TypedResults.NotFound(); } if (!access.Permissions.HasFlag(PermissionFlags.Share)) { return Problems.Coded( StatusCodes.Status403Forbidden, ProblemCodes.Forbidden, "You do not have permission to share this vault."); } try { var revoked = await grants .RevokeGrantAsync(user, access.Vault!, Route("userId"), ct) .ConfigureAwait(false); return revoked ? TypedResults.NoContent() : TypedResults.NotFound(); } catch (VaultGrantInvalidException exception) { return Problems.Coded( StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message); } } }