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

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

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

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

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

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

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

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

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

Also fixes a build break this branch started with: QuickConnectTests was never
updated when M2 added ISftpSessionFactory to the shell's constructor, so
nothing built at all.
This commit is contained in:
2026-07-31 12:18:28 +02:00
parent d1700f5a34
commit 95816de0c5
45 changed files with 6699 additions and 133 deletions
@@ -40,6 +40,17 @@ namespace DodoSSH.Contracts;
[JsonSerializable(typeof(DirectoryEntry))]
[JsonSerializable(typeof(IReadOnlyList<DirectoryEntry>))]
[JsonSerializable(typeof(VaultSummary))]
[JsonSerializable(typeof(TeamSummary))]
[JsonSerializable(typeof(IReadOnlyList<TeamSummary>))]
[JsonSerializable(typeof(CreateTeamRequest))]
[JsonSerializable(typeof(TeamMemberSummary))]
[JsonSerializable(typeof(IReadOnlyList<TeamMemberSummary>))]
[JsonSerializable(typeof(AddTeamMemberRequest))]
[JsonSerializable(typeof(ChangeTeamMemberRoleRequest))]
[JsonSerializable(typeof(CreateTeamVaultRequest))]
[JsonSerializable(typeof(IssueVaultGrantRequest))]
[JsonSerializable(typeof(VaultGrantsResponse))]
[JsonSerializable(typeof(KeyLogPage))]
[JsonSerializable(typeof(SyncPullRequest))]
[JsonSerializable(typeof(SyncPullResponse))]
[JsonSerializable(typeof(SyncPushRequest))]
+64
View File
@@ -0,0 +1,64 @@
namespace DodoSSH.Contracts;
/// <summary>
/// One entry in the append-only log of every identity key statement ever published.
/// </summary>
/// <remarks>
/// <para>
/// Served so a client can recompute the chain for itself rather than taking the directory's word for a
/// public key. Every field the hash covers is here, in the order docs/crypto.md §7.2 hashes them, so
/// verification is <c>ComputeEntryHash(previous, …) == hash</c> and nothing else.
/// </para>
/// <para>
/// <see cref="Sequence"/> is deliberately <em>not</em> an input to the hash. It is assigned by the
/// database on insert, so a renumbered or gapped column cannot silently reorder history — order
/// follows the hash links. It is here to page with, and to compare against what a directory entry
/// claims.
/// </para>
/// </remarks>
/// <param name="Sequence">Monotonic position in the log.</param>
/// <param name="UserId">Whose key this is.</param>
/// <param name="Generation">Which generation of that user's key.</param>
/// <param name="EncryptionPublicKey">X25519 public key, 32 bytes.</param>
/// <param name="SigningPublicKey">Ed25519 public key, 32 bytes.</param>
/// <param name="StatementSignature">The self-signature over the key statement.</param>
/// <param name="PreviousHash">Hash of the preceding entry; all-zero for the first.</param>
/// <param name="Hash">This entry's hash, over the previous hash and its own contents.</param>
/// <param name="CreatedAt">When it was appended, truncated to milliseconds as the hash requires.</param>
public sealed record KeyLogRecord(
long Sequence,
Guid UserId,
int Generation,
byte[] EncryptionPublicKey,
byte[] SigningPublicKey,
byte[] StatementSignature,
byte[] PreviousHash,
byte[] Hash,
DateTimeOffset CreatedAt);
/// <summary>A page of the key log, with the head as of this response.</summary>
/// <remarks>
/// <para>
/// The head is what a client records in every grant it signs, which is what makes a forked view
/// detectable: for a server to show two clients different key logs it must keep both forks consistent
/// across every later shared operation, and any two clients touching one vault then disagree. It
/// converts an otherwise invisible key substitution into a visible one. It does not prevent it — see
/// ADR 0001.
/// </para>
/// <para>
/// <see cref="Head"/> describes the whole log, not this page: a client that pages from an old cursor
/// still learns where the end is, and can tell whether it has caught up without a second call.
/// </para>
/// </remarks>
/// <param name="Entries">Entries after the requested sequence, in ascending order.</param>
/// <param name="HeadSequence">Sequence of the log's last entry; 0 when the log is empty.</param>
/// <param name="Head">
/// Hash of the log's last entry. All-zero when the log is empty, which is the same value the first
/// entry records as its predecessor.
/// </param>
/// <param name="HasMore">Whether entries beyond this page are immediately available.</param>
public sealed record KeyLogPage(
IReadOnlyList<KeyLogRecord> Entries,
long HeadSequence,
byte[] Head,
bool HasMore);
+36
View File
@@ -80,4 +80,40 @@ public static class ProblemCodes
/// <summary>A push batch exceeded the operation count or payload size cap.</summary>
public const string PushBatchTooLarge = "push-batch-too-large";
/// <summary>
/// A team create or membership change was structurally invalid: a blank name, a slug that is
/// not URL-safe, an unknown role, or an account that does not exist here.
/// </summary>
public const string InvalidTeam = "invalid-team";
/// <summary>
/// The requested slug is already in use.
/// </summary>
/// <remarks>
/// Its own code rather than folded into <see cref="InvalidTeam"/>, because it is the one create
/// failure the caller could not have predicted from their own input and the only one whose
/// remedy is "pick a different one" rather than "fix what you typed".
/// </remarks>
public const string TeamSlugTaken = "team-slug-taken";
/// <summary>
/// The change would leave a team with no owner.
/// </summary>
/// <remarks>
/// Refused rather than allowed, because a team with no owner has nobody who can appoint one —
/// and the only route back would be an operator editing the database by hand.
/// </remarks>
public const string LastTeamOwner = "last-team-owner";
/// <summary>
/// A vault key grant was rejected: a fingerprint or wrap of the wrong size, a generation that is
/// not the vault's current one, or a recipient who cannot reach the vault in the first place.
/// </summary>
/// <remarks>
/// Never a statement about the wrapped key's <em>contents</em>. The server cannot open it, so a
/// grant containing garbage is accepted here and surfaces at the recipient as a tag failure,
/// with the signature naming who issued it. See docs/crypto.md §6.
/// </remarks>
public const string InvalidVaultGrant = "invalid-vault-grant";
}
@@ -8,13 +8,61 @@ const DodoSSH.Contracts.ProblemCodes.IdentityBindingInvalid = "identity-binding-
const DodoSSH.Contracts.ProblemCodes.InvalidCursor = "invalid-cursor" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidDeviceRegistration = "invalid-device-registration" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidEnrollment = "invalid-enrollment" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidTeam = "invalid-team" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidVaultGrant = "invalid-vault-grant" -> string!
const DodoSSH.Contracts.ProblemCodes.LastTeamOwner = "last-team-owner" -> string!
const DodoSSH.Contracts.ProblemCodes.MalformedRequest = "malformed-request" -> string!
const DodoSSH.Contracts.ProblemCodes.PushBatchTooLarge = "push-batch-too-large" -> string!
const DodoSSH.Contracts.ProblemCodes.RelayLimitReached = "relay-limit-reached" -> string!
const DodoSSH.Contracts.ProblemCodes.RelayTargetRejected = "relay-target-rejected" -> string!
const DodoSSH.Contracts.ProblemCodes.RelayTicketInvalid = "relay-ticket-invalid" -> string!
const DodoSSH.Contracts.ProblemCodes.TeamSlugTaken = "team-slug-taken" -> string!
const DodoSSH.Contracts.ProblemCodes.TypeBaseUri = "https://dodossh.dev/problems/" -> string!
const DodoSSH.Contracts.ProblemCodes.VaultConflict = "vault-conflict" -> string!
DodoSSH.Contracts.AddTeamMemberRequest
DodoSSH.Contracts.AddTeamMemberRequest.<Clone>$() -> DodoSSH.Contracts.AddTeamMemberRequest!
DodoSSH.Contracts.AddTeamMemberRequest.AddTeamMemberRequest(System.Guid UserId, DodoSSH.Contracts.TeamMemberRole Role) -> void
DodoSSH.Contracts.AddTeamMemberRequest.Deconstruct(out System.Guid UserId, out DodoSSH.Contracts.TeamMemberRole Role) -> void
DodoSSH.Contracts.AddTeamMemberRequest.Equals(DodoSSH.Contracts.AddTeamMemberRequest? other) -> bool
DodoSSH.Contracts.AddTeamMemberRequest.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.AddTeamMemberRequest.Role.init -> void
DodoSSH.Contracts.AddTeamMemberRequest.UserId.get -> System.Guid
DodoSSH.Contracts.AddTeamMemberRequest.UserId.init -> void
DodoSSH.Contracts.ChangeTeamMemberRoleRequest
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.<Clone>$() -> DodoSSH.Contracts.ChangeTeamMemberRoleRequest!
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.ChangeTeamMemberRoleRequest(DodoSSH.Contracts.TeamMemberRole Role) -> void
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Deconstruct(out DodoSSH.Contracts.TeamMemberRole Role) -> void
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Equals(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? other) -> bool
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Role.init -> void
DodoSSH.Contracts.CreateTeamRequest
DodoSSH.Contracts.CreateTeamRequest.<Clone>$() -> DodoSSH.Contracts.CreateTeamRequest!
DodoSSH.Contracts.CreateTeamRequest.CreateTeamRequest(System.Guid TeamId, string! Name, string! Slug, string? Description) -> void
DodoSSH.Contracts.CreateTeamRequest.Deconstruct(out System.Guid TeamId, out string! Name, out string! Slug, out string? Description) -> void
DodoSSH.Contracts.CreateTeamRequest.Description.get -> string?
DodoSSH.Contracts.CreateTeamRequest.Description.init -> void
DodoSSH.Contracts.CreateTeamRequest.Equals(DodoSSH.Contracts.CreateTeamRequest? other) -> bool
DodoSSH.Contracts.CreateTeamRequest.Name.get -> string!
DodoSSH.Contracts.CreateTeamRequest.Name.init -> void
DodoSSH.Contracts.CreateTeamRequest.Slug.get -> string!
DodoSSH.Contracts.CreateTeamRequest.Slug.init -> void
DodoSSH.Contracts.CreateTeamRequest.TeamId.get -> System.Guid
DodoSSH.Contracts.CreateTeamRequest.TeamId.init -> void
DodoSSH.Contracts.CreateTeamVaultRequest
DodoSSH.Contracts.CreateTeamVaultRequest.<Clone>$() -> DodoSSH.Contracts.CreateTeamVaultRequest!
DodoSSH.Contracts.CreateTeamVaultRequest.CreateTeamVaultRequest(System.Guid VaultId, string! Name, byte[]! WrappedVaultKey, byte[]! GrantSignature, System.DateTimeOffset GrantedAt) -> void
DodoSSH.Contracts.CreateTeamVaultRequest.Deconstruct(out System.Guid VaultId, out string! Name, out byte[]! WrappedVaultKey, out byte[]! GrantSignature, out System.DateTimeOffset GrantedAt) -> void
DodoSSH.Contracts.CreateTeamVaultRequest.Equals(DodoSSH.Contracts.CreateTeamVaultRequest? other) -> bool
DodoSSH.Contracts.CreateTeamVaultRequest.GrantedAt.get -> System.DateTimeOffset
DodoSSH.Contracts.CreateTeamVaultRequest.GrantedAt.init -> void
DodoSSH.Contracts.CreateTeamVaultRequest.GrantSignature.get -> byte[]!
DodoSSH.Contracts.CreateTeamVaultRequest.GrantSignature.init -> void
DodoSSH.Contracts.CreateTeamVaultRequest.Name.get -> string!
DodoSSH.Contracts.CreateTeamVaultRequest.Name.init -> void
DodoSSH.Contracts.CreateTeamVaultRequest.VaultId.get -> System.Guid
DodoSSH.Contracts.CreateTeamVaultRequest.VaultId.init -> void
DodoSSH.Contracts.CreateTeamVaultRequest.WrappedVaultKey.get -> byte[]!
DodoSSH.Contracts.CreateTeamVaultRequest.WrappedVaultKey.init -> void
DodoSSH.Contracts.DirectoryEntry
DodoSSH.Contracts.DirectoryEntry.<Clone>$() -> DodoSSH.Contracts.DirectoryEntry!
DodoSSH.Contracts.DirectoryEntry.Deconstruct(out System.Guid UserId, out string? Email, out string? DisplayName, out byte[]! EncryptionPublicKey, out byte[]! SigningPublicKey, out byte[]! Fingerprint, out int KeyGeneration, out long KeyLogSequence) -> void
@@ -105,6 +153,25 @@ DodoSSH.Contracts.EnrollmentResponse.PersonalVaultId.get -> System.Guid
DodoSSH.Contracts.EnrollmentResponse.PersonalVaultId.init -> void
DodoSSH.Contracts.EnrollmentResponse.UserId.get -> System.Guid
DodoSSH.Contracts.EnrollmentResponse.UserId.init -> void
DodoSSH.Contracts.IssueVaultGrantRequest
DodoSSH.Contracts.IssueVaultGrantRequest.<Clone>$() -> DodoSSH.Contracts.IssueVaultGrantRequest!
DodoSSH.Contracts.IssueVaultGrantRequest.Deconstruct(out System.Guid RecipientUserId, out byte[]! RecipientKeyFingerprint, out uint KeyGeneration, out byte[]! WrappedVaultKey, out byte[]! KeyLogHead, out byte[]! GrantSignature, out System.DateTimeOffset GrantedAt) -> void
DodoSSH.Contracts.IssueVaultGrantRequest.Equals(DodoSSH.Contracts.IssueVaultGrantRequest? other) -> bool
DodoSSH.Contracts.IssueVaultGrantRequest.GrantedAt.get -> System.DateTimeOffset
DodoSSH.Contracts.IssueVaultGrantRequest.GrantedAt.init -> void
DodoSSH.Contracts.IssueVaultGrantRequest.GrantSignature.get -> byte[]!
DodoSSH.Contracts.IssueVaultGrantRequest.GrantSignature.init -> void
DodoSSH.Contracts.IssueVaultGrantRequest.IssueVaultGrantRequest(System.Guid RecipientUserId, byte[]! RecipientKeyFingerprint, uint KeyGeneration, byte[]! WrappedVaultKey, byte[]! KeyLogHead, byte[]! GrantSignature, System.DateTimeOffset GrantedAt) -> void
DodoSSH.Contracts.IssueVaultGrantRequest.KeyGeneration.get -> uint
DodoSSH.Contracts.IssueVaultGrantRequest.KeyGeneration.init -> void
DodoSSH.Contracts.IssueVaultGrantRequest.KeyLogHead.get -> byte[]!
DodoSSH.Contracts.IssueVaultGrantRequest.KeyLogHead.init -> void
DodoSSH.Contracts.IssueVaultGrantRequest.RecipientKeyFingerprint.get -> byte[]!
DodoSSH.Contracts.IssueVaultGrantRequest.RecipientKeyFingerprint.init -> void
DodoSSH.Contracts.IssueVaultGrantRequest.RecipientUserId.get -> System.Guid
DodoSSH.Contracts.IssueVaultGrantRequest.RecipientUserId.init -> void
DodoSSH.Contracts.IssueVaultGrantRequest.WrappedVaultKey.get -> byte[]!
DodoSSH.Contracts.IssueVaultGrantRequest.WrappedVaultKey.init -> void
DodoSSH.Contracts.KdfParameters
DodoSSH.Contracts.KdfParameters.<Clone>$() -> DodoSSH.Contracts.KdfParameters!
DodoSSH.Contracts.KdfParameters.Algorithm.get -> string!
@@ -120,6 +187,42 @@ DodoSSH.Contracts.KdfParameters.Passes.get -> int
DodoSSH.Contracts.KdfParameters.Passes.init -> void
DodoSSH.Contracts.KdfParameters.Salt.get -> byte[]!
DodoSSH.Contracts.KdfParameters.Salt.init -> void
DodoSSH.Contracts.KeyLogPage
DodoSSH.Contracts.KeyLogPage.<Clone>$() -> DodoSSH.Contracts.KeyLogPage!
DodoSSH.Contracts.KeyLogPage.Deconstruct(out System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.KeyLogRecord!>! Entries, out long HeadSequence, out byte[]! Head, out bool HasMore) -> void
DodoSSH.Contracts.KeyLogPage.Entries.get -> System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.KeyLogRecord!>!
DodoSSH.Contracts.KeyLogPage.Entries.init -> void
DodoSSH.Contracts.KeyLogPage.Equals(DodoSSH.Contracts.KeyLogPage? other) -> bool
DodoSSH.Contracts.KeyLogPage.HasMore.get -> bool
DodoSSH.Contracts.KeyLogPage.HasMore.init -> void
DodoSSH.Contracts.KeyLogPage.Head.get -> byte[]!
DodoSSH.Contracts.KeyLogPage.Head.init -> void
DodoSSH.Contracts.KeyLogPage.HeadSequence.get -> long
DodoSSH.Contracts.KeyLogPage.HeadSequence.init -> void
DodoSSH.Contracts.KeyLogPage.KeyLogPage(System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.KeyLogRecord!>! Entries, long HeadSequence, byte[]! Head, bool HasMore) -> void
DodoSSH.Contracts.KeyLogRecord
DodoSSH.Contracts.KeyLogRecord.<Clone>$() -> DodoSSH.Contracts.KeyLogRecord!
DodoSSH.Contracts.KeyLogRecord.CreatedAt.get -> System.DateTimeOffset
DodoSSH.Contracts.KeyLogRecord.CreatedAt.init -> void
DodoSSH.Contracts.KeyLogRecord.Deconstruct(out long Sequence, out System.Guid UserId, out int Generation, out byte[]! EncryptionPublicKey, out byte[]! SigningPublicKey, out byte[]! StatementSignature, out byte[]! PreviousHash, out byte[]! Hash, out System.DateTimeOffset CreatedAt) -> void
DodoSSH.Contracts.KeyLogRecord.EncryptionPublicKey.get -> byte[]!
DodoSSH.Contracts.KeyLogRecord.EncryptionPublicKey.init -> void
DodoSSH.Contracts.KeyLogRecord.Equals(DodoSSH.Contracts.KeyLogRecord? other) -> bool
DodoSSH.Contracts.KeyLogRecord.Generation.get -> int
DodoSSH.Contracts.KeyLogRecord.Generation.init -> void
DodoSSH.Contracts.KeyLogRecord.Hash.get -> byte[]!
DodoSSH.Contracts.KeyLogRecord.Hash.init -> void
DodoSSH.Contracts.KeyLogRecord.KeyLogRecord(long Sequence, System.Guid UserId, int Generation, byte[]! EncryptionPublicKey, byte[]! SigningPublicKey, byte[]! StatementSignature, byte[]! PreviousHash, byte[]! Hash, System.DateTimeOffset CreatedAt) -> void
DodoSSH.Contracts.KeyLogRecord.PreviousHash.get -> byte[]!
DodoSSH.Contracts.KeyLogRecord.PreviousHash.init -> void
DodoSSH.Contracts.KeyLogRecord.Sequence.get -> long
DodoSSH.Contracts.KeyLogRecord.Sequence.init -> void
DodoSSH.Contracts.KeyLogRecord.SigningPublicKey.get -> byte[]!
DodoSSH.Contracts.KeyLogRecord.SigningPublicKey.init -> void
DodoSSH.Contracts.KeyLogRecord.StatementSignature.get -> byte[]!
DodoSSH.Contracts.KeyLogRecord.StatementSignature.init -> void
DodoSSH.Contracts.KeyLogRecord.UserId.get -> System.Guid
DodoSSH.Contracts.KeyLogRecord.UserId.init -> void
DodoSSH.Contracts.KeyStatement
DodoSSH.Contracts.KeyStatement.<Clone>$() -> DodoSSH.Contracts.KeyStatement!
DodoSSH.Contracts.KeyStatement.CreatedAt.get -> System.DateTimeOffset
@@ -448,6 +551,96 @@ DodoSSH.Contracts.SyncPushResult.Status.init -> void
DodoSSH.Contracts.SyncPushResult.SyncPushResult(System.Guid OperationId, DodoSSH.Contracts.SyncOperationStatus Status, int? Version, long? ChangeSequence, DodoSSH.Contracts.SyncChange? ServerEntity, string? Detail) -> void
DodoSSH.Contracts.SyncPushResult.Version.get -> int?
DodoSSH.Contracts.SyncPushResult.Version.init -> void
DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberRole.Admin = 30 -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberRole.Member = 20 -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberRole.Owner = 40 -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberRole.Unspecified = 0 -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberRole.Viewer = 10 -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberStatus
DodoSSH.Contracts.TeamMemberStatus.Active = 2 -> DodoSSH.Contracts.TeamMemberStatus
DodoSSH.Contracts.TeamMemberStatus.Invited = 1 -> DodoSSH.Contracts.TeamMemberStatus
DodoSSH.Contracts.TeamMemberStatus.Revoked = 3 -> DodoSSH.Contracts.TeamMemberStatus
DodoSSH.Contracts.TeamMemberStatus.Unspecified = 0 -> DodoSSH.Contracts.TeamMemberStatus
DodoSSH.Contracts.TeamMemberSummary
DodoSSH.Contracts.TeamMemberSummary.<Clone>$() -> DodoSSH.Contracts.TeamMemberSummary!
DodoSSH.Contracts.TeamMemberSummary.Deconstruct(out System.Guid UserId, out string? Email, out string? DisplayName, out DodoSSH.Contracts.TeamMemberRole Role, out DodoSSH.Contracts.TeamMemberStatus Status, out bool IsEnrolled, out System.DateTimeOffset? JoinedAt) -> void
DodoSSH.Contracts.TeamMemberSummary.DisplayName.get -> string?
DodoSSH.Contracts.TeamMemberSummary.DisplayName.init -> void
DodoSSH.Contracts.TeamMemberSummary.Email.get -> string?
DodoSSH.Contracts.TeamMemberSummary.Email.init -> void
DodoSSH.Contracts.TeamMemberSummary.Equals(DodoSSH.Contracts.TeamMemberSummary? other) -> bool
DodoSSH.Contracts.TeamMemberSummary.IsEnrolled.get -> bool
DodoSSH.Contracts.TeamMemberSummary.IsEnrolled.init -> void
DodoSSH.Contracts.TeamMemberSummary.JoinedAt.get -> System.DateTimeOffset?
DodoSSH.Contracts.TeamMemberSummary.JoinedAt.init -> void
DodoSSH.Contracts.TeamMemberSummary.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberSummary.Role.init -> void
DodoSSH.Contracts.TeamMemberSummary.Status.get -> DodoSSH.Contracts.TeamMemberStatus
DodoSSH.Contracts.TeamMemberSummary.Status.init -> void
DodoSSH.Contracts.TeamMemberSummary.TeamMemberSummary(System.Guid UserId, string? Email, string? DisplayName, DodoSSH.Contracts.TeamMemberRole Role, DodoSSH.Contracts.TeamMemberStatus Status, bool IsEnrolled, System.DateTimeOffset? JoinedAt) -> void
DodoSSH.Contracts.TeamMemberSummary.UserId.get -> System.Guid
DodoSSH.Contracts.TeamMemberSummary.UserId.init -> void
DodoSSH.Contracts.TeamSummary
DodoSSH.Contracts.TeamSummary.<Clone>$() -> DodoSSH.Contracts.TeamSummary!
DodoSSH.Contracts.TeamSummary.CreatedAt.get -> System.DateTimeOffset
DodoSSH.Contracts.TeamSummary.CreatedAt.init -> void
DodoSSH.Contracts.TeamSummary.Deconstruct(out System.Guid TeamId, out string! Name, out string! Slug, out string? Description, out DodoSSH.Contracts.TeamMemberRole Role, out int MemberCount, out int VaultCount, out System.DateTimeOffset CreatedAt) -> void
DodoSSH.Contracts.TeamSummary.Description.get -> string?
DodoSSH.Contracts.TeamSummary.Description.init -> void
DodoSSH.Contracts.TeamSummary.Equals(DodoSSH.Contracts.TeamSummary? other) -> bool
DodoSSH.Contracts.TeamSummary.MemberCount.get -> int
DodoSSH.Contracts.TeamSummary.MemberCount.init -> void
DodoSSH.Contracts.TeamSummary.Name.get -> string!
DodoSSH.Contracts.TeamSummary.Name.init -> void
DodoSSH.Contracts.TeamSummary.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamSummary.Role.init -> void
DodoSSH.Contracts.TeamSummary.Slug.get -> string!
DodoSSH.Contracts.TeamSummary.Slug.init -> void
DodoSSH.Contracts.TeamSummary.TeamId.get -> System.Guid
DodoSSH.Contracts.TeamSummary.TeamId.init -> void
DodoSSH.Contracts.TeamSummary.TeamSummary(System.Guid TeamId, string! Name, string! Slug, string? Description, DodoSSH.Contracts.TeamMemberRole Role, int MemberCount, int VaultCount, System.DateTimeOffset CreatedAt) -> void
DodoSSH.Contracts.TeamSummary.VaultCount.get -> int
DodoSSH.Contracts.TeamSummary.VaultCount.init -> void
DodoSSH.Contracts.VaultGrantsResponse
DodoSSH.Contracts.VaultGrantsResponse.<Clone>$() -> DodoSSH.Contracts.VaultGrantsResponse!
DodoSSH.Contracts.VaultGrantsResponse.Deconstruct(out System.Guid VaultId, out uint KeyGeneration, out bool RekeyRequired, out System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.VaultGrantSummary!>! Grants) -> void
DodoSSH.Contracts.VaultGrantsResponse.Equals(DodoSSH.Contracts.VaultGrantsResponse? other) -> bool
DodoSSH.Contracts.VaultGrantsResponse.Grants.get -> System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.VaultGrantSummary!>!
DodoSSH.Contracts.VaultGrantsResponse.Grants.init -> void
DodoSSH.Contracts.VaultGrantsResponse.KeyGeneration.get -> uint
DodoSSH.Contracts.VaultGrantsResponse.KeyGeneration.init -> void
DodoSSH.Contracts.VaultGrantsResponse.RekeyRequired.get -> bool
DodoSSH.Contracts.VaultGrantsResponse.RekeyRequired.init -> void
DodoSSH.Contracts.VaultGrantsResponse.VaultGrantsResponse(System.Guid VaultId, uint KeyGeneration, bool RekeyRequired, System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.VaultGrantSummary!>! Grants) -> void
DodoSSH.Contracts.VaultGrantsResponse.VaultId.get -> System.Guid
DodoSSH.Contracts.VaultGrantsResponse.VaultId.init -> void
DodoSSH.Contracts.VaultGrantState
DodoSSH.Contracts.VaultGrantState.Active = 1 -> DodoSSH.Contracts.VaultGrantState
DodoSSH.Contracts.VaultGrantState.AwaitingRewrap = 2 -> DodoSSH.Contracts.VaultGrantState
DodoSSH.Contracts.VaultGrantState.Revoked = 3 -> DodoSSH.Contracts.VaultGrantState
DodoSSH.Contracts.VaultGrantState.Unspecified = 0 -> DodoSSH.Contracts.VaultGrantState
DodoSSH.Contracts.VaultGrantSummary
DodoSSH.Contracts.VaultGrantSummary.<Clone>$() -> DodoSSH.Contracts.VaultGrantSummary!
DodoSSH.Contracts.VaultGrantSummary.CreatedAt.get -> System.DateTimeOffset
DodoSSH.Contracts.VaultGrantSummary.CreatedAt.init -> void
DodoSSH.Contracts.VaultGrantSummary.Deconstruct(out System.Guid RecipientUserId, out string? Email, out string? DisplayName, out uint KeyGeneration, out DodoSSH.Contracts.VaultGrantState State, out System.Guid GranterUserId, out System.DateTimeOffset CreatedAt, out System.DateTimeOffset? RevokedAt) -> void
DodoSSH.Contracts.VaultGrantSummary.DisplayName.get -> string?
DodoSSH.Contracts.VaultGrantSummary.DisplayName.init -> void
DodoSSH.Contracts.VaultGrantSummary.Email.get -> string?
DodoSSH.Contracts.VaultGrantSummary.Email.init -> void
DodoSSH.Contracts.VaultGrantSummary.Equals(DodoSSH.Contracts.VaultGrantSummary? other) -> bool
DodoSSH.Contracts.VaultGrantSummary.GranterUserId.get -> System.Guid
DodoSSH.Contracts.VaultGrantSummary.GranterUserId.init -> void
DodoSSH.Contracts.VaultGrantSummary.KeyGeneration.get -> uint
DodoSSH.Contracts.VaultGrantSummary.KeyGeneration.init -> void
DodoSSH.Contracts.VaultGrantSummary.RecipientUserId.get -> System.Guid
DodoSSH.Contracts.VaultGrantSummary.RecipientUserId.init -> void
DodoSSH.Contracts.VaultGrantSummary.RevokedAt.get -> System.DateTimeOffset?
DodoSSH.Contracts.VaultGrantSummary.RevokedAt.init -> void
DodoSSH.Contracts.VaultGrantSummary.State.get -> DodoSSH.Contracts.VaultGrantState
DodoSSH.Contracts.VaultGrantSummary.State.init -> void
DodoSSH.Contracts.VaultGrantSummary.VaultGrantSummary(System.Guid RecipientUserId, string? Email, string? DisplayName, uint KeyGeneration, DodoSSH.Contracts.VaultGrantState State, System.Guid GranterUserId, System.DateTimeOffset CreatedAt, System.DateTimeOffset? RevokedAt) -> void
DodoSSH.Contracts.VaultSummary
DodoSSH.Contracts.VaultSummary.<Clone>$() -> DodoSSH.Contracts.VaultSummary!
DodoSSH.Contracts.VaultSummary.Deconstruct(out System.Guid VaultId, out string! Name, out bool IsPersonal, out System.Guid? TeamId, out uint KeyGeneration, out int Permissions, out byte[]? WrappedVaultKey, out bool RekeyRequired) -> void
@@ -469,6 +662,18 @@ DodoSSH.Contracts.VaultSummary.VaultId.init -> void
DodoSSH.Contracts.VaultSummary.VaultSummary(System.Guid VaultId, string! Name, bool IsPersonal, System.Guid? TeamId, uint KeyGeneration, int Permissions, byte[]? WrappedVaultKey, bool RekeyRequired) -> void
DodoSSH.Contracts.VaultSummary.WrappedVaultKey.get -> byte[]?
DodoSSH.Contracts.VaultSummary.WrappedVaultKey.init -> void
override DodoSSH.Contracts.AddTeamMemberRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.AddTeamMemberRequest.GetHashCode() -> int
override DodoSSH.Contracts.AddTeamMemberRequest.ToString() -> string!
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.GetHashCode() -> int
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.ToString() -> string!
override DodoSSH.Contracts.CreateTeamRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.CreateTeamRequest.GetHashCode() -> int
override DodoSSH.Contracts.CreateTeamRequest.ToString() -> string!
override DodoSSH.Contracts.CreateTeamVaultRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.CreateTeamVaultRequest.GetHashCode() -> int
override DodoSSH.Contracts.CreateTeamVaultRequest.ToString() -> string!
override DodoSSH.Contracts.DirectoryEntry.Equals(object? obj) -> bool
override DodoSSH.Contracts.DirectoryEntry.GetHashCode() -> int
override DodoSSH.Contracts.DirectoryEntry.ToString() -> string!
@@ -484,9 +689,18 @@ override DodoSSH.Contracts.EnrollmentRequest.ToString() -> string!
override DodoSSH.Contracts.EnrollmentResponse.Equals(object? obj) -> bool
override DodoSSH.Contracts.EnrollmentResponse.GetHashCode() -> int
override DodoSSH.Contracts.EnrollmentResponse.ToString() -> string!
override DodoSSH.Contracts.IssueVaultGrantRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.IssueVaultGrantRequest.GetHashCode() -> int
override DodoSSH.Contracts.IssueVaultGrantRequest.ToString() -> string!
override DodoSSH.Contracts.KdfParameters.Equals(object? obj) -> bool
override DodoSSH.Contracts.KdfParameters.GetHashCode() -> int
override DodoSSH.Contracts.KdfParameters.ToString() -> string!
override DodoSSH.Contracts.KeyLogPage.Equals(object? obj) -> bool
override DodoSSH.Contracts.KeyLogPage.GetHashCode() -> int
override DodoSSH.Contracts.KeyLogPage.ToString() -> string!
override DodoSSH.Contracts.KeyLogRecord.Equals(object? obj) -> bool
override DodoSSH.Contracts.KeyLogRecord.GetHashCode() -> int
override DodoSSH.Contracts.KeyLogRecord.ToString() -> string!
override DodoSSH.Contracts.KeyStatement.Equals(object? obj) -> bool
override DodoSSH.Contracts.KeyStatement.GetHashCode() -> int
override DodoSSH.Contracts.KeyStatement.ToString() -> string!
@@ -544,9 +758,29 @@ override DodoSSH.Contracts.SyncPushResponse.ToString() -> string!
override DodoSSH.Contracts.SyncPushResult.Equals(object? obj) -> bool
override DodoSSH.Contracts.SyncPushResult.GetHashCode() -> int
override DodoSSH.Contracts.SyncPushResult.ToString() -> string!
override DodoSSH.Contracts.TeamMemberSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.TeamMemberSummary.GetHashCode() -> int
override DodoSSH.Contracts.TeamMemberSummary.ToString() -> string!
override DodoSSH.Contracts.TeamSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.TeamSummary.GetHashCode() -> int
override DodoSSH.Contracts.TeamSummary.ToString() -> string!
override DodoSSH.Contracts.VaultGrantsResponse.Equals(object? obj) -> bool
override DodoSSH.Contracts.VaultGrantsResponse.GetHashCode() -> int
override DodoSSH.Contracts.VaultGrantsResponse.ToString() -> string!
override DodoSSH.Contracts.VaultGrantSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.VaultGrantSummary.GetHashCode() -> int
override DodoSSH.Contracts.VaultGrantSummary.ToString() -> string!
override DodoSSH.Contracts.VaultSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.VaultSummary.GetHashCode() -> int
override DodoSSH.Contracts.VaultSummary.ToString() -> string!
static DodoSSH.Contracts.AddTeamMemberRequest.operator !=(DodoSSH.Contracts.AddTeamMemberRequest? left, DodoSSH.Contracts.AddTeamMemberRequest? right) -> bool
static DodoSSH.Contracts.AddTeamMemberRequest.operator ==(DodoSSH.Contracts.AddTeamMemberRequest? left, DodoSSH.Contracts.AddTeamMemberRequest? right) -> bool
static DodoSSH.Contracts.ChangeTeamMemberRoleRequest.operator !=(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? left, DodoSSH.Contracts.ChangeTeamMemberRoleRequest? right) -> bool
static DodoSSH.Contracts.ChangeTeamMemberRoleRequest.operator ==(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? left, DodoSSH.Contracts.ChangeTeamMemberRoleRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamRequest.operator !=(DodoSSH.Contracts.CreateTeamRequest? left, DodoSSH.Contracts.CreateTeamRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamRequest.operator ==(DodoSSH.Contracts.CreateTeamRequest? left, DodoSSH.Contracts.CreateTeamRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamVaultRequest.operator !=(DodoSSH.Contracts.CreateTeamVaultRequest? left, DodoSSH.Contracts.CreateTeamVaultRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamVaultRequest.operator ==(DodoSSH.Contracts.CreateTeamVaultRequest? left, DodoSSH.Contracts.CreateTeamVaultRequest? right) -> bool
static DodoSSH.Contracts.DirectoryEntry.operator !=(DodoSSH.Contracts.DirectoryEntry? left, DodoSSH.Contracts.DirectoryEntry? right) -> bool
static DodoSSH.Contracts.DirectoryEntry.operator ==(DodoSSH.Contracts.DirectoryEntry? left, DodoSSH.Contracts.DirectoryEntry? right) -> bool
static DodoSSH.Contracts.DodoSshConfiguration.operator !=(DodoSSH.Contracts.DodoSshConfiguration? left, DodoSSH.Contracts.DodoSshConfiguration? right) -> bool
@@ -560,8 +794,14 @@ static DodoSSH.Contracts.EnrollmentRequest.operator !=(DodoSSH.Contracts.Enrollm
static DodoSSH.Contracts.EnrollmentRequest.operator ==(DodoSSH.Contracts.EnrollmentRequest? left, DodoSSH.Contracts.EnrollmentRequest? right) -> bool
static DodoSSH.Contracts.EnrollmentResponse.operator !=(DodoSSH.Contracts.EnrollmentResponse? left, DodoSSH.Contracts.EnrollmentResponse? right) -> bool
static DodoSSH.Contracts.EnrollmentResponse.operator ==(DodoSSH.Contracts.EnrollmentResponse? left, DodoSSH.Contracts.EnrollmentResponse? right) -> bool
static DodoSSH.Contracts.IssueVaultGrantRequest.operator !=(DodoSSH.Contracts.IssueVaultGrantRequest? left, DodoSSH.Contracts.IssueVaultGrantRequest? right) -> bool
static DodoSSH.Contracts.IssueVaultGrantRequest.operator ==(DodoSSH.Contracts.IssueVaultGrantRequest? left, DodoSSH.Contracts.IssueVaultGrantRequest? right) -> bool
static DodoSSH.Contracts.KdfParameters.operator !=(DodoSSH.Contracts.KdfParameters? left, DodoSSH.Contracts.KdfParameters? right) -> bool
static DodoSSH.Contracts.KdfParameters.operator ==(DodoSSH.Contracts.KdfParameters? left, DodoSSH.Contracts.KdfParameters? right) -> bool
static DodoSSH.Contracts.KeyLogPage.operator !=(DodoSSH.Contracts.KeyLogPage? left, DodoSSH.Contracts.KeyLogPage? right) -> bool
static DodoSSH.Contracts.KeyLogPage.operator ==(DodoSSH.Contracts.KeyLogPage? left, DodoSSH.Contracts.KeyLogPage? right) -> bool
static DodoSSH.Contracts.KeyLogRecord.operator !=(DodoSSH.Contracts.KeyLogRecord? left, DodoSSH.Contracts.KeyLogRecord? right) -> bool
static DodoSSH.Contracts.KeyLogRecord.operator ==(DodoSSH.Contracts.KeyLogRecord? left, DodoSSH.Contracts.KeyLogRecord? right) -> bool
static DodoSSH.Contracts.KeyStatement.operator !=(DodoSSH.Contracts.KeyStatement? left, DodoSSH.Contracts.KeyStatement? right) -> bool
static DodoSSH.Contracts.KeyStatement.operator ==(DodoSSH.Contracts.KeyStatement? left, DodoSSH.Contracts.KeyStatement? right) -> bool
static DodoSSH.Contracts.MeResponse.operator !=(DodoSSH.Contracts.MeResponse? left, DodoSSH.Contracts.MeResponse? right) -> bool
@@ -600,5 +840,13 @@ static DodoSSH.Contracts.SyncPushResponse.operator !=(DodoSSH.Contracts.SyncPush
static DodoSSH.Contracts.SyncPushResponse.operator ==(DodoSSH.Contracts.SyncPushResponse? left, DodoSSH.Contracts.SyncPushResponse? right) -> bool
static DodoSSH.Contracts.SyncPushResult.operator !=(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool
static DodoSSH.Contracts.SyncPushResult.operator ==(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool
static DodoSSH.Contracts.TeamMemberSummary.operator !=(DodoSSH.Contracts.TeamMemberSummary? left, DodoSSH.Contracts.TeamMemberSummary? right) -> bool
static DodoSSH.Contracts.TeamMemberSummary.operator ==(DodoSSH.Contracts.TeamMemberSummary? left, DodoSSH.Contracts.TeamMemberSummary? right) -> bool
static DodoSSH.Contracts.TeamSummary.operator !=(DodoSSH.Contracts.TeamSummary? left, DodoSSH.Contracts.TeamSummary? right) -> bool
static DodoSSH.Contracts.TeamSummary.operator ==(DodoSSH.Contracts.TeamSummary? left, DodoSSH.Contracts.TeamSummary? right) -> bool
static DodoSSH.Contracts.VaultGrantsResponse.operator !=(DodoSSH.Contracts.VaultGrantsResponse? left, DodoSSH.Contracts.VaultGrantsResponse? right) -> bool
static DodoSSH.Contracts.VaultGrantsResponse.operator ==(DodoSSH.Contracts.VaultGrantsResponse? left, DodoSSH.Contracts.VaultGrantsResponse? right) -> bool
static DodoSSH.Contracts.VaultGrantSummary.operator !=(DodoSSH.Contracts.VaultGrantSummary? left, DodoSSH.Contracts.VaultGrantSummary? right) -> bool
static DodoSSH.Contracts.VaultGrantSummary.operator ==(DodoSSH.Contracts.VaultGrantSummary? left, DodoSSH.Contracts.VaultGrantSummary? right) -> bool
static DodoSSH.Contracts.VaultSummary.operator !=(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool
static DodoSSH.Contracts.VaultSummary.operator ==(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool
+266
View File
@@ -0,0 +1,266 @@
namespace DodoSSH.Contracts;
/// <summary>
/// A member's role within a team, as it travels on the wire.
/// </summary>
/// <remarks>
/// <para>
/// A separate type from <c>DodoSSH.Domain.TeamRole</c> only because both are visible inside the
/// server, exactly as <c>GrantPurpose</c> is separate from <c>GrantKind</c>. The <b>numeric values
/// must match</b> that enum, and a test pins them: the two are converted by cast, so a renumbering
/// here silently promotes or demotes every member on the next deployment.
/// </para>
/// <para>
/// There is no <c>ConnectOnly</c> role, and there will not be one built this way. Connect is a
/// user-interface hint rather than a boundary — SSH terminates on the client, so opening a session
/// needs the credential's plaintext on that machine, and "may connect but may not read the key" is
/// unenforceable in this architecture. See <c>docs/adr/0001-e2ee-trust-model.md</c>.
/// </para>
/// </remarks>
public enum TeamMemberRole
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>May read the team's vaults and nothing else.</summary>
Viewer = 10,
/// <summary>May read and change the team's vaults.</summary>
Member = 20,
/// <summary>May also manage members, create vaults, and share vault keys.</summary>
Admin = 30,
/// <summary>Sole owner. Everything an admin may do, and cannot be removed while sole.</summary>
Owner = 40,
}
/// <summary>State of a team membership, as it travels on the wire.</summary>
/// <remarks>
/// Values match <c>DodoSSH.Domain.MembershipStatus</c>, for the reason
/// <see cref="TeamMemberRole"/> gives.
/// </remarks>
public enum TeamMemberStatus
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>
/// Invited but not yet accepted.
/// </summary>
/// <remarks>
/// Nothing writes this today. An invitation needs a token with a lifetime and an outbound mail
/// path, and this server has neither — so a member is added by looking their account up in the
/// directory, which requires that they have signed in here at least once. Retained because the
/// column exists and a client must not fail on a value a later server may send.
/// </remarks>
Invited = 1,
/// <summary>Active member.</summary>
Active = 2,
/// <summary>Removed. Retained so audit history stays resolvable to a person.</summary>
Revoked = 3,
}
/// <summary>State of a vault key grant, as it travels on the wire.</summary>
/// <remarks>Values match <c>DodoSSH.Domain.GrantState</c>.</remarks>
public enum VaultGrantState
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>Usable.</summary>
Active = 1,
/// <summary>
/// The recipient's identity key changed or the vault was rekeyed, so a member holding Share
/// must wrap the key afresh before the recipient can read anything again.
/// </summary>
AwaitingRewrap = 2,
/// <summary>
/// Revoked. Blocks future reads only — anything already downloaded is already gone, and the
/// remediation for a departed member is rotating the SSH credential itself. See ADR 0001.
/// </summary>
Revoked = 3,
}
/// <summary>A team the caller belongs to.</summary>
/// <param name="TeamId">The team.</param>
/// <param name="Name">Display name.</param>
/// <param name="Slug">URL-safe unique identifier.</param>
/// <param name="Description">Optional description.</param>
/// <param name="Role">The caller's own role.</param>
/// <param name="MemberCount">Active members, including the caller.</param>
/// <param name="VaultCount">Vaults the team owns.</param>
/// <param name="CreatedAt">When the team was created.</param>
public sealed record TeamSummary(
Guid TeamId,
string Name,
string Slug,
string? Description,
TeamMemberRole Role,
int MemberCount,
int VaultCount,
DateTimeOffset CreatedAt);
/// <summary>A request to create a team.</summary>
/// <remarks>
/// <see cref="TeamId"/> is chosen by the client for the same reason a vault id is: a request whose
/// response was lost can be re-sent verbatim and returns the identical team rather than creating a
/// second one under a name the user only meant to type once.
/// </remarks>
/// <param name="TeamId">Client-generated UUIDv7.</param>
/// <param name="Name">Display name.</param>
/// <param name="Slug">
/// URL-safe unique identifier, lowercase. Unique across the deployment, so this is the one field a
/// create can fail on for a reason the caller cannot see coming.
/// </param>
/// <param name="Description">Optional description.</param>
public sealed record CreateTeamRequest(
Guid TeamId,
string Name,
string Slug,
string? Description);
/// <summary>One member of a team.</summary>
/// <remarks>
/// Carries no last-active time and no avatar. <c>UserAccount.LastSeenAtUtc</c> is written at
/// provisioning and at enrollment and at no other point, so a column labelled "last active" would
/// be reporting something else entirely; and no picture is stored anywhere.
/// </remarks>
/// <param name="UserId">The member.</param>
/// <param name="Email">Email, for display.</param>
/// <param name="DisplayName">Display name.</param>
/// <param name="Role">Role within the team.</param>
/// <param name="Status">Membership state.</param>
/// <param name="IsEnrolled">
/// Whether this member has published an identity key. A member who has not cannot be granted a
/// vault key at all — there is nothing to wrap one to — so the interface has to be able to say so
/// rather than offering a share that would fail.
/// </param>
/// <param name="JoinedAt">When the membership became active.</param>
public sealed record TeamMemberSummary(
Guid UserId,
string? Email,
string? DisplayName,
TeamMemberRole Role,
TeamMemberStatus Status,
bool IsEnrolled,
DateTimeOffset? JoinedAt);
/// <summary>Adds a member to a team.</summary>
/// <remarks>
/// By user id rather than by email, and the id comes from a directory lookup the caller has already
/// made. That ordering is not incidental: whoever adds a member is usually about to wrap a vault key
/// to their public key, and the key they must verify is the one the directory returned. Adding by
/// email here would put an account resolution the client never saw between those two steps.
/// </remarks>
/// <param name="UserId">The account to add, as returned by the directory.</param>
/// <param name="Role">Role to grant.</param>
public sealed record AddTeamMemberRequest(Guid UserId, TeamMemberRole Role);
/// <summary>Changes a member's role.</summary>
/// <param name="Role">The new role.</param>
public sealed record ChangeTeamMemberRoleRequest(TeamMemberRole Role);
/// <summary>
/// Creates a vault owned by a team, with its key already wrapped to the creator.
/// </summary>
/// <remarks>
/// Shaped like <see cref="PersonalVaultRequest"/> and for the same reasons: the vault key is
/// generated on the client and sealed to the creator's own X25519 key, so the server cannot produce
/// this and cannot check that <see cref="WrappedVaultKey"/> contains anything in particular. A vault
/// created with no grant would be a container nobody could ever open, so the two arrive together.
/// <para>
/// The creator's grant carries no key log head, exactly as a personal vault's does not: there is no
/// third party whose key could have been substituted. Every <em>other</em> member's grant does carry
/// one — see <see cref="IssueVaultGrantRequest"/>.
/// </para>
/// </remarks>
/// <param name="VaultId">Client-generated UUIDv7.</param>
/// <param name="Name">Display name. Plaintext, as all vault names are.</param>
/// <param name="WrappedVaultKey">The vault key sealed to the creator's encryption key.</param>
/// <param name="GrantSignature">Ed25519 signature over the canonical grant tuple.</param>
/// <param name="GrantedAt">Signing timestamp, part of the signed tuple.</param>
public sealed record CreateTeamVaultRequest(
Guid VaultId,
string Name,
byte[] WrappedVaultKey,
byte[] GrantSignature,
DateTimeOffset GrantedAt);
/// <summary>Issues a vault key grant to another member.</summary>
/// <remarks>
/// <para>
/// The wrap is made by a client that holds the vault key, to a public key it has verified. The
/// server stores both the ciphertext and the signature and can check neither — which is the property
/// that makes it a zero-knowledge server rather than a key-holding one.
/// </para>
/// <para>
/// <see cref="KeyLogHead"/> is required here and absent for a self-grant. A third party's key could
/// have been substituted by the server; recording the log head the granter observed while wrapping
/// is what converts that from an undetectable attack into a detectable one. See docs/crypto.md §7.2.
/// </para>
/// </remarks>
/// <param name="RecipientUserId">Who the key was wrapped to.</param>
/// <param name="RecipientKeyFingerprint">
/// The exact identity key it was wrapped to. Stored so a later rotation invalidates this grant
/// explicitly rather than leaving a row that no longer opens.
/// </param>
/// <param name="KeyGeneration">
/// The generation wrapped. Rejected when it is not the vault's current one, because a grant for a
/// superseded generation opens nothing and would read as corruption at the far end.
/// </param>
/// <param name="WrappedVaultKey">The vault key sealed to the recipient. Opaque to the server.</param>
/// <param name="KeyLogHead">The key log head the granter observed while wrapping.</param>
/// <param name="GrantSignature">Ed25519 signature over the canonical grant tuple.</param>
/// <param name="GrantedAt">Signing timestamp, part of the signed tuple.</param>
public sealed record IssueVaultGrantRequest(
Guid RecipientUserId,
byte[] RecipientKeyFingerprint,
uint KeyGeneration,
byte[] WrappedVaultKey,
byte[] KeyLogHead,
byte[] GrantSignature,
DateTimeOffset GrantedAt);
/// <summary>One vault key grant, as the sharing interface sees it.</summary>
/// <remarks>
/// The wrapped key itself is deliberately not here. A member reads their own through
/// <see cref="VaultSummary.WrappedVaultKey"/>; this listing exists so somebody holding Share can see
/// <em>who has one</em>, and serving every member's sealed key to every member would be a pointless
/// widening of what a stolen access token yields.
/// </remarks>
/// <param name="RecipientUserId">Who holds it.</param>
/// <param name="Email">Their email, for display.</param>
/// <param name="DisplayName">Their display name.</param>
/// <param name="KeyGeneration">Generation this grant is for.</param>
/// <param name="State">Grant state.</param>
/// <param name="GranterUserId">Who issued it.</param>
/// <param name="CreatedAt">When it was issued.</param>
/// <param name="RevokedAt">When it was revoked, if it was.</param>
public sealed record VaultGrantSummary(
Guid RecipientUserId,
string? Email,
string? DisplayName,
uint KeyGeneration,
VaultGrantState State,
Guid GranterUserId,
DateTimeOffset CreatedAt,
DateTimeOffset? RevokedAt);
/// <summary>Who can open a vault, and at which generation.</summary>
/// <param name="VaultId">The vault.</param>
/// <param name="KeyGeneration">
/// The vault's current generation. A grant listed at anything lower is stale, which is what a client
/// compares against rather than inferring from <see cref="VaultGrantSummary.State"/> alone.
/// </param>
/// <param name="RekeyRequired">Whether a membership change has left this vault needing a rekey.</param>
/// <param name="Grants">Every grant, including revoked ones.</param>
public sealed record VaultGrantsResponse(
Guid VaultId,
uint KeyGeneration,
bool RekeyRequired,
IReadOnlyList<VaultGrantSummary> Grants);