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
+37 -63
View File
@@ -1239,86 +1239,60 @@ case, and those two have to move together — the switch mirrors that property b
## Phase 12 — Shared vaults: the operations that span two accounts
The server's own rules are covered by the endpoint suite: teams are renamed, an archive is refused while a
vault is in the way, ownership changes hands, and every branch of the invitation claim is driven with
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.
vault is in the way, ownership changes hands, and an address with no account is refused with its own code.
What is left needs two real accounts, a real sign-in, and in one case a clock — because the half of sharing
this product is built around happens on a machine rather than on the server, and no server test can reach
it.
**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.
**Two accounts, and two profiles.** The dev realm ships `alice` and `bob`; a second DodoSSH profile means a
second machine, a second OS user, or the same machine after signing out. Whichever account plays the
newcomer **must not have signed in to this deployment before**12.1 is about exactly that boundary.
### 12.1 An invitation becomes a membership at the invitee's first sign-in · **the one worth the most care**
### 12.1 An address with no account is refused, and joins nothing when it later signs in · **the one worth the most care**
1. Sign in as `alice`, make a vault on the VAULTS screen, and select it.
2. Add `bob@example.com` as a Member. **Nothing is sent, and nothing should look as though it was**
no "invitation emailed", no link to copy, no token anywhere on the screen.
3. **Pass:** the row appears as *pending*, carrying the address, the role and an expiry fourteen days out.
Bob is **not** in the members table, because he has no account here for a membership row to point at.
2. Add `bob@example.com` as a Member, with Bob having never signed in here.
3. **Pass:** it is refused. The status line names the address and says to ask them to sign in to this
server once and then add them. **Nothing on the screen should suggest anything is pending** — no
invited row, no "we will add them when they arrive", and the address stays in the box so it can be
used again in a moment.
4. Sign in as `bob` on the second profile and enroll.
5. **Pass:** the vault is in Bob's list the first time he looks, at Member, with nothing further pressed
on either side. Back on Alice's machine, refresh: the invitation reads *accepted* rather than vanishing,
and Bob is now in the members table.
6. **Pass, and this is the half that is easiest to lose:** the 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. This is where an invitation differs from an add and the difference is not a defect: adding an
account the directory already knows wraps the key on the spot, because the adding machine has it and
the recipient has a published key to wrap to. An invitation has neither at the moment it is issued —
there is no account yet — and the claim happens on Bob's machine, which holds nothing. So the key is
still owed, and somebody has to hand it over.
5. **Pass, and this is the half that is easiest to lose:** Bob's vault list holds only his personal vault.
Alice's members table is unchanged. Signing in with an address somebody typed earlier must join nothing
at all.
6. Back on Alice's machine, add `bob@example.com` again.
7. **Pass:** he is added, and — because Alice's machine holds the vault key and Bob has now published one
— the key is wrapped in the same step. Have Bob sync; the vault 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.
**Failure means:** step 5 is the one to stop on. A vault appearing in Bob's list because he signed in with
an address is the deferred-membership path coming back, and it is the property this phase exists to check:
access is granted to an account somebody named, never to an address. See ADR 0009.
### 12.2 An unverified address claims nothing, and the log is the only place that says so
Step 7 opening the vault *without* Alice's machine having wrapped a key would be the 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.
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.
### 12.2 Somebody who has signed in but not enrolled can still be added
**Pass:** they get an account and a personal vault and no shared one. The invitation stays *pending* on
the inviter's screen rather than turning into anything, and the API log carries a warning naming how many
invitations it declined to claim. Now set **Email verified** back on. The claim happens on the next request
that crosses the hourly last-seen window, so it is **not** immediate and restarting the client will not
hurry it along — the account already exists, so there is no second first-sign-in to trigger it.
Have a third account sign in and stop — no passphrase, no enrollment. Then add its address to a vault.
**Failure means:** if the vault appears while the address is unverified, the one security boundary
invitations have is not being enforced, and anybody able to obtain a token asserting a colleague's address
can walk into their vault. Stop there. If it stays pending after verifying, the claim is not reaching the
**access** token — check the provider's mappers, and set `Oidc:EmailVerifiedClaim` if it sends the claim
under some other name.
**Pass:** accepted. Their row appears saying they hold no key, and SHARE KEY does not offer to wrap one to
them. The directory returns nothing for that address, which is correct: it lists accounts that have
published a key, and this one has not.
### 12.3 An invitation can be withdrawn until it is taken up
**Failure means:** a refusal here is the old bug, and it is the reason ADD does not take the directory's
silence as an answer. Everybody passes through the window between a first sign-in and enrollment, and
being told they have no account while they are standing beside you is the worst moment to say it.
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 shared vault. Revoking one that has *already* been accepted answers that there
was nothing to withdraw.
**Failure means:** a revoked invitation that still lets somebody in is a removal that did not remove. An
accepted one that could be unpicked here would be worse: it is a membership now, and removing a member
revokes their vault key grants and flags the vault for rekey, which is not what "revoke invitation"
should quietly do.
### 12.4 An address already in the team is refused; an address that merely has an account is not
### 12.3 An address already in the team is refused, and says which
With Bob in the vault, add `bob@example.com` to it again.
**Pass:** refused, with a sentence saying the address already belongs to a member and to change their role
instead. Now make a **second** vault and invite the same address there.
instead — distinct from the "no account here" refusal in 12.1, because the two lead somewhere different.
**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 vault first. See ADR 0009.
**Failure means:** the two refusals reading alike leaves somebody checking what they typed when the answer
is that the person is already in.
### 12.5 LAST ACTIVE is a real time, and a coarse one · **needs a couple of hours**