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:
@@ -33,7 +33,7 @@ existed since the first migration — but nothing had had to name the split.
|
||||
else. Whether the caller can read what it serves is decided by whether they hold a grant, which the
|
||||
server records, cannot produce and cannot verify.
|
||||
|
||||
Four consequences, each of which is a place where a more reassuring design was rejected:
|
||||
Five consequences, each of which is a place where a more reassuring design was rejected:
|
||||
|
||||
- **A member with no grant is a normal state, not an error.** `VaultSummary.WrappedVaultKey` is null
|
||||
and the vault appears in their list saying it is waiting for a key. Hiding it until a grant existed
|
||||
@@ -51,14 +51,21 @@ Four consequences, each of which is a place where a more reassuring design was r
|
||||
- **Removal is named for what it does.** It revokes grants and flags the vault for rekey. It does not
|
||||
claim to reach anything already downloaded, and the interface says the remediation is rotating the
|
||||
credential — the same non-retroactive limit ADR 0001 records.
|
||||
- **Ownership is sole, so handing it over is one write and not a role change.** If membership
|
||||
authorises, the owner's membership is the last authority in the team, and a transfer that stopped
|
||||
halfway would leave nobody with the standing to finish it — owned twice if the promotion went first,
|
||||
owned by nobody if the demotion did, and in either case recoverable only by an operator editing the
|
||||
database. So `POST /teams/{id}/owner` promotes the recipient and demotes the outgoing owner to
|
||||
**admin** in one transaction, `ChangeRoleAsync` refuses `Owner` outright, and the recipient must
|
||||
already be an active member — handing a team to an id supplied once is the same mistake as adding
|
||||
somebody straight to the owner role. Demoting rather than removing is the deliberate half: removing
|
||||
them would revoke their vault key grants and flag every team vault for rekey, which is a far larger
|
||||
act than the one being asked for, and somebody handing over a team is usually staying in it.
|
||||
|
||||
Two things were deliberately **not** built, and both are refusals rather than omissions:
|
||||
One thing is deliberately **not** built, and it is a refusal rather than an omission:
|
||||
|
||||
- **The rekey itself.** Only a client holding the current vault key can re-wrap every item's data key
|
||||
under a new one. The server records that a rotation is owed and the interface reports it. M5.
|
||||
- **Ownership transfer.** The owner cannot be demoted or removed, with its own problem code. Allowing
|
||||
it without a transfer would leave a team nobody can administer, recoverable only by an operator
|
||||
editing the database.
|
||||
|
||||
Two smaller choices, recorded because the alternative was written down first and rejected:
|
||||
|
||||
@@ -74,11 +81,47 @@ Two smaller choices, recorded because the alternative was written down first and
|
||||
until that exists, the safe direction is the narrow one, and the cost — approving a team host's key
|
||||
once per member per machine — is stated in the README rather than hidden.
|
||||
|
||||
### An invitation is membership decided before there is an account to hold it
|
||||
|
||||
A membership names an account: `team_membership.user_id` is not nullable and carries a foreign key, so
|
||||
somebody who has never signed in here has nothing for that row to point at. `MembershipStatus.Invited`
|
||||
has existed since the first migration and is still never written — not as an oversight, but because a
|
||||
membership waiting for a person is the one shape this model cannot store. An invitation is therefore its
|
||||
own record, `team_invitation`, held against an **address**, and it becomes an ordinary active membership
|
||||
the moment an account with that address signs in.
|
||||
|
||||
That extends the model rather than bending it. An invitation grants nothing readable and cannot be a
|
||||
step towards sharing, because there is no account and so no public key to wrap a vault to. It moves the
|
||||
first half of the split earlier and leaves the second half exactly where it was.
|
||||
|
||||
Three decisions inside it belong here, because each had a more convenient alternative:
|
||||
|
||||
- **The claim requires `email_verified` on the access token, and nothing relaxes it.** This is the whole
|
||||
of the security boundary. Membership is authorisation, so an invitation that could be taken by anybody
|
||||
able to obtain a token asserting somebody else's address is a way into a team — the same attack
|
||||
`OidcOptions.AllowEmailLinking` exists to refuse, arriving by another door and deserving the same bar.
|
||||
An unverified or absent claim claims nothing and logs a warning, which is the only signal an operator
|
||||
gets that their provider is not sending it. There is deliberately no setting to trust an unverified
|
||||
address: a flag that exists is a flag somebody turns on for the afternoon their provider is
|
||||
misconfigured, and this is the one it must not be possible to turn on.
|
||||
- **Nothing is sent, and the product says so rather than implying a mail path it has not got.** There is
|
||||
no token and no link — the row is a standing instruction, and telling the invitee to go and sign in
|
||||
happens over a channel this server does not carry. A link nobody can deliver would be worse than none.
|
||||
The compensation, such as it is, is real: an invitation that is not a bearer credential is one that
|
||||
cannot be forwarded, intercepted or replayed.
|
||||
- **An address that already has an account here is accepted rather than refused.** Refusing and pointing
|
||||
at the directory would have been tidier, and would have turned the endpoint into an oracle for which
|
||||
addresses have accounts on this deployment, answerable by anybody willing to create a team first. Only
|
||||
an address already belonging to a member of *this* team is refused, and that is a fact the caller can
|
||||
already read off the members table, so naming it leaks nothing.
|
||||
|
||||
## Consequences
|
||||
|
||||
The sharing graph is visible to the operator: who is in which team, which vaults exist, and who holds
|
||||
a grant are all plaintext rows. That was already true of metadata generally (`docs/crypto.md` §10)
|
||||
and is not made worse here, but it is now a graph rather than a list.
|
||||
and is not made worse here, but it is now a graph rather than a list. Invitations widen it by one
|
||||
edge — an address that has been invited is on the graph before its owner has ever been here — which is
|
||||
the same class of fact and worth naming rather than leaving to be noticed.
|
||||
|
||||
A malicious granter can seal garbage. The recipient detects it as a tag failure and the grant's
|
||||
Ed25519 signature names who issued it — detectable and attributable, which is the most that is
|
||||
|
||||
@@ -489,7 +489,8 @@ go at 360dp:
|
||||
|
||||
The nav rail's eight destinations became four. Pins, snippets, logs, import and teams are not built here:
|
||||
import has no meaning under scoped storage, and the other four are list screens whose view models already
|
||||
exist — they are additive rather than structural.
|
||||
exist — they are additive rather than structural. *(Snippets, logs and teams have since been built, behind
|
||||
MORE. Pins and import have not, and import still cannot be.)*
|
||||
|
||||
**Superseded by v2.** A second design — *DodoSSH Android v2* — is what this head now draws, and it took
|
||||
the "additive rather than structural" claim at its word: snippets, logs, SFTP and S3 are built, over the
|
||||
@@ -519,7 +520,8 @@ What is left, in the order it matters:
|
||||
source, which is the decision recorded above and the obvious next piece of work.
|
||||
- **Editors.** There is no host editor and no keychain item editor on the phone, so both are create-on-
|
||||
desktop-and-sync. That is why the v2 design's `+` buttons on HOSTS and on the keychain are not drawn.
|
||||
- **Pins, teams and import**, which v2 does not draw either.
|
||||
- **Pins and import**, which v2 does not draw either. Teams *is* drawn, behind MORE — it was the one of
|
||||
the three whose view model needed nothing new on the phone, because none of that screen is vault content.
|
||||
- **The App Link upgrade**, unchanged from step 5.
|
||||
|
||||
---
|
||||
|
||||
@@ -21,6 +21,13 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> over a view model that already existed, plus preferences. `ShellScreen` gained `More` and `Buckets`;
|
||||
> SFTP and S3 are one screen over one `TransfersViewModel`, differing only in which picker they offer.
|
||||
>
|
||||
> **A sixth is behind MORE that v2 never drew: TEAMS.** It is the reverse case — a shipped screen the
|
||||
> design had no slot for — and it is on the phone for a reason the design could not have anticipated,
|
||||
> because invitations did not exist when it was drawn. An invitation is claimed by *signing in*, and the
|
||||
> person being invited is at least as likely to be holding a phone as sitting at a desktop; a team the
|
||||
> server has just put somebody in, visible only on a head they may not have installed, is a membership
|
||||
> they cannot see. It runs over the same view model the desktop screen drives, like the other four.
|
||||
>
|
||||
> | v2 element | What ships instead |
|
||||
> | --- | --- |
|
||||
> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Nothing, said out loud.** `ISshConnection` offers `OpenShellAsync` and nothing else, so there is no tunnel for a rule to run through; `SyncEntityType.PortForward = 9` is still reserved and still unused. The MORE screen carries a paragraph naming the absence, for the reason the desktop keeps TEAMS in its rail. |
|
||||
@@ -100,6 +107,13 @@ unused tables bought. See [Teams](#teams). What has *not* changed is the split u
|
||||
decides what it will serve, and only a client can decide who can read it — so "shared with" is two facts on
|
||||
this screen, not one.
|
||||
|
||||
One table did have to be added, and what it shows is the limit of that reservation. An invitation names an
|
||||
**address**, and `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 — which is why `MembershipStatus.Invited` has been
|
||||
reserved since the first migration and is still never written. `team_invitation` is its own table for that
|
||||
one reason. The schema was right about the shape of a team and had said nothing about the shape of joining
|
||||
one.
|
||||
|
||||
**The client has no preferences store.** It writes exactly two files — `cache.db` and `device.key` — and the
|
||||
cache has six tables, none of them settings. Nothing on the design's TERMINAL preferences panel can be
|
||||
saved, and there is no frame on the terminal data plane that would carry a change to the renderer anyway.
|
||||
@@ -289,9 +303,11 @@ and both editors. What follows is what the design drew around them.
|
||||
|
||||
## Teams
|
||||
|
||||
**Built in M3.** The screen ships: a team list, a members table, the team's vaults, and the two buttons the
|
||||
whole design was really about — add a member, and share a vault key. What follows is what it still does not
|
||||
do, and one thing this document got wrong before it was built.
|
||||
**Built in M3.** The screen ships: a team list, a members table with a real last-active column, the
|
||||
invitations standing against addresses that have no account here yet, the team's vaults, and the two buttons
|
||||
the whole design was really about — add a member, and share a vault key. A team can also be renamed, handed
|
||||
to another member, and archived, the last only while it owns no vaults. What follows is what it still does
|
||||
not do, and one thing this document got wrong before it was built.
|
||||
|
||||
**The correction.** The rows below used to describe a screen with nothing behind it, on the grounds that
|
||||
`VaultAccessService.ResolveAsync` denied every vault that was not the caller's own. That is now the one
|
||||
@@ -300,6 +316,16 @@ place that changed, exactly as its remark predicted, and no migration was needed
|
||||
What the row did not anticipate is that the interesting half is not the endpoints at all. It is that
|
||||
**membership and readability are different things**, and the screen is arranged around saying so.
|
||||
|
||||
**There are two ways into a team and they are not interchangeable.** Adding a member takes a *user id* the
|
||||
caller has already got from the directory, so that account must have signed in here at least once — and the
|
||||
ordering is deliberate rather than incidental, because whoever adds a member is usually about to wrap a
|
||||
vault key to the public key that lookup returned. Inviting takes an *address*, grants nothing readable, and
|
||||
cannot be a step towards sharing: there is no account, so there is no key to wrap to. Inviting an address
|
||||
that already belongs to a member of this team is refused and says so — that is a fact about a team the
|
||||
caller can already see. Inviting one that merely has an account somewhere on this deployment is **not**
|
||||
refused, because answering that would turn the endpoint into an oracle for which addresses have accounts,
|
||||
answerable by anybody willing to create a team first. It simply gets claimed sooner.
|
||||
|
||||
| Design element | Layer | What ships |
|
||||
| --- | --- | --- |
|
||||
| The team itself | server | `POST/GET /api/v1/teams`, plus members, roles and team vaults. Ids are client-chosen, so a create whose response was lost is safe to repeat. |
|
||||
@@ -307,17 +333,20 @@ What the row did not anticipate is that the interesting half is not the endpoint
|
||||
| Roles | contracts + server | `TeamMemberRole` on the wire, numerically pinned to `DodoSSH.Domain.TeamRole` by a test. Viewer reads, Member writes, Admin and Owner also share and administer. |
|
||||
| Members table | server | `TeamMemberSummary`, and a directory that resolves an exact email to a public key. |
|
||||
| Sharing an item | client | `VaultSession.ShareVaultAsync`: verify the recipient's key against the key log, wrap, sign, record. The server stores the wrap and the signature and can check neither. |
|
||||
| Pending invites, and withdrawing one | server | A `team_invitation` row per (team, address), listed beside the members it is about and withdrawable until it is taken up. It becomes a membership when an account with that address signs in — **and only if the access token asserts `email_verified`**, because membership is authorisation and an invitation anybody could take by naming somebody else's address is a way in. Fourteen days, because an address that is reassigned would otherwise carry a standing offer to whoever holds the job next. |
|
||||
| Ownership transfer | server | `POST /api/v1/teams/{id}/owner`, owner only. One transaction: the named member becomes owner and the outgoing owner becomes an admin. Not two role changes — ownership is sole, so promoting first leaves the team owned twice and demoting first leaves it owned by nobody. The outgoing owner is demoted rather than removed, because removing them would revoke their vault key grants and flag every team vault for rekey, which is a far larger act than the one being asked for. |
|
||||
| `LAST ACTIVE` | server | Real, and coarse on purpose. `UserAccount.LastSeenAtUtc` is now refreshed on ordinary authenticated requests, at most once per account per hour: writing it per request would put an UPDATE on the hot path of every authenticated call and start losing races on `user_account`'s own concurrency token. So the column answers "this week or not", which is the granularity the question is actually asked at, and is shown coarsely rather than to the minute. |
|
||||
| Renaming and archiving a team | server | `PUT` and `DELETE /api/v1/teams/{id}`. The slug is deliberately not renameable: it is unique only among *live* teams, so a rename could take a slug an archived team still holds and strand it. Archiving soft-deletes the team, every membership and every pending invitation in one transaction — and is refused outright while the team owns any vault. |
|
||||
|
||||
| Design element | Layer | What it would take | What ships instead |
|
||||
| --- | --- | --- | --- |
|
||||
| `CONNECT-ONLY` role | — | Nothing that would be true. Connect is a user-interface hint, not a boundary: SSH terminates on the client, so a session needs the credential's plaintext on that machine. See ADR 0001. | Four roles, all of which are enforceable. `Connect` rides along with `Read` and is documented as a hint. |
|
||||
| `2FA ENFORCED` and the per-member 2FA column | server | No two-factor concept exists anywhere — the only hit in the whole worktree is an aside in `docs/crypto.md`. | Omitted. The member column carries what *is* known and matters: whether they have published a key a vault can be wrapped to. |
|
||||
| `LAST ACTIVE` | server | `UserAccount.LastSeenAtUtc` is written at just-in-time provisioning and at enrollment and never on an ordinary authenticated request, so the column cannot answer "last active". | Omitted. |
|
||||
| `2FA ENFORCED` and the per-member 2FA column | server | No two-factor concept exists anywhere — the only hit in the whole worktree is an aside in `docs/crypto.md`. | Omitted. The member columns carry what *is* known and matters: whether they have published a key a vault can be wrapped to, and when they were last here. |
|
||||
| Avatars | server | No picture is stored anywhere. | Omitted; the row shows a name and an address. |
|
||||
| Pending invites, resend, revoke | server | An invitation entity, a token with a lifetime, and an outbound mail path. `MembershipStatus.Invited` remains unwritten. | Adding a member resolves an address the caller types against the directory, so the account has to have signed in here once. The screen says that when the lookup finds nothing. |
|
||||
| The invitation mail, and **resend** | server | An outbound mail path: an SMTP configuration, a template, a bounce story and a deliverability problem, none of which this server has. | **Nothing is sent, and the interface says so.** An invitation is a standing instruction rather than a message — the next account to sign in with that address joins the team — so there is no token, no link, and nothing to resend. Telling somebody to sign in is done over a channel this server does not carry. A link nobody can deliver would be worse than no link. |
|
||||
| Archiving a team that owns vaults | — | Nothing that would be safe. A team vault resolves through membership, so archiving would take those vaults away from everybody holding a key, silently, including the caller — and nothing in this product deletes a vault, so there is no sequence of calls that turns the refusal into a success. | Refused, with `team-not-empty` and a count of the vaults in the way. A stated limit rather than a coming feature, for the reason the SFTP layer refuses a recursive delete: a refusal is visible and a quiet removal is not. |
|
||||
| `SSO · OIDC · okta.dodotech.dev` | server | Per-team SSO. Authentication is one global JWT scheme bound to one authority. | Omitted. |
|
||||
| A rekey after a membership change | client | Re-wrapping every item's data key under a fresh vault key, which only a client holding the current one can do. M5. | The vault is flagged `RekeyRequired` and the row says a rotation is owed. |
|
||||
| Ownership transfer | server | A confirmation flow and a rule for what happens to the outgoing owner. | The owner cannot be removed or demoted, with its own problem code rather than a bare 400. |
|
||||
|
||||
> **The trap this document warned about is still a trap.** `GET /api/v1/meta` advertises
|
||||
> `features: ["teams"]` *unconditionally* (`MetaEndpoints.cs`). It was meaningless when nothing implemented
|
||||
@@ -346,7 +375,7 @@ lists the rest as absent rather than omitting it silently.
|
||||
| `GENERAL` section | ui | There is no general setting to put in it. The theme is fixed by decision, and window size is not persisted. |
|
||||
| `KEYS & AGENT` section | client-ssh | **There is no agent, at all** — no own agent, no forwarding, no Pageant or OpenSSH-agent interop. |
|
||||
| `SYNC & VAULT` section | client-domain | Nothing here is adjustable. The auto-sync interval is a `private static readonly` with a remark arguing for its value. |
|
||||
| `SECURITY & SSO` section | server | The SSO half needs team endpoints; there is no policy for the screen to show. |
|
||||
| `SECURITY & SSO` section | server | Per-team SSO, which is a refusal rather than a pending endpoint: authentication is one global JWT scheme bound to one authority, so there is no per-team policy for this screen to show. |
|
||||
| `SHORTCUTS` section | ui | There is no keybinding infrastructure and no rebinding surface. |
|
||||
| Auto-lock after idle | client-session | An activity source, a decision about what counts as idle, and — the hard part — a policy for a shell mid-job. `LockAsync`'s own remark already argues that an unattended timeout which killed a running job would be worse than the exposure it removed. |
|
||||
| Require biometric to sign | crypto | Three separate falsehoods in one row. There is no signing service — the private key is decrypted and handed to SSH.NET whole, so there is no per-signature moment to interrupt; there are no connect-only keys; and per-use consent would need the key to live in the TPM, which is a different key hierarchy from the one in `docs/crypto.md`. |
|
||||
|
||||
@@ -834,3 +834,144 @@ nothing else about them changes. Neither host is queued for push.
|
||||
On the phone's host editor with several tags: the chips are at least 36 tall, spaced enough that a miss
|
||||
lands between them rather than on the wrong tag, and the new-tag box and ADD sit on one row without either
|
||||
being squeezed to nothing.
|
||||
|
||||
## Phase 10 — Teams: 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.
|
||||
|
||||
**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.
|
||||
|
||||
### 10.1 An invitation becomes a membership at the invitee's first sign-in · **the one worth the most care**
|
||||
|
||||
1. Sign in as `alice`, make a team, and open its invitations.
|
||||
2. Invite `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.
|
||||
4. Sign in as `bob` on the second profile and enroll.
|
||||
5. **Pass:** the team 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 team's 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.
|
||||
|
||||
**Failure means:** step 5 failing with everything else passing is almost always the `email_verified` claim
|
||||
— go to 10.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.
|
||||
|
||||
### 10.2 An unverified address claims nothing, and the log is the only place that says so
|
||||
|
||||
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.
|
||||
|
||||
**Pass:** they get an account and a personal vault and no team at all. 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.
|
||||
|
||||
**Failure means:** if the team 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 team. 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.
|
||||
|
||||
### 10.3 An invitation can be withdrawn until it is taken up
|
||||
|
||||
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 team. 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 every team vault for rekey, which is not what "revoke invitation"
|
||||
should quietly do.
|
||||
|
||||
### 10.4 An address already in the team is refused; an address that merely has an account is not
|
||||
|
||||
With Bob in the team, invite `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** team and invite the same address there.
|
||||
|
||||
**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 team first. See ADR 0009.
|
||||
|
||||
### 10.5 LAST ACTIVE is a real time, and a coarse one · **needs a couple of hours**
|
||||
|
||||
Use one account and leave the other idle for two or three hours, then read the members table.
|
||||
|
||||
**Pass:** the account being used carries a recent time, the idle one does not move, and neither moves more
|
||||
than once an hour however much is done in it. It is shown as roughly-when, never to the minute.
|
||||
|
||||
**Failure means:** a value that tracks every click means the hourly gate is gone and every authenticated
|
||||
request is writing to `user_account` — which carries the xmin concurrency token, so the next symptom is a
|
||||
user's own overlapping requests failing on a version that moved under them. A value frozen at enrollment
|
||||
means the refresh is not running on ordinary requests at all, which is the state that made this column
|
||||
impossible to offer honestly before.
|
||||
|
||||
### 10.6 Ownership changes hands in one act
|
||||
|
||||
As the owner, transfer ownership to another active member, then read both rows.
|
||||
|
||||
**Pass:** they are Owner and you are **Admin** — not removed, not Member. Your vault key grants are intact
|
||||
and the team's vaults have not come back flagged for rekey. Then try to transfer to somebody who is not a
|
||||
member, and to yourself.
|
||||
|
||||
**Pass:** both refused, and the message says which.
|
||||
|
||||
**Failure means:** two owners, or none, is the state this being a single transaction exists to prevent, and
|
||||
either one leaves a team that no client can administer back into shape. If your grants were revoked or the
|
||||
vaults are now flagged for rekey, the transfer is removing the outgoing owner rather than demoting them.
|
||||
|
||||
### 10.7 Archiving is refused while the team owns a vault
|
||||
|
||||
With a team that owns at least one vault, try to archive it.
|
||||
|
||||
**Pass:** refused, and the message counts the vaults in the way and says there is no way to delete a vault
|
||||
in this product. The team is still in everybody's list afterwards and its vaults still open.
|
||||
|
||||
**Failure means:** an archive that succeeded here would have taken those vaults out of the list of
|
||||
everybody holding a key — including the person who pressed it, quietly, and with nothing in the product
|
||||
able to put them back.
|
||||
|
||||
### 10.8 Archiving an empty team takes its memberships and its invitations with it · **needs two accounts**
|
||||
|
||||
Make a team that owns no vaults, add the second account to it, invite a third address, and archive it.
|
||||
|
||||
**Pass:** the team is gone from both accounts' lists. Sign in with the invited address afterwards and it
|
||||
joins nothing. A new team can be created under the archived one's slug.
|
||||
|
||||
**Failure means:** the invited address turning up in a team nobody can see is exactly what revoking pending
|
||||
invitations inside the same transaction exists to prevent, and it would happen weeks later on a sign-in
|
||||
nobody is watching. Note that taking the freed slug is correct rather than a defect, and is also the reason
|
||||
an archived team is only restorable by an operator who checks that first.
|
||||
|
||||
### 10.9 Renaming a team, and the slug that does not move
|
||||
|
||||
Rename a team and change its description.
|
||||
|
||||
**Pass:** the new name is on every screen that names the team, on both accounts after a refresh. The slug is
|
||||
unchanged and there is nowhere to change it. Nothing claims to know *when* it was renamed.
|
||||
|
||||
**Failure means:** a rename that moved the slug could take one an archived team is still holding, and that
|
||||
archived team could then never be brought back. An "edited" timestamp anywhere on the screen is invented
|
||||
data — `team` has no updated-at column, so there is nothing behind it.
|
||||
|
||||
Reference in New Issue
Block a user