Public Access
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:
@@ -58,6 +58,115 @@ public interface IAccountApi
|
||||
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>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>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>
|
||||
@@ -99,13 +208,16 @@ public interface ISyncApi
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
|
||||
: IAccountApi, ISyncApi
|
||||
: 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.
|
||||
@@ -209,6 +321,168 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
|
||||
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<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<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,
|
||||
@@ -242,6 +516,37 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
|
||||
/// 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>
|
||||
/// <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 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);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> DeleteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, path);
|
||||
|
||||
Reference in New Issue
Block a user