Public Access
M3 built teams and stopped short of the two operations that decide who controls one. Both were written down as refusals rather than omissions: ADR 0009 listed ownership transfer under "deliberately not built", and design-import-gaps said an invitation needed "a token with a lifetime and an outbound mail path". One of those reasons had expired and the other never applied — an invitation does not need a token if it is not a thing anybody presents. Handing a team over is one write. The member you name becomes owner and you become an admin, in a single transaction, because ownership is sole: promoting first leaves the team owned twice, demoting first leaves it owned by nobody, and there is nobody left with the authority to finish a transfer that stopped in the middle. That is also why it is not two calls to the role endpoint, which refuses Owner outright. The outgoing owner is demoted 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 asked for, and somebody handing over a team is usually staying in it. It unblocks the thing that was impossible before: an owner can now leave, by handing the team on first. An invitation is a standing instruction rather than a message. This server has no outbound mail path, so nothing is sent and there is nothing for the invitee to present. The row says the next account signing in with that address joins this team at this role, and telling them to sign in is the caller's job over a channel this server does not carry. A link nobody can deliver would be worse than none. It lives in its own table rather than becoming a membership with MembershipStatus.Invited, and that member stays unwritten for the reason it always was: team_membership.user_id is not nullable and carries a foreign key, so somebody who has never signed in has nothing for that row to point at. Widening it would make the unique index on (team, user) meaningless, because PostgreSQL counts every NULL as distinct. Verification is the security boundary, and nothing in this server read it before. A claim requires the access token to assert email_verified. An invitation decides what the server will serve, so one claimable by anybody able to obtain a token carrying somebody else's address is a way into a team — which is precisely the attack OidcOptions.AllowEmailLinking exists to refuse, and it would have been reintroduced by the back door. There is deliberately no setting that relaxes it: a flag that exists is one somebody turns on for the afternoon their provider is misconfigured. Absence is refused rather than trusted, and logged, because a provider that never sends the claim otherwise leaves every invitation pending with nothing anywhere saying why. Claiming happens at just-in-time provisioning and again on an hourly sweep. The sweep is what makes it recoverable rather than one-shot — an invitation issued between an account being created and that person next signing in would otherwise be stranded for ever — and it shares its rate with the last-seen write because both are housekeeping nobody is waiting on. Archiving is refused while a team owns a vault, and that refusal is the end of the road rather than a step on it. A team vault is readable because of membership, so archiving one that still owned vaults would take them away from everybody holding a key, including the caller, quietly and all at once. Nothing in this product deletes a vault, so no order of operations gets past it today — which is stated with a count of what is in the way, for the reason the SFTP layer refuses a recursive delete: a refusal is visible and a quiet removal is not. It is owner-only, as handing over is; renaming is not, because a rename is visible to everybody and reversible by anybody who can do it. The slug is not renameable at all: it is unique only among live teams, so a rename could take one an archived team is still holding, and that team could then never be restored. LAST ACTIVE is real and coarse on purpose. UserAccount.LastSeenAtUtc is refreshed on ordinary authenticated requests, at most once per account per hour, through ExecuteUpdateAsync — user_account carries the xmin concurrency token, so a read-then-write on the hot path would start losing races between one user's own overlapping requests. An hour is the granularity the question is actually asked at, and the interface draws it to the day rather than the minute so it does not read as a precision that is not there. The remarks in Contracts and in the view model that argued at length for the column's absence are rewritten rather than extended; both had become false. Two endpoints already existed and nothing called them. ChangeTeamMemberRole and ListVaultGrants have been reachable since M3. The role picker refuses Owner itself rather than letting the server do it, since the interface already knew the rule; the key-holder list sits under the vault rather than beside the member, because a grant is per vault and a count on a member row would imply per-item sharing, which is M5. It lists withdrawn and stale grants and says which they are — a list that dropped them would show a departed colleague as merely absent rather than as somebody whose key was taken away — and staleness is decided by comparing generations, since a grant can be Active and still open nothing. ADD MEMBER stopped being a dead end. An address the directory did not know used to end at a sentence telling the user their colleague had to sign in first. It invites them instead, from the same button, because which of the two applies is a fact about the server's account table rather than about what the user is doing; which one happened is reported afterwards, because that decides what they do next. An address that merely has an account is invited rather than refused: refusing would have made the endpoint an oracle for which addresses have accounts here, answerable by anybody willing to create a team first. The phone has a TEAMS screen, behind MORE, and it is the reverse of every other row in design-import-gaps: a shipped screen the design had no slot for. It is there because an invitation is claimed by signing in, so somebody told they are now in a team is at least as likely to be holding a phone — and a membership visible only on a head they never installed is one they cannot see. It draws SHARE KEY and nothing that takes something away: wrapping a key is the one act on that screen a server cannot perform at all, and the desktop guards its revocations with a tooltip, which is a control a touch screen cannot show. Two defects were found by an adversarial pass and both were green against the whole suite at the time. The owner-only check on archiving and handing over had been weakened to the admin check while their messages and comments still said owner — and since nothing behind the archive endpoint re-checks it, an admin the owner had promoted could have archived the team out from under them. And the rename endpoint built its response with a hardcoded Owner role, so an admin who renamed a team was handed a summary claiming they owned it, and a client trusting that instead of re-listing would have offered them the two owner-only buttons the server then refuses. The new table gets its constraints tested rather than merely migrated: live uniqueness per (team, address), the citext proof that an address typed by a person matches one cased by a provider, and reissue after both revocation and acceptance. The teams screen gets its first entries in the layout suite, at the minimum window with every list populated and with each of the two states that cover half of it — it had none, and it just grew four sections and a second line in the member row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
711 lines
29 KiB
C#
711 lines
29 KiB
C#
using System.Globalization;
|
|
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using DodoSSH.Contracts;
|
|
|
|
namespace DodoSSH.Client.Api;
|
|
|
|
/// <summary>Supplies the bearer token for API calls, refreshing it when needed.</summary>
|
|
/// <remarks>
|
|
/// An abstraction because token lifetime is the auth layer's problem, not the API client's. The
|
|
/// client asks for a token per request and never caches one, so a refresh that happens mid-session is
|
|
/// invisible here rather than something every call site has to remember to handle.
|
|
/// </remarks>
|
|
public interface IAccessTokenProvider
|
|
{
|
|
/// <summary>Returns a currently-valid access token.</summary>
|
|
ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The account calls: who am I, and publish my first key.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Separated for the same reason as <see cref="ISyncApi"/>. What the session layer does with these is
|
|
/// decide between enrolling and unlocking, and persist the result so the next launch needs no network;
|
|
/// testing that against a stubbed HTTP transport would prove the right bytes were sent and nothing
|
|
/// about the decision.
|
|
/// </remarks>
|
|
public interface IAccountApi
|
|
{
|
|
/// <summary>Reads the caller's profile, unlock material and reachable vaults.</summary>
|
|
Task<MeResponse> GetMeAsync(CancellationToken cancellationToken);
|
|
|
|
/// <summary>Publishes the caller's first identity key and creates their personal vault.</summary>
|
|
Task<EnrollmentResponse> EnrollAsync(EnrollmentRequest request, CancellationToken cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Registers a device key against an account that is already enrolled.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// On the interface rather than only on the client, because the session layer decides <em>when</em> to
|
|
/// offer this — after an unlock, never before — and that decision is worth testing without HTTP.
|
|
/// </remarks>
|
|
Task<RegisterDeviceResponse> RegisterDeviceAsync(
|
|
RegisterDeviceRequest request,
|
|
CancellationToken cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Withdraws a device key, so that machine can no longer unlock without the passphrase.
|
|
/// </summary>
|
|
/// <returns>
|
|
/// Whether the account had that device. False means it did not, which a caller withdrawing its own
|
|
/// device should treat as having arrived rather than as a failure — another machine may have revoked it
|
|
/// first, and the goal state is the same either way.
|
|
/// </returns>
|
|
Task<bool> RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Teams, their members, and the vaults they own.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Separated from <see cref="IVaultGrantApi"/> although the two are used together, because they are
|
|
/// different kinds of act. Everything here changes what the <em>server</em> will serve and can be
|
|
/// performed by anything holding a token. Issuing a grant needs a vault key, which only an unlocked
|
|
/// session has — so the two live behind different interfaces and are tested against different fakes.
|
|
/// </remarks>
|
|
public interface ITeamApi
|
|
{
|
|
/// <summary>Lists the teams the caller belongs to.</summary>
|
|
Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken);
|
|
|
|
/// <summary>Creates a team, with the caller as its owner.</summary>
|
|
Task<TeamSummary> CreateTeamAsync(CreateTeamRequest request, CancellationToken cancellationToken);
|
|
|
|
/// <summary>Renames a team, or changes its description.</summary>
|
|
Task<TeamSummary> UpdateTeamAsync(
|
|
Guid teamId,
|
|
UpdateTeamRequest request,
|
|
CancellationToken cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Archives a team. Refused while it still owns vaults.
|
|
/// </summary>
|
|
/// <returns>
|
|
/// Whether there was a team to archive. False means there was not, which a caller driving towards
|
|
/// "that team is gone" should treat as having arrived.
|
|
/// </returns>
|
|
Task<bool> ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken);
|
|
|
|
/// <summary>Hands ownership to another member, demoting the outgoing owner to admin.</summary>
|
|
Task TransferTeamOwnershipAsync(
|
|
Guid teamId,
|
|
TransferTeamOwnershipRequest request,
|
|
CancellationToken cancellationToken);
|
|
|
|
/// <summary>Lists a team's members.</summary>
|
|
Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
|
|
Guid teamId,
|
|
CancellationToken cancellationToken);
|
|
|
|
/// <summary>Adds a member to a team.</summary>
|
|
Task<TeamMemberSummary> AddTeamMemberAsync(
|
|
Guid teamId,
|
|
AddTeamMemberRequest request,
|
|
CancellationToken cancellationToken);
|
|
|
|
/// <summary>Changes a member's role.</summary>
|
|
Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
|
|
Guid teamId,
|
|
Guid userId,
|
|
ChangeTeamMemberRoleRequest request,
|
|
CancellationToken cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Removes a member, revoking every vault key grant they hold from this team.
|
|
/// </summary>
|
|
/// <returns>
|
|
/// Whether the team had that member. False means it did not, which a caller driving towards
|
|
/// "they are not in this team" should treat as having arrived.
|
|
/// </returns>
|
|
Task<bool> RemoveTeamMemberAsync(Guid teamId, Guid userId, CancellationToken cancellationToken);
|
|
|
|
/// <summary>Lists a team's invitations, including the ones already dealt with.</summary>
|
|
Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
|
|
Guid teamId,
|
|
CancellationToken cancellationToken);
|
|
|
|
/// <summary>Invites an email address to a team.</summary>
|
|
Task<TeamInvitationSummary> CreateTeamInvitationAsync(
|
|
Guid teamId,
|
|
CreateTeamInvitationRequest request,
|
|
CancellationToken cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Withdraws an invitation that has not been taken up.
|
|
/// </summary>
|
|
/// <returns>
|
|
/// Whether there was a live invitation to withdraw. False covers one that was never there and one
|
|
/// already claimed — a claimed invitation is a membership now, and removing a member is a different
|
|
/// operation with different consequences.
|
|
/// </returns>
|
|
Task<bool> RevokeTeamInvitationAsync(
|
|
Guid teamId,
|
|
Guid invitationId,
|
|
CancellationToken cancellationToken);
|
|
|
|
/// <summary>Creates a vault owned by a team, with the creator's key grant.</summary>
|
|
Task<VaultSummary> CreateTeamVaultAsync(
|
|
Guid teamId,
|
|
CreateTeamVaultRequest request,
|
|
CancellationToken cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The public-key directory and the log that makes it checkable.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The two belong together and are used together: a directory answer is a claim, and the key log is
|
|
/// what turns it into something a client can verify. Splitting them would make it possible to build a
|
|
/// caller that reads one and not the other, which is precisely the mistake — see ADR 0001 — that
|
|
/// undoes end-to-end encryption entirely.
|
|
/// </remarks>
|
|
public interface IDirectoryApi
|
|
{
|
|
/// <summary>Looks a user up by exact email address. There is no search.</summary>
|
|
Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
|
|
string email,
|
|
CancellationToken cancellationToken);
|
|
|
|
/// <summary>Looks up an account the caller shares a team with.</summary>
|
|
Task<DirectoryEntry?> LookupByIdAsync(Guid userId, CancellationToken cancellationToken);
|
|
|
|
/// <summary>Reads entries after a sequence, with the log's current head.</summary>
|
|
Task<KeyLogPage> ReadKeyLogAsync(
|
|
long afterSequence,
|
|
int? limit,
|
|
CancellationToken cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Vault key grants: who can open a vault, and the record of who let them.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The wrapped key and the signature are produced by an unlocked session and are opaque to everything
|
|
/// between it and the recipient, this interface included.
|
|
/// </remarks>
|
|
public interface IVaultGrantApi
|
|
{
|
|
/// <summary>Lists who holds a key to this vault.</summary>
|
|
Task<VaultGrantsResponse> ListVaultGrantsAsync(Guid vaultId, CancellationToken cancellationToken);
|
|
|
|
/// <summary>Records a vault key wrapped to another member.</summary>
|
|
Task IssueVaultGrantAsync(
|
|
Guid vaultId,
|
|
IssueVaultGrantRequest request,
|
|
CancellationToken cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Withdraws a member's key to this vault.
|
|
/// </summary>
|
|
/// <returns>Whether there was a live grant to withdraw.</returns>
|
|
/// <remarks>
|
|
/// Blocks future reads and nothing else. Whatever they have already pulled is on their machine;
|
|
/// the remediation for a departure is rotating the SSH credential. See ADR 0001.
|
|
/// </remarks>
|
|
Task<bool> RevokeVaultGrantAsync(
|
|
Guid vaultId,
|
|
Guid userId,
|
|
CancellationToken cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The two vault-synchronisation calls, separated so the sync engine can be driven without HTTP.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The sync engine's job is a conflict-resolution policy, and testing a policy against a stubbed
|
|
/// transport only proves that the right bytes were sent. Behind this interface the suite runs an
|
|
/// in-memory server that enforces the real version checks, assigns real change sequences and issues
|
|
/// real cursors — so a test can assert what happens when two clients edit one host, which is the
|
|
/// question that actually matters.
|
|
/// </remarks>
|
|
public interface ISyncApi
|
|
{
|
|
/// <summary>Reads vault changes after a cursor.</summary>
|
|
Task<SyncPullResponse> SyncPullAsync(
|
|
Guid vaultId,
|
|
SyncPullRequest request,
|
|
CancellationToken cancellationToken);
|
|
|
|
/// <summary>Applies a batch of vault changes.</summary>
|
|
Task<SyncPushResponse> SyncPushAsync(
|
|
Guid vaultId,
|
|
SyncPushRequest request,
|
|
CancellationToken cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The typed client for one DodoSSH server.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Everything goes through <c>DodoSSH.Contracts</c> and its source-generated serialiser, which is the
|
|
/// actual contract between the two sides — not the OpenAPI document. Requests are written with
|
|
/// <c>StrictRequestOptions</c> on the server and read here with <c>ResponseOptions</c>, so an older
|
|
/// client tolerates a newer server's extra fields instead of failing on them.
|
|
/// </para>
|
|
/// <para>
|
|
/// Discovery is unauthenticated by necessity: a client has to learn how to authenticate before it can.
|
|
/// Everything else carries a bearer token.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
|
|
: IAccountApi, ISyncApi, ITeamApi, IDirectoryApi, IVaultGrantApi
|
|
{
|
|
private const string MetaPath = "/api/v1/meta";
|
|
private const string ConfigurationPath = "/.well-known/dodossh-configuration";
|
|
private const string MePath = "/api/v1/me";
|
|
private const string EnrollmentPath = "/api/v1/me/enrollment";
|
|
private const string DevicesPath = "/api/v1/me/devices";
|
|
private const string DirectoryPath = "/api/v1/directory";
|
|
private const string KeyLogPath = "/api/v1/keylog";
|
|
private const string TeamsPath = "/api/v1/teams";
|
|
|
|
/// <summary>
|
|
/// Reads the server's capabilities, versions and limits.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Unauthenticated, and the replacement for URL-based API versioning: when client and server
|
|
/// upgrade independently — normal for self-hosted software — a client has to ask what this
|
|
/// particular server supports rather than assume. See ADR 0002.
|
|
/// </remarks>
|
|
public Task<MetaResponse> GetMetaAsync(CancellationToken cancellationToken) =>
|
|
GetAnonymousAsync(MetaPath, DodoSshJsonContext.Default.MetaResponse, cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Reads everything needed to begin authenticating.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This is the onboarding story: the user types one server URL and the client discovers the OIDC
|
|
/// authority, the client id, the scopes and the relay from it.
|
|
/// </remarks>
|
|
public Task<DodoSshConfiguration> GetConfigurationAsync(CancellationToken cancellationToken) =>
|
|
GetAnonymousAsync(
|
|
ConfigurationPath,
|
|
DodoSshJsonContext.Default.DodoSshConfiguration,
|
|
cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Reads the caller's profile, unlock material and reachable vaults.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The first authenticated call a client makes, and the only one that works before enrollment. It
|
|
/// also provisions the account, so its <c>UserId</c> is available before enrolling — which matters,
|
|
/// because the secret bundle's AAD binds to that id and therefore cannot be built any earlier.
|
|
/// </remarks>
|
|
public Task<MeResponse> GetMeAsync(CancellationToken cancellationToken) =>
|
|
SendAsync(HttpMethod.Get, MePath, null, DodoSshJsonContext.Default.MeResponse, cancellationToken);
|
|
|
|
/// <summary>Publishes the caller's first identity key and creates their personal vault.</summary>
|
|
public Task<EnrollmentResponse> EnrollAsync(
|
|
EnrollmentRequest request,
|
|
CancellationToken cancellationToken) =>
|
|
SendAsync(
|
|
HttpMethod.Post,
|
|
EnrollmentPath,
|
|
JsonContent.Create(request, DodoSshJsonContext.Default.EnrollmentRequest),
|
|
DodoSshJsonContext.Default.EnrollmentResponse,
|
|
cancellationToken);
|
|
|
|
/// <summary>Registers a device key against an already-enrolled account.</summary>
|
|
/// <remarks>
|
|
/// Requires an unlocked vault, because the wrap can only be produced by something holding the secret
|
|
/// bundle. That is also what proves possession to the server, which is why there is no challenge here.
|
|
/// </remarks>
|
|
public Task<RegisterDeviceResponse> RegisterDeviceAsync(
|
|
RegisterDeviceRequest request,
|
|
CancellationToken cancellationToken) =>
|
|
SendAsync(
|
|
HttpMethod.Post,
|
|
DevicesPath,
|
|
JsonContent.Create(request, DodoSshJsonContext.Default.RegisterDeviceRequest),
|
|
DodoSshJsonContext.Default.RegisterDeviceResponse,
|
|
cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken) =>
|
|
DeleteAsync(
|
|
string.Create(CultureInfo.InvariantCulture, $"{DevicesPath}/{deviceId}"),
|
|
cancellationToken);
|
|
|
|
/// <summary>Reads vault changes after a cursor.</summary>
|
|
/// <remarks>
|
|
/// A POST despite being a read: the filters live in the body, cursors are opaque, and no caching is
|
|
/// wanted.
|
|
/// </remarks>
|
|
public Task<SyncPullResponse> SyncPullAsync(
|
|
Guid vaultId,
|
|
SyncPullRequest request,
|
|
CancellationToken cancellationToken) =>
|
|
SendAsync(
|
|
HttpMethod.Post,
|
|
$"/api/v1/vaults/{vaultId}/sync/pull",
|
|
JsonContent.Create(request, DodoSshJsonContext.Default.SyncPullRequest),
|
|
DodoSshJsonContext.Default.SyncPullResponse,
|
|
cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Applies a batch of vault changes.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Succeeds with per-operation status even when individual operations failed, so one stale item
|
|
/// cannot block everything else a client queued while offline. Callers must inspect
|
|
/// <c>SyncPushResult.Status</c> rather than treating a 200 as everything having applied.
|
|
/// </remarks>
|
|
public Task<SyncPushResponse> SyncPushAsync(
|
|
Guid vaultId,
|
|
SyncPushRequest request,
|
|
CancellationToken cancellationToken) =>
|
|
SendAsync(
|
|
HttpMethod.Post,
|
|
$"/api/v1/vaults/{vaultId}/sync/push",
|
|
JsonContent.Create(request, DodoSshJsonContext.Default.SyncPushRequest),
|
|
DodoSshJsonContext.Default.SyncPushResponse,
|
|
cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) =>
|
|
SendAsync(
|
|
HttpMethod.Get,
|
|
TeamsPath,
|
|
null,
|
|
DodoSshJsonContext.Default.IReadOnlyListTeamSummary,
|
|
cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task<TeamSummary> CreateTeamAsync(
|
|
CreateTeamRequest request,
|
|
CancellationToken cancellationToken) =>
|
|
SendAsync(
|
|
HttpMethod.Post,
|
|
TeamsPath,
|
|
JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamRequest),
|
|
DodoSshJsonContext.Default.TeamSummary,
|
|
cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task<TeamSummary> UpdateTeamAsync(
|
|
Guid teamId,
|
|
UpdateTeamRequest request,
|
|
CancellationToken cancellationToken) =>
|
|
SendAsync(
|
|
HttpMethod.Put,
|
|
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}"),
|
|
JsonContent.Create(request, DodoSshJsonContext.Default.UpdateTeamRequest),
|
|
DodoSshJsonContext.Default.TeamSummary,
|
|
cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken) =>
|
|
DeleteAsync(
|
|
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}"),
|
|
cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task TransferTeamOwnershipAsync(
|
|
Guid teamId,
|
|
TransferTeamOwnershipRequest request,
|
|
CancellationToken cancellationToken) =>
|
|
SendNoContentAsync(
|
|
HttpMethod.Post,
|
|
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/owner"),
|
|
JsonContent.Create(request, DodoSshJsonContext.Default.TransferTeamOwnershipRequest),
|
|
cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
|
|
Guid teamId,
|
|
CancellationToken cancellationToken) =>
|
|
SendAsync(
|
|
HttpMethod.Get,
|
|
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members"),
|
|
null,
|
|
DodoSshJsonContext.Default.IReadOnlyListTeamMemberSummary,
|
|
cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task<TeamMemberSummary> AddTeamMemberAsync(
|
|
Guid teamId,
|
|
AddTeamMemberRequest request,
|
|
CancellationToken cancellationToken) =>
|
|
SendAsync(
|
|
HttpMethod.Post,
|
|
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members"),
|
|
JsonContent.Create(request, DodoSshJsonContext.Default.AddTeamMemberRequest),
|
|
DodoSshJsonContext.Default.TeamMemberSummary,
|
|
cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
|
|
Guid teamId,
|
|
Guid userId,
|
|
ChangeTeamMemberRoleRequest request,
|
|
CancellationToken cancellationToken) =>
|
|
SendAsync(
|
|
HttpMethod.Put,
|
|
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}/role"),
|
|
JsonContent.Create(request, DodoSshJsonContext.Default.ChangeTeamMemberRoleRequest),
|
|
DodoSshJsonContext.Default.TeamMemberSummary,
|
|
cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> RemoveTeamMemberAsync(
|
|
Guid teamId,
|
|
Guid userId,
|
|
CancellationToken cancellationToken) =>
|
|
DeleteAsync(
|
|
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}"),
|
|
cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
|
|
Guid teamId,
|
|
CancellationToken cancellationToken) =>
|
|
SendAsync(
|
|
HttpMethod.Get,
|
|
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations"),
|
|
null,
|
|
DodoSshJsonContext.Default.IReadOnlyListTeamInvitationSummary,
|
|
cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task<TeamInvitationSummary> CreateTeamInvitationAsync(
|
|
Guid teamId,
|
|
CreateTeamInvitationRequest request,
|
|
CancellationToken cancellationToken) =>
|
|
SendAsync(
|
|
HttpMethod.Post,
|
|
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations"),
|
|
JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamInvitationRequest),
|
|
DodoSshJsonContext.Default.TeamInvitationSummary,
|
|
cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> RevokeTeamInvitationAsync(
|
|
Guid teamId,
|
|
Guid invitationId,
|
|
CancellationToken cancellationToken) =>
|
|
DeleteAsync(
|
|
string.Create(
|
|
CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations/{invitationId}"),
|
|
cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task<VaultSummary> CreateTeamVaultAsync(
|
|
Guid teamId,
|
|
CreateTeamVaultRequest request,
|
|
CancellationToken cancellationToken) =>
|
|
SendAsync(
|
|
HttpMethod.Post,
|
|
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/vaults"),
|
|
JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamVaultRequest),
|
|
DodoSshJsonContext.Default.VaultSummary,
|
|
cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Looks a user up by exact email address.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The address is escaped into the query string, which is the one place in this client where a
|
|
/// value a user typed reaches a URL. <see cref="Uri.EscapeDataString"/> rather than string
|
|
/// concatenation: an unescaped <c>&</c> or <c>#</c> in an address would silently become a
|
|
/// lookup for something else.
|
|
/// </remarks>
|
|
public Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
|
|
string email,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(email);
|
|
|
|
return SendAsync(
|
|
HttpMethod.Get,
|
|
$"{DirectoryPath}?email={Uri.EscapeDataString(email)}",
|
|
null,
|
|
DodoSshJsonContext.Default.IReadOnlyListDirectoryEntry,
|
|
cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DirectoryEntry?> LookupByIdAsync(
|
|
Guid userId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var entries = await SendAsync(
|
|
HttpMethod.Get,
|
|
string.Create(CultureInfo.InvariantCulture, $"{DirectoryPath}?userId={userId}"),
|
|
null,
|
|
DodoSshJsonContext.Default.IReadOnlyListDirectoryEntry,
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return entries.Count == 0 ? null : entries[0];
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<KeyLogPage> ReadKeyLogAsync(
|
|
long afterSequence,
|
|
int? limit,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var path = limit is null
|
|
? string.Create(CultureInfo.InvariantCulture, $"{KeyLogPath}?after={afterSequence}")
|
|
: string.Create(
|
|
CultureInfo.InvariantCulture, $"{KeyLogPath}?after={afterSequence}&limit={limit}");
|
|
|
|
return SendAsync(
|
|
HttpMethod.Get, path, null, DodoSshJsonContext.Default.KeyLogPage, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<VaultGrantsResponse> ListVaultGrantsAsync(
|
|
Guid vaultId,
|
|
CancellationToken cancellationToken) =>
|
|
SendAsync(
|
|
HttpMethod.Get,
|
|
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants"),
|
|
null,
|
|
DodoSshJsonContext.Default.VaultGrantsResponse,
|
|
cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task IssueVaultGrantAsync(
|
|
Guid vaultId,
|
|
IssueVaultGrantRequest request,
|
|
CancellationToken cancellationToken) =>
|
|
SendNoContentAsync(
|
|
HttpMethod.Post,
|
|
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants"),
|
|
JsonContent.Create(request, DodoSshJsonContext.Default.IssueVaultGrantRequest),
|
|
cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> RevokeVaultGrantAsync(
|
|
Guid vaultId,
|
|
Guid userId,
|
|
CancellationToken cancellationToken) =>
|
|
DeleteAsync(
|
|
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants/{userId}"),
|
|
cancellationToken);
|
|
|
|
private async Task<T> GetAnonymousAsync<T>(
|
|
string path,
|
|
System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, path);
|
|
return await SendCoreAsync(request, typeInfo, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task<T> SendAsync<T>(
|
|
HttpMethod method,
|
|
string path,
|
|
HttpContent? content,
|
|
System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var request = new HttpRequestMessage(method, path) { Content = content };
|
|
|
|
var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
|
|
return await SendCoreAsync(request, typeInfo, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends a request whose success carries no body.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Its own path for the reason <see cref="DeleteAsync"/> gives, minus the 404: a grant that will
|
|
/// not be recorded, or an ownership transfer that will not happen, is a failure with a problem
|
|
/// document behind it, so there is nothing here to translate into a return value.
|
|
/// </remarks>
|
|
private async Task SendNoContentAsync(
|
|
HttpMethod method,
|
|
string path,
|
|
HttpContent? content,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var request = new HttpRequestMessage(method, path) { Content = content };
|
|
|
|
var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
|
|
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var body = await response.Content
|
|
.ReadAsStringAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
throw DodoSshApiException.FromResponse(response.StatusCode, body);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends a delete whose success carries no body.
|
|
/// </summary>
|
|
/// <returns>True for a 2xx, false for a 404; anything else throws.</returns>
|
|
/// <remarks>
|
|
/// Its own path rather than <see cref="SendAsync{T}"/> with some empty response type, because the two
|
|
/// disagree about what a missing body means. Everywhere else a 200 with nothing in it is a server bug
|
|
/// worth an exception; here it is the answer.
|
|
/// </remarks>
|
|
private async Task<bool> DeleteAsync(string path, CancellationToken cancellationToken)
|
|
{
|
|
using var request = new HttpRequestMessage(HttpMethod.Delete, path);
|
|
|
|
var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
|
|
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var body = await response.Content
|
|
.ReadAsStringAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
throw DodoSshApiException.FromResponse(response.StatusCode, body);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private async Task<T> SendCoreAsync<T>(
|
|
HttpRequestMessage request,
|
|
System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var body = await response.Content
|
|
.ReadAsStringAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
throw DodoSshApiException.FromResponse(response.StatusCode, body);
|
|
}
|
|
|
|
var value = await response.Content
|
|
.ReadFromJsonAsync(typeInfo, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
// A 200 with a null body is a server bug, but it must not surface as a NullReferenceException
|
|
// three frames further up where the cause is invisible.
|
|
return value ?? throw new DodoSshApiException(
|
|
HttpStatusCode.OK,
|
|
null,
|
|
$"The server returned an empty body where a {typeof(T).Name} was expected.");
|
|
}
|
|
}
|