Let a team change hands, and be joined by somebody with no account yet

M3 built teams and stopped short of the two operations that decide who
controls one. Both were written down as refusals rather than omissions:
ADR 0009 listed ownership transfer under "deliberately not built", and
design-import-gaps said an invitation needed "a token with a lifetime and an
outbound mail path". One of those reasons had expired and the other never
applied — an invitation does not need a token if it is not a thing anybody
presents.

Handing a team over is one write. The member you name becomes owner and you
become an admin, in a single transaction, because ownership is sole: promoting
first leaves the team owned twice, demoting first leaves it owned by nobody,
and there is nobody left with the authority to finish a transfer that stopped
in the middle. That is also why it is not two calls to the role endpoint, which
refuses Owner outright. The outgoing owner is demoted rather than removed —
removing them would revoke their vault key grants and flag every team vault for
rekey, which is a far larger act than the one asked for, and somebody handing
over a team is usually staying in it. It unblocks the thing that was impossible
before: an owner can now leave, by handing the team on first.

An invitation is a standing instruction rather than a message. 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 signing in with that address joins
this team at this role, and telling them to sign in is the caller's job over a
channel this server does not carry. A link nobody can deliver would be worse
than none. It lives in its own table rather than becoming a membership with
MembershipStatus.Invited, and that member stays unwritten for the reason it
always was: 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.
Widening it would make the unique index on (team, user) meaningless, because
PostgreSQL counts every NULL as distinct.

Verification is the security boundary, and nothing in this server read it
before. A claim requires the access token to assert email_verified. An
invitation decides what the server will serve, so one claimable by anybody able
to obtain a token carrying somebody else's address is a way into a team — which
is precisely the attack OidcOptions.AllowEmailLinking exists to refuse, and it
would have been reintroduced by the back door. There is deliberately no setting
that relaxes it: a flag that exists is one somebody turns on for the afternoon
their provider is misconfigured. Absence is refused rather than trusted, and
logged, because a provider that never sends the claim otherwise leaves every
invitation pending with nothing anywhere saying why.

Claiming happens at just-in-time provisioning and again on an hourly sweep. The
sweep is what makes it recoverable rather than one-shot — an invitation issued
between an account being created and that person next signing in would
otherwise be stranded for ever — and it shares its rate with the last-seen
write because both are housekeeping nobody is waiting on.

Archiving is refused while a team owns a vault, and that refusal is the end of
the road rather than a step on it. A team vault is readable because of
membership, so archiving one that still owned vaults would take them away from
everybody holding a key, including the caller, quietly and all at once. Nothing
in this product deletes a vault, so no order of operations gets past it today —
which is stated with a count of what is in the way, for the reason the SFTP
layer refuses a recursive delete: a refusal is visible and a quiet removal is
not. It is owner-only, as handing over is; renaming is not, because a rename is
visible to everybody and reversible by anybody who can do it. The slug is not
renameable at all: it is unique only among live teams, so a rename could take
one an archived team is still holding, and that team could then never be
restored.

LAST ACTIVE is real and coarse on purpose. UserAccount.LastSeenAtUtc is
refreshed on ordinary authenticated requests, at most once per account per
hour, through ExecuteUpdateAsync — user_account carries the xmin concurrency
token, so a read-then-write on the hot path would start losing races between
one user's own overlapping requests. An hour is the granularity the question is
actually asked at, and the interface draws it to the day rather than the minute
so it does not read as a precision that is not there. The remarks in Contracts
and in the view model that argued at length for the column's absence are
rewritten rather than extended; both had become false.

Two endpoints already existed and nothing called them. ChangeTeamMemberRole and
ListVaultGrants have been reachable since M3. The role picker refuses Owner
itself rather than letting the server do it, since the interface already knew
the rule; the key-holder list sits under the vault rather than beside the
member, because a grant is per vault and a count on a member row would imply
per-item sharing, which is M5. It lists withdrawn and stale grants and says
which they are — a list that dropped them would show a departed colleague as
merely absent rather than as somebody whose key was taken away — and staleness
is decided by comparing generations, since a grant can be Active and still open
nothing.

ADD MEMBER stopped being a dead end. An address the directory did not know used
to end at a sentence telling the user their colleague had to sign in first. It
invites them instead, from the same button, because which of the two applies is
a fact about the server's account table rather than about what the user is
doing; which one happened is reported afterwards, because that decides what
they do next. An address that merely has an account is invited rather than
refused: refusing would have made the endpoint an oracle for which addresses
have accounts here, answerable by anybody willing to create a team first.

The phone has a TEAMS screen, behind MORE, and it is the reverse of every other
row in design-import-gaps: a shipped screen the design had no slot for. It is
there because an invitation is claimed by signing in, so somebody told they are
now in a team is at least as likely to be holding a phone — and a membership
visible only on a head they never installed is one they cannot see. It draws
SHARE KEY and nothing that takes something away: wrapping a key is the one act
on that screen a server cannot perform at all, and the desktop guards its
revocations with a tooltip, which is a control a touch screen cannot show.

Two defects were found by an adversarial pass and both were green against the
whole suite at the time. The owner-only check on archiving and handing over had
been weakened to the admin check while their messages and comments still said
owner — and since nothing behind the archive endpoint re-checks it, an admin
the owner had promoted could have archived the team out from under them. And
the rename endpoint built its response with a hardcoded Owner role, so an admin
who renamed a team was handed a summary claiming they owned it, and a client
trusting that instead of re-listing would have offered them the two owner-only
buttons the server then refuses.

The new table gets its constraints tested rather than merely migrated: live
uniqueness per (team, address), the citext proof that an address typed by a
person matches one cased by a provider, and reissue after both revocation and
acceptance. The teams screen gets its first entries in the layout suite, at the
minimum window with every list populated and with each of the two states that
cover half of it — it had none, and it just grew four sections and a second
line in the member row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 14:31:43 +02:00
co-authored by Claude Opus 5
parent 16e0051e89
commit a43286ece8
43 changed files with 6974 additions and 147 deletions
+79 -9
View File
@@ -226,8 +226,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 +244,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 +306,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
@@ -280,9 +337,13 @@ screen exactly the width it was designed against.
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 can be created here — the `+` on HOSTS
raises a sheet offering both — but a keychain item editor is still absent, so keys and credentials are
created on the desktop and sync down; pins and import have no phone screen either. 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
@@ -484,7 +545,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).
@@ -493,8 +557,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.