namespace DodoSSH.Contracts; /// /// A member's role within a team, as it travels on the wire. /// /// /// /// A separate type from DodoSSH.Domain.TeamRole only because both are visible inside the /// server, exactly as GrantPurpose is separate from GrantKind. The numeric values /// must match 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. /// /// /// There is no ConnectOnly 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 docs/adr/0001-e2ee-trust-model.md. /// /// public enum TeamMemberRole { /// Not a legal value. Unspecified = 0, /// May read the team's vaults and nothing else. Viewer = 10, /// May read and change the team's vaults. Member = 20, /// May also manage members, create vaults, and share vault keys. Admin = 30, /// Sole owner. Everything an admin may do, and cannot be removed while sole. Owner = 40, } /// State of a team membership, as it travels on the wire. /// /// Values match DodoSSH.Domain.MembershipStatus, for the reason /// gives. /// public enum TeamMemberStatus { /// Not a legal value. Unspecified = 0, /// /// Invited but not yet accepted. /// /// /// /// Nothing writes this, and nothing in this server can. A membership names an account — /// team_membership.user_id is not nullable and carries a foreign key — so a row in this /// state would have to point at somebody who has never signed in. There was a separate invitation /// record that stood in for exactly that, and it is gone: an address is not a way into a team, and /// only an account that exists can be added. See docs/adr/0009-team-access-model.md. /// /// /// Retained because the column exists and holds this value in nobody's database, and because a /// client must not fail on a value a later server may send. /// /// Invited = 1, /// Active member. Active = 2, /// Removed. Retained so audit history stays resolvable to a person. Revoked = 3, } /// State of a vault key grant, as it travels on the wire. /// Values match DodoSSH.Domain.GrantState. public enum VaultGrantState { /// Not a legal value. Unspecified = 0, /// Usable. Active = 1, /// /// 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. /// AwaitingRewrap = 2, /// /// 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. /// Revoked = 3, } /// A team the caller belongs to. /// The team. /// Display name. /// URL-safe unique identifier. /// Optional description. /// The caller's own role. /// Active members, including the caller. /// Vaults the team owns. /// When the team was created. public sealed record TeamSummary( Guid TeamId, string Name, string Slug, string? Description, TeamMemberRole Role, int MemberCount, int VaultCount, DateTimeOffset CreatedAt); /// A request to create a team. /// /// 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. /// /// Client-generated UUIDv7. /// Display name. /// /// 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. /// /// Optional description. public sealed record CreateTeamRequest( Guid TeamId, string Name, string Slug, string? Description); /// Renames a team, or changes its description. /// /// /// The slug is not here and cannot be changed. It is what a URL, an operator's query and any bookmark /// name, and it is unique only among live teams — so a rename could take a slug an archived /// team is still holding on to, and the archived one could then never be brought back. Renaming the /// display name is the operation people actually want; renaming the identifier is a migration. /// /// /// A whole replacement rather than a patch: both fields are always sent, so clearing a description is /// sending null rather than a distinct verb. There is nowhere to record when a team was last /// renamed — team has no updated-at column — so no client can show "edited", and this contract /// does not pretend one can. /// /// /// Display name. Required. /// Optional description. Null clears it. public sealed record UpdateTeamRequest(string Name, string? Description); /// Hands a team's ownership to another member. /// /// /// Its own operation rather than a role change, because it is two writes that must not be separable: /// the recipient becomes owner and the outgoing owner becomes an admin, in one transaction. Ownership /// is sole, so doing it as two role changes would leave the team either briefly ownerless or briefly /// owned twice, and refuses /// outright for exactly that reason. /// /// /// The outgoing owner is demoted to rather than removed. Removing /// them would revoke their vault key grants and flag every team vault for rekey, which is a far larger /// act than the one being asked for — and somebody handing over a team is usually staying in it. /// /// /// /// The member to hand it to. Must already be an active member: adding somebody and making them owner /// in one step would let an address typed once take the team. /// public sealed record TransferTeamOwnershipRequest(Guid UserId); /// One member of a team. /// /// Carries no avatar, because no picture is stored anywhere. It does now carry a last-active time — /// see , which names precisely what it measures, because the useful version /// of that column and the misleading one differ only in what the server bothered to write down. /// /// The member. /// Email, for display. /// Display name. /// Role within the team. /// Membership state. /// /// 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. /// /// When the membership became active. /// /// When this account last made an authenticated request, or null if it never has. /// /// It is deliberately coarse. The server records it at most once per account per hour, so a value an /// hour old means "recently" rather than "at that instant" — which is the granularity the question is /// actually asked at, and a far smaller thing to know about a colleague than a per-request timeline /// would be. Displaying it to the minute would be reading precision into it that is not there. /// /// public sealed record TeamMemberSummary( Guid UserId, string? Email, string? DisplayName, TeamMemberRole Role, TeamMemberStatus Status, bool IsEnrolled, DateTimeOffset? JoinedAt, DateTimeOffset? LastActiveAt = null); /// Adds a member to a team. /// /// /// By user id when the caller has one, and the id comes from a directory lookup they have 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. Resolving /// an address server-side when an id was available would put an account resolution the client never /// saw between those two steps. /// /// /// exists because the directory cannot answer for everybody. It returns /// only accounts that have published a key — an entry exists to be wrapped to, and one carrying no key /// is a check callers forget exactly once — so an account between its first sign-in and its enrollment /// is invisible there. It is still an account, and it can still be a member: membership is server-side /// authorization and grants nothing readable, which is why /// exists to say that a member has no key yet. Without this field such a person could not be added at /// all, and a caller reading the directory's silence as "no account here" would report an absence to /// somebody who is standing right there. /// /// /// This is the only way into a team. There is no invitation and no address-based path — a /// membership is granted to an account that already exists, named by somebody who can see it. That /// rules out the shape where a team is joined by whoever turns up holding a token asserting an /// address, which is the same attack OidcOptions.AllowEmailLinking refuses one door along. The /// cost is stated rather than hidden: somebody who has never signed in here cannot be added yet, and /// the refusal says so. See docs/adr/0009-team-access-model.md. /// /// /// No key is verified on this path, and none needs to be: nothing is wrapped by adding somebody. The /// key that matters is fetched and checked at share time, from the directory, by the machine holding /// the vault key. /// /// /// /// The account to add, as returned by the directory. defers to /// . /// /// Role to grant. /// /// The address to resolve, used only when is . Matched /// case-insensitively, exactly as the directory matches. An address with no account here is refused /// with , which is the end of the road rather than a step on /// it: the remedy is that person signing in once, and it belongs to them rather than to the caller. /// public sealed record AddTeamMemberRequest( Guid UserId, TeamMemberRole Role, string? Email = null); /// Changes a member's role. /// The new role. public sealed record ChangeTeamMemberRoleRequest(TeamMemberRole Role); /// /// Creates a vault owned by a team, with its key already wrapped to the creator. /// /// /// Shaped like 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 contains anything in particular. A vault /// created with no grant would be a container nobody could ever open, so the two arrive together. /// /// 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 other member's grant does carry /// one — see . /// /// /// Client-generated UUIDv7. /// Display name. Plaintext, as all vault names are. /// The vault key sealed to the creator's encryption key. /// Ed25519 signature over the canonical grant tuple. /// Signing timestamp, part of the signed tuple. public sealed record CreateTeamVaultRequest( Guid VaultId, string Name, byte[] WrappedVaultKey, byte[] GrantSignature, DateTimeOffset GrantedAt); /// Renames a vault. /// /// /// The one field of a vault a person chose, and the only one that can be changed. A vault's key /// generation, its owner and its rekey flag are all consequences of something else happening; its name /// is what somebody typed into a box, and typing the wrong thing into a box is the ordinary mistake this /// exists to undo. /// /// /// It is plaintext, as vault names have always been — a person has to be able to choose a vault before /// anything is decrypted (docs/crypto.md §10). So a rename is visible to the operator, exactly as /// the original name was, and this changes nothing about what the server can read. /// /// /// A whole replacement rather than a patch, for the reason is one: there /// is a single field, so a repeat is the same vault rather than a second edit. /// /// /// Display name. Required, 1 to 256 characters. public sealed record UpdateVaultRequest(string Name); /// Issues a vault key grant to another member. /// /// /// 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. /// /// /// 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. /// /// /// Who the key was wrapped to. /// /// 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. /// /// /// 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. /// /// The vault key sealed to the recipient. Opaque to the server. /// The key log head the granter observed while wrapping. /// Ed25519 signature over the canonical grant tuple. /// Signing timestamp, part of the signed tuple. public sealed record IssueVaultGrantRequest( Guid RecipientUserId, byte[] RecipientKeyFingerprint, uint KeyGeneration, byte[] WrappedVaultKey, byte[] KeyLogHead, byte[] GrantSignature, DateTimeOffset GrantedAt); /// /// Moves a vault to a fresh key, wrapped to the caller. /// /// /// /// The new key is generated by a client that already holds the current one, and arrives sealed to that /// same client — the server can neither produce it nor tell that it differs from the old one. What the /// server does is decide the moment it takes effect: the generation advances in one transaction, so /// there is no instant at which two clients disagree about which generation is current. /// /// /// Grants for earlier generations are kept, not revoked. Every item still carries the generation /// it was sealed under, so withdrawing them would make the vault's whole history unreadable to the /// people who are still in it. The departed member's grants are revoked — that is what /// RevokeGrantAsync and removal from the team already do — and this is what stops them reading /// anything written from here on. It does not reach back; see ADR 0001. /// /// /// /// The generation being created. Must be exactly one past the vault's current one, so two clients /// rotating at once cannot both believe they succeeded. /// /// The new vault key, sealed to the caller's own encryption key. /// Ed25519 signature over the canonical grant tuple. /// Signing timestamp, part of the signed tuple. public sealed record RekeyVaultRequest( uint KeyGeneration, byte[] WrappedVaultKey, byte[] GrantSignature, DateTimeOffset GrantedAt); /// One vault key grant, as the sharing interface sees it. /// /// The wrapped key itself is deliberately not here. A member reads their own through /// ; this listing exists so somebody holding Share can see /// who has one, and serving every member's sealed key to every member would be a pointless /// widening of what a stolen access token yields. /// /// Who holds it. /// Their email, for display. /// Their display name. /// Generation this grant is for. /// Grant state. /// Who issued it. /// When it was issued. /// When it was revoked, if it was. public sealed record VaultGrantSummary( Guid RecipientUserId, string? Email, string? DisplayName, uint KeyGeneration, VaultGrantState State, Guid GranterUserId, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt); /// Who can open a vault, and at which generation. /// The vault. /// /// The vault's current generation. A grant listed at anything lower is stale, which is what a client /// compares against rather than inferring from alone. /// /// Whether a membership change has left this vault needing a rekey. /// /// One row per holder, including those whose access has been withdrawn. Not one per grant: a rotated /// vault leaves a member holding one grant per generation, and the row carries the best of them — so /// below means they have /// not been wrapped the current key yet, rather than that one of their grants is old. /// public sealed record VaultGrantsResponse( Guid VaultId, uint KeyGeneration, bool RekeyRequired, IReadOnlyList Grants);