diff --git a/README.md b/README.md index 7f37934..7a31d1d 100644 --- a/README.md +++ b/README.md @@ -148,8 +148,9 @@ dotnet run --project src/DodoSSH.Client.App In the app, enter `http://localhost:5233` as the server. Your browser opens for sign-in — the realm ships `alice` / `alice` — then choose a vault passphrase and **write down the recovery code**, which cannot be skipped and cannot be recovered from the server. You can then add a host and open a shell on it — double-click -it in the sidebar, or select it and press **CONNECT**, which is the same command with the password box beside -it. Keycloak's admin console is at `http://localhost:18080` (`admin` / `admin`). +its card, or select it and press **CONNECT** in the drawer that opens beside the grid, which is the same +command with the password box above it. Keycloak's admin console is at `http://localhost:18080` +(`admin` / `admin`). You can also add an SSH key, which is stored in the vault like a host and synced the same way: paste the private key, then edit a host and pick that key from its **key** dropdown. From then on that host @@ -226,8 +227,8 @@ bastion — the last needs jump hosts the connection layer has not got. All thre ### 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. +**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. **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 @@ -244,7 +245,58 @@ 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. -Three limits, stated rather than discovered: +**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. + +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 +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 +standing offer for ever; it can be withdrawn until it is taken up; and like adding a member it grants +nothing readable, so somebody still has to press SHARE KEY afterwards. + +The one thing the merged button costs is worth knowing. Adding an account the directory knows also hands +you the public key you are about to verify and wrap a vault to, and an invitation cannot do that because +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 +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 +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. + +**An invitation is only claimed if your identity provider says the address is verified, and there is no way +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 +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. + +**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. + +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 @@ -255,6 +307,12 @@ Three limits, stated rather than discovered: 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. +- **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 + UPDATE on the hot path of every authenticated call for a number nobody reads that closely. It is shown + 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 @@ -270,11 +328,11 @@ 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 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 sidebar in place of the icon rail, a centred search box in the titlebar, and session tabs -as pills, and it keeps its Keychain entry — a rail with nine slots has room. Its light theme is not built — -see [`docs/design-import-gaps.md`](docs/design-import-gaps.md) — so the application is dark on both. +S3 buckets, logs, teams 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 +[`docs/design-import-gaps.md`](docs/design-import-gaps.md) — so the application is dark on both. **Connections is where a connection is made, not only where one is shown.** With nothing open it offers a box taking `user@host` or `user@host:port` and a password, and lists the machines most recently connected @@ -289,15 +347,31 @@ sessions as pills, and a `+` on the right offering the three connections this ap shell, a host's files over SFTP, or a bucket. The system back gesture does what the arrow does, and lowers that menu first if it is open. -Widening the sidebar moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every +Widening the rail moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every screen exactly the width it was designed against. +A third desktop pass has since moved the furniture. The tab strip belongs to the window rather than to the +terminal: **Vaults**, **SFTP** and **S3** are fixed tabs at its head and open terminals follow them, which +took SFTP and S3 out of the nav rail — they are the two destinations you stay in while something runs. The +hosts screen became a grid of cards, groups above and hosts below, with a right-hand drawer for whichever +host is selected and for both editors; the 268-pixel host sidebar is gone. Text is white rather than the +design's blue-tinted `#E3E7F4`, and the type scale is a point larger. + File transfer **is** here now, in the shape scoped storage allows: one remote pane and the queue, over either an SFTP host or a bucket. There is no local pane, because there is no browsable local filesystem to put in one — moving a file *in* from the phone needs the system document picker and is the next piece of -work rather than a thing the screen pretends to do. What is still absent is a host editor and a keychain -item editor, so hosts and keys are created on the desktop and sync down; pins, teams and import have no -phone screen either. Importing an `~/.ssh/config` has no meaning on a phone at all. +work rather than a thing the screen pretends to do. Hosts and groups are made and corrected here now, from +a floating + on the Hosts screen, and both editors are cards in the list's own row rather than dialogs, so +the form never covers the thing it is about. The keychain has no editor of its own: SSH keys and buckets are +created on the desktop and sync down, and the phone will delete an item — behind the same counted +confirmation — without offering to change it. What this head does make, it makes where the need arises +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 +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 leaving a gap. The v2 design draws a whole screen for it; nothing in the SSH layer forwards anything, so @@ -499,7 +573,10 @@ keychain plus a terminal — and the spike that gates all of it. 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 + 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 knowing before you rely on it; the reasoning is in [ADR 0009](docs/adr/0009-team-access-model.md). @@ -508,8 +585,14 @@ keychain plus a terminal — and the spike that gates all of it. 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. + more honest than a button that only appears to do it. + + **Ownership transfer is here, and it is one write rather than two.** The member you name becomes owner + and you become an admin, in a single transaction — because ownership is sole, so promoting first leaves + the team owned twice and demoting first leaves it owned by nobody, and there is nobody left with the + authority to finish a transfer that stopped in the middle. Nothing else is touched: you keep your vault + key grants, because removing the outgoing owner would revoke them and flag every team vault for rekey, + which is a much larger act than the one being asked for. - **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 index 33acb88..8b1efcd 100644 --- a/docs/adr/0009-team-access-model.md +++ b/docs/adr/0009-team-access-model.md @@ -33,7 +33,7 @@ existed since the first migration — but nothing had had to name the split. 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: +Five 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 @@ -51,14 +51,21 @@ Four consequences, each of which is a place where a more reassuring design was r - **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. +- **Ownership is sole, so handing it over is one write and not a role change.** If membership + authorises, the owner's membership is the last authority in the team, and a transfer that stopped + halfway would leave nobody with the standing to finish it — owned twice if the promotion went first, + owned by nobody if the demotion did, and in either case recoverable only by an operator editing the + database. So `POST /teams/{id}/owner` promotes the recipient and demotes the outgoing owner to + **admin** in one transaction, `ChangeRoleAsync` refuses `Owner` outright, and the recipient must + already be an active member — handing a team to an id supplied once is the same mistake as adding + somebody straight to the owner role. Demoting rather than removing is the deliberate half: removing + them would revoke their vault key grants and flag every team vault for rekey, which is a far larger + act than the one being asked for, and somebody handing over a team is usually staying in it. -Two things were deliberately **not** built, and both are refusals rather than omissions: +One thing is deliberately **not** built, and it is a refusal rather than an omission: - **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: @@ -74,11 +81,47 @@ Two smaller choices, recorded because the alternative was written down first and 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. +### An invitation is membership decided before there is an account to hold it + +A membership names an account: `team_membership.user_id` is not nullable and carries a foreign key, so +somebody who has never signed in here has nothing for that row to point at. `MembershipStatus.Invited` +has existed since the first migration and is still never written — not as an oversight, but because a +membership waiting for a person is the one shape this model cannot store. An invitation is therefore its +own record, `team_invitation`, held against an **address**, and it becomes an ordinary active membership +the moment an account with that address signs in. + +That extends the model rather than bending it. An invitation grants nothing readable and cannot be a +step towards sharing, because there is no account and so no public key to wrap a vault to. It moves the +first half of the split earlier and leaves the second half exactly where it was. + +Three decisions inside it belong here, because each had a more convenient alternative: + +- **The claim requires `email_verified` on the access token, and nothing relaxes it.** This is the whole + of the security boundary. Membership is authorisation, so an invitation that could be taken by anybody + able to obtain a token asserting somebody else's address is a way into a team — the same attack + `OidcOptions.AllowEmailLinking` exists to refuse, arriving by another door and deserving the same bar. + An unverified or absent claim claims nothing and logs a warning, which is the only signal an operator + gets that their provider is not sending it. There is deliberately no setting to trust an unverified + address: a flag that exists is a flag somebody turns on for the afternoon their provider is + misconfigured, and this is the one it must not be possible to turn on. +- **Nothing is sent, and the product says so rather than implying a mail path it has not got.** There is + no token and no link — the row is a standing instruction, and telling the invitee to go and sign in + happens over a channel this server does not carry. A link nobody can deliver would be worse than none. + The compensation, such as it is, is real: an invitation that is not a bearer credential is one that + cannot be forwarded, intercepted or replayed. +- **An address that already has an account here is accepted rather than refused.** Refusing and pointing + at the directory would have been tidier, and would have turned the endpoint into an oracle for which + addresses have accounts on this deployment, answerable by anybody willing to create a team first. Only + 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. + ## 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. +and is not made worse here, but it is now a graph rather than a list. Invitations widen it by one +edge — an address that has been invited is on the graph before its owner has ever been here — which is +the same class of fact and worth naming rather than leaving to be noticed. 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 diff --git a/docs/android-port.md b/docs/android-port.md index 2adb52d..027e28b 100644 --- a/docs/android-port.md +++ b/docs/android-port.md @@ -489,7 +489,8 @@ go at 360dp: The nav rail's eight destinations became four. Pins, snippets, logs, import and teams are not built here: import has no meaning under scoped storage, and the other four are list screens whose view models already - exist — they are additive rather than structural. + exist — they are additive rather than structural. *(Snippets, logs and teams have since been built, behind + MORE. Pins and import have not, and import still cannot be.)* **Superseded by v2.** A second design — *DodoSSH Android v2* — is what this head now draws, and it took the "additive rather than structural" claim at its word: snippets, logs, SFTP and S3 are built, over the @@ -552,7 +553,8 @@ What is left, in the order it matters: source, which is the decision recorded above and the obvious next piece of work. - **Editors.** There is no host editor and no keychain item editor on the phone, so both are create-on- desktop-and-sync. That is why the v2 design's `+` buttons on HOSTS and on the keychain are not drawn. -- **Pins, teams and import**, which v2 does not draw either. +- **Pins and import**, which v2 does not draw either. Teams *is* drawn, behind MORE — it was the one of + the three whose view model needed nothing new on the phone, because none of that screen is vault content. - **The App Link upgrade**, unchanged from step 5. --- diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md index 25f42fb..c6c7092 100644 --- a/docs/design-import-gaps.md +++ b/docs/design-import-gaps.md @@ -29,6 +29,13 @@ 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 +> 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 +> 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. | @@ -77,7 +84,38 @@ 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 | Nine destinations, because Pins, Teams, Import and Preferences are built screens and dropping their entry would strand them. 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, 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 desktop's v3 +> +> A third pass, and the smallest of the three: it moves furniture rather than adding screens. Nothing in it +> needed a layer below `client-app`, which is why it has no table of its own — there was nothing to omit. +> +> **The tab strip became the window's, not the terminal's.** Three fixed tabs sit at its head — Vaults, +> SFTP, S3 — and terminal tabs follow them. SFTP and S3 left the nav rail to get there, which is the one +> semantic change: they are the two destinations you *stay in* while something runs, and a rail entry is +> for somewhere you go and come back from. The rail is drawn under Vaults alone, so SFTP, S3 and a terminal +> each get the full window width instead of `826`. See `MainWindowViewModel.IsVaultsTab` for why the tab is +> a page test rather than a fourth `ShellSurface`. +> +> **The hosts screen became a grid of cards** — groups above, hosts below — and the 268-pixel host sidebar +> went with it. That column was choosing among forty machines *and* editing one of them at two-thirds +> width; the grid took the first job at full width and a 304-pixel right-hand drawer took the second. The +> drawer collapses when nothing is selected, which is most of the time. Pressing a group card narrows the +> grid to that group; `SHOW ALL` is the way back. +> +> **The type scale went up a point and the text ramp went white.** `#E3E7F4` was a blue-tinted white on +> blue-black surfaces, which costs contrast twice — once for being darker than white and once for sharing +> a hue with what it is drawn on. Every step of the ramp moved with the top, so the intervals the design +> chose are kept. Both are in the shared palette, so the phone has them too. +> +> | v3 element | What ships instead | +> | --- | --- | +> | The hosts toolbar's view-mode switch (grid / list / table) | One view. A switch between three layouts where only one is built is two disabled buttons. | +> | The hosts toolbar's tag filter, calendar and share control | Omitted. There are no saved filters, nothing in a vault carries a date, and sharing outside a team vault does not exist. Tags are real and are searched by being read off the cards. | +> | **Serial** beside **Terminal** in the toolbar | Omitted. Every session here is an SSH channel; a serial transport is not a button, it is a second session kind. | +> | The strip's tabs inside the titlebar row, with a hamburger | A row of its own under the titlebar. The titlebar already carries the drag region, the search box, the sync light and three window controls, and the strip scrolls — putting both on one 44-pixel row would make the thing that scrolls fight the thing that drags. | +> | A count on the S3 tab | Omitted. The rail entry it replaces carried one; a number on two of five tabs reads as a fact about those two rather than as the tab's own state, and a terminal tab has nothing to count. The count is on the S3 screen. | Most of it landed. This file is the rest: every element of that design with nothing behind it, which project each piece would have to land in, and **what the shipped interface does instead**. That last @@ -109,6 +147,13 @@ unused tables bought. See [Teams](#teams). What has *not* changed is the split u 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. +One table did have to be added, and what it shows is the limit of that reservation. An invitation names an +**address**, and `team_membership.user_id` is not nullable and carries a foreign key, so somebody who has +never signed in has nothing for that row to point at — which is why `MembershipStatus.Invited` has been +reserved since the first migration and is still never written. `team_invitation` is its own table for that +one reason. The schema was right about the shape of a team and had said nothing about the shape of joining +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 saved, and there is no frame on the terminal data plane that would carry a change to the renderer anyway. @@ -228,7 +273,7 @@ caption buttons and window title drawn on top of the application's own — two s | Pane header `aes256-gcm` | client-ssh | **The closest miss on this list.** `SshNetConnection` holds the `SshClient`, so `ConnectionInfo.CurrentServerEncryption` is right there — it just is not on `ISshConnection` or surfaced by `TerminalWorkspace`. | Omitted; the tab strip shows the account and endpoint actually dialled. | | Pane header showing the running command and `following` | client-ssh | The host moves opaque bytes and never parses terminal output. Would need shell integration (OSC 133) on the remote. | Omitted. | | A `local · zsh` tab | client-ssh | Every session here is an SSH channel. Needs ConPTY and a second session kind. | Omitted. | -| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the sidebar already is. | Omitted. Connect opens a tab; Ctrl+K opens one by name. | +| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the hosts grid already is. | **Shipped**, as the palette rather than a menu: it opens what Ctrl+K opens, so the strip and the shortcut are one way of doing one thing. A `MenuFlyout` offering "SSH" and "local shell" is the nicer answer and is not verifiably safe above the terminal's native child window — and there is no local shell to offer. | | Terminal font size (`--termfs`, 11–16px) | client-storage | See preferences. | Fixed at the renderer's 13px. | --- @@ -298,9 +343,11 @@ and both editors. What follows is what the design drew around them. ## Teams -**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. +**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. **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 @@ -309,6 +356,16 @@ place that changed, exactly as its remark predicted, and no migration was needed 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. +**There are two ways into a team and they are not interchangeable.** Adding a member takes a *user id* the +caller has already got from the directory, so that account must have signed in here at least once — and the +ordering is deliberate rather than incidental, because whoever adds a member is usually about to wrap a +vault key to the public key that lookup returned. Inviting takes an *address*, grants nothing readable, and +cannot be a step towards sharing: there is no account, so there is no key to wrap to. Inviting an address +that already belongs to a member of this team is refused and says so — that is a fact about a team the +caller can already see. Inviting one that merely has an account somewhere on this deployment is **not** +refused, because answering that would turn the endpoint into an oracle for which addresses have accounts, +answerable by anybody willing to create a team first. It simply gets claimed sooner. + | Design element | Layer | What ships | | --- | --- | --- | | 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. | @@ -316,17 +373,20 @@ What the row did not anticipate is that the interesting half is not the endpoint | 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. | +| Pending invites, and withdrawing one | server | A `team_invitation` row per (team, address), listed beside the members it is about and withdrawable until it is taken up. It becomes a membership when an account with that address signs in — **and only if the access token asserts `email_verified`**, because membership is authorisation and an invitation anybody could take by naming somebody else's address is a way in. Fourteen days, because an address that is reassigned would otherwise carry a standing offer to whoever holds the job next. | +| Ownership transfer | server | `POST /api/v1/teams/{id}/owner`, owner only. One transaction: the named member becomes owner and the outgoing owner becomes an admin. Not two role changes — ownership is sole, so promoting first leaves the team owned twice and demoting first leaves it owned by nobody. The outgoing owner is demoted rather than removed, because removing them would revoke their vault key grants and flag every team vault for rekey, which is a far larger act than the one being asked for. | +| `LAST ACTIVE` | server | Real, and coarse on purpose. `UserAccount.LastSeenAtUtc` is now refreshed on ordinary authenticated requests, at most once per account per hour: writing it per request would put an UPDATE on the hot path of every authenticated call and start losing races on `user_account`'s own concurrency token. So the column answers "this week or not", which is the granularity the question is actually asked at, and is shown coarsely rather than to the minute. | +| Renaming and archiving a team | server | `PUT` and `DELETE /api/v1/teams/{id}`. The slug is deliberately not renameable: it is unique only among *live* teams, so a rename could take a slug an archived team still holds and strand it. Archiving soft-deletes the team, every membership and every pending invitation in one transaction — and is refused outright while the team owns any vault. | | 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. | +| `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 columns carry what *is* known and matters: whether they have published a key a vault can be wrapped to, and when they were last here. | | 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. | +| The invitation mail, and **resend** | server | An outbound mail path: an SMTP configuration, a template, a bounce story and a deliverability problem, none of which this server has. | **Nothing is sent, and the interface says so.** An invitation is a standing instruction rather than a message — the next account to sign in with that address joins the team — so there is no token, no link, and nothing to resend. Telling somebody to sign in is done over a channel this server does not carry. A link nobody can deliver would be worse than no link. | +| Archiving a team that owns vaults | — | Nothing that would be safe. A team vault resolves through membership, so archiving would take those vaults away from everybody holding a key, silently, including the caller — and nothing in this product deletes a vault, so there is no sequence of calls that turns the refusal into a success. | Refused, with `team-not-empty` and a count of the vaults in the way. A stated limit rather than a coming feature, for the reason the SFTP layer refuses a recursive delete: a refusal is visible and a quiet removal is not. | | `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 @@ -355,7 +415,7 @@ lists the rest as absent rather than omitting it silently. | `GENERAL` section | ui | There is no general setting to put in it. The theme is fixed by decision, and window size is not persisted. | | `KEYS & AGENT` section | client-ssh | **There is no agent, at all** — no own agent, no forwarding, no Pageant or OpenSSH-agent interop. | | `SYNC & VAULT` section | client-domain | Nothing here is adjustable. The auto-sync interval is a `private static readonly` with a remark arguing for its value. | -| `SECURITY & SSO` section | server | The SSO half needs team endpoints; there is no policy for the screen to show. | +| `SECURITY & SSO` section | server | Per-team SSO, which is a refusal rather than a pending endpoint: authentication is one global JWT scheme bound to one authority, so there is no per-team policy for this screen to show. | | `SHORTCUTS` section | ui | There is no keybinding infrastructure and no rebinding surface. | | Auto-lock after idle | client-session | An activity source, a decision about what counts as idle, and — the hard part — a policy for a shell mid-job. `LockAsync`'s own remark already argues that an unattended timeout which killed a running job would be worse than the exposure it removed. | | Require biometric to sign | crypto | Three separate falsehoods in one row. There is no signing service — the private key is decrypted and handed to SSH.NET whole, so there is no per-signature moment to interrupt; there are no connect-only keys; and per-use consent would need the key to live in the TPM, which is a different key hierarchy from the one in `docs/crypto.md`. | @@ -365,10 +425,13 @@ lists the rest as absent rather than omitting it silently. ## Two things the import changed on purpose -**Hosts left the vault column.** They have their own sidebar beside the terminal, and the vault screen -holds keys, passwords and pinned host keys. This follows the design, and it is also the better split: the -host list is what you look at while you work, and the rest is what you go and manage. `VaultSection` lost -its `Hosts` member and gained `All`. +**Hosts left the vault column.** They have their own screen and the vault screen holds keys, passwords and +pinned host keys. This follows the design, and it is also the better split: the host list is what you look +at while you work, and the rest is what you go and manage. `VaultSection` lost its `Hosts` member and +gained `All`. + +That screen was a 268-pixel sidebar beside the terminal when this paragraph was written, and v3 made it a +grid of cards with a drawer — see above. The split it describes did not change; only which half is wide. **Tabs moved to the shell, not the vault.** Locking disposes the vault and deliberately leaves shells running, so a tab list rebuilt per unlock would lose track of sessions that are still connected — the very diff --git a/docs/manual-checks.md b/docs/manual-checks.md index f2d1f5e..c49dedf 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -22,10 +22,11 @@ Each item says what to do, what a pass looks like, and what a failure would mean ### 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, FILES, KEYS, TEAM, PREFS. +Open two terminals, then visit every nav rail entry in turn — Hosts, Keychain, Pins, Snippets, Logs, Teams, +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 -five. +nine. The nav rail is there for the seven and gone for the two, because it belongs to the Vaults tab. **Failure means:** a screen is not collapsing while the terminal shows. The terminal is a native child window and composites above everything Avalonia paints, so the symptom is a screen cut off at the WebView's @@ -268,8 +269,8 @@ single-process test can reach. Open the hosts screen without creating any group. -**Pass:** the sidebar list is the flat list of hosts it always was — no headings, no UNGROUPED, nothing -saying the hosts are unfiled. +**Pass:** the grid is the flat wrap of host cards it always was — no GROUPS section above it, no headings +between the cards, no UNGROUPED, nothing saying the hosts are unfiled. **Failure means:** the "invisible until used" property is gone, and every existing user gets a heading they did not ask for. `RebuildSidebarRows` returns early when `Groups` is empty; that early return is the feature. @@ -281,9 +282,13 @@ Make two groups, file some hosts into each through the host editor, then click a **Pass:** the heading's chevron flips and its hosts disappear; the count on the heading does not change, because it counts what is in the group rather than what is on screen. Clicking again brings them back. -**Also check:** clicking a heading does not change which host is selected — the buttons at the foot of the -sidebar go on acting on the same machine. This is asserted in a test, but the test drives the view model -directly; what it cannot see is whether the `ListBox` writes something else back through the binding first. +**Also check:** clicking a heading does not change which host is selected — the drawer stays open on the +same machine and its EDIT and DELETE go on acting on it. This is asserted in a test, but the test drives the +view model directly; what it cannot see is whether the `ListBox` writes something else back through the +binding first. + +**Then press a group card.** The grid narrows to that group's hosts, the card is marked as chosen, and +SHOW ALL appears beside GROUPS. Pressing it brings the rest back and unmarks the card. ### 3.3 Deleting a group with hosts in it @@ -639,10 +644,10 @@ back. ### 7.6 Dragging a host into a group · **least covered, like all drag and drop** -Make two groups and file a host into one. Drag a host row onto another group's heading; onto a host row +Make two groups and file a host into one. Drag a host card onto another group's heading; onto a host card inside another group; and onto UNGROUPED. -**Pass:** the row under the pointer washes accent while the pointer is over it, the cursor shows a move +**Pass:** whatever is under the pointer washes accent while the pointer is over it, the cursor shows a move rather than a refusal, and the drop files the host — it moves under that heading and the counts on both headings change. Dropping onto its own group's heading is refused while still in the air. @@ -651,16 +656,19 @@ automated. The write it performs is: `MovingAHostToAGroup_FilesItAndLeavesItSele ### 7.7 A click still selects, and a double click still connects -Click host rows; drag one a few pixels without releasing; double-click one. +Click host cards; drag one a few pixels without releasing; double-click one. Then double-click a group +heading. -**Pass:** a click selects, a small movement starts nothing, and a double click connects. +**Pass:** a click selects, a small movement starts nothing, and a double click connects. Double-clicking a +heading folds it and unfolds it again and connects to nothing. -**Failure means:** the 5-pixel threshold in `HostSidebar.axaml.cs` is not doing its job — the same failure -as 2.16 on the other screen, and here it would make the list unusable. +**Failure means:** the 5-pixel threshold in `HostsScreen.axaml.cs` is not doing its job — the same failure +as 2.16 on the other screen, and here it would make the grid unusable. A heading that connects means the +double-tap handler has lost its check that the pointer was over a card. ### 7.8 The highlight clears after a drag that goes nowhere -Drag a host over a heading and release outside the list, or press Escape mid-drag. +Drag a host over a heading and release outside the grid, or press Escape mid-drag. **Pass:** the wash goes away. @@ -672,8 +680,8 @@ With host A selected, right-click host B and choose Delete. **Pass:** no menu opens at all, and the host selection has not moved. -**Failure means:** a menu acting on the selection rather than on the row under the pointer deletes the wrong -machine. `HostSidebarTests` covers both halves headlessly, so this is a confirmation that a real popup +**Failure means:** a menu acting on the selection rather than on the card under the pointer deletes the +wrong machine. `HostGridTests` covers both halves headlessly, so this is a confirmation that a real popup behaves as the headless one did. ### 7.10 Clicking a host in the palette connects @@ -800,11 +808,12 @@ side of it remains unmeasurable for the reasons in phase 8. Open a host's editor with a keychain holding a dozen tags. -**Pass:** the editor pane scrolls, and FORGET HOST KEY is reachable at the bottom of it. +**Pass:** the drawer scrolls, and FORGET HOST KEY is reachable at the bottom of it. -**Failure means:** the pane's MaxHeight is gone or the ScrollViewer is. The layout harness skips anything -inside a ScrollViewer, so from that commit on it certifies the pane fits rather than the fields — it will -tell you the pane is fine while the last button sits below the window. +**Failure means:** the drawer's ScrollViewer is gone. The layout harness skips anything inside one, so from +that commit on it certifies the drawer fits rather than the fields — it will tell you the drawer is fine +while the last button sits below the window. The editor used to carry a MaxHeight of its own because the +host list shared its column; the drawer is alone in its column now, so the height is the window's. ### 9.2 A chip toggles and reads as toggled @@ -1003,3 +1012,146 @@ by gesture or by the arrow, returns to Settings and not to HOSTS; a second back **Failure means:** `ShellScreen.Vault` is missing from `IsMoreSurface` or from the back gesture's first case, and those two have to move together — the switch mirrors that property by construction. + +--- + +## Phase 12 — Teams: 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 +tokens the test mints itself. That last freedom is exactly what puts this phase here. **A test can mint a +token asserting anything it likes, so it can prove the server's rule and can say nothing whatever about +whether *your* identity provider sends the claim that rule depends on** — and an invitation that never +activates fails by sitting still, which is the failure mode nobody notices. What is left needs two real +accounts, a real sign-in, and in two cases a clock. + +**Two accounts, and two profiles.** The dev realm ships `alice` and `bob`, both with verified addresses; a +second DodoSSH profile means a second machine, a second OS user, or the same machine after signing out. +Whichever account plays the invitee **must not have signed in to this deployment before** — most of what +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** — + 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, + 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 + waiting for a key**, and nothing in it is readable. Have Alice press SHARE KEY and Bob sync; now it + opens. + +**Failure means:** step 5 failing with everything else passing is almost always the `email_verified` claim +— go to 12.2 rather than reading the invitation code, because the server is doing exactly what it should. +Step 6 opening the vault *without* Alice sharing a key would be the far more serious failure: nothing on +the server can wrap a vault key, so an item that decrypts after a membership change alone means a key +reached that machine by a route this architecture says does not exist. + +### 12.2 An unverified address claims nothing, and the log is the only place that says so + +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 +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 +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 +**access** token — check the provider's mappers, and set `Oidc:EmailVerifiedClaim` if it sends the claim +under some other name. + +### 12.3 An invitation can be withdrawn until it is taken up + +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. + +**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" +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. + +**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. + +**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. + +### 12.5 LAST ACTIVE is a real time, and a coarse one · **needs a couple of hours** + +Use one account and leave the other idle for two or three hours, then read the members table. + +**Pass:** the account being used carries a recent time, the idle one does not move, and neither moves more +than once an hour however much is done in it. It is shown as roughly-when, never to the minute. + +**Failure means:** a value that tracks every click means the hourly gate is gone and every authenticated +request is writing to `user_account` — which carries the xmin concurrency token, so the next symptom is a +user's own overlapping requests failing on a version that moved under them. A value frozen at enrollment +means the refresh is not running on ordinary requests at all, which is the state that made this column +impossible to offer honestly before. + +### 12.6 Ownership changes hands in one act + +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:** 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. + +### 12.7 Archiving is refused while the team owns a vault + +With a team that owns at least one vault, try to archive it. + +**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. + +**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. + +### 12.8 Archiving an empty team takes its memberships and its invitations with it · **needs two accounts** + +Make a team that owns no vaults, add the second account to it, invite a third address, and archive it. + +**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. + +**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. + +### 12.9 Renaming a team, and the slug that does not move + +Rename a team and change its description. + +**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. + +**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. diff --git a/src/DodoSSH.Api/Authorization/CurrentUserContext.cs b/src/DodoSSH.Api/Authorization/CurrentUserContext.cs index 34c4160..6f0907c 100644 --- a/src/DodoSSH.Api/Authorization/CurrentUserContext.cs +++ b/src/DodoSSH.Api/Authorization/CurrentUserContext.cs @@ -17,6 +17,38 @@ public interface ICurrentUserContext Task GetOrProvisionAsync(CancellationToken cancellationToken); } +/// +/// Turns pending team invitations addressed to a verified email into memberships. +/// +/// +/// +/// Declared here, beside its only caller, and implemented in Features/Teams. The direction is +/// deliberate: sign-in is what an invitation waits for, so the sign-in path names the shape it needs +/// and the teams feature supplies it — rather than , which every +/// endpoint in the server depends on, growing a reference into one feature's folder. +/// +/// +public interface ITeamInvitationClaim +{ + /// + /// Claims every live invitation addressed to for this account. + /// + /// The account signing in. + /// The address the token asserted, or null if it asserted none. + /// + /// Whether the provider marked that address verified. False refuses the claim outright and is the + /// whole of what stops an invitation being taken by anybody able to assert somebody else's + /// address. + /// + /// Cancellation. + /// How many invitations became memberships. + Task ClaimAsync( + UserAccount user, + string? email, + bool emailVerified, + CancellationToken cancellationToken); +} + /// /// Request-scoped caller identity with just-in-time provisioning. /// @@ -29,9 +61,23 @@ internal sealed class CurrentUserContext( IHttpContextAccessor accessor, DodoDbContext database, IOptions oidcOptions, + ITeamInvitationClaim invitations, TimeProvider clock) : ICurrentUserContext { + /// + /// How stale may get before a request refreshes it. + /// + /// + /// An hour, and coarse on purpose in both directions. Writing it on every request would put an + /// UPDATE on the hot path of every authenticated call and — because user_account carries + /// the xmin concurrency token — would start losing races between a user's own overlapping + /// requests. Writing it never is what made the old "last active" column impossible to offer + /// honestly. An hour answers the question a colleague actually asks, which is "this week or not", + /// and it is also the window on which a pending invitation is swept for. + /// + private static readonly TimeSpan LastSeenWindow = TimeSpan.FromHours(1); + private UserAccount? cached; /// @@ -55,14 +101,90 @@ internal sealed class CurrentUserContext( var options = oidcOptions.Value; var email = principal.FindFirstValue(options.EmailClaim); var displayName = principal.FindFirstValue(options.NameClaim); + var emailVerified = IsVerified(principal, options.EmailVerifiedClaim); - cached = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false) - ?? await ProvisionAsync(issuer, subject, email, displayName, cancellationToken) + var existing = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false); + + if (existing is null) + { + cached = await ProvisionAsync(issuer, subject, email, displayName, cancellationToken) .ConfigureAwait(false); + // A first sign-in is exactly what an invitation is waiting for, so it is claimed at once + // rather than on the next hourly sweep — which would leave somebody staring at a team + // list that does not yet contain the team they were told they had been added to. + await invitations + .ClaimAsync(cached, email, emailVerified, cancellationToken) + .ConfigureAwait(false); + + return cached; + } + + cached = existing; + + await RefreshLastSeenAsync(existing, email, emailVerified, cancellationToken) + .ConfigureAwait(false); + return cached; } + /// + /// Records that this account is active, and sweeps for invitations it can now claim. + /// + /// + /// + /// The two are one operation because they want the same rate. Both are housekeeping nobody is + /// waiting on, and doing them together costs one extra round trip per account per hour rather + /// than two. + /// + /// + /// The sweep is what makes claiming recoverable rather than one-shot. A claim that failed at + /// provisioning — or an invitation issued in the window between an account being created and this + /// person next signing in — is picked up here instead of being stranded for ever. + /// + /// + /// ExecuteUpdateAsync rather than the change tracker, and the predicate rather than a + /// read-then-write: user_account carries the xmin concurrency token, so two overlapping + /// requests from one user would each read the row, each set the timestamp, and the second would + /// fail on a version that had moved under it. This writes at most one row and cannot conflict. + /// The tracked entity is deliberately left alone — a value up to an hour stale in memory changes + /// nothing, and marking it modified would enlist the user row in whatever the request saves next. + /// + /// + private async Task RefreshLastSeenAsync( + UserAccount user, + string? email, + bool emailVerified, + CancellationToken cancellationToken) + { + var now = clock.GetUtcNow(); + + if (user.LastSeenAtUtc is { } seen && now - seen < LastSeenWindow) + { + return; + } + + await database.Users + .Where(u => u.Id == user.Id + && (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow)) + .ExecuteUpdateAsync( + setters => setters.SetProperty(u => u.LastSeenAtUtc, now), + cancellationToken) + .ConfigureAwait(false); + + await invitations + .ClaimAsync(user, email, emailVerified, cancellationToken) + .ConfigureAwait(false); + } + + /// + /// A JWT boolean arrives as the string "true", so this parses rather than compares against a + /// constant. Anything else — absent, "false", or a value this does not understand — is false, + /// because the failure that matters is treating an unverified address as verified. + /// + private static bool IsVerified(ClaimsPrincipal principal, string claimType) => + bool.TryParse(principal.FindFirstValue(claimType), out var verified) && verified; + private Task FindAsync(string issuer, string subject, CancellationToken cancellationToken) => database.Users.SingleOrDefaultAsync( u => u.Issuer == issuer && u.Subject == subject && u.DeletedAtUtc == null, diff --git a/src/DodoSSH.Api/Features/Teams/TeamEndpoints.cs b/src/DodoSSH.Api/Features/Teams/TeamEndpoints.cs index e605e1a..465a943 100644 --- a/src/DodoSSH.Api/Features/Teams/TeamEndpoints.cs +++ b/src/DodoSSH.Api/Features/Teams/TeamEndpoints.cs @@ -84,6 +84,188 @@ internal sealed class ListTeamsEndpoint(ICurrentUserContext currentUser, TeamSer } } +/// Renames a team, or changes its description. +/// +/// Admin rather than owner-only. A rename is visible to everybody and reversible by anybody who can +/// perform it, which is the test that separates it from archiving and from handing the team over. +/// +internal sealed class UpdateTeamEndpoint(ICurrentUserContext currentUser, TeamService teams) + : Endpoint, NotFound, ProblemHttpResult>> +{ + /// + public override void Configure() + { + // PUT, not PATCH: the body carries both fields every time, so clearing a description is + // sending null rather than a distinct verb, and a repeat is the same team. + Put("/api/v1/teams/{teamId:guid}"); + + Policies(Auth.AuthenticatedPolicy); + + Description(b => b + .WithName("UpdateTeam") + .WithSummary("Renames a team, or changes its description.") + .WithTags("Teams")); + } + + /// + public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync( + UpdateTeamRequest 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 rename it."); + } + + try + { + return TypedResults.Ok( + await teams.UpdateAsync(user, access, req, ct).ConfigureAwait(false)); + } + catch (TeamInvalidException exception) + { + return Problems.Coded( + StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message); + } + } +} + +/// Archives a team. +/// +/// Owner-only, and refused while the team owns vaults. See TeamService.ArchiveAsync for why +/// the refusal is the end of that road rather than a step on it. +/// +internal sealed class ArchiveTeamEndpoint(ICurrentUserContext currentUser, TeamService teams) + : EndpointWithoutRequest> +{ + /// + public override void Configure() + { + Delete("/api/v1/teams/{teamId:guid}"); + + Policies(Auth.AuthenticatedPolicy); + + Description(b => b + .WithName("ArchiveTeam") + .WithSummary("Archives a team. Refused while it still owns vaults.") + .WithTags("Teams")); + } + + /// + public override async Task> 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); + + if (!access.Granted) + { + return TypedResults.NotFound(); + } + + // Owner, not admin. An admin the owner promoted must not be able to archive the team out + // from under them — that is the boundary IsOwner exists to draw. Nothing behind this + // re-checks it, unlike the transfer below, so this line is the whole of the guard. + if (!access.IsOwner) + { + return Problems.Coded( + StatusCodes.Status403Forbidden, + ProblemCodes.Forbidden, + "Only the owner of this team can archive it."); + } + + try + { + await teams.ArchiveAsync(user, access.Team!, ct).ConfigureAwait(false); + + return TypedResults.NoContent(); + } + catch (TeamNotEmptyException exception) + { + return Problems.Coded( + StatusCodes.Status409Conflict, ProblemCodes.TeamNotEmpty, exception.Message); + } + } +} + +/// Hands a team's ownership to another member. +internal sealed class TransferTeamOwnershipEndpoint(ICurrentUserContext currentUser, TeamService teams) + : Endpoint> +{ + /// + public override void Configure() + { + // POST to a singular sub-resource rather than PUT on the member's role, because it is not a + // change to one membership: two rows move together and neither is meaningful alone. + Post("/api/v1/teams/{teamId:guid}/owner"); + + Policies(Auth.AuthenticatedPolicy); + + Description(b => b + .WithName("TransferTeamOwnership") + .WithSummary("Hands ownership to another member, demoting the outgoing owner to admin.") + .WithTags("Teams")); + } + + /// + public override async Task> ExecuteAsync( + TransferTeamOwnershipRequest 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.IsOwner) + { + return Problems.Coded( + StatusCodes.Status403Forbidden, + ProblemCodes.Forbidden, + "Only the owner of this team can hand it over."); + } + + try + { + await teams.TransferOwnershipAsync(user, teamId, req, ct).ConfigureAwait(false); + + // 204. The caller knows both ids — it supplied one and is the other — and a client that + // wants the new roles reads the members list, which is where roles live. + 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); + } + } +} + /// Lists a team's members. internal sealed class ListTeamMembersEndpoint(ICurrentUserContext currentUser, TeamService teams) : EndpointWithoutRequest>, NotFound>> @@ -306,6 +488,173 @@ internal sealed class RemoveTeamMemberEndpoint(ICurrentUserContext currentUser, } } +/// Lists a team's invitations. +/// +/// Readable by every member, as the members list is: whoever is about to be handed a vault key needs +/// to see who else is on their way in. Accepted and withdrawn invitations are included so the screen +/// can say an invitation was taken up rather than letting it vanish and read as never sent. +/// +internal sealed class ListTeamInvitationsEndpoint( + ICurrentUserContext currentUser, + TeamService teams, + TeamInvitationService invitations) + : EndpointWithoutRequest>, NotFound>> +{ + /// + public override void Configure() + { + Get("/api/v1/teams/{teamId:guid}/invitations"); + + Policies(Auth.AuthenticatedPolicy); + + Description(b => b + .WithName("ListTeamInvitations") + .WithSummary("Lists a team's invitations.") + .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); + + if (!access.Granted) + { + return TypedResults.NotFound(); + } + + return TypedResults.Ok(await invitations.ListAsync(teamId, ct).ConfigureAwait(false)); + } +} + +/// Invites an address to a team. +/// +/// Authenticated rather than Enrolled, and pointedly so. Every other write that ends in somebody +/// reading a vault needs a key of the caller's own; this one does not, because an invitation grants +/// membership and membership is not readability. Requiring enrollment here would also be requiring it +/// of the wrong person — the invitee is the one with no key, and they have no account yet either. +/// +internal sealed class CreateTeamInvitationEndpoint( + ICurrentUserContext currentUser, + TeamService teams, + TeamInvitationService invitations) + : Endpoint, NotFound, ProblemHttpResult>> +{ + /// + public override void Configure() + { + Post("/api/v1/teams/{teamId:guid}/invitations"); + + Policies(Auth.AuthenticatedPolicy); + + Description(b => b + .WithName("CreateTeamInvitation") + .WithSummary("Invites an email address to a team.") + .WithTags("Teams")); + } + + /// + public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync( + CreateTeamInvitationRequest 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 invite people to it."); + } + + try + { + var invitation = await invitations + .CreateAsync(user, teamId, req, ct) + .ConfigureAwait(false); + + return TypedResults.Ok(invitation); + } + catch (TeamInvitationInvalidException exception) + { + return Problems.Coded( + StatusCodes.Status400BadRequest, + ProblemCodes.InvalidTeamInvitation, + exception.Message); + } + } +} + +/// Withdraws an invitation that has not been taken up. +/// +/// 404 for an invitation that is not there, is not this team's, or has already been claimed — the +/// same answer for all three, and for the reason revoking a device grant gives: a caller driving +/// towards "that invitation will not let anybody in" can treat 404 as having arrived. A claimed one +/// is a membership now, and removing a member is a different operation with different consequences. +/// +internal sealed class RevokeTeamInvitationEndpoint( + ICurrentUserContext currentUser, + TeamService teams, + TeamInvitationService invitations) + : EndpointWithoutRequest> +{ + /// + public override void Configure() + { + Delete("/api/v1/teams/{teamId:guid}/invitations/{invitationId:guid}"); + + Policies(Auth.AuthenticatedPolicy); + + Description(b => b + .WithName("RevokeTeamInvitation") + .WithSummary("Withdraws an invitation that has not been taken up.") + .WithTags("Teams")); + } + + /// + public override async Task> ExecuteAsync( + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + var teamId = Route("teamId"); + var invitationId = Route("invitationId"); + + 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 withdraw an invitation."); + } + + var revoked = await invitations + .RevokeAsync(user, teamId, invitationId, ct) + .ConfigureAwait(false); + + return revoked ? TypedResults.NoContent() : TypedResults.NotFound(); + } +} + /// Creates a vault owned by a team. internal sealed class CreateTeamVaultEndpoint( ICurrentUserContext currentUser, diff --git a/src/DodoSSH.Api/Features/Teams/TeamExceptions.cs b/src/DodoSSH.Api/Features/Teams/TeamExceptions.cs index 52daa08..47b0bfb 100644 --- a/src/DodoSSH.Api/Features/Teams/TeamExceptions.cs +++ b/src/DodoSSH.Api/Features/Teams/TeamExceptions.cs @@ -20,10 +20,28 @@ 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. +/// route back would be an operator editing the database by hand. The deliberate way through it is a +/// transfer, which moves ownership and the outgoing owner's demotion together. /// internal sealed class LastTeamOwnerException(string message) : Exception(message); +/// The team still owns vaults, so it cannot be archived. +/// +/// Its own type because the remedy is neither fixing the request nor picking another value: archiving +/// would hide vaults from every member including the ones holding keys to them, and nothing in this +/// product deletes a vault, so there is no sequence of calls that turns this refusal into a success +/// today. Saying that plainly is better than a flag that hides somebody's data. +/// +internal sealed class TeamNotEmptyException(string message) : Exception(message); + +/// An invitation was rejected. +/// +/// Separate from because its commonest cause has a different +/// remedy: an address that already has an account here should be added through the directory, which +/// is the path that shows the caller the public key they are about to trust. +/// +internal sealed class TeamInvitationInvalidException(string message) : Exception(message); + /// /// A vault key grant was rejected. /// diff --git a/src/DodoSSH.Api/Features/Teams/TeamInvitationService.cs b/src/DodoSSH.Api/Features/Teams/TeamInvitationService.cs new file mode 100644 index 0000000..fda9b93 --- /dev/null +++ b/src/DodoSSH.Api/Features/Teams/TeamInvitationService.cs @@ -0,0 +1,488 @@ +using DodoSSH.Api.Authorization; +using DodoSSH.Contracts; +using DodoSSH.Domain; +using DodoSSH.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace DodoSSH.Api.Features.Teams; + +/// +/// Invitations to addresses that have no account here yet, and the sign-in path that claims them. +/// +/// +/// +/// An invitation is a standing instruction, not a message and not a token. This server has no +/// outbound mail path, so nothing is sent and there is nothing for the invitee to present. The row +/// says "the next account to sign in with this address joins this team as this role", and telling +/// them to sign in is the caller's job over a channel this server does not carry. That is a smaller +/// feature than the design drew, and it is the whole of what can be built honestly without a mail +/// path — a link nobody can deliver would be worse than none. +/// +/// +/// Verification is the security boundary, and it is the only one. Membership is authorization +/// (ADR 0009), so an invitation decides what the server will serve. Claiming one on an address the +/// identity provider has not marked verified would let anybody who can get a token asserting somebody +/// else's address walk into their team — which is precisely the attack +/// OidcOptions.AllowEmailLinking exists to refuse. So an unverified address claims nothing, +/// there is no setting that relaxes it, and the refusal is logged rather than silent. +/// +/// +/// What an invitation still cannot do is make anything readable. It creates a membership, and a +/// membership is not a key — somebody has to wrap the vault key to them afterwards, from a machine +/// that holds it. The split ADR 0009 describes is not weakened by this; the invitation simply moves +/// the first half of it earlier. +/// +/// +internal sealed class TeamInvitationService( + DodoDbContext database, + TimeProvider clock, + ILogger logger) + : ITeamInvitationClaim +{ + /// Longest acceptable address. Matches the column, and RFC 5321's own limit. + private const int MaxEmailLength = 320; + + /// + /// How long an invitation stays claimable. + /// + /// + /// Fourteen days, and finite for a reason rather than as a default. An invitation that never + /// expired would be a standing offer against an address, and addresses are reassigned — a + /// company address handed to the next person to hold the job would let them into a team the + /// person who left was invited to. Fourteen days is long enough to survive a holiday and short + /// enough that a forgotten invitation lapses rather than waiting. + /// + private static readonly TimeSpan Lifetime = TimeSpan.FromDays(14); + + /// Lists a team's invitations, including the ones already dealt with. + /// + /// Every member may read this, as with the members list and for the same reason: whoever is about + /// to be handed a vault key needs to see who else is on their way into the team. Accepted and + /// revoked rows are returned too, so the screen can show that an invitation was taken up rather + /// than having it silently vanish and read as never sent. + /// + internal async Task> ListAsync( + Guid teamId, + CancellationToken cancellationToken) + { + var invitations = await database.TeamInvitations + .Where(i => i.TeamId == teamId) + .OrderByDescending(i => i.CreatedAtUtc) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var now = clock.GetUtcNow(); + + return [.. invitations.Select(invitation => Describe(invitation, now))]; + } + + /// Invites an address to a team. + /// + /// + /// An address that already has an account here is accepted rather than refused. The obvious + /// alternative — refusing and pointing at the directory — would turn this endpoint into an oracle + /// for which addresses have accounts, answerable by anybody willing to create a team first. It + /// would also be answering a question the caller did not ask: they want that person in the team, + /// and whether the account exists yet only changes how soon it happens. An existing account picks + /// the invitation up on its next request, within the hour. + /// + /// + /// Idempotent on the client-chosen id, as team and vault creation are: the same id, team and + /// address returns the existing invitation rather than a second one. A different address under an + /// id already in use is refused rather than reinterpreted. + /// + /// + internal async Task CreateAsync( + UserAccount actor, + Guid teamId, + CreateTeamInvitationRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var email = RequireEmail(request.Email); + var role = RequireInvitableRole(request.Role); + + if (request.InvitationId == Guid.Empty) + { + throw new TeamInvitationInvalidException( + "An invitation id is required. Generate a UUIDv7 on the client."); + } + + var now = clock.GetUtcNow(); + + var existing = await database.TeamInvitations + .SingleOrDefaultAsync(i => i.Id == request.InvitationId, cancellationToken) + .ConfigureAwait(false); + + if (existing is not null) + { + return ResolveExisting(existing, teamId, email, now); + } + + await RefuseIfAlreadyAMemberAsync(teamId, email, cancellationToken).ConfigureAwait(false); + + var invitation = new TeamInvitation + { + Id = request.InvitationId, + TeamId = teamId, + Email = email, + Role = role, + InvitedByUserId = actor.Id, + CreatedAtUtc = now, + ExpiresAtUtc = now + Lifetime, + }; + + database.TeamInvitations.Add(invitation); + + try + { + await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + catch (DbUpdateException exception) when (IsUniqueViolation(exception)) + { + // The partial unique index on (team, email) among live invitations. Reported as its own + // sentence because it is the one failure the caller could not see coming from their own + // input — somebody else may have invited the same person a minute earlier. + throw new TeamInvitationInvalidException( + "There is already an invitation to that address for this team. Withdraw it first if " + + "you want to invite them at a different role."); + } + + TeamLog.InvitationIssued(logger, invitation.Id, teamId, role, actor.Id); + + return Describe(invitation, now); + } + + /// + /// A retry is the same id against the same team and address. Anything else under an id already in + /// use is refused rather than reinterpreted: returning a differently-addressed invitation would + /// tell a client its invite went to somebody it did not. + /// + private static TeamInvitationSummary ResolveExisting( + TeamInvitation existing, + Guid teamId, + string email, + DateTimeOffset now) + { + var isRetry = existing.TeamId == teamId + && string.Equals(existing.Email, email, StringComparison.OrdinalIgnoreCase); + + return isRetry + ? Describe(existing, now) + : throw new TeamInvitationInvalidException( + "That invitation id is already in use. Generate a new UUIDv7 and retry."); + } + + private static TeamRole RequireInvitableRole(TeamMemberRole role) + { + var domain = ToDomain(role); + + return domain is TeamRole.Unspecified or TeamRole.Owner + ? throw new TeamInvitationInvalidException( + "Invite somebody as a viewer, member or admin. Ownership is sole and is handed over " + + "deliberately, never conferred by an address signing in.") + : domain; + } + + /// Withdraws an invitation that has not been taken up. + /// Whether there was a live invitation to withdraw. + /// + /// An invitation that has already been claimed is not withdrawable, and answering false + /// rather than unpicking it is the honest outcome: it is a membership now, and removing a member + /// is a different operation with different consequences — it revokes their vault key grants and + /// flags every team vault for rekey. + /// + internal async Task RevokeAsync( + UserAccount actor, + Guid teamId, + Guid invitationId, + CancellationToken cancellationToken) + { + var invitation = await database.TeamInvitations + .SingleOrDefaultAsync( + i => i.Id == invitationId + && i.TeamId == teamId + && i.AcceptedAtUtc == null + && i.RevokedAtUtc == null, + cancellationToken) + .ConfigureAwait(false); + + if (invitation is null) + { + return false; + } + + invitation.RevokedAtUtc = clock.GetUtcNow(); + + await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + TeamLog.InvitationRevoked(logger, invitation.Id, teamId, actor.Id); + + return true; + } + + /// + public async Task ClaimAsync( + UserAccount user, + string? email, + bool emailVerified, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(user); + + if (string.IsNullOrWhiteSpace(email)) + { + return 0; + } + + var now = clock.GetUtcNow(); + + var pending = await database.TeamInvitations + .Where(i => i.Email == email + && i.AcceptedAtUtc == null + && i.RevokedAtUtc == null + && i.ExpiresAtUtc > now) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (pending.Count == 0) + { + return 0; + } + + if (!emailVerified) + { + // Logged rather than silent, and this is the only signal an operator gets that their + // provider is not sending the claim. Without it, invitations would simply never work and + // there would be nothing anywhere saying why. + TeamLog.InvitationNotClaimedUnverified(logger, pending.Count, user.Id); + return 0; + } + + return await ApplyAsync(user, pending, now, cancellationToken).ConfigureAwait(false); + } + + /// Turns each claimable invitation into an active membership. + private async Task ApplyAsync( + UserAccount user, + List pending, + DateTimeOffset now, + CancellationToken cancellationToken) + { + var teamIds = pending.Select(i => i.TeamId).ToArray(); + + // Archived teams are excluded here as well as at archive time. An invitation issued moments + // before an archive can still be in flight, and joining a team nobody can see is worse than + // an invitation that quietly lapses. + var liveTeamIds = await database.Teams + .Where(t => teamIds.Contains(t.Id) && t.DeletedAtUtc == null) + .Select(t => t.Id) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var live = liveTeamIds.ToHashSet(); + + var memberships = await database.TeamMemberships + .Where(m => teamIds.Contains(m.TeamId) && m.UserId == user.Id && m.DeletedAtUtc == null) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var claimed = 0; + + foreach (var invitation in pending.Where(i => live.Contains(i.TeamId))) + { + invitation.AcceptedAtUtc = now; + invitation.AcceptedByUserId = user.Id; + + if (Join(user, invitation, memberships.Find(m => m.TeamId == invitation.TeamId), now)) + { + claimed++; + + TeamLog.InvitationClaimed( + logger, user.Id, invitation.Id, invitation.TeamId, invitation.Role); + } + } + + return await SaveClaimAsync(claimed, cancellationToken).ConfigureAwait(false); + } + + /// Adds or reactivates the membership an invitation asks for. + /// Whether the membership changed. False means they were already an active member. + private bool Join( + UserAccount user, + TeamInvitation invitation, + TeamMembership? membership, + DateTimeOffset now) + { + if (membership is null) + { + database.TeamMemberships.Add(new TeamMembership + { + Id = Guid.CreateVersion7(), + TeamId = invitation.TeamId, + UserId = user.Id, + Role = invitation.Role, + Status = MembershipStatus.Active, + InvitedByUserId = invitation.InvitedByUserId, + JoinedAtUtc = now, + CreatedAtUtc = now, + }); + + return true; + } + + if (membership.Status == MembershipStatus.Active) + { + // Already in the team — the invitation is satisfied rather than applied. It must not + // change a role somebody set deliberately in the meantime, which is what re-applying an + // invitation issued weeks ago would silently do. + return false; + } + + // Removed earlier and invited again. The row is reactivated rather than duplicated, exactly + // as TeamService.AddMemberAsync does, so historic audit entries stay resolvable to one + // membership. Their revoked key grants are not restored — those were wrapped to a generation + // the vault has since been flagged to leave behind. + membership.Role = invitation.Role; + membership.Status = MembershipStatus.Active; + membership.JoinedAtUtc = now; + + return true; + } + + /// + /// Its own SaveChanges, never folded into the caller's. CurrentUserContext.ProvisionAsync + /// catches a unique violation and re-reads the account by (issuer, subject); a claim sharing that + /// call would put violations from this table inside a filter written for exactly one race, and + /// its rethrow would stop being correct. + /// + private async Task SaveClaimAsync(int claimed, CancellationToken cancellationToken) + { + try + { + await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + catch (DbUpdateException exception) when (IsUniqueViolation(exception)) + { + // Two of this account's requests claiming at once. One wins; the other finds nothing + // left to do on the next sweep. Swallowed rather than surfaced because this runs inside + // the authorization middleware, where a throw is a 500 on a request that was otherwise + // fine — and because the outcome the caller wanted has happened either way. + foreach (var entry in database.ChangeTracker.Entries().ToList()) + { + entry.State = EntityState.Detached; + } + + return 0; + } + + return claimed; + } + + /// + /// Refused only for an account that is already in this team — a fact about a team the + /// caller can see, so naming it leaks nothing. Whether an address has an account at all is + /// deliberately not answered here; see . + /// + private async Task RefuseIfAlreadyAMemberAsync( + Guid teamId, + string email, + CancellationToken cancellationToken) + { + var isMember = await database.TeamMemberships + .Where(m => m.TeamId == teamId + && m.Status == MembershipStatus.Active + && m.DeletedAtUtc == null) + .Join( + database.Users.Where(u => u.Email == email && u.DeletedAtUtc == null), + m => m.UserId, + u => u.Id, + (m, u) => m.Id) + .AnyAsync(cancellationToken) + .ConfigureAwait(false); + + if (isMember) + { + throw new TeamInvitationInvalidException( + "That address already belongs to a member of this team. Change their role instead."); + } + } + + /// Derives what has become of an invitation from its timestamps. + /// + /// Computed rather than stored, which is why has no domain twin. + /// Expiry is a fact about the clock: a stored state would need a sweeper to keep it true, and an + /// invitation that read Pending because nothing had run yet would be a lie the interface repeats. + /// + private static TeamInvitationSummary Describe(TeamInvitation invitation, DateTimeOffset now) + { + var state = invitation switch + { + { AcceptedAtUtc: not null } => TeamInvitationState.Accepted, + { RevokedAtUtc: not null } => TeamInvitationState.Revoked, + _ when invitation.ExpiresAtUtc <= now => TeamInvitationState.Expired, + _ => TeamInvitationState.Pending, + }; + + return new TeamInvitationSummary( + invitation.Id, + invitation.Email, + ToContract(invitation.Role), + state, + invitation.InvitedByUserId, + invitation.CreatedAtUtc, + invitation.ExpiresAtUtc, + invitation.AcceptedAtUtc); + } + + /// + /// Deliberately shallow. This checks the shape the column and the claim path need — one at-sign + /// with something either side, no spaces, and inside the length the column holds — and nothing + /// more. A stricter address grammar here would reject addresses that a real identity provider + /// will happily assert, and the only thing that ultimately decides whether an address is that + /// person's is the provider marking it verified. + /// + private static string RequireEmail(string? value) + { + var email = (value ?? string.Empty).Trim(); + + var at = email.IndexOf('@', StringComparison.Ordinal); + + var acceptable = email.Length is > 2 and <= MaxEmailLength + && at > 0 + && at == email.LastIndexOf('@') + && at < email.Length - 1 + && !email.Any(char.IsWhiteSpace); + + return acceptable + ? email + : throw new TeamInvitationInvalidException( + "That does not look like an email address. Invite the address they sign in with."); + } + + 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 bool IsUniqueViolation(DbUpdateException exception) => + string.Equals( + (exception.InnerException as PostgresException)?.SqlState, + PostgresErrorCodes.UniqueViolation, + StringComparison.Ordinal); +} diff --git a/src/DodoSSH.Api/Features/Teams/TeamLog.cs b/src/DodoSSH.Api/Features/Teams/TeamLog.cs index 2d12f6f..8480ea6 100644 --- a/src/DodoSSH.Api/Features/Teams/TeamLog.cs +++ b/src/DodoSSH.Api/Features/Teams/TeamLog.cs @@ -65,4 +65,80 @@ internal static partial class TeamLog + "Blocks future reads only; see ADR 0001.")] internal static partial void GrantRevoked( ILogger logger, Guid vaultId, Guid recipientId, Guid actorId); + + [LoggerMessage( + EventId = 2108, + Level = LogLevel.Information, + Message = "Renamed team {TeamId}, by {ActorId}.")] + internal static partial void TeamUpdated(ILogger logger, Guid teamId, Guid actorId); + + /// + /// Warning, and it names the member count, for the reason removal does: an archive takes a team + /// out of every member's list at once and only an operator can put it back. + /// + [LoggerMessage( + EventId = 2109, + Level = LogLevel.Warning, + Message = "Archived team {TeamId} and its {MemberCount} membership(s), by {ActorId}. " + + "Recoverable only by an operator clearing deleted_at_utc.")] + internal static partial void TeamArchived( + ILogger logger, Guid teamId, Guid actorId, int memberCount); + + /// + /// Warning rather than information: it is the only operation that takes administrative control of + /// a team away from the account that had it, and the account it is taken from is not the one + /// asking afterwards. + /// + [LoggerMessage( + EventId = 2110, + Level = LogLevel.Warning, + Message = "Transferred ownership of team {TeamId} from {FormerOwnerId} to {NewOwnerId}. " + + "The former owner is now an admin.")] + internal static partial void OwnershipTransferred( + ILogger logger, Guid teamId, Guid formerOwnerId, Guid newOwnerId); + + /// + /// The invitation id, never the address. TeamLog's rule is ids and outcomes only, and an email is + /// exactly the kind of personal detail a log aggregator would then keep for its whole retention. + /// + [LoggerMessage( + EventId = 2111, + Level = LogLevel.Information, + Message = "Issued invitation {InvitationId} to team {TeamId} as {Role}, by {ActorId}.")] + internal static partial void InvitationIssued( + ILogger logger, Guid invitationId, Guid teamId, Domain.TeamRole role, Guid actorId); + + [LoggerMessage( + EventId = 2112, + Level = LogLevel.Information, + Message = "Revoked invitation {InvitationId} to team {TeamId}, by {ActorId}.")] + internal static partial void InvitationRevoked( + ILogger logger, Guid invitationId, Guid teamId, Guid actorId); + + [LoggerMessage( + EventId = 2113, + Level = LogLevel.Information, + Message = "User {UserId} claimed invitation {InvitationId} and joined team {TeamId} as {Role}.")] + internal static partial void InvitationClaimed( + ILogger logger, Guid userId, Guid invitationId, Guid teamId, Domain.TeamRole role); + + /// + /// + /// Warning, and the one log line an operator will need when invitations appear not to work at all. + /// A provider that does not assert email_verified leaves every invitation pending for ever + /// with nothing else to show for it, and this is the only place that difference is visible. + /// + /// + /// It names the count and the account, never the address — the address is the thing being refused + /// as untrustworthy, and writing it to a log would be keeping a claim the server just rejected. + /// + /// + [LoggerMessage( + EventId = 2114, + Level = LogLevel.Warning, + Message = "Left {InvitationCount} invitation(s) unclaimed for user {UserId}: the access token " + + "does not assert that their email address is verified. Check the identity provider " + + "sends the email_verified claim.")] + internal static partial void InvitationNotClaimedUnverified( + ILogger logger, int invitationCount, Guid userId); } diff --git a/src/DodoSSH.Api/Features/Teams/TeamService.cs b/src/DodoSSH.Api/Features/Teams/TeamService.cs index 6d487c1..c7ecfe4 100644 --- a/src/DodoSSH.Api/Features/Teams/TeamService.cs +++ b/src/DodoSSH.Api/Features/Teams/TeamService.cs @@ -24,6 +24,17 @@ internal readonly record struct TeamAccess(Team? Team, TeamRole Role) /// public bool CanAdminister => Role is TeamRole.Admin or TeamRole.Owner; + /// + /// Whether the caller owns this team. + /// + /// + /// Distinct from , and the distinction is load-bearing: an admin may + /// manage members and vaults, but archiving a team and handing it to somebody else are the two + /// things that decide whether the team continues to exist and who controls it. Gating those on + /// would let anybody the owner promoted take the team from them. + /// + public bool IsOwner => Role is TeamRole.Owner; + /// Denied access. public static TeamAccess Denied => new(null, TeamRole.Unspecified); } @@ -156,6 +167,200 @@ internal sealed class TeamService( return team; } + /// Renames a team, or changes its description. + /// + /// The slug is not touched and cannot be. It is unique only among live teams, so a rename could + /// take a slug an archived team still holds, and that archived team could then never be restored + /// — a rename that quietly forecloses somebody else's recovery is worse than one the product + /// simply does not offer. There is also nowhere to record that this happened: team has no + /// updated-at column, so nothing can show "edited" and the log line is the only trace. + /// + /// Who is renaming it. + /// + /// The caller's resolved access. The role is taken from here rather than assumed, because + /// an admin may rename a team and telling them the response says would + /// hand a client a summary claiming rights it does not have — and this is the one write on a team + /// that both an admin and an owner can perform. + /// + /// The new name and description. + /// Cancellation. + internal async Task UpdateAsync( + UserAccount actor, + TeamAccess access, + UpdateTeamRequest request, + CancellationToken cancellationToken) + { + var team = access.Team + ?? throw new TeamInvalidException("That team is not there."); + + ArgumentNullException.ThrowIfNull(request); + + team.Name = RequireText(request.Name, nameof(request.Name), MaxNameLength); + team.Description = OptionalText(request.Description, MaxDescriptionLength); + + await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + TeamLog.TeamUpdated(logger, team.Id, actor.Id); + + var memberCount = await CountMembersAsync(team.Id, cancellationToken).ConfigureAwait(false); + var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false); + + return new TeamSummary( + team.Id, team.Name, team.Slug, team.Description, + ToContract(access.Role), memberCount, vaultCount, team.CreatedAtUtc); + } + + /// + /// Archives a team, provided it owns no vaults. + /// + /// + /// + /// The vault check is the whole of this operation's safety and it refuses rather than + /// cascades. Archiving a team hides it from every member's list at once, and a team vault + /// resolves through membership — so archiving one that still owned vaults would take those vaults + /// away from people who hold keys to them, silently, including the caller. Nothing in this product + /// deletes a vault, so there is no sequence of calls that turns this refusal into a success today. + /// That is stated plainly rather than worked around, for the reason the SFTP layer refuses a + /// recursive delete: a refusal is visible and a quiet removal is not. + /// + /// + /// Memberships are archived with the team, in one transaction, because a live membership pointing + /// at an archived team is a row every membership query has to remember to exclude twice. The slug + /// is freed by the same write — the unique index is filtered on deleted_at_utc IS NULL — so + /// a team can be recreated under the archived one's slug, and restoring the archived one would + /// then collide. Only an operator can restore it, and this is the thing they have to look at + /// first. + /// + /// + internal async Task ArchiveAsync( + UserAccount actor, + Team team, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(team); + + var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false); + + if (vaultCount > 0) + { + throw new TeamNotEmptyException( + string.Create( + CultureInfo.InvariantCulture, + $"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. There is no way to delete a vault in this product yet, so a team with vaults cannot be archived.")); + } + + var now = clock.GetUtcNow(); + var strategy = database.Database.CreateExecutionStrategy(); + + var archived = await strategy.ExecuteAsync(async () => + { + var transaction = await database.Database + .BeginTransactionAsync(cancellationToken) + .ConfigureAwait(false); + await using var _ = transaction.ConfigureAwait(false); + + var memberships = await database.TeamMemberships + .Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + foreach (var membership in memberships) + { + membership.Status = MembershipStatus.Revoked; + membership.DeletedAtUtc = now; + } + + // Pending invitations go too. An invitation that outlived its team would become a + // membership of something nobody can see, on a sign-in weeks later. + var invitations = await database.TeamInvitations + .Where(i => i.TeamId == team.Id + && i.AcceptedAtUtc == null + && i.RevokedAtUtc == null) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + foreach (var invitation in invitations) + { + invitation.RevokedAtUtc = now; + } + + team.DeletedAtUtc = now; + + await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + + return memberships.Count; + }).ConfigureAwait(false); + + TeamLog.TeamArchived(logger, team.Id, actor.Id, archived); + } + + /// + /// Hands ownership to another active member, demoting the outgoing owner to admin. + /// + /// + /// + /// One transaction, because ownership is sole and the two writes are not separable: promoting + /// first leaves the team owned twice, demoting first leaves it owned by nobody, and a failure + /// between them leaves whichever of those the ordering chose. That is why this is not two calls + /// to , which refuses outright. + /// + /// + /// The recipient must already be an active member. Adding somebody and handing them the team in + /// one step would let an id supplied once take it, and the reason + /// refuses the owner role is the same one. + /// + /// + /// The outgoing owner is demoted rather than removed. Removing them would revoke their vault key + /// grants and flag every team vault for rekey — a far larger act than the one asked for, and + /// somebody handing over a team is usually staying in it. + /// + /// + internal async Task TransferOwnershipAsync( + UserAccount actor, + Guid teamId, + TransferTeamOwnershipRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (request.UserId == actor.Id) + { + throw new TeamInvalidException("You already own this team."); + } + + var outgoing = await RequireMembershipAsync(teamId, actor.Id, cancellationToken) + .ConfigureAwait(false); + + // Belt and braces: the endpoint already refused anybody who is not the owner. Checking again + // here keeps the invariant with the code that enforces it rather than one layer away. + if (outgoing.Role != TeamRole.Owner) + { + throw new LastTeamOwnerException("Only this team's owner can hand it over."); + } + + var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken) + .ConfigureAwait(false); + + var strategy = database.Database.CreateExecutionStrategy(); + + await strategy.ExecuteAsync(async () => + { + var transaction = await database.Database + .BeginTransactionAsync(cancellationToken) + .ConfigureAwait(false); + await using var _ = transaction.ConfigureAwait(false); + + incoming.Role = TeamRole.Owner; + outgoing.Role = TeamRole.Admin; + + await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + }).ConfigureAwait(false); + + TeamLog.OwnershipTransferred(logger, teamId, actor.Id, request.UserId); + } + /// Lists the teams the caller is an active member of. internal async Task> ListAsync( UserAccount user, @@ -260,7 +465,8 @@ internal sealed class TeamService( ToContract(m.Role), ToContract(m.Status), enrolledIds.Contains(m.UserId), - m.JoinedAtUtc)), + m.JoinedAtUtc, + m.User?.LastSeenAtUtc)), ]; } @@ -268,8 +474,9 @@ internal sealed class TeamService( /// /// /// 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. + /// somebody else is a transfer rather than an addition — a different operation, with its own + /// endpoint, which demotes the outgoing owner in the same transaction. Adding somebody straight + /// to owner would hand a team to an id typed once. /// /// /// Re-adding a removed member reactivates the original row rather than inserting a second one, @@ -365,7 +572,8 @@ internal sealed class TeamService( ToContract(membership.Role), ToContract(membership.Status), isEnrolled, - membership.JoinedAtUtc); + membership.JoinedAtUtc, + user.LastSeenAtUtc); } /// Changes a member's role. @@ -388,12 +596,15 @@ internal sealed class TeamService( 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. + // Demoting the owner here would leave the team ownerless, because this operation cannot + // appoint a replacement in the same breath. Transferring can, and does both at once — so + // the refusal names it rather than saying the thing is impossible. if (membership.Role == TeamRole.Owner) { throw new LastTeamOwnerException( - "This team's owner cannot be demoted, because nothing can appoint a replacement yet."); + "This team's owner cannot be demoted on its own. Transfer ownership to another " + + "member instead: that hands the team over and makes the outgoing owner an admin, " + + "in one step, so the team is never left with nobody who can manage it."); } membership.Role = role; @@ -445,8 +656,9 @@ internal sealed class TeamService( 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."); + "This team's owner cannot be removed while they own it, because that would leave the " + + "team with nobody who can manage it. Transfer ownership to another member first — " + + "the outgoing owner becomes an admin and can then be removed like anybody else."); } var now = clock.GetUtcNow(); @@ -580,6 +792,27 @@ internal sealed class TeamService( ?? throw new TeamInvalidException("That account is not an active member of this team."); } + /// Counts a team's active members. + private Task CountMembersAsync(Guid teamId, CancellationToken cancellationToken) => + database.TeamMemberships.CountAsync( + m => m.TeamId == teamId + && m.Status == MembershipStatus.Active + && m.DeletedAtUtc == null, + cancellationToken); + + /// Counts the vaults a team owns. + /// + /// Filtered on OwnerKind as well as on the id, matching . A vault + /// carrying a team id it does not belong to would otherwise be counted here and not there, and + /// this count is what decides whether a team may be archived. + /// + private Task CountVaultsAsync(Guid teamId, CancellationToken cancellationToken) => + database.Vaults.CountAsync( + v => v.TeamId == teamId + && v.OwnerKind == VaultOwnerKind.Team + && v.DeletedAtUtc == null, + cancellationToken); + /// /// 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 diff --git a/src/DodoSSH.Api/Program.cs b/src/DodoSSH.Api/Program.cs index df29030..abc8cdb 100644 --- a/src/DodoSSH.Api/Program.cs +++ b/src/DodoSSH.Api/Program.cs @@ -36,6 +36,13 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Registered twice on purpose, resolving to the same scoped instance: the endpoints take the +// concrete service, and CurrentUserContext takes only the claim it needs, so the sign-in path does +// not gain a reference to the whole of a feature it calls one method on. +builder.Services.AddScoped( + provider => provider.GetRequiredService()); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/src/DodoSSH.Api/Setup/DodoOptions.cs b/src/DodoSSH.Api/Setup/DodoOptions.cs index 857a696..d51d5c1 100644 --- a/src/DodoSSH.Api/Setup/DodoOptions.cs +++ b/src/DodoSSH.Api/Setup/DodoOptions.cs @@ -63,6 +63,27 @@ public sealed class OidcOptions /// Claim type holding the user's display name. public string NameClaim { get; set; } = "name"; + + /// + /// Claim type asserting that the provider has verified the user's email. + /// + /// + /// + /// Read for exactly one purpose: deciding whether a pending team invitation addressed to that + /// email may be claimed. Nothing else in this server trusts the email claim for anything, and + /// records why — a token from any configured provider carrying a + /// victim's address must not confer access to anything of theirs. An invitation is access, so it + /// needs the same bar. + /// + /// + /// Absence is a refusal, not a default. A provider that does not send this claim leaves + /// every invitation pending for ever, which is visible on the teams screen and diagnosable in the + /// log. There is deliberately no option to trust an unverified address instead: a flag that exists + /// is a flag somebody turns on for the afternoon their provider is misconfigured, and this is the + /// one it must not be possible to turn on. + /// + /// + public string EmailVerifiedClaim { get; set; } = "email_verified"; } /// Schema management. diff --git a/src/DodoSSH.Api/Setup/EndpointRegistration.cs b/src/DodoSSH.Api/Setup/EndpointRegistration.cs index e058a62..7f63319 100644 --- a/src/DodoSSH.Api/Setup/EndpointRegistration.cs +++ b/src/DodoSSH.Api/Setup/EndpointRegistration.cs @@ -45,10 +45,16 @@ internal static class EndpointRegistration typeof(SyncPushEndpoint), typeof(CreateTeamEndpoint), typeof(ListTeamsEndpoint), + typeof(UpdateTeamEndpoint), + typeof(ArchiveTeamEndpoint), + typeof(TransferTeamOwnershipEndpoint), typeof(ListTeamMembersEndpoint), typeof(AddTeamMemberEndpoint), typeof(ChangeTeamMemberRoleEndpoint), typeof(RemoveTeamMemberEndpoint), + typeof(ListTeamInvitationsEndpoint), + typeof(CreateTeamInvitationEndpoint), + typeof(RevokeTeamInvitationEndpoint), typeof(CreateTeamVaultEndpoint), typeof(ListVaultGrantsEndpoint), typeof(IssueVaultGrantEndpoint), diff --git a/src/DodoSSH.Client.Android/Resources/drawable/ic_launcher_foreground.xml b/src/DodoSSH.Client.Android/Resources/drawable/ic_launcher_foreground.xml index 3978443..51aa79e 100644 --- a/src/DodoSSH.Client.Android/Resources/drawable/ic_launcher_foreground.xml +++ b/src/DodoSSH.Client.Android/Resources/drawable/ic_launcher_foreground.xml @@ -1,25 +1,29 @@ - - - diff --git a/src/DodoSSH.Client.Android/Resources/drawable/ic_launcher_monochrome.xml b/src/DodoSSH.Client.Android/Resources/drawable/ic_launcher_monochrome.xml index 7dcd99a..f6b6885 100644 --- a/src/DodoSSH.Client.Android/Resources/drawable/ic_launcher_monochrome.xml +++ b/src/DodoSSH.Client.Android/Resources/drawable/ic_launcher_monochrome.xml @@ -4,9 +4,14 @@ the wallpaper's colours. The system tints this by its alpha and discards the colour, so the geometry is the foreground's and white is only a way of saying "opaque here". + Note what that means for the filled design: the accent tile is the background layer, and a + themed icon drops the background entirely. So the shape that survives here is the glyph, not + the tile — which is the right way round anyway. Filling this layer to the edges to stand in + for the tile would tint to a featureless square with nothing of the mark left in it. + Worth shipping rather than leaving out: a launcher with themed icons on and no monochrome layer to use falls back to the full-colour icon, so the one app on the home screen still - drawn in green is this one. + drawn in blue is this one. --> - - diff --git a/src/DodoSSH.Client.Android/Resources/mipmap-anydpi-v26/ic_launcher.xml b/src/DodoSSH.Client.Android/Resources/mipmap-anydpi-v26/ic_launcher.xml index 3015cea..50cf812 100644 --- a/src/DodoSSH.Client.Android/Resources/mipmap-anydpi-v26/ic_launcher.xml +++ b/src/DodoSSH.Client.Android/Resources/mipmap-anydpi-v26/ic_launcher.xml @@ -5,12 +5,17 @@ adaptive icons landed in 26, so there is no device this ships to that would need the bitmaps. Density buckets exist to pick a PNG; a vector has nothing to pick between. - The background is the same @color/dodo_window the window, status bar and navigation bar - use, so the mark sits on the app's own near-black rather than on a second dark that is - almost but not quite it. + The background is the accent, and that is the tile itself rather than a backdrop for one: + the v2 mark is >_ knocked out of a solid accent square, so the square is this layer and the + launcher's mask is what rounds it. See ic_launcher_foreground.xml for why the rounding is + left to the mask instead of drawn. + + It was @color/dodo_window until the mark went from outlined to filled, which is worth + knowing if a home screen still shows the dark version: a launcher caches icons, and the + cache outlives the install that changed them. --> - + diff --git a/src/DodoSSH.Client.Android/Resources/values/colors.xml b/src/DodoSSH.Client.Android/Resources/values/colors.xml index d6dc3c9..4e5c840 100644 --- a/src/DodoSSH.Client.Android/Resources/values/colors.xml +++ b/src/DodoSSH.Client.Android/Resources/values/colors.xml @@ -10,4 +10,11 @@ hidden. --> #0E1220 + + + #5B8CFF diff --git a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml index c1fcbe6..1a8d0e8 100644 --- a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml +++ b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml @@ -418,6 +418,10 @@ @@ -426,6 +430,12 @@ + + + + diff --git a/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml b/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml index 5342a69..714a94c 100644 --- a/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml +++ b/src/DodoSSH.Client.Android/Views/KeychainScreen.axaml @@ -80,9 +80,10 @@ + + + + + + + + + + + + + + + @@ -93,10 +88,15 @@ + BorderThickness="1" CornerRadius="12" Padding="14,12"> - +