Let a team be joined only by somebody who is already here

An invitation decided access from an assertion about an address. Everything else
in this model decides it from something a person did — an admin naming an
account, a key holder wrapping a vault key to a key they verified — and this was
the one place a token's email claim was the thing that let somebody in.

It was guarded as tightly as that can be guarded: the claim was refused outright
on an unverified or absent `email_verified`, with no setting to relax it. But the
guard and the risk were the same shape. The whole defence was one boolean sent by
a system the deployment does not control.

So `POST /teams/{id}/members` is the only way in, and an address with no account
is refused with `no-such-account` — which is now the end of the road rather than
the signal to invite. Both clients say the remedy: that person signs in here
once, which is what creates the account, and then they can be added. The desktop
leaves the address in the box, because a message telling you to come back later
is one you act on later.

Gone with it: the `team_invitation` table, the claim hook in the sign-in path,
and `Oidc:EmailVerifiedClaim`, which that hook was the only reader of. Nothing in
the server now reads the email claim to decide anything.

Pending invitations are dropped rather than converted. Converting one would mean
creating a membership because an address matched, which is the property being
removed — and an invitation to an address that did have an account here had
already been claimed by the hourly sweep, so what is left is offers to people who
never arrived.

Two tests carry the property rather than the feature: the endpoint inventory
asserts the three routes are absent, and the API suite adds an address that has
no account, watches the refusal, then signs that address in and checks it joined
nothing. Without the second half, a server that merely renamed the deferred path
would pass.
This commit is contained in:
2026-08-05 08:28:57 +02:00
parent 7dc3b8950d
commit 69bc9e270b
39 changed files with 2258 additions and 2275 deletions
+25 -37
View File
@@ -268,7 +268,7 @@ bastion — the last needs jump hosts the connection layer has not got. All thre
### Sharing a vault
**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds and invites people to one,
**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds 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.
@@ -298,40 +298,28 @@ 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**
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.
**Only somebody who already has an account here can be added, and the refusal says what to do about it.**
There is one button — **ADD** — and it takes an email address. If the directory knows it, that account is
added and its public key comes back with it. If the directory does not, the address is still tried against
the server, because the directory lists only accounts that have *published a key* — everybody between
their first sign-in and setting a machine up is missing from it, and reading that silence as "no account
here" would refuse somebody standing right beside you.
An invitation is a standing instruction rather than a message: the next account that signs in with that
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
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.
What is refused is an address with no account at all. The status line names the address and says the
remedy: **ask them to sign in to this server once — that is what creates the account — and then add
them.** Nothing is held for them in the meantime.
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.
**That is a deliberate limit rather than a missing feature.** There used to be an invitation here: a
standing instruction that the next account signing in with that address joined the vault, taken up on the
strength of the token's `email_verified` claim. It worked, and it was the one place in this product where
access was decided by an assertion about an address rather than by somebody naming an account — so the
whole of its defence was a boolean sent by an identity provider. It is gone. Everything that grants access
now starts with a person who already exists. The reasoning is in
[ADR 0009](docs/adr/0009-team-access-model.md).
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 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.
**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 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.
Adding somebody still grants nothing readable: press **SHARE KEY** afterwards, or add them from a machine
that holds the vault key and it happens in the same step. A member with no key of their own yet — added
before they finished setting up — says so on their row, and cannot be shared with until they have one.
**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
@@ -513,9 +501,9 @@ rather than in an editor: a tag from inside a host's editor, and a credential fr
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. **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.
have anticipated: a vault arrives without being asked for — somebody wraps its key to you — so the person
it arrives for 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
@@ -754,8 +742,8 @@ keychain plus a terminal — and the spike that gates all of it.
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, deleted
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
and handed to another member; and a member row carries when that account was last here. Only an account
that already exists can be added — an address is not a way into a vault. 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).