From 8707629a6ccf57168658e135fdab8ccacb799b1a Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Tue, 4 Aug 2026 12:22:29 +0200 Subject: [PATCH] Make the vault the thing you share, and ask a host which one it lives in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The teams screen listed teams that owned vaults, so sharing four servers with two colleagues meant creating a team, then a vault inside it, then wrapping a key. Two of those three steps are about a concept nobody arrives wanting. The screen now lists vaults: naming one creates the membership list that carries it, named after the vault and owned by you, and members, invitations, roles, hand-over and key holders all hang off the vault they apply to. Nothing on the server moved. VaultAccessService still resolves a shared vault through team_membership and every membership call still names a team id — what went is the requirement that anybody make one. The split the whole design rests on is untouched and is still what the screen is built around: adding somebody authorises the server to serve them, and only a machine holding the key can make the vault readable. ADR 0009 keeps its decision and gains an addendum recording which half of it a person is now asked about. The one place the team resurfaces is a membership list carrying several vaults, which this screen cannot produce and does not hide: the members section says so, because "adding somebody here adds them there" is precisely the fact a vault-shaped screen is in a position to conceal. Two things left the interface and one arrived. Creating a team is gone, and so is archiving one — it was only ever possible for a team owning no vaults, and a screen whose rows are vaults has no row for one, so the button would have been unreachable or always refused. The endpoint is unchanged and the screen states the limit instead, since a vault cannot be deleted at all. The exception is a create whose second call failed: cancelling that form archives the membership list it left behind, which is a deliberate departure from this client's rule against tidying up on the user's behalf, made because nothing else can reach it. What arrived is PUT /api/v1/vaults/{id}. Without it the screen loses its only editing action, since renaming the team behind a vault is invisible to everybody who was never shown the team. It is gated on PermissionFlags.Admin — the line UpdateTeamEndpoint already draws, because a name is what everybody in the vault sees it called rather than part of its contents — and it renames the owning team with it when that team carries nothing else, so the row an operator reads and the name a user says cannot drift apart. The slug never moves, for the reason it does not move on a team rename. The session edits its cached vault row rather than replacing it with the response, which deliberately carries no wrapped key. The host editor now asks which vault a host goes into, beside the name, while adding and only where there is more than one vault to write to. It is a second picker rather than the keychain screen's reused, and the two selections are separate on purpose: that one is a standing preference about where new items go, this is a field of the host in front of you, and binding both to one selection would mean a click on the other screen could move a half-typed host. An existing host is not offered it at all rather than offered it disabled — the two vaults are encrypted under different keys, so moving an item is a delete and a retype. That forced a fix worth naming. The group picker was built from the active vault's groups whatever vault the host was being filed into, so a host put in a shared vault could be filed under a group only its author can resolve — a colleague would see it filed under nothing, which is the quietest kind of wrong. Groups are now kept per vault and the picker follows the vault choice. Two renames, because the pair they would otherwise have made is a bug farm: ShellScreen.Vault became Keychain and VaultScreen became KeychainScreen, which is what the rail has always labelled that screen, leaving Vault for one vault's contents and Vaults for the vaults themselves. The enum values are unchanged; NavRail.axaml writes them as x:Static literals. 1536 tests pass, seven more than before. Five are new on the server — the rename endpoint's success, the team it does and does not take with it, the two refusals and the empty name — and the client suite gains six and folds four together, having lost the two about archiving a team. --- README.md | 129 +- docs/adding-hosts-on-the-phone.md | 2 +- docs/adr/0009-team-access-model.md | 30 + docs/design-import-gaps.md | 46 +- docs/manual-checks.md | 90 +- src/DodoSSH.Api/Features/Teams/TeamLog.cs | 10 + .../Features/Teams/VaultGrantEndpoints.cs | 72 + .../Features/Teams/VaultGrantService.cs | 65 + src/DodoSSH.Api/Setup/EndpointRegistration.cs | 1 + .../Views/HostsScreen.axaml | 22 + .../Views/MoreScreen.axaml | 14 +- .../Views/PhoneShell.axaml | 14 +- .../Views/PhoneShell.axaml.cs | 4 +- .../Views/TeamsScreen.axaml | 367 ---- .../Views/TeamsScreen.axaml.cs | 10 - .../Views/VaultsScreen.axaml | 347 ++++ .../Views/VaultsScreen.axaml.cs | 10 + src/DodoSSH.Client.Api/DodoSshApiClient.cs | 25 + .../Views/ConfirmDeleteCard.axaml.cs | 2 +- src/DodoSSH.Client.App/Views/HostDrawer.axaml | 49 +- .../Views/HostsScreen.axaml.cs | 2 +- ...VaultScreen.axaml => KeychainScreen.axaml} | 4 +- ...creen.axaml.cs => KeychainScreen.axaml.cs} | 4 +- src/DodoSSH.Client.App/Views/MainWindow.axaml | 21 +- .../Views/MainWindow.axaml.cs | 2 +- src/DodoSSH.Client.App/Views/NavRail.axaml | 35 +- .../Views/PreferencesScreen.axaml | 2 +- .../Views/TeamsScreen.axaml.cs | 9 - .../Views/TerminalTabs.axaml | 2 +- .../{TeamsScreen.axaml => VaultsScreen.axaml} | 283 ++-- .../Views/VaultsScreen.axaml.cs | 9 + src/DodoSSH.Client.Session/VaultSharing.cs | 47 + .../ViewModels/MainWindowViewModel.cs | 95 +- .../ViewModels/VaultViewModel.cs | 182 +- .../{TeamsViewModel.cs => VaultsViewModel.cs} | 1487 +++++++++-------- src/DodoSSH.Contracts/DodoSshJsonContext.cs | 1 + src/DodoSSH.Contracts/PublicAPI.Unshipped.txt | 12 + src/DodoSSH.Contracts/Teams.cs | 21 + .../EndpointInventoryTests.cs | 5 + tests/DodoSSH.Api.Tests/TeamEndpointTests.cs | 130 ++ .../ScreenLayoutTests.cs | 121 +- .../StubTeamServer.cs | 48 +- .../FakeVaultServer.Teams.cs | 42 + .../ShellFlowTests.cs | 34 +- .../TeamSharingTests.cs | 687 -------- .../VaultSharingTests.cs | 767 +++++++++ .../VaultVisibilityTests.cs | 156 +- 47 files changed, 3204 insertions(+), 2313 deletions(-) delete mode 100644 src/DodoSSH.Client.Android/Views/TeamsScreen.axaml delete mode 100644 src/DodoSSH.Client.Android/Views/TeamsScreen.axaml.cs create mode 100644 src/DodoSSH.Client.Android/Views/VaultsScreen.axaml create mode 100644 src/DodoSSH.Client.Android/Views/VaultsScreen.axaml.cs rename src/DodoSSH.Client.App/Views/{VaultScreen.axaml => KeychainScreen.axaml} (99%) rename src/DodoSSH.Client.App/Views/{VaultScreen.axaml.cs => KeychainScreen.axaml.cs} (88%) delete mode 100644 src/DodoSSH.Client.App/Views/TeamsScreen.axaml.cs rename src/DodoSSH.Client.App/Views/{TeamsScreen.axaml => VaultsScreen.axaml} (57%) create mode 100644 src/DodoSSH.Client.App/Views/VaultsScreen.axaml.cs rename src/DodoSSH.Client.Shell/ViewModels/{TeamsViewModel.cs => VaultsViewModel.cs} (57%) delete mode 100644 tests/DodoSSH.Client.App.Tests/TeamSharingTests.cs create mode 100644 tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs diff --git a/README.md b/README.md index 3e09f56..3550199 100644 --- a/README.md +++ b/README.md @@ -226,18 +226,30 @@ 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 +### Sharing a vault -**TEAMS** in the nav rail creates a team, adds and invites members, shares vaults, hands a team over and -archives one. One distinction runs through the whole screen and is worth having before you use it. +**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds and invites people to one, +shares its key, renames it and hands it over. 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. +**A vault is the thing you make, and the group of people is behind it.** The server authorises through a +*team* — `VaultAccessService` resolves a shared vault through `team_membership`, and every membership call +names a team id — but nothing asks you to make one: naming a vault makes the membership list that carries +it, named after the vault and owned by you. So the thing you came to share is the thing you create, and +"which team is this in" stops being a question you need an answer to before you can share four servers with +two colleagues. Renaming the vault renames that membership list with it, as long as it carries nothing else. + +The one case where the distinction resurfaces is a team owning several vaults, which this screen cannot +produce and does not hide: the members section then says so, because adding somebody to one of those vaults +adds them to all of them. + +**Adding somebody to a vault and giving them its 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 vault appears in their +list immediately. It cannot make it 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. @@ -246,14 +258,14 @@ published in a log every other client also reads. **It does not prove the key is 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. -**Somebody with no account here yet can be invited, and nothing is sent.** There is one button — **ADD -MEMBER** — and it does whichever of the two applies, because which one applies is a fact about the server's -account table rather than about what you are trying to do. If the directory knows the address, that account -is added straight away. If it does not, the address is invited instead, and the status line says which -happened, because the difference decides what you do next. +**Somebody with no account here yet can be invited, and nothing is sent.** There is one button — **ADD** — +and it does whichever of the two applies, because which one applies is a fact about the server's account +table rather than about what you are trying to do. If the directory knows the address, that account is added +straight away. If it does not, the address is invited instead, and the status line says which happened, +because the difference decides what you do next. An invitation is a standing instruction rather than a message: the next account that signs in with that -address joins this team, at the role you chose. There is no link and no token, because this server has no +address joins this vault, at the role you chose. There is no link and no token, because this server has no outbound mail path and does not pretend otherwise — telling them to go and sign in is your job, over a channel this server does not carry, and a link nobody can deliver would be worse than no link. An invitation lasts fourteen days, so an address handed on to whoever takes the job next does not carry a @@ -265,9 +277,9 @@ you the public key you are about to verify and wrap a vault to, and an invitatio there may be no key yet. So when you are adding somebody *in order to* share a vault with them, the useful sequence is still the same one: add them, see them appear in the members list, then share. -Inviting an address that already belongs to a member of the team is refused and says so. Inviting one that +Inviting an address that already belongs to a member of the vault is refused and says so. Inviting one that merely *has* an account here is not — that would make this a way of asking the server which addresses have -accounts, which is not a question anybody willing to create a team first should be able to put to it. Such +accounts, which is not a question anybody willing to create a vault first should be able to put to it. Such an invitation simply gets claimed sooner: within the hour, on the same sweep that records they were here, rather than waiting for a first sign-in that has already happened. @@ -275,39 +287,34 @@ rather than waiting for a first sign-in that has already happened. to relax that.** The access token has to carry `email_verified` as true. Anything else — false, missing, or sent under another name — claims nothing at all, and no setting turns that off: an invitation decides what the server will serve, and one that could be taken by anybody able to obtain a token asserting somebody -else's address is a way into a team. **If your invitations never activate, this is the first thing to +else's address is a way into a vault. **If your invitations never activate, this is the first thing to check.** They sit at *pending* rather than failing, the server logs a warning each time it declines to claim one, and the two fixes are on your side: set `Oidc:EmailVerifiedClaim` to whatever your provider calls the claim if it is not `email_verified`, and make sure the provider puts it in the **access** token rather than only in the ID token or the userinfo response. -**Ownership is sole, and handing it over is one act.** Transferring names an existing active member: they -become owner and you become an admin, in a single transaction. Not two role changes — promoting first -leaves the team owned twice, demoting first leaves it owned by nobody, and there is nobody with the -authority to finish a transfer that stopped in the middle. You are demoted rather than removed, so you keep -your vault key grants; removing you would revoke them and flag every team vault for rekey, and somebody -handing over a team is usually staying in it. +**Ownership is sole, and handing a vault over is one act.** Transferring names an existing active member: +they become owner and you become an admin, in a single transaction. Not two role changes — promoting first +leaves it owned twice, demoting first leaves it owned by nobody, and there is nobody with the authority to +finish a transfer that stopped in the middle. You are demoted rather than removed, so you keep your vault +key grants; removing you would revoke them and flag the vault for rekey, and somebody handing a vault over +is usually staying in it. -**Archiving a team is refused while it owns a vault, and that is a limit rather than a rough edge.** A team -vault is readable *because* of membership, so archiving a team that still owned vaults would take them away -from everybody holding a key — including you — quietly and all at once. Nothing in this product deletes a -vault, so there is no order of operations that gets past the refusal today, and it says so with a count of -what is in the way rather than failing vaguely. Archiving an empty team takes its memberships and its -outstanding invitations with it, in one transaction. Its name can be changed whenever you like; its slug -cannot, because a slug is unique only among live teams and a rename could take one an archived team is -still holding. +Five limits, stated rather than discovered: -Four 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. +- **A vault cannot be deleted.** Nothing in this product removes one, and the server refuses to archive the + membership list behind a vault that still exists — a shared vault is readable *because* of membership, so + archiving it would take the vault away from everybody holding a key, including you, quietly and all at + once. The screen says so where you would otherwise go looking for the button. +- **Removing a member is not retroactive.** It revokes their grants and flags the vault 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. +- **Host key trust stays in your personal vault.** A pin approved for a shared vault's host is recorded and + used from your own vault, not the shared one, so a colleague 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 shared + host's key once on each of their machines. Shared vaults' pins are still *listed* on the Pins screen, so + you can see what has been trusted. - **LAST ACTIVE is coarse on purpose.** The server records it at most once per account per hour, so a value an hour old means "recently" and not "at that moment". That is the granularity the question is really asked at — whether somebody is still using this deployment — and writing it on every request would put an @@ -315,9 +322,14 @@ Four limits, stated rather than discovered: as roughly-when rather than to the minute, because showing it to the minute would be reading a precision into it that is not there. -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. +Items are filed into one vault at a time, and which one is asked at the moment the item is made. **A host's +editor has its own picker**, beside the name, because that is the decision that cannot be undone: the two +vaults are encrypted under different keys, so moving an item afterwards means deleting it and typing it +again — and the picker is therefore absent when you edit an existing host rather than present and refusing. +Keys, passwords and buckets take theirs from a standing "new items go to" picker on the Keychain screen. +Both default to your personal vault and neither moves on its own, because an item put in a shared vault is +visible to everybody holding that vault's key. Choosing a vault in the host editor also decides which groups +it can be filed under: a group is an item like any other and lives in exactly one vault. ### The Android head @@ -329,7 +341,7 @@ fit 360dp. Its interface is the **v2 design**: destinations in a bottom bar, with the rest one tap deeper behind the last. The bar is three — **Hosts**, **Connections** and **Settings** — with the keychain, snippets, SFTP, -S3 buckets, logs, teams and preferences behind Settings. A bottom bar is for the places a session moves +S3 buckets, logs, vaults and preferences behind Settings. A bottom bar is for the places a session moves between, and managing keys is not one of those. Both heads are on that design now; the desktop's own v2 is a 190-pixel labelled nav rail in place of the icon rail, a centred search box in the titlebar, and session tabs as pills, and it keeps its Keychain entry — its rail has the room. Its light theme is not built — see @@ -369,9 +381,9 @@ confirmation — without offering to change it. What this head does make, it mak rather than in an editor: a tag from inside a host's editor, and a credential from the connect bar's remember tick, which stores the password just typed and moves the host onto it. Renaming either is still a desktop job. Pins and import have no phone screen either, and importing an `~/.ssh/config` has no meaning -on a phone at all. **TEAMS does have one**, behind MORE, and it is there for a reason the design could not -have anticipated: an invitation is claimed by signing in, so somebody being told they have been put in a -team is at least as likely to be holding a phone as sitting at a desktop, and a membership visible only on +on a phone at all. **VAULTS does have one**, behind MORE, and it is there for a reason the design could not +have anticipated: an invitation is claimed by signing in, so somebody being told they have been added to a +vault is at least as likely to be holding a phone as sitting at a desktop, and a membership visible only on a head they have not installed is a membership they cannot see. **Port forwarding is not built anywhere**, and the phone's More screen says so in a paragraph rather than @@ -570,15 +582,16 @@ keychain plus a terminal — and the spike that gates all of it. directories, an interrupted **upload** starts again rather than resuming (an object cannot be written from the middle), and a rename is a copy and a delete rather than one atomic operation. Downloads do resume — a ranged GET is part of the protocol, which is the one place a bucket beats SFTP. -- **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 +- **M3 — shared vaults**, sharing, ACLs. *Done, except rekey.* Membership with roles, a public-key + directory, the append-only key log served for clients to verify against, shared 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. A team can be renamed, - handed to another member, and archived once it owns no vaults; a member row carries when that account was - last here; and an address with no account on this deployment can be invited, joining the moment somebody - signs in with it. See - [Working as a team](#working-as-a-team) for the one distinction the whole design rests on, and the limits worth + every vault it holds a key for, and a real VAULTS screen replaces the placeholder. The screen is + vault-shaped rather than team-shaped: naming a vault makes the membership list that carries it, so the + team is behind the vault rather than a thing anybody has to create first. A vault can be renamed and + handed to another member; a member row carries when that account was last here; and an address with no + account on this deployment can be invited, joining the moment somebody signs in with it. See + [Sharing a vault](#sharing-a-vault) 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). diff --git a/docs/adding-hosts-on-the-phone.md b/docs/adding-hosts-on-the-phone.md index 802856c..9397adf 100644 --- a/docs/adding-hosts-on-the-phone.md +++ b/docs/adding-hosts-on-the-phone.md @@ -55,7 +55,7 @@ Three of those fields do not exist at any layer, and one of them is refused on t | Asked for | What exists today | | --- | --- | | The `+` and the editors behind it | Nothing. `Theme/Phone.axaml` has no `.fab` class, and its comment says an unused style would be "a claim that the control exists somewhere". `HostsScreen.axaml`'s own v2 note says hosts are created on the desktop and sync down. *(This row named a `ConnectionsScreen.axaml` as a second site. No such file exists or ever has; and the `HostsScreen` statement is an XAML comment, so no phone screen ever rendered that sentence to a user.)* | -| Vault picker | **Built.** `VaultViewModel.TargetVaults` / `SelectedTargetVault` / `HasVaultChoice`, hidden at one vault. The desktop's `VaultScreen.axaml` already draws it. | +| Vault picker | **Built.** `VaultViewModel.TargetVaults` / `SelectedTargetVault` / `HasVaultChoice`, hidden at one vault. The desktop's keychain screen already draws it. | | Alias, hostname, port, username, key-or-password, group | **Built**, in the shared `VaultViewModel` host editor — `EditorLabel`, `EditorHostname`, `EditorPort`, `EditorUsername`, `EditorAuthenticationChoices`, `EditorGroupChoices`, `SaveHostCommand`. The phone has never bound any of it. | | Tags | **Nothing.** `SyncEntityType.Tag = 5` and `HostTag = 6` are reserved slots with nothing behind them. `HostSecret` has no tag field. | | A group's parent | **Refused on the record.** `HostGroupSecret`'s own remark says groups are flat because two clients can each re-parent A under B and B under A offline, a scalar merge accepts both, and the result is a cycle no reader can draw and the server cannot see, because it is inside the payload. | diff --git a/docs/adr/0009-team-access-model.md b/docs/adr/0009-team-access-model.md index 8b1efcd..1bb0eb1 100644 --- a/docs/adr/0009-team-access-model.md +++ b/docs/adr/0009-team-access-model.md @@ -115,6 +115,36 @@ Three decisions inside it belong here, because each had a more convenient altern an address already belonging to a member of *this* team is refused, and that is a fact the caller can already read off the members table, so naming it leaks nothing. +### Addendum: the vault is what the product shows, and the team is behind it + +The model above is unchanged. What changed afterwards is which half of it a person is asked about. + +The first interface built on this ADR made the team the subject: you created a team, then a vault in it, +then wrapped a key. Two of those three steps are about a concept nobody arrives wanting. So the screen now +lists **vaults**, and naming one creates the membership list that carries it — named after the vault, +owned by the creator, one per vault. Nothing on the server moved: `VaultAccessService` still resolves a +shared vault through `team_membership`, every membership call still names a team id, and the split this +ADR is about — membership authorises, a grant unlocks — is still what the screen is built around, now +stated per vault rather than per team. + +Three consequences of the change belong here: + +- **A team owning several vaults is still legal and is no longer produced.** The client cannot make one; + an operator or a pre-existing deployment can. The screen refuses to hide it: a vault whose membership + list carries others says so, because on a vault-shaped screen "adding somebody here adds them there" is + precisely the fact that would otherwise be invisible. +- **Archiving left the interface.** It was only ever possible for a team owning no vaults, and a screen + whose rows are vaults has no row for one — so the button would have been unreachable or always refused. + The endpoint is unchanged and the screen states the limit instead. The one place a vault-less team can + still appear is a create whose second call failed; cancelling that form archives it, which is a + deliberate exception to this client's rule against tidying up on the user's behalf, made because nothing + else can reach it. +- **A vault can be renamed**, which it could not before: `PUT /api/v1/vaults/{id}` requires + `PermissionFlags.Admin` — the line `UpdateTeamEndpoint` already draws, because a name is what everybody + in the vault sees it called rather than part of its contents. It renames the owning team with it when + that team carries nothing else, so the row an operator reads and the name a user says do not drift + apart. The slug never moves, for the reason it never moves on a team rename. + ## Consequences The sharing graph is visible to the operator: who is in which team, which vaults exist, and who holds diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md index cf55bb8..53b82c3 100644 --- a/docs/design-import-gaps.md +++ b/docs/design-import-gaps.md @@ -29,16 +29,16 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference > over a view model that already existed, plus preferences. `ShellScreen` gained `More` and `Buckets`; > SFTP and S3 are one screen over one `TransfersViewModel`, differing only in which picker they offer. > -> **A sixth is behind MORE that v2 never drew: TEAMS.** It is the reverse case — a shipped screen the +> **A sixth is behind MORE that v2 never drew: VAULTS.** It is the reverse case — a shipped screen the > design had no slot for — and it is on the phone for a reason the design could not have anticipated, > because invitations did not exist when it was drawn. An invitation is claimed by *signing in*, and the -> person being invited is at least as likely to be holding a phone as sitting at a desktop; a team the +> person being invited is at least as likely to be holding a phone as sitting at a desktop; a vault the > server has just put somebody in, visible only on a head they may not have installed, is a membership > they cannot see. It runs over the same view model the desktop screen drives, like the other four. > > | v2 element | What ships instead | > | --- | --- | -> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Nothing, said out loud.** `ISshConnection` offers `OpenShellAsync` and nothing else, so there is no tunnel for a rule to run through; `SyncEntityType.PortForward = 9` is still reserved and still unused. The MORE screen carries a paragraph naming the absence, for the reason the desktop keeps TEAMS in its rail. | +> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Nothing, said out loud.** `ISshConnection` offers `OpenShellAsync` and nothing else, so there is no tunnel for a rule to run through; `SyncEntityType.PortForward = 9` is still reserved and still unused. The MORE screen carries a paragraph naming the absence, for the reason the desktop keeps VAULTS in its rail. | > | `23 ms · fwd 5432` on the terminal's connection line | ◆ **The line is gone, and what was real on it moved.** There was never an RTT to draw — SSH.NET measures none — and nothing forwards anything, so what shipped was the account and endpoint actually dialled. In v3 a connected phone draws one 35-pixel bar and then the terminal, so a second 36-pixel row naming the machine is exactly the chrome that surface exists to give back: the address is on the connecting card, where it is read before anything has answered, and the shell's own prompt says it afterwards. The two text-size buttons that shared the line are pinned at the end of the accessory row, outside its scroller, which is what the line was protecting them from. | > | `ED25519` badge and `SHA256:kQ9f…Zw2M` on every keychain card | `Detail`, which is what is genuinely known *about* an item. Unchanged from the first import: no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim. | > | An `agent` chip on a key | Omitted. There is no agent of any kind — see the first import's Vault section. | @@ -84,7 +84,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference > | **Split ⌘D** | Still omitted — the renderer stacks panes and shows one; tiling needs a pane geometry it has not got. | > | macOS traffic lights, and `⌘K` | The window's own minimise/maximise/close, and `CTRL K`. Development is Windows-first and the chrome is `BorderOnly` for a documented reason. | > | No status bar | Kept, and cut down to the one thing the titlebar does not now carry: `Vault.Status`, which is the only channel this application has for saying a save failed or a merge picked a winner. The design is a mock-up of a working afternoon and has nowhere to put a sentence like that. | -> | The sidebar's five destinations, and a **Team vault** card at its foot | Seven destinations, because Pins, Teams and Preferences are built screens and dropping their entry would strand them — and two fewer than v2 shipped with, because SFTP and S3 became tabs; see v3 below. The card is not drawn: it is a second route to a screen already in the list, carrying a seat count nothing here produces. | +> | The sidebar's five destinations, and a **Team vault** card at its foot | Seven destinations, because Pins, Vaults and Preferences are built screens and dropping their entry would strand them — and two fewer than v2 shipped with, because SFTP and S3 became tabs; see v3 below. The card is not drawn: it is a second route to a screen already in the list, carrying a seat count nothing here produces. | > > ## The desktop's v3 > @@ -157,7 +157,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference > | **Add Telnet**, and **Serial** in the toolbar | Omitted. `ISshConnection` is the only transport there is. This is also why the card subtitle's `ssh` is a constant today rather than a reading — it is stated in `HostRowViewModel.Summary`, which is the one place in this interface where a constant is printed on purpose. | > | **+ SSH ID, Certificate, FIDO2** | Omitted. `IDENTITIES` and `CERTIFICATES` have been on this document's list since the first import — neither is even a reserved `SyncEntityType` — and there is no security-key path anywhere in the SSH layer. One control offering three item types that do not exist. | > | The **Backspace / Default** row | Omitted. It is a terminal setting, and the client has no preferences store and no frame to carry one to the renderer — see the Preferences section. It would be a control whose value could not survive the window closing. | -> | The **chevron beside the vault name** | The name alone. An item cannot be moved between vaults: the two are encrypted under different keys, so moving one is a delete and a retype. Where a *new* item is filed is chosen on the keychain screen, which is the only vault question with an answer. | +> | The **chevron beside the vault name** | The name alone, on the pane about an existing host: an item cannot be moved between vaults, because the two are encrypted under different keys and moving one is a delete and a retype. The half of the question that *does* have an answer — where a new host goes — is asked in the host editor, as a picker beside the name; keys, passwords and buckets take theirs from the keychain screen's standing picker instead. | > | **Show more ⌄** | Not drawn as a disclosure. What it would hide — notes, the relay switch, forgetting the host key — is in the editor, one press away, and a second fold inside a pane that already scrolls is a second place for a field to be missing from. | > | **Port Forwarding** in the sidebar | Nothing, for the third time in this document. | > | The host grid's toolbar avatar, share and tag-filter controls | Omitted, as in v3 and for the same reasons. | @@ -188,7 +188,7 @@ this document where what shipped differs from what the row predicted. **~~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 +unused tables bought. See [Vaults](#vaults). 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. @@ -282,7 +282,7 @@ field cannot be removed and stays as a permanently refused member; `SyncEndpoint | Design element | Layer | What it would take | What ships instead | | --- | --- | --- | --- | -| 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. | +| 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 VAULTS 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". | @@ -358,15 +358,17 @@ transfer primitive. The queue does its own 64 KiB copy loop and shares nothing w --- -## Vault +## Keychain — the design's Vault screen The screen ships and is real: four categories over the vault's four item types, one table, a detail pane, -and both editors. What follows is what the design drew around them. +and both editors. It is called the Keychain in both heads, which is what the rail has always labelled it; +the section below keeps the design's word only where it quotes the design. 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 | **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. | +| `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 VAULTS 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 | **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. | @@ -379,20 +381,30 @@ 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 | **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. | +| `REVOKE` | server | **Built in M3**, as WITHDRAW KEY on the VAULTS 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. | --- -## Teams +## Vaults -**Built in M3.** The screen ships: a team list, a members table with a real last-active column, the -invitations standing against addresses that have no account here yet, the team's vaults, and the two buttons -the whole design was really about — add a member, and share a vault key. A team can also be renamed, handed -to another member, and archived, the last only while it owns no vaults. What follows is what it still does -not do, and one thing this document got wrong before it was built. +**Built in M3, and reshaped since.** The screen ships: a vault list, a members table with a real +last-active column, the invitations standing against addresses that have no account here yet, who holds a +key, and the two buttons the whole design was really about — add somebody, and share a vault key. A vault +can also be renamed and handed to another member. + +**It lists vaults where it used to list teams, and that is the reshaping.** A team is still what the server +authorises against; what went is the requirement that anybody make one. Naming a vault makes the membership +list that carries it, named after the vault and owned by its creator, so the thing people came to share is +the thing they create. The team resurfaces in exactly one place and is not hidden there: a membership list +carrying several vaults — which this screen cannot produce — says so, because adding somebody to one of +those vaults adds them to all of them. Archiving is gone with the team list: a vault cannot be deleted at +all, the server refuses to archive a membership list while its vault exists, so the screen says so rather +than offering a button that always refuses. + +What follows is what it still does not do, and one thing this document got wrong before it was built. **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 diff --git a/docs/manual-checks.md b/docs/manual-checks.md index 7af5f01..19a75b7 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -28,7 +28,7 @@ a phase had nothing left for a person to do, which is the good outcome rather th ### 1.1 No screen is sliced at the WebView's left edge · **the important one** -Open two terminals, then visit every nav rail entry in turn — Hosts, Keychain, Pins, Snippets, Logs, Teams, +Open two terminals, then visit every nav rail entry in turn — Hosts, Keychain, Pins, Snippets, Logs, Vaults, Preferences — and both of the fixed tabs, SFTP and S3. **Pass:** each screen draws whole, its buttons all clickable, and the tab strip stays across the top of all @@ -1130,7 +1130,7 @@ case, and those two have to move together — the switch mirrors that property b --- -## Phase 12 — Teams: the operations that span two accounts +## Phase 12 — Shared vaults: the operations that span two accounts The server's own rules are covered by the endpoint suite: teams are renamed, an archive is refused while a vault is in the way, ownership changes hands, and every branch of the invitation claim is driven with @@ -1147,16 +1147,16 @@ follows is about what happens the first time it does. ### 12.1 An invitation becomes a membership at the invitee's first sign-in · **the one worth the most care** -1. Sign in as `alice`, make a team, and open its invitations. -2. Invite `bob@example.com` as a Member. **Nothing is sent, and nothing should look as though it was** — +1. Sign in as `alice`, make a vault on the VAULTS screen, and select it. +2. Add `bob@example.com` as a Member. **Nothing is sent, and nothing should look as though it was** — no "invitation emailed", no link to copy, no token anywhere on the screen. 3. **Pass:** the row appears as *pending*, carrying the address, the role and an expiry fourteen days out. Bob is **not** in the members table, because he has no account here for a membership row to point at. 4. Sign in as `bob` on the second profile and enroll. -5. **Pass:** the team is in Bob's list the first time he looks, at Member, with nothing further pressed on - either side. Back on Alice's machine, refresh: the invitation reads *accepted* rather than vanishing, +5. **Pass:** the vault is in Bob's list the first time he looks, at Member, with nothing further pressed + on either side. Back on Alice's machine, refresh: the invitation reads *accepted* rather than vanishing, and Bob is now in the members table. -6. **Pass, and this is the half that is easiest to lose:** the team's vault is in Bob's list **saying it is +6. **Pass, and this is the half that is easiest to lose:** the vault is in Bob's list **saying it is waiting for a key**, and nothing in it is readable. Have Alice press SHARE KEY and Bob sync; now it opens. @@ -1171,15 +1171,15 @@ reached that machine by a route this architecture says does not exist. In Keycloak's admin console, clear **Email verified** on the invitee *before* their first DodoSSH sign-in. Invite that address, then sign in as them. -**Pass:** they get an account and a personal vault and no team at all. The invitation stays *pending* on +**Pass:** they get an account and a personal vault and no shared one. The invitation stays *pending* on the inviter's screen rather than turning into anything, and the API log carries a warning naming how many invitations it declined to claim. Now set **Email verified** back on. The claim happens on the next request that crosses the hourly last-seen window, so it is **not** immediate and restarting the client will not hurry it along — the account already exists, so there is no second first-sign-in to trigger it. -**Failure means:** if the team appears while the address is unverified, the one security boundary +**Failure means:** if the vault appears while the address is unverified, the one security boundary invitations have is not being enforced, and anybody able to obtain a token asserting a colleague's address -can walk into their team. Stop there. If it stays pending after verifying, the claim is not reaching the +can walk into their vault. Stop there. If it stays pending after verifying, the claim is not reaching the **access** token — check the provider's mappers, and set `Oidc:EmailVerifiedClaim` if it sends the claim under some other name. @@ -1188,27 +1188,27 @@ under some other name. Invite an address, then revoke it before anybody has signed in with it. Then sign in with that address. **Pass:** the row reads *revoked* and stays on the list rather than disappearing, and the sign-in produces -an ordinary account in no team. Revoking one that has *already* been accepted answers that there was -nothing to withdraw. +an ordinary account in no shared vault. Revoking one that has *already* been accepted answers that there +was nothing to withdraw. **Failure means:** a revoked invitation that still lets somebody in is a removal that did not remove. An accepted one that could be unpicked here would be worse: it is a membership now, and removing a member -revokes their vault key grants and flags every team vault for rekey, which is not what "revoke invitation" +revokes their vault key grants and flags the vault for rekey, which is not what "revoke invitation" should quietly do. ### 12.4 An address already in the team is refused; an address that merely has an account is not -With Bob in the team, invite `bob@example.com` to it again. +With Bob in the vault, add `bob@example.com` to it again. **Pass:** refused, with a sentence saying the address already belongs to a member and to change their role -instead. Now make a **second** team and invite the same address there. +instead. Now make a **second** vault and invite the same address there. **Pass:** accepted. Bob having an account is deliberately not a reason to refuse — it is claimed within the hour on his next request rather than at a sign-in, so give it that long before deciding it has not worked. **Failure means:** if the second invitation is refused because the address already has an account, this endpoint has become a way of asking the server which addresses have accounts on it, answerable by anybody -willing to create a team first. See ADR 0009. +willing to create a vault first. See ADR 0009. ### 12.5 LAST ACTIVE is a real time, and a coarse one · **needs a couple of hours** @@ -1227,49 +1227,53 @@ impossible to offer honestly before. As the owner, transfer ownership to another active member, then read both rows. -**Pass:** they are Owner and you are **Admin** — not removed, not Member. Your vault key grants are intact -and the team's vaults have not come back flagged for rekey. Then try to transfer to somebody who is not a -member, and to yourself. +**Pass:** they are Owner and you are **Admin** — not removed, not Member. Your vault key grant is intact +and the vault has not come back flagged for rekey. Then try to hand it to somebody who is not a member, and +to yourself. **Pass:** both refused, and the message says which. **Failure means:** two owners, or none, is the state this being a single transaction exists to prevent, and -either one leaves a team that no client can administer back into shape. If your grants were revoked or the -vaults are now flagged for rekey, the transfer is removing the outgoing owner rather than demoting them. +either one leaves a vault that no client can administer back into shape. If your grant was revoked or the +vault is now flagged for rekey, the transfer is removing the outgoing owner rather than demoting them. -### 12.7 Archiving is refused while the team owns a vault +### 12.7 A vault cannot be deleted, and the screen says so rather than offering a button -With a team that owns at least one vault, try to archive it. +Look for a way to remove a vault, on both heads. -**Pass:** refused, and the message counts the vaults in the way and says there is no way to delete a vault -in this product. The team is still in everybody's list afterwards and its vaults still open. +**Pass:** there is none, and the VAULTS screen says why in a sentence: nothing in this product removes a +vault, and the server refuses to archive the membership list behind one while it exists. Archiving that +list is still reachable over the API, and the endpoint suite drives both its refusal and its success — what +is being checked here is that no button offers it. -**Failure means:** an archive that succeeded here would have taken those vaults out of the list of -everybody holding a key — including the person who pressed it, quietly, and with nothing in the product -able to put them back. +**Failure means:** a delete that worked would take the vault out of the list of everybody holding a key — +including the person who pressed it, quietly, and with nothing in the product able to put them back. A +button that always refuses is the milder failure and is still worth removing. -### 12.8 Archiving an empty team takes its memberships and its invitations with it · **needs two accounts** +### 12.8 Renaming a vault reaches every place its name is drawn · **needs two accounts** -Make a team that owns no vaults, add the second account to it, invite a third address, and archive it. +Rename a shared vault from the VAULTS screen. -**Pass:** the team is gone from both accounts' lists. Sign in with the invited address afterwards and it -joins nothing. A new team can be created under the archived one's slug. +**Pass:** the new name is on the vault list, on the badge of every host card in that vault, in the keychain +screen's "new items go to" picker, in the host editor's vault picker, and in the tab strip's vault menu — +and on the second account after a refresh. Nothing in the vault needs re-encrypting and everybody's key +still opens it. -**Failure means:** the invited address turning up in a team nobody can see is exactly what revoking pending -invitations inside the same transaction exists to prevent, and it would happen weeks later on a sign-in -nobody is watching. Note that taking the freed slug is correct rather than a defect, and is also the reason -an archived team is only restorable by an operator who checks that first. +**Failure means:** a name that moved in one place and not another is the shape this rename is most likely to +fail in, because several screens read it separately from a cached vault row. A vault that stops opening +after a rename would be far worse, and means that cached row was replaced by the server's answer rather +than edited — that answer deliberately carries no wrapped key. -### 12.9 Renaming a team, and the slug that does not move +### 12.9 Somebody who may write to a vault may not rename it -Rename a team and change its description. +As a plain Member of somebody else's vault, look for RENAME. -**Pass:** the new name is on every screen that names the team, on both accounts after a refresh. The slug is -unchanged and there is nowhere to change it. Nothing claims to know *when* it was renamed. +**Pass:** it is not drawn. Adding a host to that vault still works, which is what makes this a boundary +rather than a broken role. -**Failure means:** a rename that moved the slug could take one an archived team is still holding, and that -archived team could then never be brought back. An "edited" timestamp anywhere on the screen is invented -data — `team` has no updated-at column, so there is nothing behind it. +**Failure means:** a name is what everybody in the vault sees it called, so a member renaming it out from +under the people who share it is an administrative act reached without the role for it. The server refuses +it too — this is the interface not offering what the server would turn down. --- diff --git a/src/DodoSSH.Api/Features/Teams/TeamLog.cs b/src/DodoSSH.Api/Features/Teams/TeamLog.cs index 8480ea6..459c5f2 100644 --- a/src/DodoSSH.Api/Features/Teams/TeamLog.cs +++ b/src/DodoSSH.Api/Features/Teams/TeamLog.cs @@ -50,6 +50,16 @@ internal static partial class TeamLog internal static partial void TeamVaultCreated( ILogger logger, Guid vaultId, Guid teamId, Guid actorId); + /// + /// The vault and its team, and no names. A vault name is plaintext on this server, which is not a + /// reason to copy it into everything a log aggregator keeps for a year. + /// + [LoggerMessage( + EventId = 2115, + Level = LogLevel.Information, + Message = "Renamed vault {VaultId} of team {TeamId}.")] + internal static partial void VaultRenamed(ILogger logger, Guid vaultId, Guid? teamId); + [LoggerMessage( EventId = 2106, Level = LogLevel.Information, diff --git a/src/DodoSSH.Api/Features/Teams/VaultGrantEndpoints.cs b/src/DodoSSH.Api/Features/Teams/VaultGrantEndpoints.cs index 97b2289..f832e12 100644 --- a/src/DodoSSH.Api/Features/Teams/VaultGrantEndpoints.cs +++ b/src/DodoSSH.Api/Features/Teams/VaultGrantEndpoints.cs @@ -50,6 +50,78 @@ internal sealed class ListVaultGrantsEndpoint( } } +/// Renames a vault. +/// +/// +/// Admin rather than Write, and the line is the one UpdateTeamEndpoint draws: a name is what +/// everybody in the vault sees it called, so changing it is an administrative act rather than an edit +/// to the vault's contents. A member who may add hosts to a shared vault may not rename it out from +/// under the people who share it. +/// +/// +/// Authenticated rather than Enrolled, unlike everything else about a vault here. A rename touches no +/// key material and needs none — somebody added to a team before they have finished setting their own +/// machine up can still be reading this screen — and requiring a published identity key would refuse +/// them for a reason that has nothing to do with what they are asking. +/// +/// +internal sealed class RenameVaultEndpoint( + ICurrentUserContext currentUser, + IVaultAccessService vaultAccess, + VaultGrantService grants) + : Endpoint, NotFound, ProblemHttpResult>> +{ + /// + public override void Configure() + { + // PUT, for the reason the team rename is a PUT: one field, always sent whole, and a repeat is + // the same vault rather than a second edit. + Put("/api/v1/vaults/{vaultId:guid}"); + + Policies(Auth.AuthenticatedPolicy); + + Description(b => b + .WithName("RenameVault") + .WithSummary("Renames a vault.") + .WithTags("Vaults")); + } + + /// + public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync( + UpdateVaultRequest 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.Admin)) + { + return Problems.Coded( + StatusCodes.Status403Forbidden, + ProblemCodes.Forbidden, + "Only an admin or the owner of the team that owns this vault can rename it."); + } + + try + { + return TypedResults.Ok( + await grants.RenameVaultAsync(access.Vault!, req, ct).ConfigureAwait(false)); + } + catch (TeamInvalidException exception) + { + return Problems.Coded( + StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message); + } + } +} + /// 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 diff --git a/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs b/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs index 311e31f..bf84e41 100644 --- a/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs +++ b/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs @@ -151,6 +151,71 @@ internal sealed class VaultGrantService( : name; } + /// + /// Renames a vault, and the team behind it where that team exists to carry this vault alone. + /// + /// + /// + /// The team is renamed with it, and only when it owns nothing else. A vault made from the + /// vaults screen gets a team of its own named after it, and that team is not a thing the person who + /// made it was ever shown — so a rename that moved the vault's name and left the team's would leave + /// the operator, the logs and the database naming it something nobody uses. A team owning several + /// vaults is a different situation: it has a name of its own that somebody chose, and renaming one of + /// its vaults must not take it. + /// + /// + /// The slug never moves, exactly as UpdateTeamRequest records: it is unique only among live + /// teams, so a rename that changed it could take one an archived team is still holding. + /// + /// + internal async Task RenameVaultAsync( + Vault vault, + UpdateVaultRequest request, + CancellationToken cancellationToken) + { + var name = RequireVaultName(request.Name); + + vault.Name = name; + vault.UpdatedAtUtc = clock.GetUtcNow(); + + if (vault.TeamId is { } teamId) + { + var alone = !await database.Vaults + .AnyAsync( + other => other.TeamId == teamId + && other.Id != vault.Id + && other.DeletedAtUtc == null, + cancellationToken) + .ConfigureAwait(false); + + if (alone) + { + var team = await database.Teams + .SingleOrDefaultAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken) + .ConfigureAwait(false); + + if (team is not null) + { + team.Name = name; + } + } + } + + await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + TeamLog.VaultRenamed(logger, vault.Id, vault.TeamId); + + return new VaultSummary( + VaultId: vault.Id, + Name: name, + IsPersonal: vault.OwnerKind == VaultOwnerKind.Personal, + TeamId: vault.TeamId, + KeyGeneration: (uint)vault.KeyGeneration, + Permissions: 0, + WrappedVaultKey: null, + RekeyRequired: vault.RekeyRequired); + } + /// Lists who can open a vault. internal async Task ListGrantsAsync( Vault vault, diff --git a/src/DodoSSH.Api/Setup/EndpointRegistration.cs b/src/DodoSSH.Api/Setup/EndpointRegistration.cs index 7f63319..c9e266f 100644 --- a/src/DodoSSH.Api/Setup/EndpointRegistration.cs +++ b/src/DodoSSH.Api/Setup/EndpointRegistration.cs @@ -56,6 +56,7 @@ internal static class EndpointRegistration typeof(CreateTeamInvitationEndpoint), typeof(RevokeTeamInvitationEndpoint), typeof(CreateTeamVaultEndpoint), + typeof(RenameVaultEndpoint), typeof(ListVaultGrantsEndpoint), typeof(IssueVaultGrantEndpoint), typeof(RevokeVaultGrantEndpoint), diff --git a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml index 1a8d0e8..821f6f2 100644 --- a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml +++ b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml @@ -260,6 +260,28 @@ + + + + + + + + + + + + + - diff --git a/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml b/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml index 1def439..011adf4 100644 --- a/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml +++ b/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml @@ -191,7 +191,7 @@ + Text="SSO and organisation policy — the server has endpoints for membership and none for policy, so there is nothing for this screen to show." /> diff --git a/src/DodoSSH.Client.App/Views/TeamsScreen.axaml.cs b/src/DodoSSH.Client.App/Views/TeamsScreen.axaml.cs deleted file mode 100644 index 190e15d..0000000 --- a/src/DodoSSH.Client.App/Views/TeamsScreen.axaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Avalonia.Controls; - -namespace DodoSSH.Client.App.Views; - -/// Teams: who is in one, what they may do, and which vaults they hold a key to. -internal sealed partial class TeamsScreen : UserControl -{ - public TeamsScreen() => InitializeComponent(); -} diff --git a/src/DodoSSH.Client.App/Views/TerminalTabs.axaml b/src/DodoSSH.Client.App/Views/TerminalTabs.axaml index c8d6c68..e23bcf1 100644 --- a/src/DodoSSH.Client.App/Views/TerminalTabs.axaml +++ b/src/DodoSSH.Client.App/Views/TerminalTabs.axaml @@ -162,7 +162,7 @@ HorizontalContentAlignment="Left" Content="New vault…" Click="OnNewVaultPressed" - ToolTip.Tip="Names a vault and makes a team to own it, so you can invite people to it and give them roles" /> + ToolTip.Tip="Names a vault you can share, and opens it on the Vaults screen so you can add people to it and give them roles" /> diff --git a/src/DodoSSH.Client.App/Views/TeamsScreen.axaml b/src/DodoSSH.Client.App/Views/VaultsScreen.axaml similarity index 57% rename from src/DodoSSH.Client.App/Views/TeamsScreen.axaml rename to src/DodoSSH.Client.App/Views/VaultsScreen.axaml index 013dc1c..3b3e3c8 100644 --- a/src/DodoSSH.Client.App/Views/TeamsScreen.axaml +++ b/src/DodoSSH.Client.App/Views/VaultsScreen.axaml @@ -2,141 +2,140 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels" xmlns:contracts="using:DodoSSH.Contracts" - x:Class="DodoSSH.Client.App.Views.TeamsScreen" - x:DataType="vm:TeamsViewModel"> + x:Class="DodoSSH.Client.App.Views.VaultsScreen" + x:DataType="vm:VaultsViewModel"> - + -