Public Access
Merge branch 'main' into claude/host-management-ui-plan-7f20ab
Seven files needed a hand. Most were two branches adding something in the same place, but three were one branch changing what the other had moved or renamed, and those are the ones worth reading. The shell keeps both new fields and both constructor lines: the connection recorder this branch built and the teams view model main did. Where main put a teams load inside OnScreenChanged, it now sits beside the logs refresh rather than inside RaiseSurfaceState — this branch extracted that notification block and it is called from two properties, so a screen-specific side effect in there would fire on every terminal switch as well. Main gave four row types a vault id and a vault name, and this branch had moved one of them — KnownHostRowViewModel — into its own file when the pinned keys became a screen. Git resolved that as "deleted here, modified there" and took the delete, which compiles as long as nobody looks: the moved copy still had the two-argument constructor and the call site had grown to four. Carried over by hand, along with the ordering the pins list now does on them. The status line's quiet rule was the subtle one. Main extracted it into IsWorthReporting; this branch had changed the same condition to read item counts rather than raw ones, because every user action queues a log entry a moment later and this machine reads its own entries back on the next pull. Take main's structure and the merge builds, passes, and silently restores a bug this branch existed partly to fix — every save's message overwritten a second after it appears. The method now reads PulledItems and PushedItems, with the reason in its remarks. Two conflicts were prose that had gone stale rather than code. The keychain screen's comment said team vaults are refused by the server's access service, which was true when it was written and is not now; main's replacement stands, in this branch's vocabulary. The design-gaps row for groups was claimed by both — real host groups here, per-vault headings there — and they are different things, so both rows stay and the difference is stated: a group is a shelf the user chose, a vault is who can read the item. One defect the tests found and the compiler could not. Generating a key opens the same editor as pasting one, but not through NewKey — so it never set the target vault main added, and a generated key was filed into whatever vault was edited last, or none. Both key-generation tests failed on it. Fixed where the editor opens, with the reason recorded there. One gap is left deliberately and is written down rather than half-built. Hosts, keys, credentials and pins are read across every vault this session holds a key for; groups are read from the active vault alone, so a host a teammate filed shows under UNGROUPED. Nothing is lost or misfiled — it is what the sidebar already shows for a group that has been deleted — but closing it needs a vault id on every group row for rename and delete, and a way to tell two vaults' identically-named groups apart under a layout with one heading per group. Both are worth doing and neither is a merge's business. It is in the remarks on ReloadGroupsAsync and in docs/design-import-gaps.md. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 1282 tests, including the end-to-end suite against real containers.
This commit is contained in:
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user