Public Access
ADD MEMBER quietly issued an invitation instead of adding anybody, for everyone who had signed in here and not yet enrolled. The screen told them that address had no account, the members list did not change, and the person only actually joined on the next hourly sweep. The client decided whether an address had an account by asking the public-key directory, and the directory answers a narrower question than that. It drops every account with no current key — deliberately, because an entry exists to be wrapped to and one carrying no key is a check a caller forgets exactly once. An account exists from its owner's first authenticated request and publishes nothing until they choose a passphrase on their own machine, so every account is missing from the directory for that whole window and some indefinitely. A miss there is not an absent account, and reading it as one was the bug. The server would have taken the add. TeamService.AddMemberAsync only requires the account row, and TeamMemberSummary.IsEnrolled exists precisely so a member with no key can be listed — added on Monday, enrolled on Tuesday. The client never asked. So the directory is still asked first and the miss is retried as an add by address, and only a server saying there is no such account reaches the invitation. AddTeamMemberRequest gained an Email used when UserId is empty. The lookup-first ordering is kept because it is load-bearing for sharing and not for this: the key verified before a vault key is wrapped is the one the lookup returned, and nothing is wrapped by adding somebody. That is why resolving the address server-side is safe here and would not be there. NoSuchAccount is its own code rather than folded into InvalidTeam, because it is the one add failure the caller can act on unprompted — there is nobody to add, so invite them — and a code shared with a rejected role would leave them guessing which had happened. It does answer whether an address has an account here, which CreateTeamInvitationRequest deliberately does not. That is the property traded for the fix; the exposure is bounded by the admin check the add already needed, and it is the same fact the member list shows a moment later. Adding by a user id that does not exist now answers 404 no-such-account rather than 400 invalid-team, and nothing depended on the old pairing. The two silent returns are gone. Offline and no-team-selected set nothing and returned, so those failures were visible only as a flicker of the busy flag — which reads as a button that does nothing at all. The success line reads the enrollment flag too, because pointing an unenrolled member at SHARE KEY is pointing at a button that will refuse; their row already says it holds no key. Why nothing caught it. FakeVaultServer had one list, so it could not tell an account that does not exist from one that exists and has not enrolled — the distinction this whole path turns on — and every account it knew was enrolled by construction. It grows an accounts list beside the directory and reports IsEnrolled from whether the directory has them, rather than hardcoding true. The regression test asserts Invitations is empty, which is what fails against the old behaviour. Four tests: that pair in the shell suite, and in the API suite an unenrolled account added by address after its own directory lookup comes back empty, and an unknown address refused under the new code. 1495 tests pass.
445 lines
21 KiB
C#
445 lines
21 KiB
C#
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>
|
|
/// <para>
|
|
/// Still nothing writes this, and invitations shipping is the reason rather than an exception to
|
|
/// it. A membership names an account: <c>team_membership.user_id</c> is not nullable and carries a
|
|
/// foreign key, so somebody who has never signed in has nothing for that row to point at. An
|
|
/// invitation is therefore its own record against an <em>address</em>
|
|
/// (<see cref="TeamInvitationSummary"/>), and it becomes a membership at
|
|
/// <see cref="Active"/> the moment an account with that address first signs in.
|
|
/// </para>
|
|
/// <para>
|
|
/// Retained because the column exists and a client must not fail on a value a later server may
|
|
/// send — a server that grew a second invitation model would use it.
|
|
/// </para>
|
|
/// </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>Renames a team, or changes its description.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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 <em>live</em> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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 <em>when</em> a team was last
|
|
/// renamed — <c>team</c> has no updated-at column — so no client can show "edited", and this contract
|
|
/// does not pretend one can.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="Name">Display name. Required.</param>
|
|
/// <param name="Description">Optional description. Null clears it.</param>
|
|
public sealed record UpdateTeamRequest(string Name, string? Description);
|
|
|
|
/// <summary>Hands a team's ownership to another member.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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 <see cref="ChangeTeamMemberRoleRequest"/> refuses
|
|
/// <see cref="TeamMemberRole.Owner"/> outright for exactly that reason.
|
|
/// </para>
|
|
/// <para>
|
|
/// The outgoing owner is demoted to <see cref="TeamMemberRole.Admin"/> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="UserId">
|
|
/// 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.
|
|
/// </param>
|
|
public sealed record TransferTeamOwnershipRequest(Guid UserId);
|
|
|
|
/// <summary>One member of a team.</summary>
|
|
/// <remarks>
|
|
/// Carries no avatar, because no picture is stored anywhere. It does now carry a last-active time —
|
|
/// see <see cref="LastActiveAt"/>, 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.
|
|
/// </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>
|
|
/// <param name="LastActiveAt">
|
|
/// When this account last made an authenticated request, or null if it never has.
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </param>
|
|
public sealed record TeamMemberSummary(
|
|
Guid UserId,
|
|
string? Email,
|
|
string? DisplayName,
|
|
TeamMemberRole Role,
|
|
TeamMemberStatus Status,
|
|
bool IsEnrolled,
|
|
DateTimeOffset? JoinedAt,
|
|
DateTimeOffset? LastActiveAt = null);
|
|
|
|
/// <summary>Adds a member to a team.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>By user id when the caller has one, and the id comes from a directory lookup they have already
|
|
/// made.</b> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b><see cref="Email"/> exists because the directory cannot answer for everybody.</b> 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 <see cref="TeamMemberSummary.IsEnrolled"/>
|
|
/// 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 invite an address that
|
|
/// already has one.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="UserId">
|
|
/// The account to add, as returned by the directory. <see cref="Guid.Empty"/> defers to
|
|
/// <see cref="Email"/>.
|
|
/// </param>
|
|
/// <param name="Role">Role to grant.</param>
|
|
/// <param name="Email">
|
|
/// The address to resolve, used only when <see cref="UserId"/> is <see cref="Guid.Empty"/>. Matched
|
|
/// case-insensitively, exactly as the directory matches. An address with no account here is refused
|
|
/// with <see cref="ProblemCodes.NoSuchAccount"/> so the caller can offer an invitation instead.
|
|
/// </param>
|
|
public sealed record AddTeamMemberRequest(
|
|
Guid UserId,
|
|
TeamMemberRole Role,
|
|
string? Email = null);
|
|
|
|
/// <summary>Changes a member's role.</summary>
|
|
/// <param name="Role">The new role.</param>
|
|
public sealed record ChangeTeamMemberRoleRequest(TeamMemberRole Role);
|
|
|
|
/// <summary>What has become of an invitation.</summary>
|
|
/// <remarks>
|
|
/// Derived from the invitation's own timestamps rather than stored, so — unlike every other enum in
|
|
/// this file — it has no <c>DodoSSH.Domain</c> twin and no numbering to keep in step. That is the
|
|
/// point of computing it: <see cref="Expired"/> is a fact about the clock, and a stored state would
|
|
/// have to be swept by something that remembered to run.
|
|
/// </remarks>
|
|
public enum TeamInvitationState
|
|
{
|
|
/// <summary>Not a legal value.</summary>
|
|
Unspecified = 0,
|
|
|
|
/// <summary>Waiting. It becomes a membership when an account with this address signs in.</summary>
|
|
Pending = 1,
|
|
|
|
/// <summary>Taken up. The address signed in and is now a member.</summary>
|
|
Accepted = 2,
|
|
|
|
/// <summary>Withdrawn before it was taken up.</summary>
|
|
Revoked = 3,
|
|
|
|
/// <summary>Its lifetime ran out. It will not become a membership.</summary>
|
|
Expired = 4,
|
|
}
|
|
|
|
/// <summary>Invites an address that has no account here yet.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>By email, where <see cref="AddTeamMemberRequest"/> is by user id.</b> Adding a member resolves an
|
|
/// address through the directory first, so the caller sees the public key they are about to wrap a
|
|
/// vault to. An invitation cannot do that — there may be no account and therefore no key — so it grants
|
|
/// nothing readable and is never a step towards sharing.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>An address that already has an account is accepted rather than refused</b>, and only an address
|
|
/// already belonging to a member of this team is turned away. Refusing on the strength of an account
|
|
/// existing would make this endpoint an oracle for which addresses have accounts here, answerable by
|
|
/// anybody willing to create a team first — and it would be answering a question the caller did not
|
|
/// ask. Whether the account exists changes only how soon the invitation is taken up: an existing one
|
|
/// picks it up on its next request.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>There is no token and nothing is sent.</b> This server has no outbound mail path, so the
|
|
/// invitation is not a link: it is a standing instruction that the next account to sign in with this
|
|
/// address joins the team. Telling them to sign in is the caller's job, over a channel this server
|
|
/// does not carry. That also means the address has to be one the identity provider will assert and
|
|
/// mark verified — an unverified email is refused at claim time, because an invitation that anybody
|
|
/// could take by naming somebody else's address is a way in.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="InvitationId">
|
|
/// Client-generated UUIDv7, for the reason a team id is client-generated: a create whose response was
|
|
/// lost can be re-sent verbatim rather than leaving two invitations to the same address.
|
|
/// </param>
|
|
/// <param name="Email">The address to invite. Matched case-insensitively.</param>
|
|
/// <param name="Role">
|
|
/// Role to grant on arrival. May not be <see cref="TeamMemberRole.Owner"/> — ownership is sole and is
|
|
/// handed over deliberately, never conferred by an address signing in.
|
|
/// </param>
|
|
public sealed record CreateTeamInvitationRequest(
|
|
Guid InvitationId,
|
|
string Email,
|
|
TeamMemberRole Role);
|
|
|
|
/// <summary>One invitation, as the teams interface sees it.</summary>
|
|
/// <remarks>
|
|
/// The address is in plaintext here, as it is on <see cref="TeamMemberSummary"/>. It is readable by
|
|
/// the team's members, who are the people it concerns; the server stores it in plaintext either way
|
|
/// and docs/crypto.md §10 already records that membership metadata is not encrypted.
|
|
/// </remarks>
|
|
/// <param name="InvitationId">The invitation.</param>
|
|
/// <param name="Email">The address invited.</param>
|
|
/// <param name="Role">The role it will grant.</param>
|
|
/// <param name="State">What has become of it.</param>
|
|
/// <param name="InvitedByUserId">Who issued it.</param>
|
|
/// <param name="CreatedAt">When it was issued.</param>
|
|
/// <param name="ExpiresAt">
|
|
/// When it stops being claimable. An invitation that never expired would be a standing offer on an
|
|
/// address somebody may hand on or lose.
|
|
/// </param>
|
|
/// <param name="AcceptedAt">When an account with this address signed in and took it up, if one has.</param>
|
|
public sealed record TeamInvitationSummary(
|
|
Guid InvitationId,
|
|
string Email,
|
|
TeamMemberRole Role,
|
|
TeamInvitationState State,
|
|
Guid InvitedByUserId,
|
|
DateTimeOffset CreatedAt,
|
|
DateTimeOffset ExpiresAt,
|
|
DateTimeOffset? AcceptedAt);
|
|
|
|
/// <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);
|