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.
411 lines
15 KiB
C#
411 lines
15 KiB
C#
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;
|
|
|
|
/// <summary>Lists who can open a vault.</summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
internal sealed class ListVaultGrantsEndpoint(
|
|
ICurrentUserContext currentUser,
|
|
IVaultAccessService vaultAccess,
|
|
VaultGrantService grants)
|
|
: EndpointWithoutRequest<Results<Ok<VaultGrantsResponse>, NotFound>>
|
|
{
|
|
/// <inheritdoc />
|
|
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"));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task<Results<Ok<VaultGrantsResponse>, NotFound>> ExecuteAsync(
|
|
CancellationToken ct)
|
|
{
|
|
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
|
var access = await vaultAccess
|
|
.ResolveAsync(user.Id, Route<Guid>("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));
|
|
}
|
|
}
|
|
|
|
/// <summary>Renames a vault.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Admin rather than Write, and the line is the one <c>UpdateTeamEndpoint</c> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class RenameVaultEndpoint(
|
|
ICurrentUserContext currentUser,
|
|
IVaultAccessService vaultAccess,
|
|
VaultGrantService grants)
|
|
: Endpoint<UpdateVaultRequest, Results<Ok<VaultSummary>, NotFound, ProblemHttpResult>>
|
|
{
|
|
/// <inheritdoc />
|
|
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"));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task<Results<Ok<VaultSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
|
|
UpdateVaultRequest req,
|
|
CancellationToken ct)
|
|
{
|
|
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
|
var access = await vaultAccess
|
|
.ResolveAsync(user.Id, Route<Guid>("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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Deletes a vault.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The team behind it is archived in a second call, and the two are deliberately not one
|
|
/// transaction.</b> Archiving is <c>TeamService</c>'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.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class DeleteVaultEndpoint(
|
|
ICurrentUserContext currentUser,
|
|
IVaultAccessService vaultAccess,
|
|
VaultGrantService grants,
|
|
TeamService teams)
|
|
: EndpointWithoutRequest<Results<NoContent, NotFound, ProblemHttpResult>>
|
|
{
|
|
/// <inheritdoc />
|
|
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"));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
|
|
CancellationToken ct)
|
|
{
|
|
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
|
var access = await vaultAccess
|
|
.ResolveAsync(user.Id, Route<Guid>("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();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Wraps this vault's key to another member.</summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
internal sealed class IssueVaultGrantEndpoint(
|
|
ICurrentUserContext currentUser,
|
|
IVaultAccessService vaultAccess,
|
|
VaultGrantService grants)
|
|
: Endpoint<IssueVaultGrantRequest, Results<NoContent, NotFound, ProblemHttpResult>>
|
|
{
|
|
/// <inheritdoc />
|
|
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"));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
|
|
IssueVaultGrantRequest req,
|
|
CancellationToken ct)
|
|
{
|
|
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
|
var access = await vaultAccess
|
|
.ResolveAsync(user.Id, Route<Guid>("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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Moves this vault to a fresh key.</summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
internal sealed class RekeyVaultEndpoint(
|
|
ICurrentUserContext currentUser,
|
|
IVaultAccessService vaultAccess,
|
|
VaultGrantService grants)
|
|
: Endpoint<RekeyVaultRequest, Results<Ok<VaultSummary>, NotFound, ProblemHttpResult>>
|
|
{
|
|
/// <inheritdoc />
|
|
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"));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task<Results<Ok<VaultSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
|
|
RekeyVaultRequest req,
|
|
CancellationToken ct)
|
|
{
|
|
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
|
var access = await vaultAccess
|
|
.ResolveAsync(user.Id, Route<Guid>("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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Withdraws a member's key to this vault.</summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
internal sealed class RevokeVaultGrantEndpoint(
|
|
ICurrentUserContext currentUser,
|
|
IVaultAccessService vaultAccess,
|
|
VaultGrantService grants)
|
|
: EndpointWithoutRequest<Results<NoContent, NotFound, ProblemHttpResult>>
|
|
{
|
|
/// <inheritdoc />
|
|
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"));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
|
|
CancellationToken ct)
|
|
{
|
|
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
|
var access = await vaultAccess
|
|
.ResolveAsync(user.Id, Route<Guid>("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<Guid>("userId"), ct)
|
|
.ConfigureAwait(false);
|
|
|
|
return revoked ? TypedResults.NoContent() : TypedResults.NotFound();
|
|
}
|
|
catch (VaultGrantInvalidException exception)
|
|
{
|
|
return Problems.Coded(
|
|
StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message);
|
|
}
|
|
}
|
|
}
|