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)); } } /// 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); } } }