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