Public Access
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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user