Share a vault with a team, without the server holding a key

M3's teams, sharing and ACLs. Teams with roles, a public-key directory, the
append-only key log served for clients to check it against, team-owned vaults,
and vault key grants wrapped by a client and stored opaquely by the server.
VaultAccessService resolves team membership to PermissionFlags, so a viewer may
pull and may not push; the desktop client reads and syncs every vault it holds
a key for, and a real TEAMS screen replaces the one that said it did not exist.
No migration: team, team_membership, vault.team_id and vault_key_grant have all
been there since the first one, which is what carrying two unused tables bought.

Membership is authorisation. A grant is access. The obvious model is one
concept — "access", with a role attached, handed out by the server — and this
architecture cannot implement it: a vault key is sealed to each member's X25519
key, and only a client holding the plaintext can seal it for somebody else. So
"give Bob access" decomposes into a database write and a wrap, which happen on
different machines. Adding a member makes the server serve them the vault; it
cannot make it readable. VaultSummary.WrappedVaultKey is null in the meantime
and the vault appears in their list saying it is waiting for a key, because
hiding it until a grant existed would have been tidier and would have implied
the server was the thing granting access. The screen says the same thing after
every add, in the status line. ADR 0009 records the whole decision.

Sharing verifies or refuses. A directory lookup is a claim by the server about
a third party's public key, and wrapping to an unverified claim hands the vault
to whoever made it — no amount of transport security helps, because the server
is inside the threat model. KeyLogAudit reads the whole log, recomputes every
entry's hash from its own contents, checks the chain from genesis, and refuses
unless the offered key appears in it unchanged. There is no override flag: one
that exists gets used on the day the log is briefly unreachable, and the
resulting grant is indistinguishable from a correct one afterwards. What it
still cannot promise is that the key is the right person's, so the fingerprint
comes back for an out-of-band comparison and the success message says so every
time. A test corrupts the fake server's log by one byte and watches the client
refuse rather than warn.

The roles are only the ones that are enforceable. There is no ConnectOnly,
despite the design asking for one and TeamRole having room: SSH terminates on
the client, so a session needs the credential's plaintext on that machine, and
"may connect but may not read the key" cannot be enforced here. Shipping it as
an option in a dropdown would have been a lie. Connect rides along with Read
and is documented as an interface hint. Removal is named for what it does — it
revokes grants and flags the vault for rekey, and claims nothing about what is
already on somebody's laptop.

Three things are deliberately absent, and each is a refusal rather than an
omission. The rekey itself, because re-wrapping every item's data key under a
new vault key needs a client holding the current one; the server records that a
rotation is owed and the interface reports it, which is more honest than a
button that only appears to do it. Ownership transfer, because allowing an
owner to be removed without one leaves a team nobody can administer. And
cross-vault host key trust: a pin in a team vault is listed but not consulted
at connect time, because any member with Write could otherwise pre-approve a
fingerprint another member's client then trusts silently for a host in their
own vault. Scoping trust properly needs a scope on the SSH connect path, which
IKnownHostStore has not got; until then the narrow direction is the safe one
and the cost is in the README rather than hidden.

Reading now spans vaults and writing still does not. Every list on the vault
and hosts screens covers each vault the keyring opened, rows carry the vault
they came from, and an edit goes back to that vault rather than to the active
one — writing it to the active vault would fork the item and only show up when
a colleague wondered why their change never arrived. A new item goes wherever a
picker says, defaulting to the personal vault and never moving on its own,
because an item filed into a team's vault is visible to that team and moving it
back means deleting and retyping. The sidebar heading stops naming one vault
once there are two, and each row names its own.

The server checks what it can and nothing it cannot. It will not record a grant
for a key its recipient no longer holds, for a superseded generation, or for
somebody who is not in the team — each of those would otherwise surface days
later at the far end as a tag failure indistinguishable from corruption. It
does not verify the wrap or the signature, and the grant service says so: that
would be a convenience and never the boundary, and would put an asymmetric
implementation on a machine that is supposed to hold no keys.

Two bugs the tests found. TeamsViewModel's busy gate blocked its own reload, so
a team created a moment earlier was missing from the list it had just been
added to. And syncing every vault turned a failure from an exception into a
report, which made a background pass announce an unreachable vault once a
minute — the exact behaviour AnAutomaticPassThatFails_LeavesTheStatusAlone
exists to prevent. The fact is recorded and the message swallowed, as it was
before; pressing Sync still names the vault and the reason.

Also fixes a build break this branch started with: QuickConnectTests was never
updated when M2 added ISftpSessionFactory to the shell's constructor, so
nothing built at all.
This commit is contained in:
2026-07-31 12:18:28 +02:00
parent d1700f5a34
commit 95816de0c5
45 changed files with 6699 additions and 133 deletions
@@ -46,13 +46,23 @@ public interface IVaultAccessService
/// </summary>
/// <remarks>
/// <para>
/// M1 supports personal vaults only, so the rule is ownership. Team vaults, the
/// <c>v_user_vault_permission</c> view and per-resource ACLs arrive in M3 — this is the one place
/// that changes, which is why every caller goes through it rather than comparing owner ids inline.
/// Two rules, and only two. A personal vault answers to its owner. A team vault answers to the
/// team's active members, with the role deciding how much. Everything else is denied, which is what
/// keeps an unimplemented ownership kind from falling through to a permissive default.
/// </para>
/// <para>
/// A team vault is explicitly denied for now rather than falling through to a permissive default.
/// Failing closed on an unimplemented path is the only safe direction.
/// <b>Permission is not the same thing as readability.</b> This service decides what the
/// <em>server</em> will serve; whether the caller can decrypt what it serves depends on holding a
/// vault key grant, which the server cannot produce and cannot verify. A member with Read and no
/// grant is a normal, temporary state — they have just been added, or the vault has been rekeyed —
/// and <c>VaultSummary.WrappedVaultKey</c> is null for exactly that case. Conflating the two here
/// would mean a newly added member's vault silently vanished from their list instead of appearing
/// and saying it is waiting for a key.
/// </para>
/// <para>
/// Deliberately not a database view. <c>v_user_vault_permission</c> was sketched for this, and the
/// rules turned out to be sixteen lines of C# that both methods share — a view would have put the
/// authorization model somewhere migrations own and tests cannot reach without a container.
/// </para>
/// </remarks>
internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessService
@@ -80,14 +90,23 @@ internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessS
return VaultAccess.Denied;
}
if (vault.OwnerKind == VaultOwnerKind.Personal && vault.OwnerUserId == userId)
if (vault.OwnerKind == VaultOwnerKind.Personal)
{
return new VaultAccess(vault, OwnerPermissions);
return vault.OwnerUserId == userId
? new VaultAccess(vault, OwnerPermissions)
: VaultAccess.Denied;
}
// Team vaults are not readable until M3 wires up membership and grants. Denying is the
// correct behaviour in the meantime.
return VaultAccess.Denied;
if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is not { } teamId)
{
return VaultAccess.Denied;
}
var role = await FindRoleAsync(userId, teamId, cancellationToken).ConfigureAwait(false);
return role is { } granted
? new VaultAccess(vault, ForRole(granted))
: VaultAccess.Denied;
}
/// <inheritdoc />
@@ -95,16 +114,98 @@ internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessS
Guid userId,
CancellationToken cancellationToken)
{
// Personal ownership only, matching ResolveAsync. When M3 adds the
// v_user_vault_permission view, both methods change together and neither can drift.
// The memberships first, then one pass over the vaults. The alternative — a join per vault —
// would be the same answer at more round trips, and a user belongs to a handful of teams.
var roles = await database.TeamMemberships
.Where(m => m.UserId == userId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null)
.ToDictionaryAsync(m => m.TeamId, m => m.Role, cancellationToken)
.ConfigureAwait(false);
var teamIds = roles.Keys.ToArray();
var vaults = await database.Vaults
.Where(v => v.OwnerKind == VaultOwnerKind.Personal
&& v.OwnerUserId == userId
&& v.DeletedAtUtc == null)
.Where(v => v.DeletedAtUtc == null
&& ((v.OwnerKind == VaultOwnerKind.Personal && v.OwnerUserId == userId)
|| (v.OwnerKind == VaultOwnerKind.Team
&& v.TeamId != null
&& teamIds.Contains(v.TeamId.Value))))
.OrderBy(v => v.CreatedAtUtc)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return [.. vaults.Select(v => new VaultAccess(v, OwnerPermissions))];
var accessible = new List<VaultAccess>(vaults.Count);
foreach (var vault in vaults)
{
if (vault.OwnerKind == VaultOwnerKind.Personal)
{
accessible.Add(new VaultAccess(vault, OwnerPermissions));
continue;
}
// The dictionary lookup cannot miss — the query filtered on the same set — but a role
// that somehow is not there must not become a permissive default.
if (vault.TeamId is { } teamId && roles.TryGetValue(teamId, out var role))
{
accessible.Add(new VaultAccess(vault, ForRole(role)));
}
}
return accessible;
}
/// <summary>
/// Maps a team role onto vault permissions.
/// </summary>
/// <remarks>
/// <para>
/// Union-only, with no Deny: evaluation stays monotonic and testable, and restriction is
/// expressed by granting narrowly. See <see cref="PermissionFlags"/>.
/// </para>
/// <para>
/// <see cref="PermissionFlags.Connect"/> rides along with Read for every role that has it,
/// because it is a user-interface hint rather than a boundary — a role that could read a private
/// key but was refused Connect would be describing a restriction this architecture cannot
/// enforce. Granting it to a viewer is honest about that; withholding it would not be.
/// </para>
/// <para>
/// Owner and Admin resolve identically here on purpose. What separates them is what they may do
/// to the <em>team</em> — appoint owners, delete it — which is not a vault permission and is
/// checked where those operations live.
/// </para>
/// </remarks>
private static PermissionFlags ForRole(TeamRole role) => role switch
{
TeamRole.Viewer => PermissionFlags.Read | PermissionFlags.Connect,
TeamRole.Member => PermissionFlags.Read | PermissionFlags.Connect | PermissionFlags.Write,
TeamRole.Admin or TeamRole.Owner => OwnerPermissions,
// Unspecified, or a value written by a newer server. Failing closed is the only safe
// direction for a role this build does not understand.
_ => PermissionFlags.None,
};
/// <remarks>
/// Only an <see cref="MembershipStatus.Active"/> membership confers anything. An invited member
/// has not accepted and a revoked one has been removed; neither is a state in which the server
/// should be serving ciphertext.
/// </remarks>
private async Task<TeamRole?> FindRoleAsync(
Guid userId,
Guid teamId,
CancellationToken cancellationToken)
{
var membership = await database.TeamMemberships
.SingleOrDefaultAsync(
m => m.TeamId == teamId
&& m.UserId == userId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
return membership?.Role;
}
}
@@ -0,0 +1,162 @@
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Api.Features.Identity;
/// <summary>
/// The public-key directory.
/// </summary>
/// <remarks>
/// <para>
/// This is where a client gets the key it is about to wrap a vault key to, so its shape is a security
/// decision rather than a convenience one. Two rules follow from that.
/// </para>
/// <para>
/// <b>Lookup by exact email, never by prefix.</b> There is no search, no wildcard and no listing of
/// everybody. A caller has to already know the address, which keeps this from being a way to
/// enumerate an organisation's staff out of a server that stores their addresses in plaintext.
/// </para>
/// <para>
/// <b>Lookup by id is restricted to people the caller shares a team with.</b> Ids come from a member
/// list the caller can already read, so nothing is hidden that they cannot reach another way — but an
/// unrestricted id lookup would turn a leaked id from any source into a directory hit.
/// </para>
/// <para>
/// What this returns is <em>evidence</em>, not authority. A client must check the identity-provider
/// binding, compare against any fingerprint it has pinned, and confirm the key log head before
/// wrapping anything. Trusting the directory's word is the one mistake that undoes end-to-end
/// encryption entirely; see ADR 0001 and <c>DirectoryEntry</c>'s own remarks.
/// </para>
/// </remarks>
internal sealed class DirectoryService(DodoDbContext database)
{
/// <summary>Looks a user up by exact email address.</summary>
internal async Task<IReadOnlyList<DirectoryEntry>> FindByEmailAsync(
string email,
CancellationToken cancellationToken)
{
var normalised = email.Trim();
if (normalised.Length == 0)
{
return [];
}
// The email column is citext, so this comparison is case-insensitive in the database and the
// partial unique index on it means at most one row can match. Written as a list anyway
// because the response shape must not have to change if a second issuer ever shares one.
var users = await database.Users
.Where(u => u.Email == normalised
&& u.DeletedAtUtc == null
&& u.Status == UserStatus.Active)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return await BuildAsync(users, cancellationToken).ConfigureAwait(false);
}
/// <summary>Looks up accounts the caller shares an active team with.</summary>
internal async Task<IReadOnlyList<DirectoryEntry>> FindTeammatesAsync(
Guid callerId,
IReadOnlyList<Guid> userIds,
CancellationToken cancellationToken)
{
if (userIds.Count == 0)
{
return [];
}
var teamIds = await database.TeamMemberships
.Where(m => m.UserId == callerId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null)
.Select(m => m.TeamId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (teamIds.Count == 0)
{
return [];
}
var visible = await database.TeamMemberships
.Where(m => teamIds.Contains(m.TeamId)
&& userIds.Contains(m.UserId)
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null)
.Select(m => m.UserId)
.Distinct()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var users = await database.Users
.Where(u => visible.Contains(u.Id) && u.DeletedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return await BuildAsync(users, cancellationToken).ConfigureAwait(false);
}
/// <remarks>
/// A user with no current key is dropped rather than returned with empty key fields. The entry
/// exists to be wrapped to, and one carrying no key is something a caller would have to remember
/// to check for — which is the kind of check that gets forgotten exactly once.
/// </remarks>
private async Task<IReadOnlyList<DirectoryEntry>> BuildAsync(
List<UserAccount> users,
CancellationToken cancellationToken)
{
if (users.Count == 0)
{
return [];
}
var userIds = users.Select(u => u.Id).ToArray();
var keys = await database.UserKeys
.Where(k => userIds.Contains(k.UserId) && k.IsCurrent)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
// The log position of the statement that introduced each key, so a client can compare what
// it is told here against the append-only chain rather than taking this response on trust.
var keyIds = keys.Select(k => k.UserId).ToArray();
var sequences = await database.KeyLog
.Where(e => keyIds.Contains(e.UserId))
.GroupBy(e => new { e.UserId, e.Generation })
.Select(g => new { g.Key.UserId, g.Key.Generation, Sequence = g.Min(e => e.Sequence) })
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var entries = new List<DirectoryEntry>(users.Count);
foreach (var user in users)
{
var key = keys.Find(k => k.UserId == user.Id);
if (key is null)
{
continue;
}
var sequence = sequences
.Find(s => s.UserId == user.Id && s.Generation == key.Generation)?
.Sequence ?? 0;
entries.Add(new DirectoryEntry(
UserId: user.Id,
Email: user.Email,
DisplayName: user.DisplayName,
EncryptionPublicKey: key.EncryptionPublicKey,
SigningPublicKey: key.SigningPublicKey,
Fingerprint: key.FingerprintSha256,
KeyGeneration: key.Generation,
KeyLogSequence: sequence));
}
return entries;
}
}
@@ -104,6 +104,117 @@ internal sealed class EnrollEndpoint(ICurrentUserContext currentUser, Enrollment
}
}
/// <summary>
/// Looks up the public keys a vault key can be wrapped to.
/// </summary>
/// <remarks>
/// <para>
/// A GET with query parameters rather than a request DTO, because <c>BodyOnlyRequestBinder</c> binds
/// bodies and nothing else — deliberately, so that a query string can never overwrite a body field —
/// and this call has no body to speak of. The parameters are read one at a time, as route values are.
/// </para>
/// <para>
/// Exactly one of <c>email</c> and <c>userId</c> is expected. They are separate parameters rather than
/// one polymorphic term because they answer to different rules: an email may name anybody enrolled
/// here, an id only somebody the caller shares a team with. See <see cref="DirectoryService"/>.
/// </para>
/// </remarks>
internal sealed class LookupDirectoryEndpoint(
ICurrentUserContext currentUser,
DirectoryService directory)
: EndpointWithoutRequest<Results<Ok<IReadOnlyList<DirectoryEntry>>, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Get("/api/v1/directory");
// Enrolled. The answer exists to be wrapped to, and a caller with no identity key of their
// own has nothing to wrap and no signature to attribute it with.
Policies(Auth.EnrolledPolicy);
Description(b => b
.WithName("LookupDirectory")
.WithSummary("Looks up a user's published identity keys, by exact email or by id.")
.WithTags("Identity"));
}
/// <inheritdoc />
public override async Task<Results<Ok<IReadOnlyList<DirectoryEntry>>, ProblemHttpResult>> ExecuteAsync(
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var email = HttpContext.Request.Query["email"].ToString();
var rawUserId = HttpContext.Request.Query["userId"].ToString();
if (!string.IsNullOrWhiteSpace(email))
{
return TypedResults.Ok(await directory.FindByEmailAsync(email, ct).ConfigureAwait(false));
}
if (Guid.TryParse(rawUserId, out var userId))
{
return TypedResults.Ok(
await directory.FindTeammatesAsync(user.Id, [userId], ct).ConfigureAwait(false));
}
// An empty result would be indistinguishable from "nobody has that address", which is a
// different fact and one a client would go on to act on.
return Problems.Coded(
StatusCodes.Status400BadRequest,
ProblemCodes.MalformedRequest,
"Supply either an exact 'email' or a 'userId'. This directory has no search.");
}
}
/// <summary>
/// Serves the append-only key log, so a client can verify a public key rather than trust one.
/// </summary>
/// <remarks>
/// Paged with <c>after</c> and <c>limit</c> on the query string, read one at a time as route values
/// are — see <see cref="LookupDirectoryEndpoint"/> for why this endpoint has no request DTO.
/// </remarks>
internal sealed class ReadKeyLogEndpoint(KeyLogService keyLog)
: EndpointWithoutRequest<Ok<KeyLogPage>>
{
/// <inheritdoc />
public override void Configure()
{
Get("/api/v1/keylog");
// Enrolled rather than authenticated, matching the directory it exists to check. Nothing here
// is secret, but a caller with no key of their own has nothing to verify against.
Policies(Auth.EnrolledPolicy);
Description(b => b
.WithName("ReadKeyLog")
.WithSummary("Reads the append-only key log, with its current head.")
.WithTags("Identity"));
}
/// <inheritdoc />
public override async Task<Ok<KeyLogPage>> ExecuteAsync(CancellationToken ct)
{
// A malformed value reads as "from the beginning" rather than as an error. The log is public
// and ordered, so the worst a bad cursor costs is a larger response — and a 400 here would
// make a client's own paging bug look like a server refusal.
_ = long.TryParse(
HttpContext.Request.Query["after"],
System.Globalization.CultureInfo.InvariantCulture,
out var after);
int? limit = int.TryParse(
HttpContext.Request.Query["limit"],
System.Globalization.CultureInfo.InvariantCulture,
out var parsed)
? parsed
: null;
return TypedResults.Ok(await keyLog.ReadAsync(after, limit, ct).ConfigureAwait(false));
}
}
/// <summary>Registers a device key so this machine can unlock without the passphrase.</summary>
/// <remarks>
/// 200 rather than 201, for the reason enrollment gives: re-registering the same public key returns the
@@ -0,0 +1,87 @@
using DodoSSH.Contracts;
using DodoSSH.Crypto;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Api.Features.Identity;
/// <summary>
/// Serves the append-only key log.
/// </summary>
/// <remarks>
/// <para>
/// Readable by every enrolled caller, in full. That is the point of it: a log only one party can read
/// proves nothing, and the whole mechanism is that independent clients compare what they were shown.
/// Nothing here is secret — public keys, signatures over them, and hashes.
/// </para>
/// <para>
/// The server never edits this table and this service never writes to it. Appends happen in exactly
/// one place, under a deployment-wide advisory lock, inside the enrollment transaction; see
/// <see cref="EnrollmentService"/> and docs/crypto.md §7.2 for why serialising them is load-bearing.
/// </para>
/// </remarks>
internal sealed class KeyLogService(DodoDbContext database)
{
/// <summary>Largest page served, whatever a caller asks for.</summary>
/// <remarks>
/// A client verifying the chain has to read every entry in order, so paging is a transfer-size
/// concern rather than a filter. The cap is generous because skipping entries is not an option:
/// a gap breaks the link and the verification fails, correctly, on data that was fine.
/// </remarks>
private const int MaxPageSize = 500;
/// <summary>Reads entries after a sequence, with the log's current head.</summary>
internal async Task<KeyLogPage> ReadAsync(
long afterSequence,
int? limit,
CancellationToken cancellationToken)
{
var take = Math.Clamp(limit ?? MaxPageSize, 1, MaxPageSize);
var entries = await database.KeyLog
.Where(e => e.Sequence > afterSequence)
.OrderBy(e => e.Sequence)
// One more than asked for, so "is there another page" is answered by what came back
// rather than by a second count that could disagree with it.
.Take(take + 1)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var hasMore = entries.Count > take;
if (hasMore)
{
entries.RemoveAt(entries.Count - 1);
}
var head = await database.KeyLog
.OrderByDescending(e => e.Sequence)
.Select(e => new { e.Sequence, e.Hash })
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return new KeyLogPage(
Entries:
[
.. entries.Select(e => new KeyLogRecord(
e.Sequence,
e.UserId,
e.Generation,
e.EncryptionPublicKey,
e.SigningPublicKey,
e.StatementSignature,
e.PreviousHash,
e.Hash,
e.CreatedAtUtc)),
],
HeadSequence: head?.Sequence ?? 0,
// The genesis predecessor for an empty log, which is the same value the first entry will
// record. A client comparing heads therefore needs no special case for "nothing yet".
Head: head?.Hash ?? KeyLogChain.CreateGenesisPreviousHash(),
HasMore: hasMore);
}
}
@@ -0,0 +1,371 @@
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>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);
}
}
}
/// <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);
}
}
}
@@ -0,0 +1,35 @@
namespace DodoSSH.Api.Features.Teams;
/// <summary>
/// A team create or membership change was structurally unacceptable.
/// </summary>
/// <remarks>
/// The message is returned to the caller. Keep it about the shape of their own request, and never
/// about accounts or teams they cannot already see — "no such user" is safe when the caller supplied
/// the id from a directory lookup they just made, and is an enumeration oracle everywhere else.
/// </remarks>
internal sealed class TeamInvalidException(string message) : Exception(message);
/// <summary>The requested slug is already in use.</summary>
/// <remarks>
/// Its own type because it is the one create failure the caller could not have predicted from their
/// own input, and the only one whose remedy is choosing a different value rather than fixing one.
/// </remarks>
internal sealed class TeamSlugTakenException(string message) : Exception(message);
/// <summary>The change would leave a team with no owner.</summary>
/// <remarks>
/// Refused rather than allowed: a team with no owner has nobody who can appoint one, so the only
/// route back would be an operator editing the database by hand.
/// </remarks>
internal sealed class LastTeamOwnerException(string message) : Exception(message);
/// <summary>
/// A vault key grant was rejected.
/// </summary>
/// <remarks>
/// Never about the wrapped key's contents. The server cannot open it, so a grant sealing garbage is
/// accepted here and fails at the recipient as a tag failure, with the signature naming who issued
/// it. See docs/crypto.md §6.
/// </remarks>
internal sealed class VaultGrantInvalidException(string message) : Exception(message);
+68
View File
@@ -0,0 +1,68 @@
namespace DodoSSH.Api.Features.Teams;
/// <summary>
/// Source-generated log events for teams, membership and vault key grants.
/// </summary>
/// <remarks>
/// Ids, roles and outcomes only. Never a wrapped key, a signature or a fingerprint: the sharing graph
/// is already visible to the operator (docs/crypto.md §10) and there is nothing to gain by adding key
/// material to what a log aggregator keeps.
/// </remarks>
internal static partial class TeamLog
{
[LoggerMessage(
EventId = 2101,
Level = LogLevel.Information,
Message = "Created team {TeamId} for user {UserId}.")]
internal static partial void TeamCreated(ILogger logger, Guid teamId, Guid userId);
[LoggerMessage(
EventId = 2102,
Level = LogLevel.Information,
Message = "Added user {MemberId} to team {TeamId} as {Role}, by {ActorId}.")]
internal static partial void MemberAdded(
ILogger logger, Guid teamId, Guid memberId, Domain.TeamRole role, Guid actorId);
[LoggerMessage(
EventId = 2103,
Level = LogLevel.Information,
Message = "Changed user {MemberId} in team {TeamId} to {Role}, by {ActorId}.")]
internal static partial void MemberRoleChanged(
ILogger logger, Guid teamId, Guid memberId, Domain.TeamRole role, Guid actorId);
/// <remarks>
/// Warning rather than information, and it names the grant count. Removal is the operation whose
/// consequences are least like what the word implies — it blocks future reads and returns nothing
/// already downloaded — so it is the one worth being able to find in a log afterwards.
/// </remarks>
[LoggerMessage(
EventId = 2104,
Level = LogLevel.Warning,
Message = "Removed user {MemberId} from team {TeamId} by {ActorId}; revoked {GrantCount} vault "
+ "key grant(s). Vaults are flagged for rekey; already-downloaded data is unaffected.")]
internal static partial void MemberRemoved(
ILogger logger, Guid teamId, Guid memberId, Guid actorId, int grantCount);
[LoggerMessage(
EventId = 2105,
Level = LogLevel.Information,
Message = "Created team vault {VaultId} for team {TeamId}, by {ActorId}.")]
internal static partial void TeamVaultCreated(
ILogger logger, Guid vaultId, Guid teamId, Guid actorId);
[LoggerMessage(
EventId = 2106,
Level = LogLevel.Information,
Message = "Issued a key grant on vault {VaultId} generation {KeyGeneration} to {RecipientId}, "
+ "by {ActorId}.")]
internal static partial void GrantIssued(
ILogger logger, Guid vaultId, int keyGeneration, Guid recipientId, Guid actorId);
[LoggerMessage(
EventId = 2107,
Level = LogLevel.Warning,
Message = "Revoked the key grant on vault {VaultId} held by {RecipientId}, by {ActorId}. "
+ "Blocks future reads only; see ADR 0001.")]
internal static partial void GrantRevoked(
ILogger logger, Guid vaultId, Guid recipientId, Guid actorId);
}
@@ -0,0 +1,745 @@
using System.Globalization;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace DodoSSH.Api.Features.Teams;
/// <summary>The result of a team access check.</summary>
/// <param name="Team">The team, when the caller is an active member.</param>
/// <param name="Role">The caller's role.</param>
internal readonly record struct TeamAccess(Team? Team, TeamRole Role)
{
/// <summary>Whether the caller is in this team at all.</summary>
public bool Granted => Team is not null;
/// <summary>
/// Whether the caller may manage members and vaults.
/// </summary>
/// <remarks>
/// The team-level counterpart of <c>PermissionFlags.Admin</c>, and deliberately not derived from
/// it: those flags describe a vault, and adding a member is not an operation on any vault.
/// </remarks>
public bool CanAdminister => Role is TeamRole.Admin or TeamRole.Owner;
/// <summary>Denied access.</summary>
public static TeamAccess Denied => new(null, TeamRole.Unspecified);
}
/// <summary>
/// Teams and their membership.
/// </summary>
/// <remarks>
/// <para>
/// <b>Membership is authorization; a key grant is access.</b> Everything in this class moves rows
/// that decide what the <em>server</em> will serve. None of it can make a vault readable, because
/// making a vault readable means wrapping its key to somebody's public key and only a client holding
/// that key can do it. Adding a member is therefore two deliberate steps, and the interface says so:
/// add them here, then share the vault key from a machine that has one. Collapsing the two would
/// require the server to hold a key, which is the one thing this design is built to avoid.
/// </para>
/// <para>
/// The reverse direction is the honest half of the same split. Removing a member revokes their
/// grants and flags every team vault for rekey, and that blocks <em>future</em> reads only. Anything
/// already on their laptop is already gone; the real remediation is rotating the SSH credential. See
/// ADR 0001, and note that this class deliberately does not offer a "revoke access" verb that would
/// imply more than it delivers.
/// </para>
/// </remarks>
internal sealed class TeamService(
DodoDbContext database,
TimeProvider clock,
ILogger<TeamService> logger)
{
/// <summary>Longest acceptable slug. Matches the column.</summary>
private const int MaxSlugLength = 128;
/// <summary>Longest acceptable display name. Matches the column.</summary>
private const int MaxNameLength = 256;
/// <summary>Longest acceptable description. Matches the column.</summary>
private const int MaxDescriptionLength = 2048;
/// <summary>Creates a team, with the caller as its owner.</summary>
/// <remarks>
/// Idempotent on the client-chosen id, exactly as enrollment is: a request whose response was
/// lost can be re-sent verbatim and returns the same team rather than creating a second one under
/// a name the user meant to type once. A different body under the same id is a client that has
/// lost track of its own state and is refused rather than silently reinterpreted.
/// </remarks>
internal async Task<TeamSummary> CreateAsync(
UserAccount user,
CreateTeamRequest request,
CancellationToken cancellationToken)
{
var name = RequireText(request.Name, nameof(request.Name), MaxNameLength);
var slug = RequireSlug(request.Slug);
var description = OptionalText(request.Description, MaxDescriptionLength);
if (request.TeamId == Guid.Empty)
{
throw new TeamInvalidException("A team id is required. Generate a UUIDv7 on the client.");
}
var existing = await database.Teams
.SingleOrDefaultAsync(t => t.Id == request.TeamId, cancellationToken)
.ConfigureAwait(false);
if (existing is not null)
{
return await ResolveExistingAsync(user, existing, name, slug, cancellationToken)
.ConfigureAwait(false);
}
var team = AddTeamWithOwner(user, request.TeamId, name, slug, description);
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
{
// The partial unique index on slug. Reported as its own code because it is the one
// failure the caller could not have foreseen from their own input.
throw new TeamSlugTakenException(
$"The slug '{slug}' is already in use. Choose another.");
}
TeamLog.TeamCreated(logger, team.Id, user.Id);
return new TeamSummary(
team.Id, team.Name, team.Slug, team.Description,
TeamMemberRole.Owner, MemberCount: 1, VaultCount: 0, team.CreatedAtUtc);
}
/// <summary>
/// Adds the team row and the creator's owner membership.
/// </summary>
/// <remarks>
/// The two together, never one: a team with no members has nobody who can add any, and the row
/// would have to be found and fixed by hand.
/// </remarks>
private Team AddTeamWithOwner(
UserAccount user,
Guid teamId,
string name,
string slug,
string? description)
{
var now = clock.GetUtcNow();
var team = new Team
{
Id = teamId,
Name = name,
Slug = slug,
Description = description,
CreatedByUserId = user.Id,
CreatedAtUtc = now,
};
database.Teams.Add(team);
database.TeamMemberships.Add(new TeamMembership
{
Id = Guid.CreateVersion7(),
TeamId = team.Id,
UserId = user.Id,
Role = TeamRole.Owner,
Status = MembershipStatus.Active,
JoinedAtUtc = now,
CreatedAtUtc = now,
});
return team;
}
/// <summary>Lists the teams the caller is an active member of.</summary>
internal async Task<IReadOnlyList<TeamSummary>> ListAsync(
UserAccount user,
CancellationToken cancellationToken)
{
var memberships = await database.TeamMemberships
.Where(m => m.UserId == user.Id
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (memberships.Count == 0)
{
return [];
}
var teamIds = memberships.Select(m => m.TeamId).ToArray();
var teams = await database.Teams
.Where(t => teamIds.Contains(t.Id) && t.DeletedAtUtc == null)
.OrderBy(t => t.CreatedAtUtc)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var memberCounts = await database.TeamMemberships
.Where(m => teamIds.Contains(m.TeamId)
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null)
.GroupBy(m => m.TeamId)
.Select(g => new { TeamId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.TeamId, x => x.Count, cancellationToken)
.ConfigureAwait(false);
var vaultCounts = await database.Vaults
.Where(v => v.OwnerKind == VaultOwnerKind.Team
&& v.TeamId != null
&& teamIds.Contains(v.TeamId.Value)
&& v.DeletedAtUtc == null)
.GroupBy(v => v.TeamId!.Value)
.Select(g => new { TeamId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.TeamId, x => x.Count, cancellationToken)
.ConfigureAwait(false);
return
[
.. teams.Select(team => new TeamSummary(
team.Id,
team.Name,
team.Slug,
team.Description,
ToContract(memberships.Find(m => m.TeamId == team.Id)!.Role),
memberCounts.GetValueOrDefault(team.Id),
vaultCounts.GetValueOrDefault(team.Id),
team.CreatedAtUtc)),
];
}
/// <summary>
/// Lists a team's members.
/// </summary>
/// <remarks>
/// Available to every member, not only to admins. Whoever is about to be handed a vault key needs
/// to know who else already holds one, and a directory that only administrators can read makes
/// the sharing graph less visible to the people it is about than it is to the operator — who can
/// read it straight out of the database either way.
/// </remarks>
internal async Task<IReadOnlyList<TeamMemberSummary>> ListMembersAsync(
Guid teamId,
CancellationToken cancellationToken)
{
var memberships = await database.TeamMemberships
.Where(m => m.TeamId == teamId && m.DeletedAtUtc == null)
.Include(m => m.User)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (memberships.Count == 0)
{
return [];
}
var userIds = memberships.Select(m => m.UserId).ToArray();
var enrolled = await database.UserKeys
.Where(k => userIds.Contains(k.UserId) && k.IsCurrent)
.Select(k => k.UserId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var enrolledIds = enrolled.ToHashSet();
return
[
.. memberships
.OrderByDescending(m => m.Role)
.ThenBy(m => m.CreatedAtUtc)
.Select(m => new TeamMemberSummary(
m.UserId,
m.User?.Email,
m.User?.DisplayName,
ToContract(m.Role),
ToContract(m.Status),
enrolledIds.Contains(m.UserId),
m.JoinedAtUtc)),
];
}
/// <summary>Adds a member, or reactivates one who was removed.</summary>
/// <remarks>
/// <para>
/// The role may not be <see cref="TeamMemberRole.Owner"/>. Ownership is sole, so granting it to
/// somebody else is a transfer rather than an addition — a different operation with a different
/// confirmation, and not one M3 offers.
/// </para>
/// <para>
/// Re-adding a removed member reactivates the original row rather than inserting a second one,
/// which is what keeps historic audit entries resolvable to one membership. It does <em>not</em>
/// restore their revoked key grants: those were wrapped to a generation the vault has since been
/// flagged to leave behind, and a member holding Share has to wrap the key afresh.
/// </para>
/// </remarks>
internal async Task<TeamMemberSummary> AddMemberAsync(
UserAccount actor,
Guid teamId,
AddTeamMemberRequest request,
CancellationToken cancellationToken)
{
var role = ToDomain(request.Role);
if (role is TeamRole.Unspecified or TeamRole.Owner)
{
throw new TeamInvalidException(
"Add a member as viewer, member or admin. Ownership is sole and is not transferred "
+ "by adding somebody.");
}
var target = await database.Users
.SingleOrDefaultAsync(
u => u.Id == request.UserId && u.DeletedAtUtc == null,
cancellationToken)
.ConfigureAwait(false)
// Safe to be specific: the caller supplied this id from a directory lookup they just
// made, so it confirms nothing they did not already know.
?? throw new TeamInvalidException(
"No such account on this server. A member has to sign in here once before they can "
+ "be added — that is what creates the account and publishes the key a vault would "
+ "be shared with.");
var now = clock.GetUtcNow();
var membership = await database.TeamMemberships
.SingleOrDefaultAsync(
m => m.TeamId == teamId && m.UserId == target.Id && m.DeletedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
if (membership is null)
{
membership = new TeamMembership
{
Id = Guid.CreateVersion7(),
TeamId = teamId,
UserId = target.Id,
InvitedByUserId = actor.Id,
CreatedAtUtc = now,
};
database.TeamMemberships.Add(membership);
}
else if (membership.Status == MembershipStatus.Active)
{
throw new TeamInvalidException(
"That account is already a member of this team. Change their role instead.");
}
membership.Role = role;
membership.Status = MembershipStatus.Active;
membership.JoinedAtUtc = now;
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
TeamLog.MemberAdded(logger, teamId, target.Id, role, actor.Id);
return await DescribeAsync(target, membership, cancellationToken).ConfigureAwait(false);
}
/// <remarks>
/// Enrollment is looked up rather than inferred, because it is the one field on a member row that
/// is about them and not about the membership: somebody can be added on Monday and set their
/// vault up on Tuesday, and the interface has to stop offering to share with them in between.
/// </remarks>
private async Task<TeamMemberSummary> DescribeAsync(
UserAccount user,
TeamMembership membership,
CancellationToken cancellationToken)
{
var isEnrolled = await database.UserKeys
.AnyAsync(k => k.UserId == user.Id && k.IsCurrent, cancellationToken)
.ConfigureAwait(false);
return new TeamMemberSummary(
user.Id,
user.Email,
user.DisplayName,
ToContract(membership.Role),
ToContract(membership.Status),
isEnrolled,
membership.JoinedAtUtc);
}
/// <summary>Changes a member's role.</summary>
internal async Task<TeamMemberSummary> ChangeRoleAsync(
UserAccount actor,
Guid teamId,
Guid memberId,
ChangeTeamMemberRoleRequest request,
CancellationToken cancellationToken)
{
var role = ToDomain(request.Role);
if (role is TeamRole.Unspecified or TeamRole.Owner)
{
throw new TeamInvalidException(
"A member may be made a viewer, a member or an admin. Ownership is sole and is not "
+ "granted this way.");
}
var membership = await RequireMembershipAsync(teamId, memberId, cancellationToken)
.ConfigureAwait(false);
// Demoting the owner is what would leave the team ownerless, and there is no transfer to
// do it through yet. Refused with the code a client can act on rather than a bare 400.
if (membership.Role == TeamRole.Owner)
{
throw new LastTeamOwnerException(
"This team's owner cannot be demoted, because nothing can appoint a replacement yet.");
}
membership.Role = role;
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
TeamLog.MemberRoleChanged(logger, teamId, memberId, role, actor.Id);
// The account cannot be missing — a membership has a foreign key to it — but the query is
// written to tolerate it rather than to assert, because a null here would become an
// exception on a change that has already been committed.
var user = await database.Users
.SingleOrDefaultAsync(u => u.Id == memberId, cancellationToken)
.ConfigureAwait(false);
return user is null
? new TeamMemberSummary(
memberId, null, null, ToContract(role), ToContract(membership.Status), false,
membership.JoinedAtUtc)
: await DescribeAsync(user, membership, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Removes a member, revoking every vault key grant they hold from this team.
/// </summary>
/// <remarks>
/// <para>
/// One transaction, because the two halves are not separable: a membership revoked without its
/// grants leaves a departed member holding a key the server will happily keep serving, and grants
/// revoked without the membership leaves an active member whose vaults have silently stopped
/// opening.
/// </para>
/// <para>
/// Every affected vault is flagged <c>RekeyRequired</c> rather than rekeyed. A rekey re-wraps
/// every item's data key under a new vault key and can only be performed by a client that holds
/// the current one; the server can record that one is owed and nothing more. That is M5's key
/// rotation, and until it lands the flag is what the interface reads to say so out loud.
/// </para>
/// </remarks>
internal async Task RemoveMemberAsync(
UserAccount actor,
Guid teamId,
Guid memberId,
CancellationToken cancellationToken)
{
var membership = await RequireMembershipAsync(teamId, memberId, cancellationToken)
.ConfigureAwait(false);
if (membership.Role == TeamRole.Owner)
{
throw new LastTeamOwnerException(
"This team's owner cannot be removed. Ownership transfer is not implemented, so "
+ "removing them would leave the team with nobody who can manage it.");
}
var now = clock.GetUtcNow();
var strategy = database.Database.CreateExecutionStrategy();
var revoked = await strategy.ExecuteAsync(async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
membership.Status = MembershipStatus.Revoked;
membership.DeletedAtUtc = now;
var count = await RevokeTeamGrantsAsync(teamId, memberId, now, cancellationToken)
.ConfigureAwait(false);
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return count;
}).ConfigureAwait(false);
TeamLog.MemberRemoved(logger, teamId, memberId, actor.Id, revoked);
}
/// <summary>Revokes one user's grants on every vault a team owns, and flags each for rekey.</summary>
private async Task<int> RevokeTeamGrantsAsync(
Guid teamId,
Guid memberId,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var vaults = await database.Vaults
.Where(v => v.TeamId == teamId
&& v.OwnerKind == VaultOwnerKind.Team
&& v.DeletedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (vaults.Count == 0)
{
return 0;
}
var vaultIds = vaults.Select(v => v.Id).ToArray();
var grants = await database.VaultKeyGrants
.Where(g => vaultIds.Contains(g.VaultId)
&& g.RecipientUserId == memberId
&& g.RevokedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var grant in grants)
{
grant.State = GrantState.Revoked;
grant.RevokedAtUtc = now;
}
// Flagged whether or not this member held a grant. Somebody who was a member without a key
// still saw the vault's existence, its item count and its plaintext columns, and the vault's
// key is what a rekey would change — so "they never had a grant" is not a reason to leave the
// flag clear.
foreach (var vault in vaults)
{
vault.RekeyRequired = true;
vault.RekeyReason = RekeyReason.MemberRemoved;
vault.UpdatedAtUtc = now;
}
return grants.Count;
}
/// <summary>Reads the caller's own membership, for authorization checks.</summary>
internal Task<TeamMembership?> FindActiveMembershipAsync(
Guid teamId,
Guid userId,
CancellationToken cancellationToken) =>
database.TeamMemberships.SingleOrDefaultAsync(
m => m.TeamId == teamId
&& m.UserId == userId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null,
cancellationToken);
/// <summary>
/// Resolves what the caller may do with a team.
/// </summary>
/// <remarks>
/// Answers <see cref="TeamAccess.Denied"/> identically for a team that does not exist and one the
/// caller is not in, for the reason <c>VaultAccessService</c> gives: a distinct "exists but
/// forbidden" is an oracle for other tenants' team ids.
/// </remarks>
internal async Task<TeamAccess> ResolveAsync(
Guid userId,
Guid teamId,
CancellationToken cancellationToken)
{
var team = await database.Teams
.SingleOrDefaultAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
.ConfigureAwait(false);
if (team is null)
{
return TeamAccess.Denied;
}
var membership = await FindActiveMembershipAsync(teamId, userId, cancellationToken)
.ConfigureAwait(false);
return membership is null ? TeamAccess.Denied : new TeamAccess(team, membership.Role);
}
private async Task<TeamMembership> RequireMembershipAsync(
Guid teamId,
Guid memberId,
CancellationToken cancellationToken)
{
var membership = await database.TeamMemberships
.SingleOrDefaultAsync(
m => m.TeamId == teamId
&& m.UserId == memberId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
return membership
?? throw new TeamInvalidException("That account is not an active member of this team.");
}
/// <remarks>
/// A retry is the same id with the same name and slug, from the account that owns it. Anything
/// else under an id that is already taken is refused: silently returning somebody else's team
/// would be an existence oracle, and returning a differently-named one would tell a client its
/// rename succeeded when nothing changed.
/// </remarks>
private async Task<TeamSummary> ResolveExistingAsync(
UserAccount user,
Team existing,
string name,
string slug,
CancellationToken cancellationToken)
{
var membership = await FindActiveMembershipAsync(existing.Id, user.Id, cancellationToken)
.ConfigureAwait(false);
var isRetry = membership?.Role == TeamRole.Owner
&& existing.DeletedAtUtc == null
&& string.Equals(existing.Name, name, StringComparison.Ordinal)
&& string.Equals(existing.Slug, slug, StringComparison.Ordinal);
if (!isRetry)
{
throw new TeamInvalidException(
"That team id is already in use. Generate a new UUIDv7 and retry.");
}
var memberCount = await database.TeamMemberships
.CountAsync(
m => m.TeamId == existing.Id
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
var vaultCount = await database.Vaults
.CountAsync(
v => v.TeamId == existing.Id && v.DeletedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
return new TeamSummary(
existing.Id, existing.Name, existing.Slug, existing.Description,
TeamMemberRole.Owner, memberCount, vaultCount, existing.CreatedAtUtc);
}
/// <summary>
/// Validates a slug.
/// </summary>
/// <remarks>
/// Lowercase ASCII letters, digits and single hyphens, not starting or ending with one. Narrow on
/// purpose: the column is <c>citext</c>, so a slug differing only in case is the same slug, and a
/// value that renders differently from how it compares is how two teams end up looking distinct
/// in a list and colliding on insert.
/// </remarks>
private static string RequireSlug(string? value)
{
var slug = (value ?? string.Empty).Trim();
if (slug.Length is 0 or > MaxSlugLength)
{
throw new TeamInvalidException(
$"A slug of 1 to {MaxSlugLength} characters is required.");
}
var previousWasHyphen = false;
for (var index = 0; index < slug.Length; index++)
{
var character = slug[index];
var isHyphen = character == '-';
var acceptable = (character is >= 'a' and <= 'z')
|| (character is >= '0' and <= '9')
|| isHyphen;
if (!acceptable
|| (isHyphen && (previousWasHyphen || index == 0 || index == slug.Length - 1)))
{
throw new TeamInvalidException(
"A slug is lowercase letters, digits and single hyphens, and cannot start or end "
+ "with a hyphen.");
}
previousWasHyphen = isHyphen;
}
return slug;
}
private static string RequireText(string? value, string field, int maxLength)
{
var text = (value ?? string.Empty).Trim();
if (text.Length == 0 || text.Length > maxLength)
{
throw new TeamInvalidException(
string.Create(
CultureInfo.InvariantCulture,
$"{field} is required, and at most {maxLength} characters."));
}
return text;
}
private static string? OptionalText(string? value, int maxLength)
{
var text = value?.Trim();
if (string.IsNullOrEmpty(text))
{
return null;
}
if (text.Length > maxLength)
{
throw new TeamInvalidException(
string.Create(
CultureInfo.InvariantCulture,
$"A description is at most {maxLength} characters."));
}
return text;
}
/// <remarks>
/// A plain cast, which is why <c>TeamMemberRole</c> pins the same numeric values as
/// <see cref="TeamRole"/> and a test asserts it. An unknown value becomes
/// <see cref="TeamRole.Unspecified"/> rather than a silent cast to a role nobody defined, so a
/// newer client's role is refused instead of resolving to whatever bit pattern it happens to be.
/// </remarks>
private static TeamRole ToDomain(TeamMemberRole role) => role switch
{
TeamMemberRole.Viewer => TeamRole.Viewer,
TeamMemberRole.Member => TeamRole.Member,
TeamMemberRole.Admin => TeamRole.Admin,
TeamMemberRole.Owner => TeamRole.Owner,
_ => TeamRole.Unspecified,
};
private static TeamMemberRole ToContract(TeamRole role) => role switch
{
TeamRole.Viewer => TeamMemberRole.Viewer,
TeamRole.Member => TeamMemberRole.Member,
TeamRole.Admin => TeamMemberRole.Admin,
TeamRole.Owner => TeamMemberRole.Owner,
_ => TeamMemberRole.Unspecified,
};
private static TeamMemberStatus ToContract(MembershipStatus status) => status switch
{
MembershipStatus.Invited => TeamMemberStatus.Invited,
MembershipStatus.Active => TeamMemberStatus.Active,
MembershipStatus.Revoked => TeamMemberStatus.Revoked,
_ => TeamMemberStatus.Unspecified,
};
private static bool IsUniqueViolation(DbUpdateException exception) =>
string.Equals(
(exception.InnerException as PostgresException)?.SqlState,
PostgresErrorCodes.UniqueViolation,
StringComparison.Ordinal);
}
@@ -0,0 +1,179 @@
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>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>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);
}
}
}
@@ -0,0 +1,415 @@
using System.Security.Cryptography;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace DodoSSH.Api.Features.Teams;
/// <summary>
/// Team vaults and the key grants that make them readable.
/// </summary>
/// <remarks>
/// <para>
/// Everything here stores bytes it cannot interpret. A wrapped vault key is sealed to a recipient's
/// X25519 key, and a grant signature is Ed25519 over a tuple this server never verifies — the two
/// together are what let a client detect a fabricated grant, and moving either check onto the server
/// would make it a convenience rather than the boundary. See docs/crypto.md §6 and §7.
/// </para>
/// <para>
/// What the server <em>can</em> check is that a grant is not obviously useless: that the recipient is
/// enrolled, that the fingerprint names their current key, and that the generation is the vault's
/// current one. Each of those would otherwise surface at the far end as a tag failure the recipient
/// reads as data corruption, days later, with nothing pointing at the grant that caused it.
/// </para>
/// </remarks>
internal sealed class VaultGrantService(
DodoDbContext database,
TimeProvider clock,
ILogger<VaultGrantService> logger)
{
/// <summary>
/// Largest wrapped vault key accepted.
/// </summary>
/// <remarks>
/// A <c>SealTo</c> envelope over a 32-byte key is 6 + 32 + 24 + 48 = 110 bytes. The cap is loose
/// enough to survive a future envelope — the reserved hybrid seal in docs/crypto.md §8 is far
/// larger — and tight enough that this column cannot be used as free storage on a server that
/// stores it without being able to read it.
/// </remarks>
private const int MaxWrappedKeyBytes = 4096;
/// <summary>Creates a vault owned by a team, with the creator's own grant.</summary>
/// <remarks>
/// The vault and its first grant are written together, for the reason enrollment gives about a
/// personal vault: a vault with no grant is a container nobody can ever open, including whoever
/// created it, because only a client can wrap the key and it has already moved on.
/// </remarks>
internal async Task<VaultSummary> CreateTeamVaultAsync(
UserAccount user,
Team team,
CreateTeamVaultRequest request,
CancellationToken cancellationToken)
{
var name = RequireVaultName(request.Name);
if (request.VaultId == Guid.Empty)
{
throw new TeamInvalidException("A vault id is required. Generate a UUIDv7 on the client.");
}
RequireWrappedKey(request.WrappedVaultKey);
RequireSignature(request.GrantSignature);
var key = await RequireCurrentKeyAsync(user.Id, cancellationToken).ConfigureAwait(false);
var taken = await database.Vaults
.AnyAsync(v => v.Id == request.VaultId, cancellationToken)
.ConfigureAwait(false);
if (taken)
{
throw new TeamInvalidException(
"That vault id is already in use. Generate a new UUIDv7 and retry.");
}
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
{
// The pre-check covers the ordinary case; this is the race between two creates choosing
// the same id, which must not surface as a 500 about a constraint.
throw new TeamInvalidException(
"That vault id is already in use. Generate a new UUIDv7 and retry.");
}
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
return new VaultSummary(
VaultId: request.VaultId,
Name: name,
IsPersonal: false,
TeamId: team.Id,
KeyGeneration: 1,
Permissions: 0,
WrappedVaultKey: request.WrappedVaultKey,
RekeyRequired: false);
}
/// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
private void AddVaultWithSelfGrant(
UserAccount user,
Team team,
CreateTeamVaultRequest request,
string name,
UserKey key,
DateTimeOffset now)
{
database.Vaults.Add(new Vault
{
Id = request.VaultId,
Name = name,
OwnerKind = VaultOwnerKind.Team,
TeamId = team.Id,
KeyGeneration = 1,
CreatedAtUtc = now,
UpdatedAtUtc = now,
});
database.VaultKeyGrants.Add(new VaultKeyGrant
{
Id = Guid.CreateVersion7(),
VaultId = request.VaultId,
KeyGeneration = 1,
Kind = GrantKind.Member,
RecipientUserId = user.Id,
RecipientKeyFingerprint = key.FingerprintSha256,
WrappedKey = request.WrappedVaultKey,
GranterUserId = user.Id,
GranterKeyFingerprint = key.FingerprintSha256,
// No key log head, exactly as a personal vault's self-grant carries none: there is no
// third party whose key could have been substituted here.
KeyLogHead = null,
Signature = request.GrantSignature,
State = GrantState.Active,
CreatedAtUtc = now,
});
}
private static string RequireVaultName(string? value)
{
var name = (value ?? string.Empty).Trim();
return name.Length is 0 or > 256
? throw new TeamInvalidException("A vault name of 1 to 256 characters is required.")
: name;
}
/// <summary>Lists who can open a vault.</summary>
internal async Task<VaultGrantsResponse> ListGrantsAsync(
Vault vault,
CancellationToken cancellationToken)
{
var grants = await database.VaultKeyGrants
.Where(g => g.VaultId == vault.Id && g.Kind == GrantKind.Member)
.Include(g => g.RecipientUser)
.OrderBy(g => g.CreatedAtUtc)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return new VaultGrantsResponse(
VaultId: vault.Id,
KeyGeneration: (uint)vault.KeyGeneration,
RekeyRequired: vault.RekeyRequired,
Grants:
[
.. grants.Select(g => new VaultGrantSummary(
g.RecipientUserId!.Value,
g.RecipientUser?.Email,
g.RecipientUser?.DisplayName,
(uint)g.KeyGeneration,
ToContract(g.State),
g.GranterUserId,
g.CreatedAtUtc,
g.RevokedAtUtc)),
]);
}
/// <summary>Wraps a vault key to another member.</summary>
/// <remarks>
/// Re-issuing to a recipient who already holds a live grant replaces it in place rather than
/// inserting a second row, because the unique index permits exactly one live grant per recipient
/// per generation — and because the operation somebody is actually performing when they do this
/// is "wrap it again", after a rotation or a botched first attempt.
/// </remarks>
internal async Task IssueGrantAsync(
UserAccount actor,
Vault vault,
IssueVaultGrantRequest request,
CancellationToken cancellationToken)
{
await RequireIssuableAsync(vault, request, cancellationToken).ConfigureAwait(false);
var granterKey = await RequireCurrentKeyAsync(actor.Id, cancellationToken)
.ConfigureAwait(false);
var existing = await database.VaultKeyGrants
.SingleOrDefaultAsync(
g => g.VaultId == vault.Id
&& g.KeyGeneration == vault.KeyGeneration
&& g.RecipientUserId == request.RecipientUserId
&& g.RevokedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
var grant = existing ?? new VaultKeyGrant
{
Id = Guid.CreateVersion7(),
VaultId = vault.Id,
KeyGeneration = vault.KeyGeneration,
Kind = GrantKind.Member,
RecipientUserId = request.RecipientUserId,
CreatedAtUtc = clock.GetUtcNow(),
};
grant.RecipientKeyFingerprint = request.RecipientKeyFingerprint;
grant.WrappedKey = request.WrappedVaultKey;
grant.GranterUserId = actor.Id;
// Taken from the server's own view of the caller's key rather than from the request. The
// client signed over the same value, so an honest client is unaffected; a field that could
// disagree with reality is one a reader would have to decide which copy to believe.
grant.GranterKeyFingerprint = granterKey.FingerprintSha256;
grant.KeyLogHead = request.KeyLogHead;
grant.Signature = request.GrantSignature;
grant.State = GrantState.Active;
if (existing is null)
{
database.VaultKeyGrants.Add(grant);
}
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
TeamLog.GrantIssued(
logger, vault.Id, vault.KeyGeneration, request.RecipientUserId, actor.Id);
}
/// <summary>
/// Everything that can be checked about a grant without holding the vault key.
/// </summary>
/// <remarks>
/// None of this verifies that the wrap contains the right key — nothing on this machine can. Each
/// check exists because failing it would otherwise surface at the recipient as a tag failure they
/// read as data corruption, long after the request that caused it.
/// </remarks>
private async Task RequireIssuableAsync(
Vault vault,
IssueVaultGrantRequest request,
CancellationToken cancellationToken)
{
// Team vaults only. A personal vault has exactly one subject who may reach it, so a grant on
// one would seal a key to somebody the access check will go on refusing — a row that looks
// like sharing and is not. Moving the items into a team vault is the operation that shares.
if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is not { } teamId)
{
throw new VaultGrantInvalidException(
"Only a team vault can be shared. A personal vault is reachable by its owner alone, "
+ "so a grant on one would seal a key to somebody who still could not fetch it.");
}
RequireWrappedKey(request.WrappedVaultKey);
RequireSignature(request.GrantSignature);
RequireDigest(request.RecipientKeyFingerprint, "recipient key fingerprint");
RequireDigest(request.KeyLogHead, "key log head");
if (request.KeyGeneration != (uint)vault.KeyGeneration)
{
throw new VaultGrantInvalidException(
$"This vault is at key generation {vault.KeyGeneration}. A grant for generation "
+ $"{request.KeyGeneration} would open nothing.");
}
var member = await database.TeamMemberships
.AnyAsync(
m => m.TeamId == teamId
&& m.UserId == request.RecipientUserId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
if (!member)
{
throw new VaultGrantInvalidException(
"That account is not an active member of the team that owns this vault. Add them to "
+ "the team first — a key wrapped to somebody the server will refuse to serve is a "
+ "grant that does nothing.");
}
var recipientKey = await RequireCurrentKeyAsync(request.RecipientUserId, cancellationToken)
.ConfigureAwait(false);
// The fingerprint the client signed over must be the key the recipient actually holds.
// Otherwise the grant is sealed to a superseded key, opens nothing, and surfaces at the far
// end as an unexplained decryption failure rather than as the mistake it is.
if (!CryptographicOperations.FixedTimeEquals(
recipientKey.FingerprintSha256, request.RecipientKeyFingerprint))
{
throw new VaultGrantInvalidException(
"The fingerprint does not name the recipient's current identity key. Re-read the "
+ "directory and wrap the key again — theirs has been rotated since you fetched it.");
}
}
/// <summary>
/// Withdraws a member's key grant.
/// </summary>
/// <returns>Whether there was a live grant to withdraw.</returns>
/// <remarks>
/// Blocks future reads and nothing else. Anything the recipient has already pulled is on their
/// machine and stays there, which is why the vault is flagged for rekey and why the honest
/// remediation for a departure is rotating the SSH credential itself. See ADR 0001.
/// </remarks>
internal async Task<bool> RevokeGrantAsync(
UserAccount actor,
Vault vault,
Guid recipientUserId,
CancellationToken cancellationToken)
{
if (recipientUserId == actor.Id)
{
throw new VaultGrantInvalidException(
"You cannot withdraw your own key. It would leave you unable to read a vault you can "
+ "still write to, and nothing here can hand it back.");
}
var grants = await database.VaultKeyGrants
.Where(g => g.VaultId == vault.Id
&& g.RecipientUserId == recipientUserId
&& g.RevokedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (grants.Count == 0)
{
return false;
}
var now = clock.GetUtcNow();
foreach (var grant in grants)
{
grant.State = GrantState.Revoked;
grant.RevokedAtUtc = now;
}
vault.RekeyRequired = true;
vault.RekeyReason = RekeyReason.Requested;
vault.UpdatedAtUtc = now;
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
TeamLog.GrantRevoked(logger, vault.Id, recipientUserId, actor.Id);
return true;
}
private async Task<UserKey> RequireCurrentKeyAsync(Guid userId, CancellationToken cancellationToken)
{
var key = await database.UserKeys
.SingleOrDefaultAsync(k => k.UserId == userId && k.IsCurrent, cancellationToken)
.ConfigureAwait(false);
return key
?? throw new VaultGrantInvalidException(
"That account has not published an identity key yet, so there is nothing to wrap a "
+ "vault key to. They have to sign in and set up their vault first.");
}
private static void RequireWrappedKey(byte[]? value)
{
if (value is null || value.Length == 0 || value.Length > MaxWrappedKeyBytes)
{
throw new VaultGrantInvalidException(
$"A wrapped vault key of 1 to {MaxWrappedKeyBytes} bytes is required.");
}
}
private static void RequireSignature(byte[]? value)
{
if (value is null || value.Length != 64)
{
throw new VaultGrantInvalidException("An Ed25519 grant signature is 64 bytes.");
}
}
private static void RequireDigest(byte[]? value, string field)
{
if (value is null || value.Length != 32)
{
throw new VaultGrantInvalidException($"A {field} is 32 bytes.");
}
}
private static VaultGrantState ToContract(GrantState state) => state switch
{
GrantState.Active => VaultGrantState.Active,
GrantState.AwaitingRewrap => VaultGrantState.AwaitingRewrap,
GrantState.Revoked => VaultGrantState.Revoked,
_ => VaultGrantState.Unspecified,
};
private static bool IsUniqueViolation(DbUpdateException exception) =>
string.Equals(
(exception.InnerException as PostgresException)?.SqlState,
PostgresErrorCodes.UniqueViolation,
StringComparison.Ordinal);
}
+5
View File
@@ -1,6 +1,7 @@
using DodoSSH.Api.Authorization;
using DodoSSH.Api.Features.Identity;
using DodoSSH.Api.Features.Sync;
using DodoSSH.Api.Features.Teams;
using DodoSSH.Api.Setup;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy;
@@ -32,6 +33,10 @@ builder.Services.AddScoped<SyncService>();
builder.Services.AddScoped<IdentityService>();
builder.Services.AddScoped<EnrollmentService>();
builder.Services.AddScoped<DeviceService>();
builder.Services.AddScoped<DirectoryService>();
builder.Services.AddScoped<KeyLogService>();
builder.Services.AddScoped<TeamService>();
builder.Services.AddScoped<VaultGrantService>();
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
+15 -3
View File
@@ -1,6 +1,7 @@
using DodoSSH.Api.Features.Identity;
using DodoSSH.Api.Features.Meta;
using DodoSSH.Api.Features.Sync;
using DodoSSH.Api.Features.Teams;
using DodoSSH.Contracts;
using FastEndpoints;
@@ -38,15 +39,26 @@ internal static class EndpointRegistration
typeof(EnrollEndpoint),
typeof(RegisterDeviceEndpoint),
typeof(RevokeDeviceEndpoint),
typeof(LookupDirectoryEndpoint),
typeof(ReadKeyLogEndpoint),
typeof(SyncPullEndpoint),
typeof(SyncPushEndpoint),
typeof(CreateTeamEndpoint),
typeof(ListTeamsEndpoint),
typeof(ListTeamMembersEndpoint),
typeof(AddTeamMemberEndpoint),
typeof(ChangeTeamMemberRoleEndpoint),
typeof(RemoveTeamMemberEndpoint),
typeof(CreateTeamVaultEndpoint),
typeof(ListVaultGrantsEndpoint),
typeof(IssueVaultGrantEndpoint),
typeof(RevokeVaultGrantEndpoint),
// Registered as each feature lands:
// Identity — key rotation, passphrase change
// Directory — public-key lookup
// Vaults — grants, rekey, ACL
// Vaults — rekey, per-item ACLs
// Relay — tickets and the WebSocket
// Teams, Audit, Admin
// Audit, Admin
});
/// <summary>Hides the endpoint listing FastEndpoints publishes at <c>GET /_test_url_cache_</c>.</summary>