From 95816de0c5cb73f4690418254854137d959fdc86 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Fri, 31 Jul 2026 12:18:28 +0200 Subject: [PATCH] Share a vault with a team, without the server holding a key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 52 +- docs/adr/0009-team-access-model.md | 89 +++ docs/design-import-gaps.md | 70 +- .../Authorization/VaultAccessService.cs | 133 +++- .../Features/Identity/DirectoryService.cs | 162 ++++ .../Features/Identity/IdentityEndpoints.cs | 111 +++ .../Features/Identity/KeyLogService.cs | 87 ++ .../Features/Teams/TeamEndpoints.cs | 371 +++++++++ .../Features/Teams/TeamExceptions.cs | 35 + src/DodoSSH.Api/Features/Teams/TeamLog.cs | 68 ++ src/DodoSSH.Api/Features/Teams/TeamService.cs | 745 ++++++++++++++++++ .../Features/Teams/VaultGrantEndpoints.cs | 179 +++++ .../Features/Teams/VaultGrantService.cs | 415 ++++++++++ src/DodoSSH.Api/Program.cs | 5 + src/DodoSSH.Api/Setup/EndpointRegistration.cs | 18 +- src/DodoSSH.Client.Api/DodoSshApiClient.cs | 307 +++++++- src/DodoSSH.Client.Api/KeyLogAudit.cs | 313 ++++++++ .../ViewModels/MainWindowViewModel.cs | 28 + .../ViewModels/TeamsViewModel.cs | 523 ++++++++++++ .../ViewModels/VaultViewModel.cs | 414 ++++++++-- .../Views/HostSidebar.axaml | 8 + src/DodoSSH.Client.App/Views/MainWindow.axaml | 23 +- .../Views/TeamsScreen.axaml | 188 +++++ .../Views/TeamsScreen.axaml.cs | 9 + .../Views/VaultScreen.axaml | 29 +- .../ServerConnection.cs | 25 + src/DodoSSH.Client.Session/VaultSession.cs | 96 ++- src/DodoSSH.Client.Session/VaultSharing.cs | 293 +++++++ src/DodoSSH.Client.Storage/StoredTypes.cs | 25 +- src/DodoSSH.Client.Storage/VaultStore.cs | 31 + src/DodoSSH.Client.Sync/VaultKeyring.cs | 87 ++ src/DodoSSH.Contracts/DodoSshJsonContext.cs | 11 + src/DodoSSH.Contracts/KeyLog.cs | 64 ++ src/DodoSSH.Contracts/ProblemCodes.cs | 36 + src/DodoSSH.Contracts/PublicAPI.Unshipped.txt | 248 ++++++ src/DodoSSH.Contracts/Teams.cs | 266 +++++++ .../EndpointInventoryTests.cs | 30 + tests/DodoSSH.Api.Tests/TeamEndpointTests.cs | 498 ++++++++++++ .../TeamEnumAlignmentTests.cs | 76 ++ .../QuickConnectTests.cs | 1 + .../FakeVaultServer.Teams.cs | 366 +++++++++ .../FakeVaultServer.cs | 12 +- .../TeamSharingTests.cs | 277 +++++++ .../SessionLifecycleTests.cs | 2 +- .../M1VerticalSliceTests.cs | 6 +- 45 files changed, 6699 insertions(+), 133 deletions(-) create mode 100644 docs/adr/0009-team-access-model.md create mode 100644 src/DodoSSH.Api/Features/Identity/DirectoryService.cs create mode 100644 src/DodoSSH.Api/Features/Identity/KeyLogService.cs create mode 100644 src/DodoSSH.Api/Features/Teams/TeamEndpoints.cs create mode 100644 src/DodoSSH.Api/Features/Teams/TeamExceptions.cs create mode 100644 src/DodoSSH.Api/Features/Teams/TeamLog.cs create mode 100644 src/DodoSSH.Api/Features/Teams/TeamService.cs create mode 100644 src/DodoSSH.Api/Features/Teams/VaultGrantEndpoints.cs create mode 100644 src/DodoSSH.Api/Features/Teams/VaultGrantService.cs create mode 100644 src/DodoSSH.Client.Api/KeyLogAudit.cs create mode 100644 src/DodoSSH.Client.App/ViewModels/TeamsViewModel.cs create mode 100644 src/DodoSSH.Client.App/Views/TeamsScreen.axaml create mode 100644 src/DodoSSH.Client.App/Views/TeamsScreen.axaml.cs create mode 100644 src/DodoSSH.Client.Session/VaultSharing.cs create mode 100644 src/DodoSSH.Contracts/KeyLog.cs create mode 100644 src/DodoSSH.Contracts/Teams.cs create mode 100644 tests/DodoSSH.Api.Tests/TeamEndpointTests.cs create mode 100644 tests/DodoSSH.Api.Tests/TeamEnumAlignmentTests.cs create mode 100644 tests/DodoSSH.Client.App.Tests/FakeVaultServer.Teams.cs create mode 100644 tests/DodoSSH.Client.App.Tests/TeamSharingTests.cs diff --git a/README.md b/README.md index f018ec1..bfeef22 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,42 @@ What is not here: transferring a directory, dragging between the panes, and rout bastion — the last needs jump hosts the connection layer has not got. All three are in [`docs/design-import-gaps.md`](docs/design-import-gaps.md). +### Working as a team + +**TEAMS** in the nav rail creates a team, adds members and shares vaults. One distinction runs through the +whole screen and is worth having before you use it. + +**Adding somebody to a team and giving them a key are two different acts, and only the first is something +the server can do.** Adding a member changes what the server will *serve* them: the team's vaults appear in +their list immediately. It cannot make those vaults readable, because a vault key is sealed to each member's +public key and this server never holds one — so until somebody presses **SHARE KEY** from a machine that has +the key, their vault sits in the list saying it is waiting for one. That is not a rough edge to be smoothed +over later; it is what "the operator cannot read the credentials it stores" costs, and the screen says so +rather than implying the server handed anything out. + +Sharing verifies before it wraps. The client reads the server's append-only key log, checks its hash chain +from the first entry, and refuses unless the key the directory just offered appears in that log unchanged. +That converts a key substitution by the server from invisible into visible — a substituted key has to be +published in a log every other client also reads. **It does not prove the key is the right person's.** +Compare the fingerprint with them over something this server does not carry; that is the only step that +closes it, and the success message says so every time. + +Three limits, stated rather than discovered: + +- **Removing a member is not retroactive.** It revokes their grants and flags the team's vaults for rekey, + and blocks future reads. Everything they already pulled is on their machine. Rotate the SSH credentials + that matter — that is the actual remediation, and it is why there is no button labelled anything stronger. +- **The rekey is flagged, never performed.** See the milestone note above. +- **Host key trust stays in your personal vault.** A pin approved for a team's host is recorded and used + from your own vault, not the team's, so a teammate cannot pre-approve a fingerprint that your client will + then trust silently for a host you defined. The cost is that each member approves a team host's key once + on each of their machines. Team vaults' pins are still *listed* on the Vault screen, so you can see what + has been trusted. + +Items are filed into one vault at a time. When more than one vault is writable, the host and vault editors +show a picker; it defaults to your personal vault and never moves on its own, because an item put in a team +vault is visible to everybody in that team and moving it back means deleting and retyping. + ### End-to-end verification One suite runs against a real server rather than a stub. It needs a Docker daemon and nothing else, so it @@ -267,7 +303,21 @@ off-Windows. and a queue that moves one file at a time with progress, throughput and resume. See [Moving files](#moving-files) for the two things about it worth knowing before you use it, both of which are consequences rather than choices. -- **M3 — teams**, sharing, ACLs. +- **M3 — teams**, sharing, ACLs. *Done, except rekey.* Teams with roles, a public-key directory, the + append-only key log served for clients to verify against, team-owned vaults, and vault key grants + wrapped by a client and stored opaquely by the server. `VaultAccessService` now resolves team + membership to permissions, 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 placeholder. See + [Working as a team](#working-as-a-team) for the one distinction the whole design rests on, and the limits worth + knowing before you rely on it; the reasoning is in + [ADR 0009](docs/adr/0009-team-access-model.md). + + **What is deliberately not here: the rekey itself.** Removing a member revokes their grants and flags + every team vault `RekeyRequired`, and nothing acts on that flag. A rekey re-wraps every item's data key + under a fresh vault key and can only be performed by a client that holds the current one; that is M5's + key rotation. Until it lands the flag is what the interface reads to say a rotation is owed, which is + more honest than a button that only appears to do it. Ownership transfer is absent for the same kind of + reason — the owner cannot be removed or demoted, because nothing can appoint a replacement. - **M4 — hardening and ops**, packaging, self-hosting guide. - **M5 — multi-provider OIDC**, key rotation, per-item content keys. diff --git a/docs/adr/0009-team-access-model.md b/docs/adr/0009-team-access-model.md new file mode 100644 index 0000000..33acb88 --- /dev/null +++ b/docs/adr/0009-team-access-model.md @@ -0,0 +1,89 @@ +# ADR 0009 — Team access: membership authorises, a grant unlocks + +- Status: accepted +- Date: 2026-07-31 +- Builds on: [ADR 0001](0001-e2ee-trust-model.md) + +## Context + +M3 makes vaults shareable. The obvious way to model that is one concept — "access" — with a role +attached, and to let the server hand it out. Every hosted competitor works that way, and it is what +the imported design drew: a members table with a role column, and a share button beside each item. + +This architecture cannot implement that concept, and the interesting part of M3 was working out +what it can implement instead. + +The server holds ciphertext and no keys. A vault key is 32 random bytes sealed to each member's +X25519 public key (`docs/crypto.md` §3), and only a client holding the plaintext key can produce a +seal for somebody else. So "give Bob access" decomposes into two operations that live on different +machines and cannot be performed by the same actor: + +- deciding that the server will **serve** Bob this vault's rows, which is a database write; and +- **wrapping** the vault key to Bob's public key, which needs a client that already holds it. + +The schema anticipated this — `team`, `team_membership`, `vault.team_id` and `vault_key_grant` have +existed since the first migration — but nothing had had to name the split. + +## Decision + +**Membership is authorisation. A grant is access. The product says so out loud.** + +`VaultAccessService` resolves a team vault through `team_membership`, mapping the role to +`PermissionFlags` by a union with no Deny rules. That decides what the server serves and nothing +else. Whether the caller can read what it serves is decided by whether they hold a grant, which the +server records, cannot produce and cannot verify. + +Four consequences, each of which is a place where a more reassuring design was rejected: + +- **A member with no grant is a normal state, not an error.** `VaultSummary.WrappedVaultKey` is null + and the vault appears in their list saying it is waiting for a key. Hiding it until a grant existed + would have been tidier and would have implied the server was the thing granting access. +- **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 opening a session needs + the credential's plaintext on that machine; "may connect but may not read the key" cannot be + enforced here, and shipping it as a role would have been a lie in a dropdown. `Connect` rides along + with `Read` and is documented as an interface hint. +- **Sharing verifies the recipient's key against the append-only key log, or refuses.** A directory + lookup is a claim by the server about a third party's public key; wrapping to an unverified claim + hands the vault to whoever made it. `KeyLogAudit` reads the whole log, checks its hash chain from + genesis, and refuses unless the offered key appears in it unchanged. There is no override flag, + because a flag that exists gets used on the day the log is briefly unreachable. +- **Removal is named for what it does.** It revokes grants and flags the vault for rekey. It does not + claim to reach anything already downloaded, and the interface says the remediation is rotating the + credential — the same non-retroactive limit ADR 0001 records. + +Two things were deliberately **not** built, and both are refusals rather than omissions: + +- **The rekey itself.** Only a client holding the current vault key can re-wrap every item's data key + under a new one. The server records that a rotation is owed and the interface reports it. M5. +- **Ownership transfer.** The owner cannot be demoted or removed, with its own problem code. Allowing + it without a transfer would leave a team nobody can administer, recoverable only by an operator + editing the database. + +Two smaller choices, recorded because the alternative was written down first and rejected: + +- **No `v_user_vault_permission` view.** ADR-adjacent notes and the old `VaultAccessService` remark + both anticipated one. The rules turned out to be about sixteen lines of C# shared by the two + methods that need them; a view would have moved the authorisation model into migrations, where a + test cannot reach it without a container. +- **Host key trust stays vault-scoped to the personal vault.** Pins in a team vault are listed but + not consulted at connect time. Consulting them would let any member with Write pre-approve a + fingerprint that another member's client then trusts silently for a host in their *own* vault, + which is a cross-boundary trust escalation. Scoping trust properly needs a scope on the SSH connect + path (`IKnownHostStore.FindAsync` takes host, port and algorithm and knows nothing about vaults); + until that exists, the safe direction is the narrow one, and the cost — approving a team host's key + once per member per machine — is stated in the README rather than hidden. + +## Consequences + +The sharing graph is visible to the operator: who is in which team, which vaults exist, and who holds +a grant are all plaintext rows. That was already true of metadata generally (`docs/crypto.md` §10) +and is not made worse here, but it is now a graph rather than a list. + +A malicious granter can seal garbage. The recipient detects it as a tag failure and the grant's +Ed25519 signature names who issued it — detectable and attributable, which is the most that is +achievable without the server holding a key. + +The two-step model costs a step in the interface and buys the property the whole product is for. It +also makes a class of bug impossible: there is no code path on the server that could accidentally +grant read access to plaintext, because there is no plaintext on the server to grant. diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md index 0717a27..9a9c378 100644 --- a/docs/design-import-gaps.md +++ b/docs/design-import-gaps.md @@ -26,11 +26,12 @@ file transfer is a *separate connection* rather than a second channel, because S its own transport. See [File transfer](#file-transfer-the-designs-sftp-screen), which is the one place in this document where what shipped differs from what the row predicted. -**Teams are schema and nothing else.** The `team` and `team_membership` tables exist from the first -migration, with entities in `DodoSSH.Domain/Teams.cs` and a `TeamRole` enum — and no endpoint reads or -writes any of it. `VaultAccessService.ResolveAsync` returns `Denied` for every vault that is not the -caller's own personal one. That removes the Teams screen entirely, and with it every role chip, scope and -"shared with" affordance the vault screen was drawn with. +**~~Teams are schema and nothing else.~~ Built in M3.** The `team` and `team_membership` tables were there +from the first migration with nothing reading them, and `VaultAccessService.ResolveAsync` denied every vault +that was not the caller's own. Both changed in M3 and neither needed a migration, which is what carrying two +unused tables bought. See [Teams](#teams). What has *not* changed is the split underneath: the server +decides what it will serve, and only a client can decide who can read it — so "shared with" is two facts on +this screen, not one. **The client has no preferences store.** It writes exactly two files — `cache.db` and `device.key` — and the cache has six tables, none of them settings. Nothing on the design's TERMINAL preferences panel can be @@ -48,7 +49,7 @@ protocol rather than a protocol change. | Design element | Layer | What it would take | What ships instead | | --- | --- | --- | --- | -| Org chip `dodotech / platform` | contracts + server | An organisation name a client can read. `VaultSummary.TeamId` exists and is always null. | The vault's own name, and the account this machine is enrolled as. | +| Org chip `dodotech / platform` | contracts + server | An *organisation* above teams, which does not exist — `VaultSummary.TeamId` is no longer always null since M3, but a team is not an org and there is exactly one tenant per deployment. | The vault's own name, and the account this machine is enrolled as. Team names are on the TEAMS screen, where they are about something. | | `SYNCED` dot, always green | client-app | Nothing — the design's claim is simply unconditional. | Green **only** when a connection is held, the last sync pass actually reached the server, and the outbox is empty; otherwise `UNREACHABLE`, the count of changes still waiting, or `OFFLINE`. Holding an `IVaultServer` proves a sign-in once succeeded and nothing more, so a laptop whose lid has been shut all afternoon still has one — reachability comes from the outcome of the last pass. A permanently green light is the same as no light. | | `VAULT SYNCED 11:02` | client-session | `StoredSyncState.LastPulledAt`/`LastPushedAt` are persisted, but `VaultSession` exposes the store as `internal`. A property away. | Omitted. The one honest sync fact — the outbox depth — is in the titlebar and the status bar. | | `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter. The box says "search hosts", not "search hosts · run command". | @@ -71,8 +72,8 @@ caption buttons and window title drawn on top of the application's own — two s | Design element | Layer | What it would take | What ships instead | | --- | --- | --- | --- | | Tag chips (`nginx`, `eu`, `pg16`) | client-domain | A tag item type and a host-tag join. Both reserved on the wire (`Tag = 5`, `HostTag = 6`), neither implemented, plus a payload schema bump on `HostSecret`. | Omitted. The filter box searches name, address and notes instead. | -| Groups `PRODUCTION` / `STAGING` / `PERSONAL` | client-domain | A host-group item type (`HostGroup = 4`, reserved) or a group field on `HostSecret`. | One collapsible heading, named after the vault — the only grouping a host actually has. A second appears when a second vault becomes reachable. | -| Group badge `TEAM·PLATFORM` | server | Teams. | Omitted. | +| Groups `PRODUCTION` / `STAGING` / `PERSONAL` | client-domain | A host-group item type (`HostGroup = 4`, reserved) or a group field on `HostSecret`. | One heading per vault — the only grouping a host actually has. Since M3 there is genuinely more than one when somebody is in a team, which is what that row always predicted. | +| Group badge `TEAM·PLATFORM` | server | **Built in M3.** | The vault's name on each row, and the personal vault ordered first. Not the team's name: two of a team's vaults would then carry the same badge and the badge would be naming the wrong thing. | | Per-host status dot, three colours | client-ssh | The amber state would mean "reachable but not connected", and nothing here ever probes a host. | Two states, both real: green when a terminal is open on that host, grey when not. | | `· ⤷ bastion-eu` in the host subtitle | client-ssh | **Jump hosts are data-only.** `HostSecret.JumpHostIds` is a `JumpChain` that is stored, encrypted, synced and three-way merged — and nothing reads it at connect time. `SshConnectionRequest` carries one host. | Omitted. The stored chain is preserved untouched by every edit. | | `SPLIT ⌘D` and side-by-side panes | client-ssh + ui | The renderer stacks panes and shows one (`terminal.css`: `.pane { position:absolute; inset:0; display:none }`). Tiling needs a real pane geometry and a splitter. | Omitted. Tabs ship instead, over the same one-WebView multiplexing. | @@ -131,10 +132,10 @@ and both editors. What follows is what the design drew around them. | Design element | Layer | What it would take | What ships instead | | --- | --- | --- | --- | | `ACCESS` column and `your access → CONNECT-ONLY` | server | Per-item ACLs. `EncryptedPayload.DataKeyId` is documented as the seam for per-item grants **in M5**. `VaultSummary` does carry an opaque `int Permissions`, but nothing gives the bits a meaning on the wire — `PermissionFlags` itself lives in `DodoSSH.Domain` — and those are per *vault*, never per item. There is no `ConnectOnly` role in `TeamRole` at all. | Omitted. The column shows sync state instead — whether a change is still sitting in this machine's outbox — which the design had no column for. | -| `SHARED WITH · 6` avatars | server | The whole sharing stack: grants, a member directory, and re-wrapping a vault key for another account. | Omitted. | +| `SHARED WITH · 6` avatars | server | **Built in M3**, minus the avatars — no picture is stored anywhere. `GET /api/v1/vaults/{id}/grants` lists who holds a key. | The list lives on the TEAMS screen, beside the members it is about, rather than as a count on an item row: a grant is per *vault*, and putting a number on an item would imply per-item sharing, which is M5. | | "Private key never leaves the vault. Sessions sign through the team agent (dodod)" | client-ssh | **Both sentences are false here, and the second cannot be made true by this architecture.** There is no agent of any kind, and the connect path decrypts the private key and hands the bytes to SSH.NET. | Omitted. The key editor already says what is true: the key and its passphrase are encrypted here and never reach the server in a readable form. | -| Scope rail: `PERSONAL` / `TEAM · PLATFORM` / `TEAM · DATA` | server | Team vaults. `VaultAccessService.ListAsync` filters to personal vaults owned by the caller. | The real vault list, which today has one entry, with a line saying why. | -| `SCOPE` column | server | As above — and note scope here is a property of a *vault*, never of an item. | Omitted. | +| Scope rail: `PERSONAL` / `TEAM · PLATFORM` / `TEAM · DATA` | server | **Built in M3.** `VaultAccessService.ListAsync` resolves team membership, so a session can hold several vaults. | Not a rail, because it would be a selector with nothing to select: every list on the screen already spans every vault this session can read. What replaces it is a picker for where a *new* item is filed, which is the only vault question with an answer. | +| `SCOPE` column | server | **Built in M3.** Scope is a property of a vault, never of an item, and that has not changed. | Each row names the vault it is in, and rows are grouped by vault. | | `LAST` column (`11:02`, `1d ago`) | contracts | No last-used timestamp at any layer. `VaultItem` is `(id, secret, version, three sync flags)`. | Omitted. | | `FINGERPRINT` for SSH keys | client-domain | `SshKeySecret` has no fingerprint field, and computing one means parsing key formats the type deliberately stores verbatim. | The `DETAIL` column carries what *is* known — whether a passphrase and a public half are stored. Pins show their real fingerprint, in full and untruncated. | | `•••• rotated jul 14` for passwords | client-domain | `CredentialSecret` has no rotation date or password age. | The account the password is for. | @@ -144,7 +145,7 @@ and both editors. What follows is what the design drew around them. | `added by anna@dodotech.dev` | contracts | The server records `CreatedByUserId`, but `SyncChange` carries no actor field and no other user's name is fetchable. | Omitted, and the detail pane says in one line that items record no author, no timestamps and no sharing. | | `created 2026-03-14` | client-sync | Recoverable in principle — entity ids are UUIDv7 and carry a timestamp — but nothing surfaces it. | Omitted. | | `TEST CONNECT` | ui | Connecting is host-scoped, not credential-scoped: there is nothing to test a credential *against* without a host. | Omitted. | -| `REVOKE` | server | Revoking someone else's access needs grants to revoke. Deleting an item is a different act and is already there. **ADR 0001** also constrains how any future revoke may be presented: revocation is not retroactive. | Delete, named for what it does. | +| `REVOKE` | server | **Built in M3**, as WITHDRAW KEY on the TEAMS screen — because there are now grants to revoke, and a grant is what it acts on. ADR 0001 constrains how it is presented, and it is: the message says it blocks future reads only, and that what they already hold is unaffected. | On this screen, still Delete, named for what it does. Deleting an item and withdrawing somebody's key remain different acts. | | `SSH KEY · ED25519` | client-domain | No algorithm field, and deriving it means parsing the armour. | The type without the algorithm. | | One `+ ADD CREDENTIAL` button | ui | — | Two buttons, one per kind that can be added. "Credential" is a specific item type in this codebase (a username and a password), so using it as an umbrella word would collide with the vocabulary. | | — | — | — | **`HOST KEYS` is the reverse case:** a fully-backed, shipped category the design had no slot for. It is in the rail. | @@ -153,23 +154,40 @@ and both editors. What follows is what the design drew around them. ## Teams -Nothing on this screen exists. It is in the nav rail and reaches a screen that says so. +**Built in M3.** The screen ships: a team list, a members table, the team's vaults, and the two buttons the +whole design was really about — add a member, and share a vault key. What follows is what it still does not +do, and one thing this document got wrong before it was built. -| Design element | Layer | What it would take | +**The correction.** The rows below used to describe a screen with nothing behind it, on the grounds that +`VaultAccessService.ResolveAsync` denied every vault that was not the caller's own. That is now the one +place that changed, exactly as its remark predicted, and no migration was needed: `team`, +`team_membership`, `vault.team_id` and `vault_key_grant` have all been there since the first migration. +What the row did not anticipate is that the interesting half is not the endpoints at all. It is that +**membership and readability are different things**, and the screen is arranged around saying so. + +| Design element | Layer | What ships | | --- | --- | --- | -| The team itself | server | Team endpoints. The server has eight routes and none is about people. | -| Shared vaults | server | Vault creation, grants, and membership evaluation. Exactly one vault exists per user, created as a side effect of enrollment. | -| Roles (`OWNER`/`ADMIN`/`MEMBER`/`CONNECT-ONLY`/`VIEWER`) | contracts + server | `TeamRole` exists in `DodoSSH.Domain`, is read by no code path, has no wire representation, and has no `ConnectOnly` member. | -| Members table | server | A member DTO and a directory endpoint. | -| `2FA ENFORCED` and the per-member 2FA column | server | No two-factor concept exists anywhere — the only hit in the whole worktree is an aside in `docs/crypto.md`. | -| `LAST ACTIVE` | server | `UserAccount.LastSeenAtUtc` exists and is written at just-in-time provisioning and at enrollment, and never on an ordinary authenticated request — so the column cannot answer "last active". | -| Avatars | server | `MeResponse` has no picture field, and no other user's display name is fetchable. | -| Pending invites, resend, revoke | server | An invitation entity, a token with a lifetime, and an outbound mail path. `MembershipStatus.Invited` and `TeamMembership.InvitedByUserId` are the only hints, and nothing writes them. | -| `SSO · OIDC · okta.dodotech.dev` | server | Per-team SSO. Authentication is one global JWT scheme bound to one authority. | +| The team itself | server | `POST/GET /api/v1/teams`, plus members, roles and team vaults. Ids are client-chosen, so a create whose response was lost is safe to repeat. | +| Shared vaults | server + client | A team owns vaults; each is created with the creator's own grant, because a vault with no grant is a container nobody can open. | +| Roles | contracts + server | `TeamMemberRole` on the wire, numerically pinned to `DodoSSH.Domain.TeamRole` by a test. Viewer reads, Member writes, Admin and Owner also share and administer. | +| Members table | server | `TeamMemberSummary`, and a directory that resolves an exact email to a public key. | +| Sharing an item | client | `VaultSession.ShareVaultAsync`: verify the recipient's key against the key log, wrap, sign, record. The server stores the wrap and the signature and can check neither. | -> **A trap for whoever builds this.** `GET /api/v1/meta` advertises `features: ["teams"]` -> *unconditionally* (`MetaEndpoints.cs`). Do not gate a Teams screen on that string — it is true of every -> deployment today and means nothing. +| Design element | Layer | What it would take | What ships instead | +| --- | --- | --- | --- | +| `CONNECT-ONLY` role | — | Nothing that would be true. Connect is a user-interface hint, not a boundary: SSH terminates on the client, so a session needs the credential's plaintext on that machine. See ADR 0001. | Four roles, all of which are enforceable. `Connect` rides along with `Read` and is documented as a hint. | +| `2FA ENFORCED` and the per-member 2FA column | server | No two-factor concept exists anywhere — the only hit in the whole worktree is an aside in `docs/crypto.md`. | Omitted. The member column carries what *is* known and matters: whether they have published a key a vault can be wrapped to. | +| `LAST ACTIVE` | server | `UserAccount.LastSeenAtUtc` is written at just-in-time provisioning and at enrollment and never on an ordinary authenticated request, so the column cannot answer "last active". | Omitted. | +| Avatars | server | No picture is stored anywhere. | Omitted; the row shows a name and an address. | +| Pending invites, resend, revoke | server | An invitation entity, a token with a lifetime, and an outbound mail path. `MembershipStatus.Invited` remains unwritten. | Adding a member resolves an address the caller types against the directory, so the account has to have signed in here once. The screen says that when the lookup finds nothing. | +| `SSO · OIDC · okta.dodotech.dev` | server | Per-team SSO. Authentication is one global JWT scheme bound to one authority. | Omitted. | +| A rekey after a membership change | client | Re-wrapping every item's data key under a fresh vault key, which only a client holding the current one can do. M5. | The vault is flagged `RekeyRequired` and the row says a rotation is owed. | +| Ownership transfer | server | A confirmation flow and a rule for what happens to the outgoing owner. | The owner cannot be removed or demoted, with its own problem code rather than a bare 400. | + +> **The trap this document warned about is still a trap.** `GET /api/v1/meta` advertises +> `features: ["teams"]` *unconditionally* (`MetaEndpoints.cs`). It was meaningless when nothing implemented +> teams and it is meaningless now that everything does — it has never been computed from anything. Do not +> gate a client feature on it. --- diff --git a/src/DodoSSH.Api/Authorization/VaultAccessService.cs b/src/DodoSSH.Api/Authorization/VaultAccessService.cs index b461d91..ccc19cb 100644 --- a/src/DodoSSH.Api/Authorization/VaultAccessService.cs +++ b/src/DodoSSH.Api/Authorization/VaultAccessService.cs @@ -46,13 +46,23 @@ public interface IVaultAccessService /// /// /// -/// M1 supports personal vaults only, so the rule is ownership. Team vaults, the -/// v_user_vault_permission view and per-resource ACLs arrive in M3 — this is the one place -/// that changes, which is why every caller goes through it rather than comparing owner ids inline. +/// Two rules, and only two. A personal vault answers to its owner. A team vault answers to the +/// team's active members, with the role deciding how much. Everything else is denied, which is what +/// keeps an unimplemented ownership kind from falling through to a permissive default. /// /// -/// A team vault is explicitly denied for now rather than falling through to a permissive default. -/// Failing closed on an unimplemented path is the only safe direction. +/// Permission is not the same thing as readability. This service decides what the +/// server will serve; whether the caller can decrypt what it serves depends on holding a +/// vault key grant, which the server cannot produce and cannot verify. A member with Read and no +/// grant is a normal, temporary state — they have just been added, or the vault has been rekeyed — +/// and VaultSummary.WrappedVaultKey is null for exactly that case. Conflating the two here +/// would mean a newly added member's vault silently vanished from their list instead of appearing +/// and saying it is waiting for a key. +/// +/// +/// Deliberately not a database view. v_user_vault_permission was sketched for this, and the +/// rules turned out to be sixteen lines of C# that both methods share — a view would have put the +/// authorization model somewhere migrations own and tests cannot reach without a container. /// /// internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessService @@ -80,14 +90,23 @@ internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessS return VaultAccess.Denied; } - if (vault.OwnerKind == VaultOwnerKind.Personal && vault.OwnerUserId == userId) + if (vault.OwnerKind == VaultOwnerKind.Personal) { - return new VaultAccess(vault, OwnerPermissions); + return vault.OwnerUserId == userId + ? new VaultAccess(vault, OwnerPermissions) + : VaultAccess.Denied; } - // Team vaults are not readable until M3 wires up membership and grants. Denying is the - // correct behaviour in the meantime. - return VaultAccess.Denied; + if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is not { } teamId) + { + return VaultAccess.Denied; + } + + var role = await FindRoleAsync(userId, teamId, cancellationToken).ConfigureAwait(false); + + return role is { } granted + ? new VaultAccess(vault, ForRole(granted)) + : VaultAccess.Denied; } /// @@ -95,16 +114,98 @@ internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessS Guid userId, CancellationToken cancellationToken) { - // Personal ownership only, matching ResolveAsync. When M3 adds the - // v_user_vault_permission view, both methods change together and neither can drift. + // The memberships first, then one pass over the vaults. The alternative — a join per vault — + // would be the same answer at more round trips, and a user belongs to a handful of teams. + var roles = await database.TeamMemberships + .Where(m => m.UserId == userId + && m.Status == MembershipStatus.Active + && m.DeletedAtUtc == null) + .ToDictionaryAsync(m => m.TeamId, m => m.Role, cancellationToken) + .ConfigureAwait(false); + + var teamIds = roles.Keys.ToArray(); + var vaults = await database.Vaults - .Where(v => v.OwnerKind == VaultOwnerKind.Personal - && v.OwnerUserId == userId - && v.DeletedAtUtc == null) + .Where(v => v.DeletedAtUtc == null + && ((v.OwnerKind == VaultOwnerKind.Personal && v.OwnerUserId == userId) + || (v.OwnerKind == VaultOwnerKind.Team + && v.TeamId != null + && teamIds.Contains(v.TeamId.Value)))) .OrderBy(v => v.CreatedAtUtc) .ToListAsync(cancellationToken) .ConfigureAwait(false); - return [.. vaults.Select(v => new VaultAccess(v, OwnerPermissions))]; + var accessible = new List(vaults.Count); + + foreach (var vault in vaults) + { + if (vault.OwnerKind == VaultOwnerKind.Personal) + { + accessible.Add(new VaultAccess(vault, OwnerPermissions)); + continue; + } + + // The dictionary lookup cannot miss — the query filtered on the same set — but a role + // that somehow is not there must not become a permissive default. + if (vault.TeamId is { } teamId && roles.TryGetValue(teamId, out var role)) + { + accessible.Add(new VaultAccess(vault, ForRole(role))); + } + } + + return accessible; + } + + /// + /// Maps a team role onto vault permissions. + /// + /// + /// + /// Union-only, with no Deny: evaluation stays monotonic and testable, and restriction is + /// expressed by granting narrowly. See . + /// + /// + /// rides along with Read for every role that has it, + /// because it is a user-interface hint rather than a boundary — a role that could read a private + /// key but was refused Connect would be describing a restriction this architecture cannot + /// enforce. Granting it to a viewer is honest about that; withholding it would not be. + /// + /// + /// Owner and Admin resolve identically here on purpose. What separates them is what they may do + /// to the team — appoint owners, delete it — which is not a vault permission and is + /// checked where those operations live. + /// + /// + private static PermissionFlags ForRole(TeamRole role) => role switch + { + TeamRole.Viewer => PermissionFlags.Read | PermissionFlags.Connect, + TeamRole.Member => PermissionFlags.Read | PermissionFlags.Connect | PermissionFlags.Write, + TeamRole.Admin or TeamRole.Owner => OwnerPermissions, + + // Unspecified, or a value written by a newer server. Failing closed is the only safe + // direction for a role this build does not understand. + _ => PermissionFlags.None, + }; + + /// + /// Only an membership confers anything. An invited member + /// has not accepted and a revoked one has been removed; neither is a state in which the server + /// should be serving ciphertext. + /// + private async Task FindRoleAsync( + Guid userId, + Guid teamId, + CancellationToken cancellationToken) + { + var membership = await database.TeamMemberships + .SingleOrDefaultAsync( + m => m.TeamId == teamId + && m.UserId == userId + && m.Status == MembershipStatus.Active + && m.DeletedAtUtc == null, + cancellationToken) + .ConfigureAwait(false); + + return membership?.Role; } } diff --git a/src/DodoSSH.Api/Features/Identity/DirectoryService.cs b/src/DodoSSH.Api/Features/Identity/DirectoryService.cs new file mode 100644 index 0000000..be06c5c --- /dev/null +++ b/src/DodoSSH.Api/Features/Identity/DirectoryService.cs @@ -0,0 +1,162 @@ +using DodoSSH.Contracts; +using DodoSSH.Domain; +using DodoSSH.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace DodoSSH.Api.Features.Identity; + +/// +/// The public-key directory. +/// +/// +/// +/// This is where a client gets the key it is about to wrap a vault key to, so its shape is a security +/// decision rather than a convenience one. Two rules follow from that. +/// +/// +/// Lookup by exact email, never by prefix. There is no search, no wildcard and no listing of +/// everybody. A caller has to already know the address, which keeps this from being a way to +/// enumerate an organisation's staff out of a server that stores their addresses in plaintext. +/// +/// +/// Lookup by id is restricted to people the caller shares a team with. Ids come from a member +/// list the caller can already read, so nothing is hidden that they cannot reach another way — but an +/// unrestricted id lookup would turn a leaked id from any source into a directory hit. +/// +/// +/// What this returns is evidence, not authority. A client must check the identity-provider +/// binding, compare against any fingerprint it has pinned, and confirm the key log head before +/// wrapping anything. Trusting the directory's word is the one mistake that undoes end-to-end +/// encryption entirely; see ADR 0001 and DirectoryEntry's own remarks. +/// +/// +internal sealed class DirectoryService(DodoDbContext database) +{ + /// Looks a user up by exact email address. + internal async Task> FindByEmailAsync( + string email, + CancellationToken cancellationToken) + { + var normalised = email.Trim(); + + if (normalised.Length == 0) + { + return []; + } + + // The email column is citext, so this comparison is case-insensitive in the database and the + // partial unique index on it means at most one row can match. Written as a list anyway + // because the response shape must not have to change if a second issuer ever shares one. + var users = await database.Users + .Where(u => u.Email == normalised + && u.DeletedAtUtc == null + && u.Status == UserStatus.Active) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return await BuildAsync(users, cancellationToken).ConfigureAwait(false); + } + + /// Looks up accounts the caller shares an active team with. + internal async Task> FindTeammatesAsync( + Guid callerId, + IReadOnlyList userIds, + CancellationToken cancellationToken) + { + if (userIds.Count == 0) + { + return []; + } + + var teamIds = await database.TeamMemberships + .Where(m => m.UserId == callerId + && m.Status == MembershipStatus.Active + && m.DeletedAtUtc == null) + .Select(m => m.TeamId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (teamIds.Count == 0) + { + return []; + } + + var visible = await database.TeamMemberships + .Where(m => teamIds.Contains(m.TeamId) + && userIds.Contains(m.UserId) + && m.Status == MembershipStatus.Active + && m.DeletedAtUtc == null) + .Select(m => m.UserId) + .Distinct() + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var users = await database.Users + .Where(u => visible.Contains(u.Id) && u.DeletedAtUtc == null) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return await BuildAsync(users, cancellationToken).ConfigureAwait(false); + } + + /// + /// A user with no current key is dropped rather than returned with empty key fields. The entry + /// exists to be wrapped to, and one carrying no key is something a caller would have to remember + /// to check for — which is the kind of check that gets forgotten exactly once. + /// + private async Task> BuildAsync( + List users, + CancellationToken cancellationToken) + { + if (users.Count == 0) + { + return []; + } + + var userIds = users.Select(u => u.Id).ToArray(); + + var keys = await database.UserKeys + .Where(k => userIds.Contains(k.UserId) && k.IsCurrent) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + // The log position of the statement that introduced each key, so a client can compare what + // it is told here against the append-only chain rather than taking this response on trust. + var keyIds = keys.Select(k => k.UserId).ToArray(); + + var sequences = await database.KeyLog + .Where(e => keyIds.Contains(e.UserId)) + .GroupBy(e => new { e.UserId, e.Generation }) + .Select(g => new { g.Key.UserId, g.Key.Generation, Sequence = g.Min(e => e.Sequence) }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var entries = new List(users.Count); + + foreach (var user in users) + { + var key = keys.Find(k => k.UserId == user.Id); + + if (key is null) + { + continue; + } + + var sequence = sequences + .Find(s => s.UserId == user.Id && s.Generation == key.Generation)? + .Sequence ?? 0; + + entries.Add(new DirectoryEntry( + UserId: user.Id, + Email: user.Email, + DisplayName: user.DisplayName, + EncryptionPublicKey: key.EncryptionPublicKey, + SigningPublicKey: key.SigningPublicKey, + Fingerprint: key.FingerprintSha256, + KeyGeneration: key.Generation, + KeyLogSequence: sequence)); + } + + return entries; + } +} diff --git a/src/DodoSSH.Api/Features/Identity/IdentityEndpoints.cs b/src/DodoSSH.Api/Features/Identity/IdentityEndpoints.cs index fb8cab0..bb79fff 100644 --- a/src/DodoSSH.Api/Features/Identity/IdentityEndpoints.cs +++ b/src/DodoSSH.Api/Features/Identity/IdentityEndpoints.cs @@ -104,6 +104,117 @@ internal sealed class EnrollEndpoint(ICurrentUserContext currentUser, Enrollment } } +/// +/// Looks up the public keys a vault key can be wrapped to. +/// +/// +/// +/// A GET with query parameters rather than a request DTO, because BodyOnlyRequestBinder binds +/// bodies and nothing else — deliberately, so that a query string can never overwrite a body field — +/// and this call has no body to speak of. The parameters are read one at a time, as route values are. +/// +/// +/// Exactly one of email and userId is expected. They are separate parameters rather than +/// one polymorphic term because they answer to different rules: an email may name anybody enrolled +/// here, an id only somebody the caller shares a team with. See . +/// +/// +internal sealed class LookupDirectoryEndpoint( + ICurrentUserContext currentUser, + DirectoryService directory) + : EndpointWithoutRequest>, ProblemHttpResult>> +{ + /// + public override void Configure() + { + Get("/api/v1/directory"); + + // Enrolled. The answer exists to be wrapped to, and a caller with no identity key of their + // own has nothing to wrap and no signature to attribute it with. + Policies(Auth.EnrolledPolicy); + + Description(b => b + .WithName("LookupDirectory") + .WithSummary("Looks up a user's published identity keys, by exact email or by id.") + .WithTags("Identity")); + } + + /// + public override async Task>, ProblemHttpResult>> ExecuteAsync( + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + + var email = HttpContext.Request.Query["email"].ToString(); + var rawUserId = HttpContext.Request.Query["userId"].ToString(); + + if (!string.IsNullOrWhiteSpace(email)) + { + return TypedResults.Ok(await directory.FindByEmailAsync(email, ct).ConfigureAwait(false)); + } + + if (Guid.TryParse(rawUserId, out var userId)) + { + return TypedResults.Ok( + await directory.FindTeammatesAsync(user.Id, [userId], ct).ConfigureAwait(false)); + } + + // An empty result would be indistinguishable from "nobody has that address", which is a + // different fact and one a client would go on to act on. + return Problems.Coded( + StatusCodes.Status400BadRequest, + ProblemCodes.MalformedRequest, + "Supply either an exact 'email' or a 'userId'. This directory has no search."); + } +} + +/// +/// Serves the append-only key log, so a client can verify a public key rather than trust one. +/// +/// +/// Paged with after and limit on the query string, read one at a time as route values +/// are — see for why this endpoint has no request DTO. +/// +internal sealed class ReadKeyLogEndpoint(KeyLogService keyLog) + : EndpointWithoutRequest> +{ + /// + public override void Configure() + { + Get("/api/v1/keylog"); + + // Enrolled rather than authenticated, matching the directory it exists to check. Nothing here + // is secret, but a caller with no key of their own has nothing to verify against. + Policies(Auth.EnrolledPolicy); + + Description(b => b + .WithName("ReadKeyLog") + .WithSummary("Reads the append-only key log, with its current head.") + .WithTags("Identity")); + } + + /// + public override async Task> ExecuteAsync(CancellationToken ct) + { + // A malformed value reads as "from the beginning" rather than as an error. The log is public + // and ordered, so the worst a bad cursor costs is a larger response — and a 400 here would + // make a client's own paging bug look like a server refusal. + _ = long.TryParse( + HttpContext.Request.Query["after"], + System.Globalization.CultureInfo.InvariantCulture, + out var after); + + int? limit = int.TryParse( + HttpContext.Request.Query["limit"], + System.Globalization.CultureInfo.InvariantCulture, + out var parsed) + ? parsed + : null; + + return TypedResults.Ok(await keyLog.ReadAsync(after, limit, ct).ConfigureAwait(false)); + } +} + /// Registers a device key so this machine can unlock without the passphrase. /// /// 200 rather than 201, for the reason enrollment gives: re-registering the same public key returns the diff --git a/src/DodoSSH.Api/Features/Identity/KeyLogService.cs b/src/DodoSSH.Api/Features/Identity/KeyLogService.cs new file mode 100644 index 0000000..89b3280 --- /dev/null +++ b/src/DodoSSH.Api/Features/Identity/KeyLogService.cs @@ -0,0 +1,87 @@ +using DodoSSH.Contracts; +using DodoSSH.Crypto; +using DodoSSH.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace DodoSSH.Api.Features.Identity; + +/// +/// Serves the append-only key log. +/// +/// +/// +/// Readable by every enrolled caller, in full. That is the point of it: a log only one party can read +/// proves nothing, and the whole mechanism is that independent clients compare what they were shown. +/// Nothing here is secret — public keys, signatures over them, and hashes. +/// +/// +/// The server never edits this table and this service never writes to it. Appends happen in exactly +/// one place, under a deployment-wide advisory lock, inside the enrollment transaction; see +/// and docs/crypto.md §7.2 for why serialising them is load-bearing. +/// +/// +internal sealed class KeyLogService(DodoDbContext database) +{ + /// Largest page served, whatever a caller asks for. + /// + /// A client verifying the chain has to read every entry in order, so paging is a transfer-size + /// concern rather than a filter. The cap is generous because skipping entries is not an option: + /// a gap breaks the link and the verification fails, correctly, on data that was fine. + /// + private const int MaxPageSize = 500; + + /// Reads entries after a sequence, with the log's current head. + internal async Task ReadAsync( + long afterSequence, + int? limit, + CancellationToken cancellationToken) + { + var take = Math.Clamp(limit ?? MaxPageSize, 1, MaxPageSize); + + var entries = await database.KeyLog + .Where(e => e.Sequence > afterSequence) + .OrderBy(e => e.Sequence) + + // One more than asked for, so "is there another page" is answered by what came back + // rather than by a second count that could disagree with it. + .Take(take + 1) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var hasMore = entries.Count > take; + + if (hasMore) + { + entries.RemoveAt(entries.Count - 1); + } + + var head = await database.KeyLog + .OrderByDescending(e => e.Sequence) + .Select(e => new { e.Sequence, e.Hash }) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + return new KeyLogPage( + Entries: + [ + .. entries.Select(e => new KeyLogRecord( + e.Sequence, + e.UserId, + e.Generation, + e.EncryptionPublicKey, + e.SigningPublicKey, + e.StatementSignature, + e.PreviousHash, + e.Hash, + e.CreatedAtUtc)), + ], + + HeadSequence: head?.Sequence ?? 0, + + // The genesis predecessor for an empty log, which is the same value the first entry will + // record. A client comparing heads therefore needs no special case for "nothing yet". + Head: head?.Hash ?? KeyLogChain.CreateGenesisPreviousHash(), + + HasMore: hasMore); + } +} diff --git a/src/DodoSSH.Api/Features/Teams/TeamEndpoints.cs b/src/DodoSSH.Api/Features/Teams/TeamEndpoints.cs new file mode 100644 index 0000000..e605e1a --- /dev/null +++ b/src/DodoSSH.Api/Features/Teams/TeamEndpoints.cs @@ -0,0 +1,371 @@ +using DodoSSH.Api.Authorization; +using DodoSSH.Api.Setup; +using DodoSSH.Contracts; +using FastEndpoints; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace DodoSSH.Api.Features.Teams; + +/// Creates a team, with the caller as its owner. +/// +/// 200 rather than 201, for the reason enrollment gives: the id is chosen by the client, so a retried +/// request returns the identical team and there is no single moment of creation to point a Location +/// header at. +/// +internal sealed class CreateTeamEndpoint(ICurrentUserContext currentUser, TeamService teams) + : Endpoint, ProblemHttpResult>> +{ + /// + public override void Configure() + { + Post("/api/v1/teams"); + + // Enrolled, not merely authenticated. Somebody who has not published an identity key cannot + // be wrapped a vault key, so a team they created would be one they could never share + // anything into — and the flag they would hit instead is a 400 from the grant endpoint, + // several steps later, about a request that was fine. + Policies(Auth.EnrolledPolicy); + + Description(b => b + .WithName("CreateTeam") + .WithSummary("Creates a team, with the caller as its owner.") + .WithTags("Teams")); + } + + /// + public override async Task, ProblemHttpResult>> ExecuteAsync( + CreateTeamRequest req, + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + + try + { + return TypedResults.Ok(await teams.CreateAsync(user, req, ct).ConfigureAwait(false)); + } + catch (TeamSlugTakenException exception) + { + return Problems.Coded( + StatusCodes.Status409Conflict, ProblemCodes.TeamSlugTaken, exception.Message); + } + catch (TeamInvalidException exception) + { + return Problems.Coded( + StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message); + } + } +} + +/// Lists the teams the caller belongs to. +internal sealed class ListTeamsEndpoint(ICurrentUserContext currentUser, TeamService teams) + : EndpointWithoutRequest>> +{ + /// + public override void Configure() + { + Get("/api/v1/teams"); + + // Authenticated rather than enrolled: reading which teams you are in needs no key, and a + // member who has just been added should be able to see that before they set a vault up. + Policies(Auth.AuthenticatedPolicy); + + Description(b => b + .WithName("ListTeams") + .WithSummary("Lists the teams the caller belongs to.") + .WithTags("Teams")); + } + + /// + public override async Task>> ExecuteAsync(CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + + return TypedResults.Ok(await teams.ListAsync(user, ct).ConfigureAwait(false)); + } +} + +/// Lists a team's members. +internal sealed class ListTeamMembersEndpoint(ICurrentUserContext currentUser, TeamService teams) + : EndpointWithoutRequest>, NotFound>> +{ + /// + public override void Configure() + { + Get("/api/v1/teams/{teamId:guid}/members"); + + Policies(Auth.AuthenticatedPolicy); + + Description(b => b + .WithName("ListTeamMembers") + .WithSummary("Lists a team's members.") + .WithTags("Teams")); + } + + /// + public override async Task>, NotFound>> ExecuteAsync( + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + var teamId = Route("teamId"); + + var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false); + + // 404 for a team that is not there and one the caller is not in, identically. See + // VaultAccessService for why the two must not be distinguishable. + if (!access.Granted) + { + return TypedResults.NotFound(); + } + + return TypedResults.Ok(await teams.ListMembersAsync(teamId, ct).ConfigureAwait(false)); + } +} + +/// Adds a member to a team. +internal sealed class AddTeamMemberEndpoint(ICurrentUserContext currentUser, TeamService teams) + : Endpoint, NotFound, ProblemHttpResult>> +{ + /// + public override void Configure() + { + Post("/api/v1/teams/{teamId:guid}/members"); + + Policies(Auth.AuthenticatedPolicy); + + Description(b => b + .WithName("AddTeamMember") + .WithSummary("Adds a member to a team.") + .WithTags("Teams")); + } + + /// + public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync( + AddTeamMemberRequest req, + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + var teamId = Route("teamId"); + + var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false); + + if (!access.Granted) + { + return TypedResults.NotFound(); + } + + // 403 rather than 404 here: the team is visible to this caller, so refusing by name leaks + // nothing and "you are not an admin" is a far more useful answer than "no such team". + if (!access.CanAdminister) + { + return Problems.Coded( + StatusCodes.Status403Forbidden, + ProblemCodes.Forbidden, + "Only an admin or the owner of this team can add members."); + } + + try + { + var member = await teams.AddMemberAsync(user, teamId, req, ct).ConfigureAwait(false); + + return TypedResults.Ok(member); + } + catch (TeamInvalidException exception) + { + return Problems.Coded( + StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message); + } + } +} + +/// Changes a member's role. +internal sealed class ChangeTeamMemberRoleEndpoint(ICurrentUserContext currentUser, TeamService teams) + : Endpoint, NotFound, ProblemHttpResult>> +{ + /// + public override void Configure() + { + // PUT rather than PATCH. The body is the whole of what a role is, so this replaces it + // outright and is idempotent; PATCH would promise a partial update of a single scalar. + Put("/api/v1/teams/{teamId:guid}/members/{userId:guid}/role"); + + Policies(Auth.AuthenticatedPolicy); + + Description(b => b + .WithName("ChangeTeamMemberRole") + .WithSummary("Changes a member's role.") + .WithTags("Teams")); + } + + /// + public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync( + ChangeTeamMemberRoleRequest req, + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + var teamId = Route("teamId"); + var memberId = Route("userId"); + + var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false); + + if (!access.Granted) + { + return TypedResults.NotFound(); + } + + if (!access.CanAdminister) + { + return Problems.Coded( + StatusCodes.Status403Forbidden, + ProblemCodes.Forbidden, + "Only an admin or the owner of this team can change roles."); + } + + try + { + var member = await teams + .ChangeRoleAsync(user, teamId, memberId, req, ct) + .ConfigureAwait(false); + + return TypedResults.Ok(member); + } + catch (LastTeamOwnerException exception) + { + return Problems.Coded( + StatusCodes.Status409Conflict, ProblemCodes.LastTeamOwner, exception.Message); + } + catch (TeamInvalidException exception) + { + return Problems.Coded( + StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message); + } + } +} + +/// +/// Removes a member from a team. +/// +/// +/// A member may remove themselves — leaving a team needs nobody's permission — but not while they +/// own it. Everyone else needs to be an admin. +/// +internal sealed class RemoveTeamMemberEndpoint(ICurrentUserContext currentUser, TeamService teams) + : EndpointWithoutRequest> +{ + /// + public override void Configure() + { + Delete("/api/v1/teams/{teamId:guid}/members/{userId:guid}"); + + Policies(Auth.AuthenticatedPolicy); + + Description(b => b + .WithName("RemoveTeamMember") + .WithSummary("Removes a member from a team, revoking their vault key grants.") + .WithTags("Teams")); + } + + /// + public override async Task> ExecuteAsync( + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + var teamId = Route("teamId"); + var memberId = Route("userId"); + + var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false); + + if (!access.Granted) + { + return TypedResults.NotFound(); + } + + if (!access.CanAdminister && memberId != user.Id) + { + return Problems.Coded( + StatusCodes.Status403Forbidden, + ProblemCodes.Forbidden, + "Only an admin or the owner of this team can remove other members."); + } + + try + { + await teams.RemoveMemberAsync(user, teamId, memberId, ct).ConfigureAwait(false); + + return TypedResults.NoContent(); + } + catch (LastTeamOwnerException exception) + { + return Problems.Coded( + StatusCodes.Status409Conflict, ProblemCodes.LastTeamOwner, exception.Message); + } + catch (TeamInvalidException exception) + { + return Problems.Coded( + StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message); + } + } +} + +/// Creates a vault owned by a team. +internal sealed class CreateTeamVaultEndpoint( + ICurrentUserContext currentUser, + TeamService teams, + VaultGrantService grants) + : Endpoint, NotFound, ProblemHttpResult>> +{ + /// + public override void Configure() + { + Post("/api/v1/teams/{teamId:guid}/vaults"); + + Policies(Auth.EnrolledPolicy); + + Description(b => b + .WithName("CreateTeamVault") + .WithSummary("Creates a vault owned by a team, with the creator's key grant.") + .WithTags("Teams")); + } + + /// + public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync( + CreateTeamVaultRequest req, + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + var teamId = Route("teamId"); + + var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false); + + if (!access.Granted) + { + return TypedResults.NotFound(); + } + + if (!access.CanAdminister) + { + return Problems.Coded( + StatusCodes.Status403Forbidden, + ProblemCodes.Forbidden, + "Only an admin or the owner of this team can create a vault in it."); + } + + try + { + var vault = await grants + .CreateTeamVaultAsync(user, access.Team!, req, ct) + .ConfigureAwait(false); + + return TypedResults.Ok(vault); + } + catch (VaultGrantInvalidException exception) + { + return Problems.Coded( + StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message); + } + catch (TeamInvalidException exception) + { + return Problems.Coded( + StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message); + } + } +} diff --git a/src/DodoSSH.Api/Features/Teams/TeamExceptions.cs b/src/DodoSSH.Api/Features/Teams/TeamExceptions.cs new file mode 100644 index 0000000..52daa08 --- /dev/null +++ b/src/DodoSSH.Api/Features/Teams/TeamExceptions.cs @@ -0,0 +1,35 @@ +namespace DodoSSH.Api.Features.Teams; + +/// +/// A team create or membership change was structurally unacceptable. +/// +/// +/// The message is returned to the caller. Keep it about the shape of their own request, and never +/// about accounts or teams they cannot already see — "no such user" is safe when the caller supplied +/// the id from a directory lookup they just made, and is an enumeration oracle everywhere else. +/// +internal sealed class TeamInvalidException(string message) : Exception(message); + +/// The requested slug is already in use. +/// +/// Its own type because it is the one create failure the caller could not have predicted from their +/// own input, and the only one whose remedy is choosing a different value rather than fixing one. +/// +internal sealed class TeamSlugTakenException(string message) : Exception(message); + +/// The change would leave a team with no owner. +/// +/// Refused rather than allowed: a team with no owner has nobody who can appoint one, so the only +/// route back would be an operator editing the database by hand. +/// +internal sealed class LastTeamOwnerException(string message) : Exception(message); + +/// +/// A vault key grant was rejected. +/// +/// +/// Never about the wrapped key's contents. The server cannot open it, so a grant sealing garbage is +/// accepted here and fails at the recipient as a tag failure, with the signature naming who issued +/// it. See docs/crypto.md §6. +/// +internal sealed class VaultGrantInvalidException(string message) : Exception(message); diff --git a/src/DodoSSH.Api/Features/Teams/TeamLog.cs b/src/DodoSSH.Api/Features/Teams/TeamLog.cs new file mode 100644 index 0000000..2d12f6f --- /dev/null +++ b/src/DodoSSH.Api/Features/Teams/TeamLog.cs @@ -0,0 +1,68 @@ +namespace DodoSSH.Api.Features.Teams; + +/// +/// Source-generated log events for teams, membership and vault key grants. +/// +/// +/// Ids, roles and outcomes only. Never a wrapped key, a signature or a fingerprint: the sharing graph +/// is already visible to the operator (docs/crypto.md §10) and there is nothing to gain by adding key +/// material to what a log aggregator keeps. +/// +internal static partial class TeamLog +{ + [LoggerMessage( + EventId = 2101, + Level = LogLevel.Information, + Message = "Created team {TeamId} for user {UserId}.")] + internal static partial void TeamCreated(ILogger logger, Guid teamId, Guid userId); + + [LoggerMessage( + EventId = 2102, + Level = LogLevel.Information, + Message = "Added user {MemberId} to team {TeamId} as {Role}, by {ActorId}.")] + internal static partial void MemberAdded( + ILogger logger, Guid teamId, Guid memberId, Domain.TeamRole role, Guid actorId); + + [LoggerMessage( + EventId = 2103, + Level = LogLevel.Information, + Message = "Changed user {MemberId} in team {TeamId} to {Role}, by {ActorId}.")] + internal static partial void MemberRoleChanged( + ILogger logger, Guid teamId, Guid memberId, Domain.TeamRole role, Guid actorId); + + /// + /// Warning rather than information, and it names the grant count. Removal is the operation whose + /// consequences are least like what the word implies — it blocks future reads and returns nothing + /// already downloaded — so it is the one worth being able to find in a log afterwards. + /// + [LoggerMessage( + EventId = 2104, + Level = LogLevel.Warning, + Message = "Removed user {MemberId} from team {TeamId} by {ActorId}; revoked {GrantCount} vault " + + "key grant(s). Vaults are flagged for rekey; already-downloaded data is unaffected.")] + internal static partial void MemberRemoved( + ILogger logger, Guid teamId, Guid memberId, Guid actorId, int grantCount); + + [LoggerMessage( + EventId = 2105, + Level = LogLevel.Information, + Message = "Created team vault {VaultId} for team {TeamId}, by {ActorId}.")] + internal static partial void TeamVaultCreated( + ILogger logger, Guid vaultId, Guid teamId, Guid actorId); + + [LoggerMessage( + EventId = 2106, + Level = LogLevel.Information, + Message = "Issued a key grant on vault {VaultId} generation {KeyGeneration} to {RecipientId}, " + + "by {ActorId}.")] + internal static partial void GrantIssued( + ILogger logger, Guid vaultId, int keyGeneration, Guid recipientId, Guid actorId); + + [LoggerMessage( + EventId = 2107, + Level = LogLevel.Warning, + Message = "Revoked the key grant on vault {VaultId} held by {RecipientId}, by {ActorId}. " + + "Blocks future reads only; see ADR 0001.")] + internal static partial void GrantRevoked( + ILogger logger, Guid vaultId, Guid recipientId, Guid actorId); +} diff --git a/src/DodoSSH.Api/Features/Teams/TeamService.cs b/src/DodoSSH.Api/Features/Teams/TeamService.cs new file mode 100644 index 0000000..6d487c1 --- /dev/null +++ b/src/DodoSSH.Api/Features/Teams/TeamService.cs @@ -0,0 +1,745 @@ +using System.Globalization; +using DodoSSH.Contracts; +using DodoSSH.Domain; +using DodoSSH.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace DodoSSH.Api.Features.Teams; + +/// The result of a team access check. +/// The team, when the caller is an active member. +/// The caller's role. +internal readonly record struct TeamAccess(Team? Team, TeamRole Role) +{ + /// Whether the caller is in this team at all. + public bool Granted => Team is not null; + + /// + /// Whether the caller may manage members and vaults. + /// + /// + /// The team-level counterpart of PermissionFlags.Admin, and deliberately not derived from + /// it: those flags describe a vault, and adding a member is not an operation on any vault. + /// + public bool CanAdminister => Role is TeamRole.Admin or TeamRole.Owner; + + /// Denied access. + public static TeamAccess Denied => new(null, TeamRole.Unspecified); +} + +/// +/// Teams and their membership. +/// +/// +/// +/// Membership is authorization; a key grant is access. Everything in this class moves rows +/// that decide what the server will serve. None of it can make a vault readable, because +/// making a vault readable means wrapping its key to somebody's public key and only a client holding +/// that key can do it. Adding a member is therefore two deliberate steps, and the interface says so: +/// add them here, then share the vault key from a machine that has one. Collapsing the two would +/// require the server to hold a key, which is the one thing this design is built to avoid. +/// +/// +/// The reverse direction is the honest half of the same split. Removing a member revokes their +/// grants and flags every team vault for rekey, and that blocks future reads only. Anything +/// already on their laptop is already gone; the real remediation is rotating the SSH credential. See +/// ADR 0001, and note that this class deliberately does not offer a "revoke access" verb that would +/// imply more than it delivers. +/// +/// +internal sealed class TeamService( + DodoDbContext database, + TimeProvider clock, + ILogger logger) +{ + /// Longest acceptable slug. Matches the column. + private const int MaxSlugLength = 128; + + /// Longest acceptable display name. Matches the column. + private const int MaxNameLength = 256; + + /// Longest acceptable description. Matches the column. + private const int MaxDescriptionLength = 2048; + + /// Creates a team, with the caller as its owner. + /// + /// Idempotent on the client-chosen id, exactly as enrollment is: a request whose response was + /// lost can be re-sent verbatim and returns the same team rather than creating a second one under + /// a name the user meant to type once. A different body under the same id is a client that has + /// lost track of its own state and is refused rather than silently reinterpreted. + /// + internal async Task CreateAsync( + UserAccount user, + CreateTeamRequest request, + CancellationToken cancellationToken) + { + var name = RequireText(request.Name, nameof(request.Name), MaxNameLength); + var slug = RequireSlug(request.Slug); + var description = OptionalText(request.Description, MaxDescriptionLength); + + if (request.TeamId == Guid.Empty) + { + throw new TeamInvalidException("A team id is required. Generate a UUIDv7 on the client."); + } + + var existing = await database.Teams + .SingleOrDefaultAsync(t => t.Id == request.TeamId, cancellationToken) + .ConfigureAwait(false); + + if (existing is not null) + { + return await ResolveExistingAsync(user, existing, name, slug, cancellationToken) + .ConfigureAwait(false); + } + + var team = AddTeamWithOwner(user, request.TeamId, name, slug, description); + + try + { + await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + catch (DbUpdateException exception) when (IsUniqueViolation(exception)) + { + // The partial unique index on slug. Reported as its own code because it is the one + // failure the caller could not have foreseen from their own input. + throw new TeamSlugTakenException( + $"The slug '{slug}' is already in use. Choose another."); + } + + TeamLog.TeamCreated(logger, team.Id, user.Id); + + return new TeamSummary( + team.Id, team.Name, team.Slug, team.Description, + TeamMemberRole.Owner, MemberCount: 1, VaultCount: 0, team.CreatedAtUtc); + } + + /// + /// Adds the team row and the creator's owner membership. + /// + /// + /// The two together, never one: a team with no members has nobody who can add any, and the row + /// would have to be found and fixed by hand. + /// + private Team AddTeamWithOwner( + UserAccount user, + Guid teamId, + string name, + string slug, + string? description) + { + var now = clock.GetUtcNow(); + + var team = new Team + { + Id = teamId, + Name = name, + Slug = slug, + Description = description, + CreatedByUserId = user.Id, + CreatedAtUtc = now, + }; + + database.Teams.Add(team); + + database.TeamMemberships.Add(new TeamMembership + { + Id = Guid.CreateVersion7(), + TeamId = team.Id, + UserId = user.Id, + Role = TeamRole.Owner, + Status = MembershipStatus.Active, + JoinedAtUtc = now, + CreatedAtUtc = now, + }); + + return team; + } + + /// Lists the teams the caller is an active member of. + internal async Task> ListAsync( + UserAccount user, + CancellationToken cancellationToken) + { + var memberships = await database.TeamMemberships + .Where(m => m.UserId == user.Id + && m.Status == MembershipStatus.Active + && m.DeletedAtUtc == null) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (memberships.Count == 0) + { + return []; + } + + var teamIds = memberships.Select(m => m.TeamId).ToArray(); + + var teams = await database.Teams + .Where(t => teamIds.Contains(t.Id) && t.DeletedAtUtc == null) + .OrderBy(t => t.CreatedAtUtc) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var memberCounts = await database.TeamMemberships + .Where(m => teamIds.Contains(m.TeamId) + && m.Status == MembershipStatus.Active + && m.DeletedAtUtc == null) + .GroupBy(m => m.TeamId) + .Select(g => new { TeamId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.TeamId, x => x.Count, cancellationToken) + .ConfigureAwait(false); + + var vaultCounts = await database.Vaults + .Where(v => v.OwnerKind == VaultOwnerKind.Team + && v.TeamId != null + && teamIds.Contains(v.TeamId.Value) + && v.DeletedAtUtc == null) + .GroupBy(v => v.TeamId!.Value) + .Select(g => new { TeamId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.TeamId, x => x.Count, cancellationToken) + .ConfigureAwait(false); + + return + [ + .. teams.Select(team => new TeamSummary( + team.Id, + team.Name, + team.Slug, + team.Description, + ToContract(memberships.Find(m => m.TeamId == team.Id)!.Role), + memberCounts.GetValueOrDefault(team.Id), + vaultCounts.GetValueOrDefault(team.Id), + team.CreatedAtUtc)), + ]; + } + + /// + /// Lists a team's members. + /// + /// + /// Available to every member, not only to admins. Whoever is about to be handed a vault key needs + /// to know who else already holds one, and a directory that only administrators can read makes + /// the sharing graph less visible to the people it is about than it is to the operator — who can + /// read it straight out of the database either way. + /// + internal async Task> ListMembersAsync( + Guid teamId, + CancellationToken cancellationToken) + { + var memberships = await database.TeamMemberships + .Where(m => m.TeamId == teamId && m.DeletedAtUtc == null) + .Include(m => m.User) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (memberships.Count == 0) + { + return []; + } + + var userIds = memberships.Select(m => m.UserId).ToArray(); + + var enrolled = await database.UserKeys + .Where(k => userIds.Contains(k.UserId) && k.IsCurrent) + .Select(k => k.UserId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var enrolledIds = enrolled.ToHashSet(); + + return + [ + .. memberships + .OrderByDescending(m => m.Role) + .ThenBy(m => m.CreatedAtUtc) + .Select(m => new TeamMemberSummary( + m.UserId, + m.User?.Email, + m.User?.DisplayName, + ToContract(m.Role), + ToContract(m.Status), + enrolledIds.Contains(m.UserId), + m.JoinedAtUtc)), + ]; + } + + /// Adds a member, or reactivates one who was removed. + /// + /// + /// The role may not be . Ownership is sole, so granting it to + /// somebody else is a transfer rather than an addition — a different operation with a different + /// confirmation, and not one M3 offers. + /// + /// + /// Re-adding a removed member reactivates the original row rather than inserting a second one, + /// which is what keeps historic audit entries resolvable to one membership. It does not + /// restore their revoked key grants: those were wrapped to a generation the vault has since been + /// flagged to leave behind, and a member holding Share has to wrap the key afresh. + /// + /// + internal async Task AddMemberAsync( + UserAccount actor, + Guid teamId, + AddTeamMemberRequest request, + CancellationToken cancellationToken) + { + var role = ToDomain(request.Role); + + if (role is TeamRole.Unspecified or TeamRole.Owner) + { + throw new TeamInvalidException( + "Add a member as viewer, member or admin. Ownership is sole and is not transferred " + + "by adding somebody."); + } + + var target = await database.Users + .SingleOrDefaultAsync( + u => u.Id == request.UserId && u.DeletedAtUtc == null, + cancellationToken) + .ConfigureAwait(false) + + // Safe to be specific: the caller supplied this id from a directory lookup they just + // made, so it confirms nothing they did not already know. + ?? throw new TeamInvalidException( + "No such account on this server. A member has to sign in here once before they can " + + "be added — that is what creates the account and publishes the key a vault would " + + "be shared with."); + + var now = clock.GetUtcNow(); + + var membership = await database.TeamMemberships + .SingleOrDefaultAsync( + m => m.TeamId == teamId && m.UserId == target.Id && m.DeletedAtUtc == null, + cancellationToken) + .ConfigureAwait(false); + + if (membership is null) + { + membership = new TeamMembership + { + Id = Guid.CreateVersion7(), + TeamId = teamId, + UserId = target.Id, + InvitedByUserId = actor.Id, + CreatedAtUtc = now, + }; + + database.TeamMemberships.Add(membership); + } + else if (membership.Status == MembershipStatus.Active) + { + throw new TeamInvalidException( + "That account is already a member of this team. Change their role instead."); + } + + membership.Role = role; + membership.Status = MembershipStatus.Active; + membership.JoinedAtUtc = now; + + await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + TeamLog.MemberAdded(logger, teamId, target.Id, role, actor.Id); + + return await DescribeAsync(target, membership, cancellationToken).ConfigureAwait(false); + } + + /// + /// Enrollment is looked up rather than inferred, because it is the one field on a member row that + /// is about them and not about the membership: somebody can be added on Monday and set their + /// vault up on Tuesday, and the interface has to stop offering to share with them in between. + /// + private async Task DescribeAsync( + UserAccount user, + TeamMembership membership, + CancellationToken cancellationToken) + { + var isEnrolled = await database.UserKeys + .AnyAsync(k => k.UserId == user.Id && k.IsCurrent, cancellationToken) + .ConfigureAwait(false); + + return new TeamMemberSummary( + user.Id, + user.Email, + user.DisplayName, + ToContract(membership.Role), + ToContract(membership.Status), + isEnrolled, + membership.JoinedAtUtc); + } + + /// Changes a member's role. + internal async Task ChangeRoleAsync( + UserAccount actor, + Guid teamId, + Guid memberId, + ChangeTeamMemberRoleRequest request, + CancellationToken cancellationToken) + { + var role = ToDomain(request.Role); + + if (role is TeamRole.Unspecified or TeamRole.Owner) + { + throw new TeamInvalidException( + "A member may be made a viewer, a member or an admin. Ownership is sole and is not " + + "granted this way."); + } + + var membership = await RequireMembershipAsync(teamId, memberId, cancellationToken) + .ConfigureAwait(false); + + // Demoting the owner is what would leave the team ownerless, and there is no transfer to + // do it through yet. Refused with the code a client can act on rather than a bare 400. + if (membership.Role == TeamRole.Owner) + { + throw new LastTeamOwnerException( + "This team's owner cannot be demoted, because nothing can appoint a replacement yet."); + } + + membership.Role = role; + + await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + TeamLog.MemberRoleChanged(logger, teamId, memberId, role, actor.Id); + + // The account cannot be missing — a membership has a foreign key to it — but the query is + // written to tolerate it rather than to assert, because a null here would become an + // exception on a change that has already been committed. + var user = await database.Users + .SingleOrDefaultAsync(u => u.Id == memberId, cancellationToken) + .ConfigureAwait(false); + + return user is null + ? new TeamMemberSummary( + memberId, null, null, ToContract(role), ToContract(membership.Status), false, + membership.JoinedAtUtc) + : await DescribeAsync(user, membership, cancellationToken).ConfigureAwait(false); + } + + /// + /// Removes a member, revoking every vault key grant they hold from this team. + /// + /// + /// + /// One transaction, because the two halves are not separable: a membership revoked without its + /// grants leaves a departed member holding a key the server will happily keep serving, and grants + /// revoked without the membership leaves an active member whose vaults have silently stopped + /// opening. + /// + /// + /// Every affected vault is flagged RekeyRequired rather than rekeyed. A rekey re-wraps + /// every item's data key under a new vault key and can only be performed by a client that holds + /// the current one; the server can record that one is owed and nothing more. That is M5's key + /// rotation, and until it lands the flag is what the interface reads to say so out loud. + /// + /// + internal async Task RemoveMemberAsync( + UserAccount actor, + Guid teamId, + Guid memberId, + CancellationToken cancellationToken) + { + var membership = await RequireMembershipAsync(teamId, memberId, cancellationToken) + .ConfigureAwait(false); + + if (membership.Role == TeamRole.Owner) + { + throw new LastTeamOwnerException( + "This team's owner cannot be removed. Ownership transfer is not implemented, so " + + "removing them would leave the team with nobody who can manage it."); + } + + var now = clock.GetUtcNow(); + var strategy = database.Database.CreateExecutionStrategy(); + + var revoked = await strategy.ExecuteAsync(async () => + { + var transaction = await database.Database + .BeginTransactionAsync(cancellationToken) + .ConfigureAwait(false); + await using var _ = transaction.ConfigureAwait(false); + + membership.Status = MembershipStatus.Revoked; + membership.DeletedAtUtc = now; + + var count = await RevokeTeamGrantsAsync(teamId, memberId, now, cancellationToken) + .ConfigureAwait(false); + + await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + + return count; + }).ConfigureAwait(false); + + TeamLog.MemberRemoved(logger, teamId, memberId, actor.Id, revoked); + } + + /// Revokes one user's grants on every vault a team owns, and flags each for rekey. + private async Task RevokeTeamGrantsAsync( + Guid teamId, + Guid memberId, + DateTimeOffset now, + CancellationToken cancellationToken) + { + var vaults = await database.Vaults + .Where(v => v.TeamId == teamId + && v.OwnerKind == VaultOwnerKind.Team + && v.DeletedAtUtc == null) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (vaults.Count == 0) + { + return 0; + } + + var vaultIds = vaults.Select(v => v.Id).ToArray(); + + var grants = await database.VaultKeyGrants + .Where(g => vaultIds.Contains(g.VaultId) + && g.RecipientUserId == memberId + && g.RevokedAtUtc == null) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + foreach (var grant in grants) + { + grant.State = GrantState.Revoked; + grant.RevokedAtUtc = now; + } + + // Flagged whether or not this member held a grant. Somebody who was a member without a key + // still saw the vault's existence, its item count and its plaintext columns, and the vault's + // key is what a rekey would change — so "they never had a grant" is not a reason to leave the + // flag clear. + foreach (var vault in vaults) + { + vault.RekeyRequired = true; + vault.RekeyReason = RekeyReason.MemberRemoved; + vault.UpdatedAtUtc = now; + } + + return grants.Count; + } + + /// Reads the caller's own membership, for authorization checks. + internal Task FindActiveMembershipAsync( + Guid teamId, + Guid userId, + CancellationToken cancellationToken) => + database.TeamMemberships.SingleOrDefaultAsync( + m => m.TeamId == teamId + && m.UserId == userId + && m.Status == MembershipStatus.Active + && m.DeletedAtUtc == null, + cancellationToken); + + /// + /// Resolves what the caller may do with a team. + /// + /// + /// Answers identically for a team that does not exist and one the + /// caller is not in, for the reason VaultAccessService gives: a distinct "exists but + /// forbidden" is an oracle for other tenants' team ids. + /// + internal async Task ResolveAsync( + Guid userId, + Guid teamId, + CancellationToken cancellationToken) + { + var team = await database.Teams + .SingleOrDefaultAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken) + .ConfigureAwait(false); + + if (team is null) + { + return TeamAccess.Denied; + } + + var membership = await FindActiveMembershipAsync(teamId, userId, cancellationToken) + .ConfigureAwait(false); + + return membership is null ? TeamAccess.Denied : new TeamAccess(team, membership.Role); + } + + private async Task RequireMembershipAsync( + Guid teamId, + Guid memberId, + CancellationToken cancellationToken) + { + var membership = await database.TeamMemberships + .SingleOrDefaultAsync( + m => m.TeamId == teamId + && m.UserId == memberId + && m.Status == MembershipStatus.Active + && m.DeletedAtUtc == null, + cancellationToken) + .ConfigureAwait(false); + + return membership + ?? throw new TeamInvalidException("That account is not an active member of this team."); + } + + /// + /// A retry is the same id with the same name and slug, from the account that owns it. Anything + /// else under an id that is already taken is refused: silently returning somebody else's team + /// would be an existence oracle, and returning a differently-named one would tell a client its + /// rename succeeded when nothing changed. + /// + private async Task ResolveExistingAsync( + UserAccount user, + Team existing, + string name, + string slug, + CancellationToken cancellationToken) + { + var membership = await FindActiveMembershipAsync(existing.Id, user.Id, cancellationToken) + .ConfigureAwait(false); + + var isRetry = membership?.Role == TeamRole.Owner + && existing.DeletedAtUtc == null + && string.Equals(existing.Name, name, StringComparison.Ordinal) + && string.Equals(existing.Slug, slug, StringComparison.Ordinal); + + if (!isRetry) + { + throw new TeamInvalidException( + "That team id is already in use. Generate a new UUIDv7 and retry."); + } + + var memberCount = await database.TeamMemberships + .CountAsync( + m => m.TeamId == existing.Id + && m.Status == MembershipStatus.Active + && m.DeletedAtUtc == null, + cancellationToken) + .ConfigureAwait(false); + + var vaultCount = await database.Vaults + .CountAsync( + v => v.TeamId == existing.Id && v.DeletedAtUtc == null, + cancellationToken) + .ConfigureAwait(false); + + return new TeamSummary( + existing.Id, existing.Name, existing.Slug, existing.Description, + TeamMemberRole.Owner, memberCount, vaultCount, existing.CreatedAtUtc); + } + + /// + /// Validates a slug. + /// + /// + /// Lowercase ASCII letters, digits and single hyphens, not starting or ending with one. Narrow on + /// purpose: the column is citext, so a slug differing only in case is the same slug, and a + /// value that renders differently from how it compares is how two teams end up looking distinct + /// in a list and colliding on insert. + /// + private static string RequireSlug(string? value) + { + var slug = (value ?? string.Empty).Trim(); + + if (slug.Length is 0 or > MaxSlugLength) + { + throw new TeamInvalidException( + $"A slug of 1 to {MaxSlugLength} characters is required."); + } + + var previousWasHyphen = false; + + for (var index = 0; index < slug.Length; index++) + { + var character = slug[index]; + var isHyphen = character == '-'; + + var acceptable = (character is >= 'a' and <= 'z') + || (character is >= '0' and <= '9') + || isHyphen; + + if (!acceptable + || (isHyphen && (previousWasHyphen || index == 0 || index == slug.Length - 1))) + { + throw new TeamInvalidException( + "A slug is lowercase letters, digits and single hyphens, and cannot start or end " + + "with a hyphen."); + } + + previousWasHyphen = isHyphen; + } + + return slug; + } + + private static string RequireText(string? value, string field, int maxLength) + { + var text = (value ?? string.Empty).Trim(); + + if (text.Length == 0 || text.Length > maxLength) + { + throw new TeamInvalidException( + string.Create( + CultureInfo.InvariantCulture, + $"{field} is required, and at most {maxLength} characters.")); + } + + return text; + } + + private static string? OptionalText(string? value, int maxLength) + { + var text = value?.Trim(); + + if (string.IsNullOrEmpty(text)) + { + return null; + } + + if (text.Length > maxLength) + { + throw new TeamInvalidException( + string.Create( + CultureInfo.InvariantCulture, + $"A description is at most {maxLength} characters.")); + } + + return text; + } + + /// + /// A plain cast, which is why TeamMemberRole pins the same numeric values as + /// and a test asserts it. An unknown value becomes + /// rather than a silent cast to a role nobody defined, so a + /// newer client's role is refused instead of resolving to whatever bit pattern it happens to be. + /// + private static TeamRole ToDomain(TeamMemberRole role) => role switch + { + TeamMemberRole.Viewer => TeamRole.Viewer, + TeamMemberRole.Member => TeamRole.Member, + TeamMemberRole.Admin => TeamRole.Admin, + TeamMemberRole.Owner => TeamRole.Owner, + _ => TeamRole.Unspecified, + }; + + private static TeamMemberRole ToContract(TeamRole role) => role switch + { + TeamRole.Viewer => TeamMemberRole.Viewer, + TeamRole.Member => TeamMemberRole.Member, + TeamRole.Admin => TeamMemberRole.Admin, + TeamRole.Owner => TeamMemberRole.Owner, + _ => TeamMemberRole.Unspecified, + }; + + private static TeamMemberStatus ToContract(MembershipStatus status) => status switch + { + MembershipStatus.Invited => TeamMemberStatus.Invited, + MembershipStatus.Active => TeamMemberStatus.Active, + MembershipStatus.Revoked => TeamMemberStatus.Revoked, + _ => TeamMemberStatus.Unspecified, + }; + + private static bool IsUniqueViolation(DbUpdateException exception) => + string.Equals( + (exception.InnerException as PostgresException)?.SqlState, + PostgresErrorCodes.UniqueViolation, + StringComparison.Ordinal); +} diff --git a/src/DodoSSH.Api/Features/Teams/VaultGrantEndpoints.cs b/src/DodoSSH.Api/Features/Teams/VaultGrantEndpoints.cs new file mode 100644 index 0000000..97b2289 --- /dev/null +++ b/src/DodoSSH.Api/Features/Teams/VaultGrantEndpoints.cs @@ -0,0 +1,179 @@ +using DodoSSH.Api.Authorization; +using DodoSSH.Api.Setup; +using DodoSSH.Contracts; +using DodoSSH.Domain.Authorization; +using FastEndpoints; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace DodoSSH.Api.Features.Teams; + +/// Lists who can open a vault. +/// +/// Read, not Share. Every member who can read a vault can already see the sharing graph — the server +/// stores it in plaintext and says so in docs/crypto.md §10 — so gating this on Share would hide from +/// the people it is about something the operator can read either way. +/// +internal sealed class ListVaultGrantsEndpoint( + ICurrentUserContext currentUser, + IVaultAccessService vaultAccess, + VaultGrantService grants) + : EndpointWithoutRequest, NotFound>> +{ + /// + public override void Configure() + { + Get("/api/v1/vaults/{vaultId:guid}/grants"); + + Policies(Auth.EnrolledPolicy); + + Description(b => b + .WithName("ListVaultGrants") + .WithSummary("Lists who holds a key to this vault.") + .WithTags("Vaults")); + } + + /// + public override async Task, NotFound>> ExecuteAsync( + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + var access = await vaultAccess + .ResolveAsync(user.Id, Route("vaultId"), ct) + .ConfigureAwait(false); + + if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read)) + { + return TypedResults.NotFound(); + } + + return TypedResults.Ok(await grants.ListGrantsAsync(access.Vault!, ct).ConfigureAwait(false)); + } +} + +/// Wraps this vault's key to another member. +/// +/// The one call in this API whose body the server can neither produce nor check. It stores a sealed +/// key and a signature over a tuple it never verifies — see docs/crypto.md §6 and §7 — which is +/// exactly why sharing is a client operation with a server-side record rather than a server feature. +/// +internal sealed class IssueVaultGrantEndpoint( + ICurrentUserContext currentUser, + IVaultAccessService vaultAccess, + VaultGrantService grants) + : Endpoint> +{ + /// + public override void Configure() + { + Post("/api/v1/vaults/{vaultId:guid}/grants"); + + Policies(Auth.EnrolledPolicy); + + Description(b => b + .WithName("IssueVaultGrant") + .WithSummary("Records a vault key wrapped to another member.") + .WithTags("Vaults")); + } + + /// + public override async Task> ExecuteAsync( + IssueVaultGrantRequest req, + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + var access = await vaultAccess + .ResolveAsync(user.Id, Route("vaultId"), ct) + .ConfigureAwait(false); + + if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read)) + { + return TypedResults.NotFound(); + } + + if (!access.Permissions.HasFlag(PermissionFlags.Share)) + { + return Problems.Coded( + StatusCodes.Status403Forbidden, + ProblemCodes.Forbidden, + "You do not have permission to share this vault."); + } + + try + { + await grants.IssueGrantAsync(user, access.Vault!, req, ct).ConfigureAwait(false); + + // 204. There is nothing to return that the caller does not already hold — it produced + // the wrap — and echoing the sealed key back would put it on the wire twice for nothing. + return TypedResults.NoContent(); + } + catch (VaultGrantInvalidException exception) + { + return Problems.Coded( + StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message); + } + } +} + +/// Withdraws a member's key to this vault. +/// +/// 404 for a member who holds no live grant, rather than a bland 204, for the reason device +/// revocation gives: "revoked" is what the user reads, and reading it about the wrong account is +/// worse than being told to look again. A caller driving towards "they cannot read this any more" +/// can treat 404 as having arrived. +/// +internal sealed class RevokeVaultGrantEndpoint( + ICurrentUserContext currentUser, + IVaultAccessService vaultAccess, + VaultGrantService grants) + : EndpointWithoutRequest> +{ + /// + public override void Configure() + { + Delete("/api/v1/vaults/{vaultId:guid}/grants/{userId:guid}"); + + Policies(Auth.EnrolledPolicy); + + Description(b => b + .WithName("RevokeVaultGrant") + .WithSummary("Withdraws a member's key to this vault. Blocks future reads only.") + .WithTags("Vaults")); + } + + /// + public override async Task> ExecuteAsync( + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + var access = await vaultAccess + .ResolveAsync(user.Id, Route("vaultId"), ct) + .ConfigureAwait(false); + + if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read)) + { + return TypedResults.NotFound(); + } + + if (!access.Permissions.HasFlag(PermissionFlags.Share)) + { + return Problems.Coded( + StatusCodes.Status403Forbidden, + ProblemCodes.Forbidden, + "You do not have permission to share this vault."); + } + + try + { + var revoked = await grants + .RevokeGrantAsync(user, access.Vault!, Route("userId"), ct) + .ConfigureAwait(false); + + return revoked ? TypedResults.NoContent() : TypedResults.NotFound(); + } + catch (VaultGrantInvalidException exception) + { + return Problems.Coded( + StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message); + } + } +} diff --git a/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs b/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs new file mode 100644 index 0000000..311e31f --- /dev/null +++ b/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs @@ -0,0 +1,415 @@ +using System.Security.Cryptography; +using DodoSSH.Contracts; +using DodoSSH.Domain; +using DodoSSH.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace DodoSSH.Api.Features.Teams; + +/// +/// Team vaults and the key grants that make them readable. +/// +/// +/// +/// Everything here stores bytes it cannot interpret. A wrapped vault key is sealed to a recipient's +/// X25519 key, and a grant signature is Ed25519 over a tuple this server never verifies — the two +/// together are what let a client detect a fabricated grant, and moving either check onto the server +/// would make it a convenience rather than the boundary. See docs/crypto.md §6 and §7. +/// +/// +/// What the server can check is that a grant is not obviously useless: that the recipient is +/// enrolled, that the fingerprint names their current key, and that the generation is the vault's +/// current one. Each of those would otherwise surface at the far end as a tag failure the recipient +/// reads as data corruption, days later, with nothing pointing at the grant that caused it. +/// +/// +internal sealed class VaultGrantService( + DodoDbContext database, + TimeProvider clock, + ILogger logger) +{ + /// + /// Largest wrapped vault key accepted. + /// + /// + /// A SealTo envelope over a 32-byte key is 6 + 32 + 24 + 48 = 110 bytes. The cap is loose + /// enough to survive a future envelope — the reserved hybrid seal in docs/crypto.md §8 is far + /// larger — and tight enough that this column cannot be used as free storage on a server that + /// stores it without being able to read it. + /// + private const int MaxWrappedKeyBytes = 4096; + + /// Creates a vault owned by a team, with the creator's own grant. + /// + /// The vault and its first grant are written together, for the reason enrollment gives about a + /// personal vault: a vault with no grant is a container nobody can ever open, including whoever + /// created it, because only a client can wrap the key and it has already moved on. + /// + internal async Task CreateTeamVaultAsync( + UserAccount user, + Team team, + CreateTeamVaultRequest request, + CancellationToken cancellationToken) + { + var name = RequireVaultName(request.Name); + + if (request.VaultId == Guid.Empty) + { + throw new TeamInvalidException("A vault id is required. Generate a UUIDv7 on the client."); + } + + RequireWrappedKey(request.WrappedVaultKey); + RequireSignature(request.GrantSignature); + + var key = await RequireCurrentKeyAsync(user.Id, cancellationToken).ConfigureAwait(false); + + var taken = await database.Vaults + .AnyAsync(v => v.Id == request.VaultId, cancellationToken) + .ConfigureAwait(false); + + if (taken) + { + throw new TeamInvalidException( + "That vault id is already in use. Generate a new UUIDv7 and retry."); + } + + AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow()); + + try + { + await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + catch (DbUpdateException exception) when (IsUniqueViolation(exception)) + { + // The pre-check covers the ordinary case; this is the race between two creates choosing + // the same id, which must not surface as a 500 about a constraint. + throw new TeamInvalidException( + "That vault id is already in use. Generate a new UUIDv7 and retry."); + } + + TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id); + + return new VaultSummary( + VaultId: request.VaultId, + Name: name, + IsPersonal: false, + TeamId: team.Id, + KeyGeneration: 1, + Permissions: 0, + WrappedVaultKey: request.WrappedVaultKey, + RekeyRequired: false); + } + + /// Adds the vault row and the creator's own grant, in one unit of work. + private void AddVaultWithSelfGrant( + UserAccount user, + Team team, + CreateTeamVaultRequest request, + string name, + UserKey key, + DateTimeOffset now) + { + database.Vaults.Add(new Vault + { + Id = request.VaultId, + Name = name, + OwnerKind = VaultOwnerKind.Team, + TeamId = team.Id, + KeyGeneration = 1, + CreatedAtUtc = now, + UpdatedAtUtc = now, + }); + + database.VaultKeyGrants.Add(new VaultKeyGrant + { + Id = Guid.CreateVersion7(), + VaultId = request.VaultId, + KeyGeneration = 1, + Kind = GrantKind.Member, + RecipientUserId = user.Id, + RecipientKeyFingerprint = key.FingerprintSha256, + WrappedKey = request.WrappedVaultKey, + GranterUserId = user.Id, + GranterKeyFingerprint = key.FingerprintSha256, + + // No key log head, exactly as a personal vault's self-grant carries none: there is no + // third party whose key could have been substituted here. + KeyLogHead = null, + Signature = request.GrantSignature, + State = GrantState.Active, + CreatedAtUtc = now, + }); + } + + private static string RequireVaultName(string? value) + { + var name = (value ?? string.Empty).Trim(); + + return name.Length is 0 or > 256 + ? throw new TeamInvalidException("A vault name of 1 to 256 characters is required.") + : name; + } + + /// Lists who can open a vault. + internal async Task ListGrantsAsync( + Vault vault, + CancellationToken cancellationToken) + { + var grants = await database.VaultKeyGrants + .Where(g => g.VaultId == vault.Id && g.Kind == GrantKind.Member) + .Include(g => g.RecipientUser) + .OrderBy(g => g.CreatedAtUtc) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return new VaultGrantsResponse( + VaultId: vault.Id, + KeyGeneration: (uint)vault.KeyGeneration, + RekeyRequired: vault.RekeyRequired, + Grants: + [ + .. grants.Select(g => new VaultGrantSummary( + g.RecipientUserId!.Value, + g.RecipientUser?.Email, + g.RecipientUser?.DisplayName, + (uint)g.KeyGeneration, + ToContract(g.State), + g.GranterUserId, + g.CreatedAtUtc, + g.RevokedAtUtc)), + ]); + } + + /// Wraps a vault key to another member. + /// + /// Re-issuing to a recipient who already holds a live grant replaces it in place rather than + /// inserting a second row, because the unique index permits exactly one live grant per recipient + /// per generation — and because the operation somebody is actually performing when they do this + /// is "wrap it again", after a rotation or a botched first attempt. + /// + internal async Task IssueGrantAsync( + UserAccount actor, + Vault vault, + IssueVaultGrantRequest request, + CancellationToken cancellationToken) + { + await RequireIssuableAsync(vault, request, cancellationToken).ConfigureAwait(false); + + var granterKey = await RequireCurrentKeyAsync(actor.Id, cancellationToken) + .ConfigureAwait(false); + + var existing = await database.VaultKeyGrants + .SingleOrDefaultAsync( + g => g.VaultId == vault.Id + && g.KeyGeneration == vault.KeyGeneration + && g.RecipientUserId == request.RecipientUserId + && g.RevokedAtUtc == null, + cancellationToken) + .ConfigureAwait(false); + + var grant = existing ?? new VaultKeyGrant + { + Id = Guid.CreateVersion7(), + VaultId = vault.Id, + KeyGeneration = vault.KeyGeneration, + Kind = GrantKind.Member, + RecipientUserId = request.RecipientUserId, + CreatedAtUtc = clock.GetUtcNow(), + }; + + grant.RecipientKeyFingerprint = request.RecipientKeyFingerprint; + grant.WrappedKey = request.WrappedVaultKey; + grant.GranterUserId = actor.Id; + + // Taken from the server's own view of the caller's key rather than from the request. The + // client signed over the same value, so an honest client is unaffected; a field that could + // disagree with reality is one a reader would have to decide which copy to believe. + grant.GranterKeyFingerprint = granterKey.FingerprintSha256; + + grant.KeyLogHead = request.KeyLogHead; + grant.Signature = request.GrantSignature; + grant.State = GrantState.Active; + + if (existing is null) + { + database.VaultKeyGrants.Add(grant); + } + + await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + TeamLog.GrantIssued( + logger, vault.Id, vault.KeyGeneration, request.RecipientUserId, actor.Id); + } + + /// + /// Everything that can be checked about a grant without holding the vault key. + /// + /// + /// None of this verifies that the wrap contains the right key — nothing on this machine can. Each + /// check exists because failing it would otherwise surface at the recipient as a tag failure they + /// read as data corruption, long after the request that caused it. + /// + private async Task RequireIssuableAsync( + Vault vault, + IssueVaultGrantRequest request, + CancellationToken cancellationToken) + { + // Team vaults only. A personal vault has exactly one subject who may reach it, so a grant on + // one would seal a key to somebody the access check will go on refusing — a row that looks + // like sharing and is not. Moving the items into a team vault is the operation that shares. + if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is not { } teamId) + { + throw new VaultGrantInvalidException( + "Only a team vault can be shared. A personal vault is reachable by its owner alone, " + + "so a grant on one would seal a key to somebody who still could not fetch it."); + } + + RequireWrappedKey(request.WrappedVaultKey); + RequireSignature(request.GrantSignature); + RequireDigest(request.RecipientKeyFingerprint, "recipient key fingerprint"); + RequireDigest(request.KeyLogHead, "key log head"); + + if (request.KeyGeneration != (uint)vault.KeyGeneration) + { + throw new VaultGrantInvalidException( + $"This vault is at key generation {vault.KeyGeneration}. A grant for generation " + + $"{request.KeyGeneration} would open nothing."); + } + + var member = await database.TeamMemberships + .AnyAsync( + m => m.TeamId == teamId + && m.UserId == request.RecipientUserId + && m.Status == MembershipStatus.Active + && m.DeletedAtUtc == null, + cancellationToken) + .ConfigureAwait(false); + + if (!member) + { + throw new VaultGrantInvalidException( + "That account is not an active member of the team that owns this vault. Add them to " + + "the team first — a key wrapped to somebody the server will refuse to serve is a " + + "grant that does nothing."); + } + + var recipientKey = await RequireCurrentKeyAsync(request.RecipientUserId, cancellationToken) + .ConfigureAwait(false); + + // The fingerprint the client signed over must be the key the recipient actually holds. + // Otherwise the grant is sealed to a superseded key, opens nothing, and surfaces at the far + // end as an unexplained decryption failure rather than as the mistake it is. + if (!CryptographicOperations.FixedTimeEquals( + recipientKey.FingerprintSha256, request.RecipientKeyFingerprint)) + { + throw new VaultGrantInvalidException( + "The fingerprint does not name the recipient's current identity key. Re-read the " + + "directory and wrap the key again — theirs has been rotated since you fetched it."); + } + } + + /// + /// Withdraws a member's key grant. + /// + /// Whether there was a live grant to withdraw. + /// + /// Blocks future reads and nothing else. Anything the recipient has already pulled is on their + /// machine and stays there, which is why the vault is flagged for rekey and why the honest + /// remediation for a departure is rotating the SSH credential itself. See ADR 0001. + /// + internal async Task RevokeGrantAsync( + UserAccount actor, + Vault vault, + Guid recipientUserId, + CancellationToken cancellationToken) + { + if (recipientUserId == actor.Id) + { + throw new VaultGrantInvalidException( + "You cannot withdraw your own key. It would leave you unable to read a vault you can " + + "still write to, and nothing here can hand it back."); + } + + var grants = await database.VaultKeyGrants + .Where(g => g.VaultId == vault.Id + && g.RecipientUserId == recipientUserId + && g.RevokedAtUtc == null) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (grants.Count == 0) + { + return false; + } + + var now = clock.GetUtcNow(); + + foreach (var grant in grants) + { + grant.State = GrantState.Revoked; + grant.RevokedAtUtc = now; + } + + vault.RekeyRequired = true; + vault.RekeyReason = RekeyReason.Requested; + vault.UpdatedAtUtc = now; + + await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + TeamLog.GrantRevoked(logger, vault.Id, recipientUserId, actor.Id); + + return true; + } + + private async Task RequireCurrentKeyAsync(Guid userId, CancellationToken cancellationToken) + { + var key = await database.UserKeys + .SingleOrDefaultAsync(k => k.UserId == userId && k.IsCurrent, cancellationToken) + .ConfigureAwait(false); + + return key + ?? throw new VaultGrantInvalidException( + "That account has not published an identity key yet, so there is nothing to wrap a " + + "vault key to. They have to sign in and set up their vault first."); + } + + private static void RequireWrappedKey(byte[]? value) + { + if (value is null || value.Length == 0 || value.Length > MaxWrappedKeyBytes) + { + throw new VaultGrantInvalidException( + $"A wrapped vault key of 1 to {MaxWrappedKeyBytes} bytes is required."); + } + } + + private static void RequireSignature(byte[]? value) + { + if (value is null || value.Length != 64) + { + throw new VaultGrantInvalidException("An Ed25519 grant signature is 64 bytes."); + } + } + + private static void RequireDigest(byte[]? value, string field) + { + if (value is null || value.Length != 32) + { + throw new VaultGrantInvalidException($"A {field} is 32 bytes."); + } + } + + private static VaultGrantState ToContract(GrantState state) => state switch + { + GrantState.Active => VaultGrantState.Active, + GrantState.AwaitingRewrap => VaultGrantState.AwaitingRewrap, + GrantState.Revoked => VaultGrantState.Revoked, + _ => VaultGrantState.Unspecified, + }; + + private static bool IsUniqueViolation(DbUpdateException exception) => + string.Equals( + (exception.InnerException as PostgresException)?.SqlState, + PostgresErrorCodes.UniqueViolation, + StringComparison.Ordinal); +} diff --git a/src/DodoSSH.Api/Program.cs b/src/DodoSSH.Api/Program.cs index 901e85c..68bdf46 100644 --- a/src/DodoSSH.Api/Program.cs +++ b/src/DodoSSH.Api/Program.cs @@ -1,6 +1,7 @@ using DodoSSH.Api.Authorization; using DodoSSH.Api.Features.Identity; using DodoSSH.Api.Features.Sync; +using DodoSSH.Api.Features.Teams; using DodoSSH.Api.Setup; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization.Policy; @@ -32,6 +33,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/src/DodoSSH.Api/Setup/EndpointRegistration.cs b/src/DodoSSH.Api/Setup/EndpointRegistration.cs index 2b8eccd..e058a62 100644 --- a/src/DodoSSH.Api/Setup/EndpointRegistration.cs +++ b/src/DodoSSH.Api/Setup/EndpointRegistration.cs @@ -1,6 +1,7 @@ using DodoSSH.Api.Features.Identity; using DodoSSH.Api.Features.Meta; using DodoSSH.Api.Features.Sync; +using DodoSSH.Api.Features.Teams; using DodoSSH.Contracts; using FastEndpoints; @@ -38,15 +39,26 @@ internal static class EndpointRegistration typeof(EnrollEndpoint), typeof(RegisterDeviceEndpoint), typeof(RevokeDeviceEndpoint), + typeof(LookupDirectoryEndpoint), + typeof(ReadKeyLogEndpoint), typeof(SyncPullEndpoint), typeof(SyncPushEndpoint), + typeof(CreateTeamEndpoint), + typeof(ListTeamsEndpoint), + typeof(ListTeamMembersEndpoint), + typeof(AddTeamMemberEndpoint), + typeof(ChangeTeamMemberRoleEndpoint), + typeof(RemoveTeamMemberEndpoint), + typeof(CreateTeamVaultEndpoint), + typeof(ListVaultGrantsEndpoint), + typeof(IssueVaultGrantEndpoint), + typeof(RevokeVaultGrantEndpoint), // Registered as each feature lands: // Identity — key rotation, passphrase change - // Directory — public-key lookup - // Vaults — grants, rekey, ACL + // Vaults — rekey, per-item ACLs // Relay — tickets and the WebSocket - // Teams, Audit, Admin + // Audit, Admin }); /// Hides the endpoint listing FastEndpoints publishes at GET /_test_url_cache_. diff --git a/src/DodoSSH.Client.Api/DodoSshApiClient.cs b/src/DodoSSH.Client.Api/DodoSshApiClient.cs index a5edd25..2788583 100644 --- a/src/DodoSSH.Client.Api/DodoSshApiClient.cs +++ b/src/DodoSSH.Client.Api/DodoSshApiClient.cs @@ -58,6 +58,115 @@ public interface IAccountApi Task RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken); } +/// +/// Teams, their members, and the vaults they own. +/// +/// +/// Separated from although the two are used together, because they are +/// different kinds of act. Everything here changes what the server 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. +/// +public interface ITeamApi +{ + /// Lists the teams the caller belongs to. + Task> ListTeamsAsync(CancellationToken cancellationToken); + + /// Creates a team, with the caller as its owner. + Task CreateTeamAsync(CreateTeamRequest request, CancellationToken cancellationToken); + + /// Lists a team's members. + Task> ListTeamMembersAsync( + Guid teamId, + CancellationToken cancellationToken); + + /// Adds a member to a team. + Task AddTeamMemberAsync( + Guid teamId, + AddTeamMemberRequest request, + CancellationToken cancellationToken); + + /// Changes a member's role. + Task ChangeTeamMemberRoleAsync( + Guid teamId, + Guid userId, + ChangeTeamMemberRoleRequest request, + CancellationToken cancellationToken); + + /// + /// Removes a member, revoking every vault key grant they hold from this team. + /// + /// + /// 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. + /// + Task RemoveTeamMemberAsync(Guid teamId, Guid userId, CancellationToken cancellationToken); + + /// Creates a vault owned by a team, with the creator's key grant. + Task CreateTeamVaultAsync( + Guid teamId, + CreateTeamVaultRequest request, + CancellationToken cancellationToken); +} + +/// +/// The public-key directory and the log that makes it checkable. +/// +/// +/// 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. +/// +public interface IDirectoryApi +{ + /// Looks a user up by exact email address. There is no search. + Task> LookupByEmailAsync( + string email, + CancellationToken cancellationToken); + + /// Looks up an account the caller shares a team with. + Task LookupByIdAsync(Guid userId, CancellationToken cancellationToken); + + /// Reads entries after a sequence, with the log's current head. + Task ReadKeyLogAsync( + long afterSequence, + int? limit, + CancellationToken cancellationToken); +} + +/// +/// Vault key grants: who can open a vault, and the record of who let them. +/// +/// +/// 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. +/// +public interface IVaultGrantApi +{ + /// Lists who holds a key to this vault. + Task ListVaultGrantsAsync(Guid vaultId, CancellationToken cancellationToken); + + /// Records a vault key wrapped to another member. + Task IssueVaultGrantAsync( + Guid vaultId, + IssueVaultGrantRequest request, + CancellationToken cancellationToken); + + /// + /// Withdraws a member's key to this vault. + /// + /// Whether there was a live grant to withdraw. + /// + /// 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. + /// + Task RevokeVaultGrantAsync( + Guid vaultId, + Guid userId, + CancellationToken cancellationToken); +} + /// /// The two vault-synchronisation calls, separated so the sync engine can be driven without HTTP. /// @@ -99,13 +208,16 @@ public interface ISyncApi /// /// 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"; /// /// Reads the server's capabilities, versions and limits. @@ -209,6 +321,168 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token DodoSshJsonContext.Default.SyncPushResponse, cancellationToken); + /// + public Task> ListTeamsAsync(CancellationToken cancellationToken) => + SendAsync( + HttpMethod.Get, + TeamsPath, + null, + DodoSshJsonContext.Default.IReadOnlyListTeamSummary, + cancellationToken); + + /// + public Task CreateTeamAsync( + CreateTeamRequest request, + CancellationToken cancellationToken) => + SendAsync( + HttpMethod.Post, + TeamsPath, + JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamRequest), + DodoSshJsonContext.Default.TeamSummary, + cancellationToken); + + /// + public Task> ListTeamMembersAsync( + Guid teamId, + CancellationToken cancellationToken) => + SendAsync( + HttpMethod.Get, + string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members"), + null, + DodoSshJsonContext.Default.IReadOnlyListTeamMemberSummary, + cancellationToken); + + /// + public Task 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); + + /// + public Task 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); + + /// + public Task RemoveTeamMemberAsync( + Guid teamId, + Guid userId, + CancellationToken cancellationToken) => + DeleteAsync( + string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}"), + cancellationToken); + + /// + public Task 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); + + /// + /// Looks a user up by exact email address. + /// + /// + /// 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. rather than string + /// concatenation: an unescaped & or # in an address would silently become a + /// lookup for something else. + /// + public Task> LookupByEmailAsync( + string email, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(email); + + return SendAsync( + HttpMethod.Get, + $"{DirectoryPath}?email={Uri.EscapeDataString(email)}", + null, + DodoSshJsonContext.Default.IReadOnlyListDirectoryEntry, + cancellationToken); + } + + /// + public async Task 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]; + } + + /// + public Task 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); + } + + /// + public Task ListVaultGrantsAsync( + Guid vaultId, + CancellationToken cancellationToken) => + SendAsync( + HttpMethod.Get, + string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants"), + null, + DodoSshJsonContext.Default.VaultGrantsResponse, + cancellationToken); + + /// + 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); + + /// + public Task RevokeVaultGrantAsync( + Guid vaultId, + Guid userId, + CancellationToken cancellationToken) => + DeleteAsync( + string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants/{userId}"), + cancellationToken); + private async Task GetAnonymousAsync( string path, System.Text.Json.Serialization.Metadata.JsonTypeInfo 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. /// + /// + /// Sends a request whose success carries no body. + /// + /// + /// Its own path for the reason 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. + /// + 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 DeleteAsync(string path, CancellationToken cancellationToken) { using var request = new HttpRequestMessage(HttpMethod.Delete, path); diff --git a/src/DodoSSH.Client.Api/KeyLogAudit.cs b/src/DodoSSH.Client.Api/KeyLogAudit.cs new file mode 100644 index 0000000..6e1e292 --- /dev/null +++ b/src/DodoSSH.Client.Api/KeyLogAudit.cs @@ -0,0 +1,313 @@ +using System.Security.Cryptography; +using DodoSSH.Contracts; +using DodoSSH.Crypto; + +namespace DodoSSH.Client.Api; + +/// Why a directory entry was or was not accepted. +public enum RecipientVerdict +{ + /// Not a legal value. + Unspecified = 0, + + /// + /// The key log verifies, and it introduces exactly the key the directory described. + /// + /// + /// This is the strongest statement a client can make without an out-of-band fingerprint check. It + /// says the server has been consistent, not that the key is the right person's — see + /// and ADR 0001. + /// + Verified = 1, + + /// No account with that address, or none the caller may look up. + NotFound = 2, + + /// The account exists but has published no identity key, so there is nothing to wrap to. + NotEnrolled = 3, + + /// + /// The key log's hash chain does not verify. + /// + /// + /// Either the log has been edited or this build and the server disagree about how an entry is + /// hashed. Both are refusals: wrapping a vault key against a log that cannot be checked is the + /// same as not checking one. + /// + ChainBroken = 4, + + /// + /// The log holds no entry matching the key the directory returned. + /// + /// + /// The exact case key transparency exists for. A server that wants to substitute a key it holds + /// has to publish it in the append-only log to get past this, where every other client will see + /// it. + /// + NotInKeyLog = 5, + + /// The fingerprint does not match the keys it is supposed to be over. + FingerprintMismatch = 6, + + /// + /// The log introduces a newer generation for this user than the directory returned. + /// + /// + /// A rotation the directory has not caught up with, or a stale answer being served on purpose. + /// Refused either way: a key wrapped to a superseded generation opens nothing, and the recipient + /// reads that as corruption rather than as a race. + /// + Superseded = 7, +} + +/// +/// A recipient whose published key has been checked against the key log. +/// +/// The directory entry, as returned. +/// +/// The log head observed while verifying, to be recorded in the grant. This is what makes a forked +/// view detectable: two clients handed different logs sign over different heads, and the mismatch +/// surfaces the next time either touches a vault the other can see. +/// +/// +/// The recipient's identity fingerprint, recomputed here rather than taken from the response. +/// +/// Show this to a human before sharing anything that matters. Everything above proves the +/// server has been internally consistent; only somebody comparing this value with the recipient over +/// a channel the server does not control can prove it is the right person's key. +/// +/// +public sealed record VerifiedRecipient( + DirectoryEntry Entry, + byte[] KeyLogHead, + byte[] Fingerprint); + +/// The outcome of verifying a recipient. +/// What happened. +/// The recipient, present only when verified. +/// One line for a person. Never contains key material. +public sealed record RecipientVerification( + RecipientVerdict Verdict, + VerifiedRecipient? Recipient, + string Message) +{ + /// Whether a key came back that is safe to wrap to. + public bool IsVerified => Verdict == RecipientVerdict.Verified && Recipient is not null; +} + +/// +/// Reads the whole key log, checks its hash chain, and decides whether a directory answer agrees +/// with it. +/// +/// +/// +/// This is the check that makes sharing safe to offer at all. A directory lookup is a claim by +/// the server about somebody else's public key; wrapping a vault key to an unverified claim hands the +/// vault to whoever made it, and no amount of transport security helps, because the server is inside +/// the threat model. See ADR 0001 and docs/crypto.md §7.2. +/// +/// +/// The whole log is read from the beginning, every time, rather than from a cached cursor. It is +/// small — one entry per identity key ever published, so a few hundred rows for a large deployment — +/// and a client that verified only the tail would accept a chain whose earlier links it had never +/// seen. Caching a verified prefix is a worthwhile optimisation and is deliberately not done yet: +/// it needs somewhere to keep the prefix that the server cannot influence, and the client's +/// preferences store does not exist. +/// +/// +/// What this cannot do is tell you the key belongs to the person you mean. A server that publishes a +/// substituted key in the log passes every check here — it is now on the record, which is the whole +/// mechanism: detectable, attributable, not prevented. The fingerprint comes back for a human to +/// compare out of band, which is the only step that closes it. +/// +/// +public static class KeyLogAudit +{ + /// Entries requested per page. + private const int PageSize = 500; + + /// + /// Pages the whole log with the chain checked link by link. + /// + /// The verified log, or a null Entries when a link did not hold. + public static async Task ReadAsync( + IDirectoryApi directory, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(directory); + + var entries = new List(); + var previous = KeyLogChain.CreateGenesisPreviousHash(); + var after = 0L; + var head = previous; + + while (true) + { + var page = await directory.ReadKeyLogAsync(after, PageSize, cancellationToken) + .ConfigureAwait(false); + + foreach (var entry in page.Entries) + { + if (!Links(entry, previous)) + { + return new AuditedKeyLog(null, head); + } + + entries.Add(entry); + previous = entry.Hash; + after = entry.Sequence; + } + + head = page.Head; + + if (!page.HasMore) + { + break; + } + + // A page that advanced nothing would loop for ever. It means the server is answering a + // cursor it will not move past, which is a broken log from this side of the wire. + if (page.Entries.Count == 0) + { + return new AuditedKeyLog(null, head); + } + } + + // The last link has to be the head the server claims, or the log served and the log + // summarised are two different things. + return entries.Count > 0 && !CryptographicOperations.FixedTimeEquals(previous, head) + ? new AuditedKeyLog(null, head) + : new AuditedKeyLog(entries, head); + } + + /// Decides whether a directory entry agrees with a verified log. + public static RecipientVerification Verify(AuditedKeyLog log, DirectoryEntry? entry) + { + ArgumentNullException.ThrowIfNull(log); + + if (log.Entries is null) + { + return new RecipientVerification( + RecipientVerdict.ChainBroken, + null, + "The server's key log does not verify. Nothing will be shared with anyone until it " + + "does — an unverifiable log is the same as no log."); + } + + if (entry is null) + { + return new RecipientVerification( + RecipientVerdict.NotFound, + null, + "No account here has that address. They have to sign in to this server once before " + + "anything can be shared with them."); + } + + var fingerprint = DshCrypto.ComputeFingerprint( + entry.EncryptionPublicKey, entry.SigningPublicKey); + + if (!CryptographicOperations.FixedTimeEquals(fingerprint, entry.Fingerprint)) + { + return new RecipientVerification( + RecipientVerdict.FingerprintMismatch, + null, + "The fingerprint the directory returned is not the fingerprint of the keys it " + + "returned with it."); + } + + return Compare(log, entry, fingerprint); + } + + /// Compares one directory entry with the log entries for that account. + private static RecipientVerification Compare( + AuditedKeyLog log, + DirectoryEntry entry, + byte[] fingerprint) + { + var forUser = log.Entries!.Where(e => e.UserId == entry.UserId).ToList(); + + if (forUser.Count == 0) + { + return new RecipientVerification( + RecipientVerdict.NotEnrolled, + null, + "That account has published no identity key, so there is nothing to wrap a vault key " + + "to."); + } + + var latest = forUser.Max(e => e.Generation); + + if (latest > entry.KeyGeneration) + { + return new RecipientVerification( + RecipientVerdict.Superseded, + null, + $"The key log has generation {latest} for that account and the directory offered " + + $"{entry.KeyGeneration}. Wrapping to a superseded key would open nothing."); + } + + var matching = forUser.Find(e => + e.Generation == entry.KeyGeneration + && e.EncryptionPublicKey.AsSpan().SequenceEqual(entry.EncryptionPublicKey) + && e.SigningPublicKey.AsSpan().SequenceEqual(entry.SigningPublicKey)); + + if (matching is null) + { + return new RecipientVerification( + RecipientVerdict.NotInKeyLog, + null, + "The key the directory returned does not appear in the append-only key log. This is " + + "exactly the substitution the log exists to catch; do not share anything with this " + + "account until it is explained."); + } + + return new RecipientVerification( + RecipientVerdict.Verified, + new VerifiedRecipient(entry, log.Head, fingerprint), + "Verified against the key log. Compare the fingerprint with them out of band before " + + "sharing anything that matters."); + } + + /// + /// Recomputed rather than compared: the point is that this client derives the hash from the + /// entry's own contents, so a server that edited a field cannot hand over a hash that covers the + /// original. + /// + private static bool Links(KeyLogRecord entry, byte[] previous) + { + if (!CryptographicOperations.FixedTimeEquals(entry.PreviousHash, previous)) + { + return false; + } + + byte[] computed; + + try + { + computed = KeyLogChain.ComputeEntryHash( + entry.PreviousHash, + entry.UserId, + entry.Generation, + entry.EncryptionPublicKey, + entry.SigningPublicKey, + entry.StatementSignature, + entry.CreatedAt); + } + catch (ArgumentException) + { + // A key or signature of the wrong length. Malformed rather than merely mismatched, and a + // refusal either way. + return false; + } + + return CryptographicOperations.FixedTimeEquals(computed, entry.Hash); + } +} + +/// A key log that has been read, with its chain checked. +/// +/// Every entry in order, or when a link did not hold. Null is the only +/// signal a caller needs: a partially verified log is not a weaker answer, it is no answer. +/// +/// The head the server reported, for recording in a grant. +public sealed record AuditedKeyLog(IReadOnlyList? Entries, byte[] Head); diff --git a/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs index e5294ca..5a1122c 100644 --- a/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs +++ b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs @@ -126,6 +126,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp /// private readonly TransfersViewModel transfers; + private readonly TeamsViewModel teams; + private IVaultServer? connection; private bool disposed; @@ -167,6 +169,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp transfers = new TransfersViewModel(sftpSessions, clock); + // Both dependencies as functions rather than values: the connection arrives after sign-in and the + // session after unlock, and both go away again on lock. Capturing either would give this screen a + // reference that outlives what it points at — which for a session means holding vault keys past the + // moment locking is supposed to have zeroed them. + teams = new TeamsViewModel(() => connection, () => Vault?.Session); + // Subscribed for the life of the process, because the workspace lives that long and so does the tab // list. Detached in DisposeAsync, which is the only point either of them ends. this.workspace.SessionEnded += OnWorkspaceSessionEnded; @@ -238,6 +246,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp [ObservableProperty] private VaultViewModel? vault; + /// + /// The teams screen, which the window binds to whether or not a vault is open. + /// + /// + /// Not nullable and never replaced, for the reason is not: the screen reads a + /// server rather than a vault, and both of its dependencies are fetched through a function at the + /// moment they are needed. That means a lock does not have to tear it down and an unlock does not have + /// to rebuild it, and the list it is showing survives both. + /// + internal TeamsViewModel Teams => teams; + /// The transfers screen, which the window binds to whether or not a vault is open. /// /// Not nullable and never replaced, unlike . The screen is unreachable while locked — @@ -1290,6 +1309,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp /// partial void OnScreenChanged(ShellScreen value) { + // Teams are read from the server rather than from the vault, so there is nothing to show until + // somebody asks for it — and asking for it on every unlock would be a request per launch for a + // screen most people never open. Fire-and-forget because a property change cannot await, and + // because the view model turns every failure into its own status line rather than throwing. + if (value is ShellScreen.Team) + { + _ = teams.LoadAsync(CancellationToken.None); + } + OnPropertyChanged(nameof(IsHostsScreen)); OnPropertyChanged(nameof(IsTransfersScreen)); OnPropertyChanged(nameof(IsVaultScreen)); diff --git a/src/DodoSSH.Client.App/ViewModels/TeamsViewModel.cs b/src/DodoSSH.Client.App/ViewModels/TeamsViewModel.cs new file mode 100644 index 0000000..8d5d9cd --- /dev/null +++ b/src/DodoSSH.Client.App/ViewModels/TeamsViewModel.cs @@ -0,0 +1,523 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DodoSSH.Client.Api; +using DodoSSH.Client.Session; +using DodoSSH.Contracts; + +namespace DodoSSH.Client.App.ViewModels; + +/// One team, as a row in the list. +internal sealed record TeamRowViewModel(TeamSummary Team) +{ + internal Guid TeamId => Team.TeamId; + + internal string Name => Team.Name; + + internal string Slug => Team.Slug; + + /// The caller's own role, as the chip the list shows. + internal string Role => Team.Role.ToString().ToUpperInvariant(); + + internal string Detail => string.Create( + CultureInfo.CurrentCulture, + $"{Team.MemberCount} member(s) · {Team.VaultCount} vault(s)"); + + /// Whether this account may add members and create vaults here. + internal bool CanAdminister => + Team.Role is TeamMemberRole.Admin or TeamMemberRole.Owner; +} + +/// One member, as a row in the members table. +internal sealed record TeamMemberRowViewModel(TeamMemberSummary Member, bool IsSelf) +{ + internal Guid UserId => Member.UserId; + + /// What to call them. The address, or the id when the account has neither. + /// + /// Falling through to the id rather than to "Unknown": an account with no display name and no email is + /// rare and is exactly the row somebody needs to be able to identify in order to remove it. + /// + internal string Name => + Member.DisplayName ?? Member.Email ?? Member.UserId.ToString(); + + internal string Email => Member.Email ?? "—"; + + internal string Role => Member.Role.ToString().ToUpperInvariant(); + + /// + /// What the account can be given, in one phrase. + /// + /// + /// Not a two-factor column, not a last-active column. The server records neither: there is no + /// second-factor concept anywhere in it, and LastSeenAtUtc is written at provisioning and at + /// enrollment and nowhere else, so a column headed "last active" would be reporting something else. + /// What is true and worth a column is whether a vault key can be wrapped to them at all. + /// + internal string KeyState => Member.IsEnrolled + ? "key published" + : "no key yet — cannot be given a vault"; + + internal bool CanBeRemoved => Member.Role != TeamMemberRole.Owner; +} + +/// One vault of the selected team, with what this account can do to it. +internal sealed record TeamVaultRowViewModel(Guid VaultId, string Name, bool IsReadable, bool RekeyRequired) +{ + /// What the row says about itself. + /// + /// The unreadable case is the one that has to read clearly, because it is normal rather than broken: + /// somebody has been added to a team and nobody has wrapped the vault key to them yet. + /// + internal string State => (IsReadable, RekeyRequired) switch + { + (false, _) => "waiting for a key — ask a member who has one to share it", + (true, true) => "readable · a rekey is owed after a membership change", + _ => "readable", + }; +} + +/// +/// The teams screen: who is in a team, what they may do, and which vaults they hold a key to. +/// +/// +/// +/// Two separate acts, and the screen is built around saying so. Adding somebody to a team is a +/// server-side authorization change and takes effect immediately. Giving them a vault key is a +/// cryptographic act only a machine with that key can perform, and until somebody does it their vault +/// list shows an entry they cannot open. Every product that hides this ends up implying the server can +/// hand out access on its own — which, here, it cannot. See TeamService and ADR 0001. +/// +/// +/// Nothing on this screen is cached across a lock. It reads the server on open and after each change, +/// because membership is not vault content and has no local mirror — a team list in the encrypted cache +/// would be a second copy of something the server is authoritative for. +/// +/// +internal sealed partial class TeamsViewModel( + Func connection, + Func session) : ObservableObject +{ + /// Teams this account belongs to. + internal ObservableCollection Teams { get; } = []; + + /// Members of the selected team. + internal ObservableCollection Members { get; } = []; + + /// Vaults the selected team owns, as far as this account can see them. + internal ObservableCollection Vaults { get; } = []; + + [ObservableProperty] + private TeamRowViewModel? selectedTeam; + + [ObservableProperty] + private TeamMemberRowViewModel? selectedMember; + + [ObservableProperty] + private TeamVaultRowViewModel? selectedVault; + + [ObservableProperty] + private string status = string.Empty; + + [ObservableProperty] + private bool isBusy; + + // ---- Creating a team ---- + + [ObservableProperty] + private bool isCreatingTeam; + + [ObservableProperty] + private string newTeamName = string.Empty; + + [ObservableProperty] + private string newTeamSlug = string.Empty; + + // ---- Adding a member ---- + + [ObservableProperty] + private string inviteEmail = string.Empty; + + /// Whether there is a server to talk to at all. + internal bool IsOnline => connection() is not null; + + /// Whether the selected team can be administered by this account. + internal bool CanAdministerSelected => SelectedTeam?.CanAdminister == true; + + /// Whether there is anything to show below the team list. + internal bool HasSelection => SelectedTeam is not null; + + internal bool HasTeams => Teams.Count > 0; + + /// Reads the teams this account belongs to, and the selected one's detail. + internal Task LoadAsync(CancellationToken cancellationToken) => + RunAsync(() => ReloadAsync(cancellationToken)); + + /// + /// The reload itself, without the busy gate. + /// + /// + /// Separate from because every command ends by reloading, and a command that + /// called the gated version would find the gate held by itself and skip the reload silently — leaving + /// a team that was created moments ago missing from the list it was just added to. + /// + private async Task ReloadAsync(CancellationToken cancellationToken) + { + if (connection() is not { } server) + { + Teams.Clear(); + Members.Clear(); + Vaults.Clear(); + RaiseState(); + + Status = "Offline. Teams are read from the server, so this screen needs a connection."; + return; + } + + var selectedId = SelectedTeam?.TeamId; + + var teams = await server.Teams.ListTeamsAsync(cancellationToken).ConfigureAwait(true); + + Teams.Clear(); + + foreach (var team in teams) + { + Teams.Add(new TeamRowViewModel(team)); + } + + SelectedTeam = + Teams.FirstOrDefault(row => row.TeamId == selectedId) ?? Teams.FirstOrDefault(); + + RaiseState(); + + await LoadSelectedAsync(cancellationToken).ConfigureAwait(true); + + Status = Teams.Count == 0 + ? "You are not in a team yet. Create one to share hosts and credentials with colleagues." + : string.Empty; + } + + /// Opens the create-a-team form. + [RelayCommand] + private void NewTeam() + { + NewTeamName = string.Empty; + NewTeamSlug = string.Empty; + IsCreatingTeam = true; + Status = string.Empty; + } + + /// Abandons the create-a-team form. + [RelayCommand] + private void CancelNewTeam() + { + IsCreatingTeam = false; + Status = string.Empty; + } + + /// Creates a team, with this account as its owner. + /// + /// The id is generated here, which is what makes a create whose response was lost safe to send again — + /// the server treats an identical repeat as the same team rather than a second one. + /// + [RelayCommand] + private async Task CreateTeamAsync(CancellationToken cancellationToken) + { + if (connection() is not { } server) + { + Status = "Offline. Creating a team needs a connection."; + return; + } + + var name = NewTeamName.Trim(); + var slug = NewTeamSlug.Trim().ToLowerInvariant(); + + if (name.Length == 0 || slug.Length == 0) + { + Status = "A team needs a name and a slug."; + return; + } + + await RunAsync(async () => + { + var created = await server.Teams + .CreateTeamAsync( + new CreateTeamRequest(Guid.CreateVersion7(), name, slug, null), cancellationToken) + .ConfigureAwait(true); + + IsCreatingTeam = false; + + await ReloadAsync(cancellationToken).ConfigureAwait(true); + + SelectedTeam = Teams.FirstOrDefault(row => row.TeamId == created.TeamId) ?? SelectedTeam; + + Status = $"Created '{created.Name}'. Add a vault to it, then share that vault's key with " + + "whoever needs it."; + }).ConfigureAwait(true); + } + + /// + /// Adds a member, by looking their address up in the directory first. + /// + /// + /// Two calls rather than one, and the order is the point: the directory is what turns an address into + /// an account and a public key, and the key that gets verified before any sharing is the one that + /// lookup returned. Letting the server resolve an address to an account inside the add would put an + /// unwitnessed step between the two. + /// + [RelayCommand] + private async Task AddMemberAsync(CancellationToken cancellationToken) + { + if (connection() is not { } server || SelectedTeam is not { } team) + { + return; + } + + var email = InviteEmail.Trim(); + + if (email.Length == 0) + { + Status = "Type the email address of somebody who has signed in to this server."; + return; + } + + await RunAsync(async () => + { + var found = await server.Directory.LookupByEmailAsync(email, cancellationToken) + .ConfigureAwait(true); + + if (found.Count == 0) + { + Status = $"No account here has the address '{email}'. They have to sign in to this " + + "server once before they can be added — that is what publishes the key a vault " + + "would be shared with."; + return; + } + + var member = await server.Teams + .AddTeamMemberAsync( + team.TeamId, + new AddTeamMemberRequest(found[0].UserId, TeamMemberRole.Member), + cancellationToken) + .ConfigureAwait(true); + + InviteEmail = string.Empty; + + await ReloadAsync(cancellationToken).ConfigureAwait(true); + + // Said out loud, every time. The single most common misunderstanding this design invites is + // that adding somebody gave them the vault. + Status = $"Added {member.Email ?? member.DisplayName ?? "the account"} as a member. They " + + "cannot read anything yet — select a vault below and share its key."; + }).ConfigureAwait(true); + } + + /// Removes a member, revoking every vault key grant they hold from this team. + [RelayCommand] + private async Task RemoveMemberAsync(CancellationToken cancellationToken) + { + if (connection() is not { } server + || SelectedTeam is not { } team + || SelectedMember is not { } member) + { + return; + } + + await RunAsync(async () => + { + await server.Teams + .RemoveTeamMemberAsync(team.TeamId, member.UserId, cancellationToken) + .ConfigureAwait(true); + + await ReloadAsync(cancellationToken).ConfigureAwait(true); + + // The honest sentence, not the reassuring one. See ADR 0001: revocation is not retroactive, + // and a message implying otherwise is the one thing this screen must not say. + Status = $"Removed {member.Name}. They can no longer fetch this team's vaults, and anything " + + "they had already downloaded is still on their machine — rotate the credentials that " + + "matter."; + }).ConfigureAwait(true); + } + + /// Creates a vault owned by the selected team. + [RelayCommand] + private async Task CreateVaultAsync(CancellationToken cancellationToken) + { + if (connection() is not { } server + || session() is not { } open + || SelectedTeam is not { } team) + { + return; + } + + await RunAsync(async () => + { + var vault = await open + .CreateTeamVaultAsync(server.Teams, team.TeamId, team.Name, cancellationToken) + .ConfigureAwait(true); + + await ReloadAsync(cancellationToken).ConfigureAwait(true); + + Status = $"Created the vault '{vault.Name}'. It is yours alone until you share its key; new " + + "hosts and credentials can be filed into it from the Vault screen."; + }).ConfigureAwait(true); + } + + /// + /// Wraps the selected vault's key to the selected member. + /// + /// + /// Everything that makes this safe happens inside : the key + /// log is read and its chain verified, and the directory's answer has to appear in it unchanged before + /// anything is wrapped. A refusal is reported here in full rather than as "sharing failed", because + /// the reasons are not interchangeable — one of them means somebody is substituting keys. + /// + [RelayCommand] + private async Task ShareVaultAsync(CancellationToken cancellationToken) + { + if (connection() is not { } server + || session() is not { } open + || SelectedVault is not { } vault + || SelectedMember is not { } member) + { + return; + } + + if (member.IsSelf) + { + Status = "You already hold this vault's key."; + return; + } + + await RunAsync(async () => + { + var outcome = await open + .ShareVaultAsync(server.Grants, server.Directory, vault.VaultId, member.UserId, cancellationToken) + .ConfigureAwait(true); + + Status = outcome.Shared + ? $"Shared '{vault.Name}' with {member.Name}. {outcome.Message}" + : $"Did not share '{vault.Name}': {outcome.Message}"; + }).ConfigureAwait(true); + } + + /// Withdraws the selected member's key to the selected vault. + [RelayCommand] + private async Task RevokeVaultAsync(CancellationToken cancellationToken) + { + if (connection() is not { } server + || SelectedVault is not { } vault + || SelectedMember is not { } member) + { + return; + } + + await RunAsync(async () => + { + var revoked = await server.Grants + .RevokeVaultGrantAsync(vault.VaultId, member.UserId, cancellationToken) + .ConfigureAwait(true); + + await ReloadAsync(cancellationToken).ConfigureAwait(true); + + Status = revoked + ? $"Withdrew {member.Name}'s key to '{vault.Name}'. Future reads are blocked; what they " + + "already have is unaffected." + : $"{member.Name} held no key to '{vault.Name}'."; + }).ConfigureAwait(true); + } + + partial void OnSelectedTeamChanged(TeamRowViewModel? value) + { + RaiseState(); + + // Fire-and-forget on purpose, and the only place in this class that is: selection changes come + // from a list box, which has no cancellation token and no way to await. Failures land in Status + // through RunAsync exactly as a command's would. + _ = LoadSelectedAsync(CancellationToken.None); + } + + /// Reads the selected team's members and vaults. + private async Task LoadSelectedAsync(CancellationToken cancellationToken) + { + Members.Clear(); + Vaults.Clear(); + + if (connection() is not { } server || SelectedTeam is not { } team) + { + return; + } + + var open = session(); + var selfId = open?.Profile.UserId; + + var members = await server.Teams + .ListTeamMembersAsync(team.TeamId, cancellationToken) + .ConfigureAwait(true); + + foreach (var member in members) + { + Members.Add(new TeamMemberRowViewModel(member, member.UserId == selfId)); + } + + if (open is null) + { + return; + } + + // Read from the session rather than from a team-vaults endpoint, because the interesting fact + // about a team vault here is whether *this* machine can open it — which is a property of the + // keyring and not something the server can answer. + var readable = open.ReadableVaults.Select(vault => vault.VaultId).ToHashSet(); + + foreach (var vault in open.Vaults.Where(vault => vault.TeamId == team.TeamId)) + { + Vaults.Add(new TeamVaultRowViewModel( + vault.VaultId, vault.Name, readable.Contains(vault.VaultId), vault.RekeyRequired)); + } + + SelectedVault = Vaults.FirstOrDefault(); + } + + private void RaiseState() + { + OnPropertyChanged(nameof(HasTeams)); + OnPropertyChanged(nameof(HasSelection)); + OnPropertyChanged(nameof(CanAdministerSelected)); + OnPropertyChanged(nameof(IsOnline)); + } + + /// + /// One place that raises the busy flag and turns a failure into a sentence. An API exception's message + /// is the server's problem detail, which is written for a person to read — see Problems — so it + /// is shown rather than replaced with something vaguer. + /// + private async Task RunAsync(Func work) + { + if (IsBusy) + { + return; + } + + IsBusy = true; + + try + { + await work().ConfigureAwait(true); + } + catch (DodoSshApiException exception) + { + Status = exception.Message; + } + catch (Exception exception) when (exception is not OutOfMemoryException + and not OperationCanceledException) + { + Status = exception.Message; + } + finally + { + IsBusy = false; + } + } +} diff --git a/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs index 7d52025..3685664 100644 --- a/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs +++ b/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs @@ -19,10 +19,40 @@ namespace DodoSSH.Client.App.ViewModels; /// the flags the list has to show: an edit this machine has not pushed, a change the server refused, and /// an item a newer client wrote that must not be re-encoded here. /// -internal sealed partial class HostRowViewModel(VaultItem host) : ObservableObject +internal sealed partial class HostRowViewModel( + VaultItem host, + Guid vaultId, + string vaultName) : ObservableObject { internal Guid EntityId => host.EntityId; + /// + /// Which vault this host lives in. + /// + /// + /// Carried on the row rather than read from the session, because a session now holds several and an + /// edit has to return to the vault the item came from. Writing it to the active vault instead would + /// create a second copy in the personal vault and leave the team's original untouched — a silent fork + /// that only shows up when somebody else wonders why their change never arrived. + /// + internal Guid VaultId => vaultId; + + /// The vault's display name, for the heading the sidebar groups under. + internal string VaultName => vaultName; + + /// + /// The vault name to print on this row, or empty when there is only one vault to be in. + /// + /// + /// Decided by the list rather than by the row, because "is there more than one vault" is not + /// something a row can see — and the alternative, a binding that reaches out to the parent view + /// model from inside an item template, is the kind of thing that silently resolves to nothing. + /// + internal string VaultBadge { get; init; } = string.Empty; + + /// Whether this row has a vault to name. + internal bool HasVaultBadge => VaultBadge.Length > 0; + internal HostSecret Host => host.Secret; internal string Label => host.Secret.Label; @@ -165,10 +195,16 @@ internal sealed record AuthenticationChoice( /// property. /// /// -internal sealed class SshKeyRowViewModel(VaultItem key) +internal sealed class SshKeyRowViewModel(VaultItem key, Guid vaultId, string vaultName) { internal Guid EntityId => key.EntityId; + /// Which vault this key lives in. See . + internal Guid VaultId => vaultId; + + /// The vault's display name. + internal string VaultName => vaultName; + internal SshKeySecret Key => key.Secret; internal string Label => key.Secret.Label; @@ -202,10 +238,19 @@ internal sealed class SshKeyRowViewModel(VaultItem key) /// can render a password by being pointed at the obvious property. /// /// -internal sealed class CredentialRowViewModel(VaultItem credential) +internal sealed class CredentialRowViewModel( + VaultItem credential, + Guid vaultId, + string vaultName) { internal Guid EntityId => credential.EntityId; + /// Which vault this credential lives in. See . + internal Guid VaultId => vaultId; + + /// The vault's display name. + internal string VaultName => vaultName; + internal CredentialSecret Credential => credential.Secret; internal string Label => credential.Secret.Label; @@ -244,8 +289,18 @@ internal sealed class CredentialRowViewModel(VaultItem credent /// of pinning one is to compare it with what they published. /// /// -internal sealed class KnownHostRowViewModel(VaultItem pin, bool isDialledByAHost) +internal sealed class KnownHostRowViewModel( + VaultItem pin, + bool isDialledByAHost, + Guid vaultId, + string vaultName) { + /// Which vault this pin lives in. See . + internal Guid VaultId => vaultId; + + /// The vault's display name. + internal string VaultName => vaultName; + internal Guid EntityId => pin.EntityId; internal KnownHostSecret Pin => pin.Secret; @@ -409,6 +464,23 @@ internal enum VaultItemKind /// the badge rather than read back out of it, because the badge is a sentence for a person and a count built /// by comparing it against the literal "not synced" would break the day that wording improves. /// +/// One vault, as an option in the "file this into" picker. +/// The vault. +/// Its display name, which is plaintext as all vault names are. +/// Whether this is the caller's own vault rather than a team's. +internal sealed record VaultChoiceViewModel(Guid VaultId, string Name, bool IsPersonal) +{ + /// + /// What the picker shows. + /// + /// + /// A team vault is marked as one. The whole risk this picker introduces is putting a credential + /// somewhere more people can read it, so the option that does that must not look like the option + /// that does not. + /// + internal string Display => IsPersonal ? Name : $"{Name} · TEAM"; +} + internal sealed record VaultItemRowViewModel( VaultItemKind Kind, Guid EntityId, @@ -505,10 +577,16 @@ internal sealed partial class VaultViewModel( /// /// The vault's name, because the vault is the only grouping a host has — there are no tags and no /// folders on HostSecret, and deriving a group from a naming convention would be a guess - /// presented as structure. One heading, because one vault is reachable: the server denies access to - /// every vault that is not this user's own. See docs/design-import-gaps.md. + /// presented as structure. + /// + /// One heading while one vault is reachable, which is the ordinary case. Since M3 a session can hold + /// several, and then the heading stops naming one of them and each row names its own — a heading that + /// went on saying "PERSONAL" over a list containing a team's hosts would be the sort of quiet lie this + /// interface is otherwise careful about. + /// /// - internal string HostsHeading => VaultName.ToUpperInvariant(); + internal string HostsHeading => + session.ReadableVaults.Take(2).Count() > 1 ? "ALL VAULTS" : VaultName.ToUpperInvariant(); /// Whether the host list under the heading is folded away. [ObservableProperty] @@ -529,6 +607,36 @@ internal sealed partial class VaultViewModel( internal string VaultName => session.Vaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId)?.Name ?? "Vault"; + /// + /// The vaults a new item may be filed into: readable, and writable by this account. + /// + /// + /// Both conditions, not either. A vault this session cannot read has no key to encrypt with, and one + /// it can read but not write is a team vault this member is a viewer of — offering either would end + /// in a Save that fails, one of them locally and one at the server. + /// + internal ObservableCollection TargetVaults { get; } = []; + + /// + /// Where the next new item goes. + /// + /// + /// Falls back to the session's active vault, which is the personal one wherever there is one. Filing + /// into a team's vault has to be chosen, never defaulted into: an item put in the wrong vault is + /// visible to people who should not have it, and moving it afterwards means deleting and retyping. + /// + internal Guid TargetVaultId => SelectedTargetVault?.VaultId ?? session.ActiveVaultId; + + /// Whether there is more than one vault to choose between. + /// + /// The picker is hidden entirely at one, rather than shown disabled. A control offering one option is + /// a question with no answer, and for most people this stays at one for ever. + /// + internal bool HasVaultChoice => TargetVaults.Count > 1; + + [ObservableProperty] + private VaultChoiceViewModel? selectedTargetVault; + [ObservableProperty] private HostRowViewModel? selectedHost; @@ -759,6 +867,24 @@ internal sealed partial class VaultViewModel( /// The item being edited, or null when creating. private Guid? editingEntityId; + /// + /// Which vault the editor will write to. + /// + /// + /// Captured when the editor opens rather than read at save time, and there are two different reasons + /// for that depending on which way the editor was opened. Editing an existing item, it is the vault + /// that item came from — saving to anywhere else would fork it. Creating one, it is whatever the + /// target picker said at that moment, so changing the picker afterwards cannot silently move + /// a half-typed host into a team's vault. + /// + private Guid editingHostVaultId; + + /// Which vault the key editor will write to. See . + private Guid editingKeyVaultId; + + /// Which vault the credential editor will write to. See . + private Guid editingCredentialVaultId; + /// /// Whether the editor is showing a host that could have a pinned key to forget. /// @@ -950,6 +1076,10 @@ internal sealed partial class VaultViewModel( /// private async Task ReloadAsync(CancellationToken cancellationToken) { + // First, because the four lists below are read across the same set and a vault admitted by the + // last refresh should appear in the picker on the same pass its items do. + RebuildTargetVaults(); + var unreadable = await ReloadHostsAsync(cancellationToken).ConfigureAwait(true); unreadable += await ReloadKeysAsync(cancellationToken).ConfigureAwait(true); @@ -967,20 +1097,74 @@ internal sealed partial class VaultViewModel( await LoadConflictsAsync(cancellationToken).ConfigureAwait(true); } + /// Refills the "file this into" picker from the vaults this session can read and write. + /// + /// The selection is restored by id rather than kept, because the option objects are rebuilt. Where the + /// previously selected vault has gone — a grant withdrawn, a team left — it falls back to the active + /// vault rather than to nothing, so the next Save still has somewhere to go. + /// + private void RebuildTargetVaults() + { + var selectedId = TargetVaultId; + + TargetVaults.Clear(); + + foreach (var vault in session.ReadableVaults + .Where(vault => vault.CanWrite) + .OrderByDescending(vault => vault.IsPersonal) + .ThenBy(vault => vault.Name, StringComparer.CurrentCulture)) + { + TargetVaults.Add(new VaultChoiceViewModel(vault.VaultId, vault.Name, vault.IsPersonal)); + } + + SelectedTargetVault = + TargetVaults.FirstOrDefault(choice => choice.VaultId == selectedId) + ?? TargetVaults.FirstOrDefault(choice => choice.VaultId == session.ActiveVaultId) + ?? TargetVaults.FirstOrDefault(); + + OnPropertyChanged(nameof(HasVaultChoice)); + } + /// How many hosts would not decrypt. private async Task ReloadHostsAsync(CancellationToken cancellationToken) { - var listing = await session.Hosts - .ListAsync(session.ActiveVaultId, cancellationToken) - .ConfigureAwait(true); - var selectedId = SelectedHost?.EntityId; + var unreadable = 0; + var rows = new List(); + + // Every vault this session holds a key for, not only the one new items are filed into. A team + // vault whose hosts never reached this list would make sharing look as though it had not worked. + var readable = session.ReadableVaults.ToList(); + var several = readable.Count > 1; + + foreach (var vault in readable) + { + var listing = await session.Hosts + .ListAsync(vault.VaultId, cancellationToken) + .ConfigureAwait(true); + + unreadable += listing.Unreadable; + + rows.AddRange(listing.Items.Select( + item => new HostRowViewModel(item, vault.VaultId, vault.Name) + { + // Only when there is something to tell apart. A badge on every row of a + // single-vault list is noise that says the same thing on all of them. + VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty, + })); + } Hosts.Clear(); - foreach (var host in listing.Items.OrderBy(host => host.Secret.Label, StringComparer.CurrentCulture)) + // Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can + // hold a host with the same label and both are shown: which vault it is in is what tells them + // apart, which is why the row carries the name rather than the list deduplicating. + foreach (var host in rows + .OrderByDescending(row => row.VaultId == session.ActiveVaultId) + .ThenBy(row => row.VaultName, StringComparer.CurrentCulture) + .ThenBy(row => row.Label, StringComparer.CurrentCulture)) { - Hosts.Add(new HostRowViewModel(host)); + Hosts.Add(host); } // Selection survives a reload. Losing it on every sync would move the terminal's target out from @@ -989,7 +1173,7 @@ internal sealed partial class VaultViewModel( RebuildVisibleHosts(); - return listing.Unreadable; + return unreadable; } /// Refills the sidebar's list from and the filter. @@ -1048,22 +1232,35 @@ internal sealed partial class VaultViewModel( /// private async Task ReloadKeysAsync(CancellationToken cancellationToken) { - var listing = await session.SshKeys - .ListAsync(session.ActiveVaultId, cancellationToken) - .ConfigureAwait(true); - var selectedId = SelectedKey?.EntityId; + var unreadable = 0; + var rows = new List(); + + foreach (var vault in session.ReadableVaults) + { + var listing = await session.SshKeys + .ListAsync(vault.VaultId, cancellationToken) + .ConfigureAwait(true); + + unreadable += listing.Unreadable; + + rows.AddRange(listing.Items.Select( + item => new SshKeyRowViewModel(item, vault.VaultId, vault.Name))); + } Keys.Clear(); - foreach (var key in listing.Items.OrderBy(key => key.Secret.Label, StringComparer.CurrentCulture)) + foreach (var key in rows + .OrderByDescending(row => row.VaultId == session.ActiveVaultId) + .ThenBy(row => row.VaultName, StringComparer.CurrentCulture) + .ThenBy(row => row.Label, StringComparer.CurrentCulture)) { - Keys.Add(new SshKeyRowViewModel(key)); + Keys.Add(key); } SelectedKey = Keys.FirstOrDefault(row => row.EntityId == selectedId); - return listing.Unreadable; + return unreadable; } /// How many credentials would not decrypt. @@ -1075,23 +1272,35 @@ internal sealed partial class VaultViewModel( /// private async Task ReloadCredentialsAsync(CancellationToken cancellationToken) { - var listing = await session.Credentials - .ListAsync(session.ActiveVaultId, cancellationToken) - .ConfigureAwait(true); - var selectedId = SelectedCredential?.EntityId; + var unreadable = 0; + var rows = new List(); + + foreach (var vault in session.ReadableVaults) + { + var listing = await session.Credentials + .ListAsync(vault.VaultId, cancellationToken) + .ConfigureAwait(true); + + unreadable += listing.Unreadable; + + rows.AddRange(listing.Items.Select( + item => new CredentialRowViewModel(item, vault.VaultId, vault.Name))); + } Credentials.Clear(); - foreach (var credential in listing.Items - .OrderBy(credential => credential.Secret.Label, StringComparer.CurrentCulture)) + foreach (var credential in rows + .OrderByDescending(row => row.VaultId == session.ActiveVaultId) + .ThenBy(row => row.VaultName, StringComparer.CurrentCulture) + .ThenBy(row => row.Label, StringComparer.CurrentCulture)) { - Credentials.Add(new CredentialRowViewModel(credential)); + Credentials.Add(credential); } SelectedCredential = Credentials.FirstOrDefault(row => row.EntityId == selectedId); - return listing.Unreadable; + return unreadable; } /// How many pins would not decrypt. @@ -1102,11 +1311,9 @@ internal sealed partial class VaultViewModel( /// private async Task ReloadKnownHostsAsync(CancellationToken cancellationToken) { - var listing = await session.KnownHosts - .ListAsync(session.ActiveVaultId, cancellationToken) - .ConfigureAwait(true); - var selectedId = SelectedKnownHost?.EntityId; + var unreadable = 0; + var rows = new List(); // Built once rather than searched per pin. A vault with a hundred of each would otherwise be a // hundred scans of the host list on every background sync. @@ -1114,20 +1321,39 @@ internal sealed partial class VaultViewModel( .Select(host => Endpoint(host.Host.Hostname, host.Host.Port)) .ToHashSet(StringComparer.OrdinalIgnoreCase); + // Listed across every readable vault, unlike the trust the SSH handshake consults, which stays in + // the active vault alone. The difference is deliberate and is stated in the README: a pin in a + // team vault is something a teammate can write, and letting it answer for a host in somebody's + // personal vault would let one member suppress another's first-contact prompt. Showing them is + // safe and is the only way somebody can see what their team has trusted. + foreach (var vault in session.ReadableVaults) + { + var listing = await session.KnownHosts + .ListAsync(vault.VaultId, cancellationToken) + .ConfigureAwait(true); + + unreadable += listing.Unreadable; + + rows.AddRange(listing.Items.Select(item => new KnownHostRowViewModel( + item, + dialled.Contains(Endpoint(item.Secret.Host, item.Secret.Port)), + vault.VaultId, + vault.Name))); + } + KnownHostPins.Clear(); - foreach (var pin in listing.Items - .OrderBy(pin => pin.Secret.Host, StringComparer.CurrentCulture) - .ThenBy(pin => pin.Secret.Port) - .ThenBy(pin => pin.Secret.Algorithm, StringComparer.Ordinal)) + foreach (var pin in rows + .OrderByDescending(row => row.VaultId == session.ActiveVaultId) + .ThenBy(row => row.VaultName, StringComparer.CurrentCulture) + .ThenBy(row => row.Label, StringComparer.CurrentCulture)) { - KnownHostPins.Add(new KnownHostRowViewModel( - pin, dialled.Contains(Endpoint(pin.Secret.Host, pin.Secret.Port)))); + KnownHostPins.Add(pin); } SelectedKnownHost = KnownHostPins.FirstOrDefault(row => row.EntityId == selectedId); - return listing.Unreadable; + return unreadable; } /// @@ -1208,7 +1434,22 @@ internal sealed partial class VaultViewModel( { var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true); - if (report is not null && (report.Pulled > 0 || report.Pushed > 0 || report.NeedsAttention)) + if (report is null) + { + return; + } + + // A vault that failed now arrives as a report rather than as an exception, because one + // unreachable team vault must not stop the others syncing. It still has to be treated the way + // the catch below treats a total failure: the fact recorded, the message swallowed. Otherwise + // a laptop with a lid shut all afternoon replaces whatever the user was reading, once a + // minute, with the name of a vault it could not reach. + if (report.Any(vault => !vault.Succeeded)) + { + LastSyncFailed = true; + } + + if (IsWorthReporting(report)) { Status = Describe(report); } @@ -1234,7 +1475,9 @@ internal sealed partial class VaultViewModel( /// zero timeout rather than awaited: a pass that arrives while another is running has nothing to add by /// waiting for it, and queueing them would turn a slow server into a backlog of identical work. /// - private async Task SyncOnceAsync(ISyncApi api, CancellationToken cancellationToken) + private async Task?> SyncOnceAsync( + ISyncApi api, + CancellationToken cancellationToken) { if (!await syncGate.WaitAsync(0, cancellationToken).ConfigureAwait(true)) { @@ -1243,7 +1486,10 @@ internal sealed partial class VaultViewModel( try { - var report = await session.SyncAsync(api, cancellationToken).ConfigureAwait(true); + // Every vault this session can read, not only the one new items are filed into. A team's + // vault that never synced would show its hosts exactly once — at the unlock that first + // pulled it — and then quietly stop, which reads as the feature not working. + var report = await session.SyncAllAsync(api, cancellationToken).ConfigureAwait(true); LastSyncFailed = false; @@ -1328,6 +1574,7 @@ internal sealed partial class VaultViewModel( } editingEntityId = null; + editingHostVaultId = TargetVaultId; EditorLabel = string.Empty; EditorHostname = string.Empty; EditorPort = HostSecret.DefaultPort; @@ -1357,6 +1604,7 @@ internal sealed partial class VaultViewModel( } editingEntityId = row.EntityId; + editingHostVaultId = row.VaultId; EditorLabel = row.Host.Label; EditorHostname = row.Host.Hostname; EditorPort = row.Host.Port; @@ -1454,13 +1702,13 @@ internal sealed partial class VaultViewModel( if (editingEntityId is { } entityId) { await session.Hosts - .UpdateAsync(session.ActiveVaultId, entityId, host, cancellationToken) + .UpdateAsync(editingHostVaultId, entityId, host, cancellationToken) .ConfigureAwait(true); } else { editingEntityId = await session.Hosts - .CreateAsync(session.ActiveVaultId, host, cancellationToken) + .CreateAsync(editingHostVaultId, host, cancellationToken) .ConfigureAwait(true); } @@ -1494,7 +1742,7 @@ internal sealed partial class VaultViewModel( async () => { await session.Hosts - .DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken) + .DeleteAsync(row.VaultId, row.EntityId, cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); @@ -1517,6 +1765,7 @@ internal sealed partial class VaultViewModel( Section = VaultSection.Keys; editingKeyId = null; + editingKeyVaultId = TargetVaultId; ClearKeyEditor(); IsEditingKey = true; Status = "Adding an SSH key."; @@ -1543,6 +1792,7 @@ internal sealed partial class VaultViewModel( Section = VaultSection.Keys; editingKeyId = row.EntityId; + editingKeyVaultId = row.VaultId; KeyEditorLabel = row.Key.Label; KeyEditorPrivateKey = row.Key.PrivateKeyPem; KeyEditorPassphrase = row.Key.Passphrase ?? string.Empty; @@ -1581,13 +1831,13 @@ internal sealed partial class VaultViewModel( if (editingKeyId is { } entityId) { await session.SshKeys - .UpdateAsync(session.ActiveVaultId, entityId, key, cancellationToken) + .UpdateAsync(editingKeyVaultId, entityId, key, cancellationToken) .ConfigureAwait(true); } else { editingKeyId = await session.SshKeys - .CreateAsync(session.ActiveVaultId, key, cancellationToken) + .CreateAsync(editingKeyVaultId, key, cancellationToken) .ConfigureAwait(true); } @@ -1621,7 +1871,7 @@ internal sealed partial class VaultViewModel( async () => { await session.SshKeys - .DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken) + .DeleteAsync(row.VaultId, row.EntityId, cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); @@ -1642,6 +1892,7 @@ internal sealed partial class VaultViewModel( Section = VaultSection.Credentials; editingCredentialId = null; + editingCredentialVaultId = TargetVaultId; ClearCredentialEditor(); IsEditingCredential = true; Status = "Adding a credential."; @@ -1668,6 +1919,7 @@ internal sealed partial class VaultViewModel( Section = VaultSection.Credentials; editingCredentialId = row.EntityId; + editingCredentialVaultId = row.VaultId; CredentialEditorLabel = row.Credential.Label; CredentialEditorUsername = row.Credential.Username ?? string.Empty; CredentialEditorPassword = row.Credential.Password; @@ -1705,13 +1957,13 @@ internal sealed partial class VaultViewModel( if (editingCredentialId is { } entityId) { await session.Credentials - .UpdateAsync(session.ActiveVaultId, entityId, credential, cancellationToken) + .UpdateAsync(editingCredentialVaultId, entityId, credential, cancellationToken) .ConfigureAwait(true); } else { editingCredentialId = await session.Credentials - .CreateAsync(session.ActiveVaultId, credential, cancellationToken) + .CreateAsync(editingCredentialVaultId, credential, cancellationToken) .ConfigureAwait(true); } @@ -1746,7 +1998,7 @@ internal sealed partial class VaultViewModel( async () => { await session.Credentials - .DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken) + .DeleteAsync(row.VaultId, row.EntityId, cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); @@ -2416,6 +2668,64 @@ internal sealed partial class VaultViewModel( /// resurrected a host or parked a change looks identical to a quiet one otherwise, and the whole point /// of recording those is that somebody sees them. /// + /// + /// Movement and attention only — deliberately not failure. A background pass that announced every + /// unreachable vault would be a socket error on screen once a minute, which is the thing + /// 's catch block exists to avoid; the caller records + /// instead, and the titlebar stops claiming to be up to date. Pressing + /// Sync reports the failure in full, because somebody who pressed it is waiting for an answer. + /// + private static bool IsWorthReporting(IReadOnlyList reports) => + reports.Any(vault => vault.Succeeded + && (vault.Report!.Pulled > 0 || vault.Report.Pushed > 0 || vault.Report.NeedsAttention)); + + /// + /// Counts are summed across vaults, and a failure is named with its reason. Both halves + /// matter: "1 vault could not be synchronised" sends somebody hunting for which, and a name without a + /// reason sends them hunting for why. There are rarely more than a handful of vaults, so listing them + /// costs nothing. + /// + private static string Describe(IReadOnlyList reports) + { + var failed = reports + .Where(vault => !vault.Succeeded) + .Select(vault => $"{vault.Name} ({vault.Failure?.Message})") + .ToList(); + + var succeeded = reports.Where(vault => vault.Succeeded).Select(vault => vault.Report!).ToList(); + + var line = succeeded.Count switch + { + 0 => string.Empty, + 1 => Describe(succeeded[0]), + _ => DescribeMany(succeeded), + }; + + if (failed.Count == 0) + { + return line.Length == 0 ? "Nothing to synchronise." : line; + } + + var names = string.Join("; ", failed); + + return line.Length == 0 + ? $"Could not synchronise {names}." + : $"{line} Could not synchronise {names}."; + } + + private static string DescribeMany(List reports) + { + var pulled = reports.Sum(report => report.Pulled); + var pushed = reports.Sum(report => report.Pushed); + var attention = reports.Count(report => report.NeedsAttention); + + var line = pulled == 0 && pushed == 0 + ? $"Already up to date across {reports.Count} vaults." + : $"Synchronised {reports.Count} vaults: {pulled} in, {pushed} out."; + + return attention == 0 ? line : $"{line} {attention} need attention — see the conflicts list."; + } + private static string Describe(SyncReport report) { if (!report.NeedsAttention) diff --git a/src/DodoSSH.Client.App/Views/HostSidebar.axaml b/src/DodoSSH.Client.App/Views/HostSidebar.axaml index 4146659..8a9240e 100644 --- a/src/DodoSSH.Client.App/Views/HostSidebar.axaml +++ b/src/DodoSSH.Client.App/Views/HostSidebar.axaml @@ -101,6 +101,14 @@ --> + + diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml b/src/DodoSSH.Client.App/Views/MainWindow.axaml index 241a3b7..2d948a1 100644 --- a/src/DodoSSH.Client.App/Views/MainWindow.axaml +++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml @@ -205,21 +205,14 @@ - - - - Endpoints for teams, membership, roles and invitations — the server exposes eight routes and none of them is about people (DodoSSH.Api). - Access to a vault somebody else owns: VaultAccessService resolves personal ownership and denies everything else (DodoSSH.Api). - Roles on the wire. VaultSummary carries a nullable TeamId and an opaque permissions flag, and no DTO gives either a meaning (DodoSSH.Contracts). - Per-member facts the design shows — two-factor state, last-active time, avatars — none of which the server records. - Sharing an item, which is the point of the screen: today a vault key is sealed to one account, and sharing means re-wrapping it for another. - - - + + + + diff --git a/src/DodoSSH.Client.App/Views/TeamsScreen.axaml b/src/DodoSSH.Client.App/Views/TeamsScreen.axaml new file mode 100644 index 0000000..9c95970 --- /dev/null +++ b/src/DodoSSH.Client.App/Views/TeamsScreen.axaml @@ -0,0 +1,188 @@ + + + + + + + + + + + + + +