Make the vault the thing you share, and ask a host which one it lives in

The teams screen listed teams that owned vaults, so sharing four servers with two
colleagues meant creating a team, then a vault inside it, then wrapping a key.
Two of those three steps are about a concept nobody arrives wanting. The screen
now lists vaults: naming one creates the membership list that carries it, named
after the vault and owned by you, and members, invitations, roles, hand-over and
key holders all hang off the vault they apply to.

Nothing on the server moved. VaultAccessService still resolves a shared vault
through team_membership and every membership call still names a team id — what
went is the requirement that anybody make one. The split the whole design rests
on is untouched and is still what the screen is built around: adding somebody
authorises the server to serve them, and only a machine holding the key can make
the vault readable. ADR 0009 keeps its decision and gains an addendum recording
which half of it a person is now asked about.

The one place the team resurfaces is a membership list carrying several vaults,
which this screen cannot produce and does not hide: the members section says so,
because "adding somebody here adds them there" is precisely the fact a
vault-shaped screen is in a position to conceal.

Two things left the interface and one arrived. Creating a team is gone, and so is
archiving one — it was only ever possible for a team owning no vaults, and a
screen whose rows are vaults has no row for one, so the button would have been
unreachable or always refused. The endpoint is unchanged and the screen states
the limit instead, since a vault cannot be deleted at all. The exception is a
create whose second call failed: cancelling that form archives the membership
list it left behind, which is a deliberate departure from this client's rule
against tidying up on the user's behalf, made because nothing else can reach it.

What arrived is PUT /api/v1/vaults/{id}. Without it the screen loses its only
editing action, since renaming the team behind a vault is invisible to everybody
who was never shown the team. It is gated on PermissionFlags.Admin — the line
UpdateTeamEndpoint already draws, because a name is what everybody in the vault
sees it called rather than part of its contents — and it renames the owning team
with it when that team carries nothing else, so the row an operator reads and the
name a user says cannot drift apart. The slug never moves, for the reason it does
not move on a team rename. The session edits its cached vault row rather than
replacing it with the response, which deliberately carries no wrapped key.

The host editor now asks which vault a host goes into, beside the name, while
adding and only where there is more than one vault to write to. It is a second
picker rather than the keychain screen's reused, and the two selections are
separate on purpose: that one is a standing preference about where new items go,
this is a field of the host in front of you, and binding both to one selection
would mean a click on the other screen could move a half-typed host. An existing
host is not offered it at all rather than offered it disabled — the two vaults
are encrypted under different keys, so moving an item is a delete and a retype.

That forced a fix worth naming. The group picker was built from the active
vault's groups whatever vault the host was being filed into, so a host put in a
shared vault could be filed under a group only its author can resolve — a
colleague would see it filed under nothing, which is the quietest kind of wrong.
Groups are now kept per vault and the picker follows the vault choice.

Two renames, because the pair they would otherwise have made is a bug farm:
ShellScreen.Vault became Keychain and VaultScreen became KeychainScreen, which is
what the rail has always labelled that screen, leaving Vault for one vault's
contents and Vaults for the vaults themselves. The enum values are unchanged;
NavRail.axaml writes them as x:Static literals.

1536 tests pass, seven more than before. Five are new on the server — the rename
endpoint's success, the team it does and does not take with it, the two refusals
and the empty name — and the client suite gains six and folds four together,
having lost the two about archiving a team.
This commit is contained in:
2026-08-04 12:22:29 +02:00
parent 6ae1912c34
commit 8707629a6c
47 changed files with 3204 additions and 2313 deletions
+71 -58
View File
@@ -226,18 +226,30 @@ What is not here: transferring a directory, dragging between the panes, and rout
bastion — the last needs jump hosts the connection layer has not got. All three are in
[`docs/design-import-gaps.md`](docs/design-import-gaps.md).
### Working as a team
### Sharing a vault
**TEAMS** in the nav rail creates a team, adds and invites members, shares vaults, hands a team over and
archives one. One distinction runs through the whole screen and is worth having before you use it.
**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds and invites people to one,
shares its key, renames it and hands it over. One distinction runs through the whole screen and is worth
having before you use it.
**Adding somebody to a team and giving them a key are two different acts, and only the first is something
the server can do.** Adding a member changes what the server will *serve* them: the team's vaults appear in
their list immediately. It cannot make those vaults readable, because a vault key is sealed to each member's
public key and this server never holds one — so until somebody presses **SHARE KEY** from a machine that has
the key, their vault sits in the list saying it is waiting for one. That is not a rough edge to be smoothed
over later; it is what "the operator cannot read the credentials it stores" costs, and the screen says so
rather than implying the server handed anything out.
**A vault is the thing you make, and the group of people is behind it.** The server authorises through a
*team*`VaultAccessService` resolves a shared vault through `team_membership`, and every membership call
names a team id — but nothing asks you to make one: naming a vault makes the membership list that carries
it, named after the vault and owned by you. So the thing you came to share is the thing you create, and
"which team is this in" stops being a question you need an answer to before you can share four servers with
two colleagues. Renaming the vault renames that membership list with it, as long as it carries nothing else.
The one case where the distinction resurfaces is a team owning several vaults, which this screen cannot
produce and does not hide: the members section then says so, because adding somebody to one of those vaults
adds them to all of them.
**Adding somebody to a vault and giving them its key are two different acts, and only the first is something
the server can do.** Adding a member changes what the server will *serve* them: the vault appears in their
list immediately. It cannot make it readable, because a vault key is sealed to each member's public key and
this server never holds one — so until somebody presses **SHARE KEY** from a machine that has the key, their
vault sits in the list saying it is waiting for one. That is not a rough edge to be smoothed over later; it
is what "the operator cannot read the credentials it stores" costs, and the screen says so rather than
implying the server handed anything out.
Sharing verifies before it wraps. The client reads the server's append-only key log, checks its hash chain
from the first entry, and refuses unless the key the directory just offered appears in that log unchanged.
@@ -246,14 +258,14 @@ published in a log every other client also reads. **It does not prove the key is
Compare the fingerprint with them over something this server does not carry; that is the only step that
closes it, and the success message says so every time.
**Somebody with no account here yet can be invited, and nothing is sent.** There is one button — **ADD
MEMBER** — and it does whichever of the two applies, because which one applies is a fact about the server's
account table rather than about what you are trying to do. If the directory knows the address, that account
is added straight away. If it does not, the address is invited instead, and the status line says which
happened, because the difference decides what you do next.
**Somebody with no account here yet can be invited, and nothing is sent.** There is one button — **ADD**
and it does whichever of the two applies, because which one applies is a fact about the server's account
table rather than about what you are trying to do. If the directory knows the address, that account is added
straight away. If it does not, the address is invited instead, and the status line says which happened,
because the difference decides what you do next.
An invitation is a standing instruction rather than a message: the next account that signs in with that
address joins this team, at the role you chose. There is no link and no token, because this server has no
address joins this vault, at the role you chose. There is no link and no token, because this server has no
outbound mail path and does not pretend otherwise — telling them to go and sign in is your job, over a
channel this server does not carry, and a link nobody can deliver would be worse than no link. An
invitation lasts fourteen days, so an address handed on to whoever takes the job next does not carry a
@@ -265,9 +277,9 @@ you the public key you are about to verify and wrap a vault to, and an invitatio
there may be no key yet. So when you are adding somebody *in order to* share a vault with them, the useful
sequence is still the same one: add them, see them appear in the members list, then share.
Inviting an address that already belongs to a member of the team is refused and says so. Inviting one that
Inviting an address that already belongs to a member of the vault is refused and says so. Inviting one that
merely *has* an account here is not — that would make this a way of asking the server which addresses have
accounts, which is not a question anybody willing to create a team first should be able to put to it. Such
accounts, which is not a question anybody willing to create a vault first should be able to put to it. Such
an invitation simply gets claimed sooner: within the hour, on the same sweep that records they were here,
rather than waiting for a first sign-in that has already happened.
@@ -275,39 +287,34 @@ rather than waiting for a first sign-in that has already happened.
to relax that.** The access token has to carry `email_verified` as true. Anything else — false, missing, or
sent under another name — claims nothing at all, and no setting turns that off: an invitation decides what
the server will serve, and one that could be taken by anybody able to obtain a token asserting somebody
else's address is a way into a team. **If your invitations never activate, this is the first thing to
else's address is a way into a vault. **If your invitations never activate, this is the first thing to
check.** They sit at *pending* rather than failing, the server logs a warning each time it declines to
claim one, and the two fixes are on your side: set `Oidc:EmailVerifiedClaim` to whatever your provider
calls the claim if it is not `email_verified`, and make sure the provider puts it in the **access** token
rather than only in the ID token or the userinfo response.
**Ownership is sole, and handing it over is one act.** Transferring names an existing active member: they
become owner and you become an admin, in a single transaction. Not two role changes — promoting first
leaves the team owned twice, demoting first leaves it owned by nobody, and there is nobody with the
authority to finish a transfer that stopped in the middle. You are demoted rather than removed, so you keep
your vault key grants; removing you would revoke them and flag every team vault for rekey, and somebody
handing over a team is usually staying in it.
**Ownership is sole, and handing a vault over is one act.** Transferring names an existing active member:
they become owner and you become an admin, in a single transaction. Not two role changes — promoting first
leaves it owned twice, demoting first leaves it owned by nobody, and there is nobody with the authority to
finish a transfer that stopped in the middle. You are demoted rather than removed, so you keep your vault
key grants; removing you would revoke them and flag the vault for rekey, and somebody handing a vault over
is usually staying in it.
**Archiving a team is refused while it owns a vault, and that is a limit rather than a rough edge.** A team
vault is readable *because* of membership, so archiving a team that still owned vaults would take them away
from everybody holding a key — including you — quietly and all at once. Nothing in this product deletes a
vault, so there is no order of operations that gets past the refusal today, and it says so with a count of
what is in the way rather than failing vaguely. Archiving an empty team takes its memberships and its
outstanding invitations with it, in one transaction. Its name can be changed whenever you like; its slug
cannot, because a slug is unique only among live teams and a rename could take one an archived team is
still holding.
Five limits, stated rather than discovered:
Four limits, stated rather than discovered:
- **Removing a member is not retroactive.** It revokes their grants and flags the team's vaults for rekey,
and blocks future reads. Everything they already pulled is on their machine. Rotate the SSH credentials
that matter — that is the actual remediation, and it is why there is no button labelled anything stronger.
- **A vault cannot be deleted.** Nothing in this product removes one, and the server refuses to archive the
membership list behind a vault that still exists — a shared vault is readable *because* of membership, so
archiving it would take the vault away from everybody holding a key, including you, quietly and all at
once. The screen says so where you would otherwise go looking for the button.
- **Removing a member is not retroactive.** It revokes their grants and flags the vault for rekey, and
blocks future reads. Everything they already pulled is on their machine. Rotate the SSH credentials that
matter — that is the actual remediation, and it is why there is no button labelled anything stronger.
- **The rekey is flagged, never performed.** See the milestone note above.
- **Host key trust stays in your personal vault.** A pin approved for a team's host is recorded and used
from your own vault, not the team's, so a teammate cannot pre-approve a fingerprint that your client will
then trust silently for a host you defined. The cost is that each member approves a team host's key once
on each of their machines. Team vaults' pins are still *listed* on the Vault screen, so you can see what
has been trusted.
- **Host key trust stays in your personal vault.** A pin approved for a shared vault's host is recorded and
used from your own vault, not the shared one, so a colleague cannot pre-approve a fingerprint that your
client will then trust silently for a host you defined. The cost is that each member approves a shared
host's key once on each of their machines. Shared vaults' pins are still *listed* on the Pins screen, so
you can see what has been trusted.
- **LAST ACTIVE is coarse on purpose.** The server records it at most once per account per hour, so a value
an hour old means "recently" and not "at that moment". That is the granularity the question is really
asked at — whether somebody is still using this deployment — and writing it on every request would put an
@@ -315,9 +322,14 @@ Four limits, stated rather than discovered:
as roughly-when rather than to the minute, because showing it to the minute would be reading a precision
into it that is not there.
Items are filed into one vault at a time. When more than one vault is writable, the host and vault editors
show a picker; it defaults to your personal vault and never moves on its own, because an item put in a team
vault is visible to everybody in that team and moving it back means deleting and retyping.
Items are filed into one vault at a time, and which one is asked at the moment the item is made. **A host's
editor has its own picker**, beside the name, because that is the decision that cannot be undone: the two
vaults are encrypted under different keys, so moving an item afterwards means deleting it and typing it
again — and the picker is therefore absent when you edit an existing host rather than present and refusing.
Keys, passwords and buckets take theirs from a standing "new items go to" picker on the Keychain screen.
Both default to your personal vault and neither moves on its own, because an item put in a shared vault is
visible to everybody holding that vault's key. Choosing a vault in the host editor also decides which groups
it can be filed under: a group is an item like any other and lives in exactly one vault.
### The Android head
@@ -329,7 +341,7 @@ fit 360dp.
Its interface is the **v2 design**: destinations in a bottom bar, with the rest one tap deeper behind the
last. The bar is three — **Hosts**, **Connections** and **Settings** — with the keychain, snippets, SFTP,
S3 buckets, logs, teams and preferences behind Settings. A bottom bar is for the places a session moves
S3 buckets, logs, vaults and preferences behind Settings. A bottom bar is for the places a session moves
between, and managing keys is not one of those. Both heads are on that design now; the desktop's own v2 is
a 190-pixel labelled nav rail in place of the icon rail, a centred search box in the titlebar, and session
tabs as pills, and it keeps its Keychain entry — its rail has the room. Its light theme is not built — see
@@ -369,9 +381,9 @@ confirmation — without offering to change it. What this head does make, it mak
rather than in an editor: a tag from inside a host's editor, and a credential from the connect bar's
remember tick, which stores the password just typed and moves the host onto it. Renaming either is still a
desktop job. Pins and import have no phone screen either, and importing an `~/.ssh/config` has no meaning
on a phone at all. **TEAMS does have one**, behind MORE, and it is there for a reason the design could not
have anticipated: an invitation is claimed by signing in, so somebody being told they have been put in a
team is at least as likely to be holding a phone as sitting at a desktop, and a membership visible only on
on a phone at all. **VAULTS does have one**, behind MORE, and it is there for a reason the design could not
have anticipated: an invitation is claimed by signing in, so somebody being told they have been added to a
vault is at least as likely to be holding a phone as sitting at a desktop, and a membership visible only on
a head they have not installed is a membership they cannot see.
**Port forwarding is not built anywhere**, and the phone's More screen says so in a paragraph rather than
@@ -570,15 +582,16 @@ keychain plus a terminal — and the spike that gates all of it.
directories, an interrupted **upload** starts again rather than resuming (an object cannot be written from
the middle), and a rename is a copy and a delete rather than one atomic operation. Downloads do resume — a
ranged GET is part of the protocol, which is the one place a bucket beats SFTP.
- **M3 — teams**, sharing, ACLs. *Done, except rekey.* Teams with roles, a public-key directory, the
append-only key log served for clients to verify against, team-owned vaults, and vault key grants
wrapped by a client and stored opaquely by the server. `VaultAccessService` now resolves team
- **M3 — shared vaults**, sharing, ACLs. *Done, except rekey.* Membership with roles, a public-key
directory, the append-only key log served for clients to verify against, shared vaults, and vault key
grants wrapped by a client and stored opaquely by the server. `VaultAccessService` now resolves team
membership to permissions, so a viewer may pull and may not push; the desktop client reads and syncs
every vault it holds a key for, and a real TEAMS screen replaces the placeholder. A team can be renamed,
handed to another member, and archived once it owns no vaults; a member row carries when that account was
last here; and an address with no account on this deployment can be invited, joining the moment somebody
signs in with it. See
[Working as a team](#working-as-a-team) for the one distinction the whole design rests on, and the limits worth
every vault it holds a key for, and a real VAULTS screen replaces the placeholder. The screen is
vault-shaped rather than team-shaped: naming a vault makes the membership list that carries it, so the
team is behind the vault rather than a thing anybody has to create first. A vault can be renamed and
handed to another member; a member row carries when that account was last here; and an address with no
account on this deployment can be invited, joining the moment somebody signs in with it. See
[Sharing a vault](#sharing-a-vault) for the one distinction the whole design rests on, and the limits worth
knowing before you rely on it; the reasoning is in
[ADR 0009](docs/adr/0009-team-access-model.md).