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 ### 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 shares its key, renames it and hands it over. One distinction runs through the whole screen and is worth
having before you use it. 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 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. 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** **Only somebody who already has an account here can be added, and the refusal says what to do about it.**
and it does whichever of the two applies, because which one applies is a fact about the server's account There is one button — **ADD** — and it takes an email address. If the directory knows it, that account is
table rather than about what you are trying to do. If the directory knows the address, that account is added added and its public key comes back with it. If the directory does not, the address is still tried against
straight away. If it does not, the address is invited instead, and the status line says which happened, the server, because the directory lists only accounts that have *published a key* — everybody between
because the difference decides what you do next. 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 What is refused is an address with no account at all. The status line names the address and says the
address joins this vault, at the role you chose. There is no link and no token, because this server has no remedy: **ask them to sign in to this server once — that is what creates the account — and then add
outbound mail path and does not pretend otherwise — telling them to go and sign in is your job, over a them.** Nothing is held for them in the meantime.
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 **That is a deliberate limit rather than a missing feature.** There used to be an invitation here: a
you the public key you are about to verify and wrap a vault to, and an invitation cannot do that because standing instruction that the next account signing in with that address joined the vault, taken up on the
there may be no key yet. So when you are adding somebody *in order to* share a vault with them, the useful strength of the token's `email_verified` claim. It worked, and it was the one place in this product where
sequence is still the same one: add them, see them appear in the members list, then share. 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 Adding somebody still grants nothing readable: press **SHARE KEY** afterwards, or add them from a machine
merely *has* an account here is not — that would make this a way of asking the server which addresses have that holds the vault key and it happens in the same step. A member with no key of their own yet — added
accounts, which is not a question anybody willing to create a vault first should be able to put to it. Such before they finished setting up — says so on their row, and cannot be shared with until they have one.
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.
**Ownership is sole, and handing a vault over is one act.** Transferring names an existing active member: **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 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 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 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 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 have anticipated: a vault arrives without being asked for — somebody wraps its key to you — so the person
vault is at least as likely to be holding a phone as sitting at a desktop, and a membership visible only on it arrives for is at least as likely to be holding a phone as sitting at a desktop, and a membership
a head they have not installed is a membership they cannot see. 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 **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 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 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 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 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 and handed to another member; and a member row carries when that account was last here. Only an account
account on this deployment can be invited, joining the moment somebody signs in with it. See 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 [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 knowing before you rely on it; the reasoning is in
[ADR 0009](docs/adr/0009-team-access-model.md). [ADR 0009](docs/adr/0009-team-access-model.md).
+44 -31
View File
@@ -87,39 +87,52 @@ 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 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. 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 ### Addendum: invitations are gone, and an address is not a way into a team
There was an invitation here, and it is worth recording what it was before recording why it is not.
A membership names an account: `team_membership.user_id` is not nullable and carries a foreign key, so 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` somebody who has never signed in has nothing for that row to point at. `MembershipStatus.Invited` has
has existed since the first migration and is still never written — not as an oversight, but because a existed since the first migration and has never been written, because a membership waiting for a person
membership waiting for a person is the one shape this model cannot store. An invitation is therefore its is the one shape this model cannot store. So an invitation was its own record, `team_invitation`, held
own record, `team_invitation`, held against an **address**, and it becomes an ordinary active membership against an **address**, and it became an ordinary active membership the moment an account with that
the moment an account with that address signs in. address signed in — provided the access token asserted `email_verified` over it.
That extends the model rather than bending it. An invitation grants nothing readable and cannot be a **That last clause was the whole feature, and it is why the feature is gone.** Everything else in this
step towards sharing, because there is no account and so no public key to wrap a vault to. It moves the model decides access from something a person did: an admin naming an account, a key holder wrapping a
first half of the split earlier and leaves the second half exactly where it was. vault key to a public key they verified. The invitation decided access from an *assertion about an
address*, made by an identity provider, on behalf of somebody the deployment had never seen. It was
guarded as tightly as that can be guarded — the claim was refused outright on an unverified or absent
claim, with no setting to relax it, for the same reason `OidcOptions.AllowEmailLinking` is off and stays
off. But the guard and the risk were the same shape: the server was trusting a token's email claim to
decide who is in a team, and the whole of the defence was one boolean sent by a system this deployment
does not control.
Three decisions inside it belong here, because each had a more convenient alternative: So the decision now reads: **membership is granted only to an account that already exists, named by
somebody who can already see it.** `POST /api/v1/teams/{id}/members` takes a user id from the directory,
or an address it resolves to an existing account, and an address with no account is refused with
`ProblemCodes.NoSuchAccount`. That refusal is the end of the road rather than a step on it — there is
nothing to fall through to.
- **The claim requires `email_verified` on the access token, and nothing relaxes it.** This is the whole Three consequences, all of them stated rather than mitigated:
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 - **Somebody who has never signed in here cannot be added yet.** The remedy is theirs: they sign in
`OidcOptions.AllowEmailLinking` exists to refuse, arriving by another door and deserving the same bar. once, which is what creates the account, and then they can be added. Both clients say exactly that
An unverified or absent claim claims nothing and logs a warning, which is the only signal an operator when the refusal comes back. This is a real loss of convenience, and it is the price of not having a
gets that their provider is not sending it. There is deliberately no setting to trust an unverified path where an address is a credential.
address: a flag that exists is a flag somebody turns on for the afternoon their provider is - **Nothing reads the email claim for authorisation any more**, anywhere in the server.
misconfigured, and this is the one it must not be possible to turn on. `OidcOptions.EmailVerifiedClaim` was read by the claim path and by nothing else, so it went with it —
- **Nothing is sent, and the product says so rather than implying a mail path it has not got.** There is a setting that changes no outcome is worse than no setting. The address is still recorded and still
no token and no link — the row is a standing instruction, and telling the invitee to go and sign in resolved by the directory, both for display and for naming an account that exists.
happens over a channel this server does not carry. A link nobody can deliver would be worse than none. - **Pending invitations were dropped rather than converted.** Converting one would have meant creating a
The compensation, such as it is, is real: an invitation that is not a bearer credential is one that membership because an address matched, which is the property being removed; and an invitation to an
cannot be forwarded, intercepted or replayed. address that *did* have an account here had already been claimed by the hourly sweep, so what was left
- **An address that already has an account here is accepted rather than refused.** Refusing and pointing was offers to people who never arrived. See the `DropTeamInvitation` migration.
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 The oracle question the old design worried about — whether this endpoint tells a caller which addresses
an address already belonging to a member of *this* team is refused, and that is a fact the caller can have accounts here — is answered rather than avoided. `NoSuchAccount` does say so, to an admin or owner
already read off the members table, so naming it leaks nothing. of the team the add names, which is the same fact the member list shows them a moment later. That was
already true of adding by address before invitations existed.
### Addendum: the vault is what the product shows, and the team is behind it ### Addendum: the vault is what the product shows, and the team is behind it
@@ -165,9 +178,9 @@ Three consequences of the change belong here:
The sharing graph is visible to the operator: who is in which team, which vaults exist, and who holds 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) 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. Invitations widen it by one and is not made worse here, but it is now a graph rather than a list. Every node on it is an account
edge — an address that has been invited is on the graph before its owner has ever been here which is that exists: there is no longer an edge to an address whose owner has never been here, which is what
the same class of fact and worth naming rather than leaving to be noticed. invitations added and what removing them took back.
A malicious granter can seal garbage. The recipient detects it as a tag failure and the grant's 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 Ed25519 signature names who issued it — detectable and attributable, which is the most that is
-1
View File
@@ -48,7 +48,6 @@ this specification is not implementable.
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| Passphrase → master key | 256 MiB | 4 | 1 | 32 B | | Passphrase → master key | 256 MiB | 4 | 1 | 32 B |
| Recovery code → KEK | 64 MiB | 3 | 1 | 32 B | | Recovery code → KEK | 64 MiB | 3 | 1 | 32 B |
| Invite secret → KEK | 64 MiB | 3 | 1 | 32 B |
Salt is 16 bytes from a CSPRNG, fresh on every passphrase change. Salt is 16 bytes from a CSPRNG, fresh on every passphrase change.
+27 -24
View File
@@ -30,11 +30,11 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> SFTP and S3 are one screen over one `TransfersViewModel`, differing only in which picker they offer. > 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: VAULTS.** It is the reverse case — a shipped screen the > **A sixth is behind MORE that v2 never drew: VAULTS.** 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, > design had no slot for — and it is on the phone because a vault arrives without being asked for.
> because invitations did not exist when it was drawn. An invitation is claimed by *signing in*, and the > Somebody wraps its key to you from their machine, and the person it arrives for is at least as likely to
> person being invited is at least as likely to be holding a phone as sitting at a desktop; a vault the > be holding a phone as sitting at a desktop; a vault the server has just put somebody in, visible only on
> server has just put somebody in, visible only on a head they may not have installed, is a membership > a head they may not have installed, is a membership they cannot see. It runs over the same view model
> they cannot see. It runs over the same view model the desktop screen drives, like the other four. > the desktop screen drives, like the other four.
> >
> | v2 element | What ships instead | > | v2 element | What ships instead |
> | --- | --- | > | --- | --- |
@@ -195,12 +195,13 @@ unused tables bought. See [Vaults](#vaults). What has *not* changed is the split
decides what it will serve, and only a client can decide who can read it — so "shared with" is two facts on 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. 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 One table was added and then dropped again, and the round trip is worth a line. An invitation named an
**address**, and `team_membership.user_id` is not nullable and carries a foreign key, so somebody who has **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 never signed in had 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 reserved since the first migration and has never once been written. `team_invitation` existed for that one
one reason. The schema was right about the shape of a team and had said nothing about the shape of joining reason and is gone: membership is granted only to an account that exists, so there is no longer a shape the
one. reservation fails to cover. The status stays reserved, because the column holds it in nobody's database and
a client must not fail on a value a later server might send. See [ADR 0009](adr/0009-team-access-model.md).
**The client has no preferences store.** It writes exactly two files — `cache.db` and `device.key` — and the **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 cache has six tables, none of them settings. Nothing on the design's TERMINAL preferences panel can be
@@ -394,9 +395,8 @@ drew around them.
## Vaults ## Vaults
**Built in M3, and reshaped since.** The screen ships: a vault list, a members table with a real **Built in M3, and reshaped since.** The screen ships: a vault list, a members table with a real
last-active column, the invitations standing against addresses that have no account here yet, who holds a last-active column, who holds a key, and the two buttons the whole design was really about — add somebody,
key, and the two buttons the whole design was really about — add somebody, and share a vault key. A vault and share a vault key. A vault can also be renamed and handed to another member.
can also be renamed and handed to another member.
**It lists vaults where it used to list teams, and that is the reshaping.** A team is still what the server **It lists vaults where it used to list teams, and that is the reshaping.** A team is still what the server
authorises against; what went is the requirement that anybody make one. Naming a vault makes the membership authorises against; what went is the requirement that anybody make one. Naming a vault makes the membership
@@ -416,15 +416,19 @@ 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 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. **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 **There is one way into a team, and that is the decision.** Adding a member takes a *user id* the caller
caller has already got from the directory, so that account must have signed in here at least once — and the 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 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 vault key to the public key that lookup returned. An *address* is accepted too, and has to be: the
cannot be a step towards sharing: there is no account, so there is no key to wrap to. Inviting an address directory lists only accounts that have published a key, so everybody between a first sign-in and their
that already belongs to a member of this team is refused and says so — that is a fact about a team the enrollment is invisible there and would otherwise be unaddable. What is refused is an address with no
caller can already see. Inviting one that merely has an account somewhere on this deployment is **not** account at all, under `no-such-account`, and that refusal is the end of the road — the remedy is that
refused, because answering that would turn the endpoint into an oracle for which addresses have accounts, person signing in once.
answerable by anybody willing to create a team first. It simply gets claimed sooner.
There was a second way in, and there is not any more. An invitation took an address, waited, and became a
membership the next time somebody signed in asserting it. It is gone because it was the one place in this
product where access followed from an identity provider's claim about an address rather than from somebody
naming an account. See [ADR 0009](adr/0009-team-access-model.md).
| Design element | Layer | What ships | | Design element | Layer | What ships |
| --- | --- | --- | | --- | --- | --- |
@@ -436,17 +440,16 @@ answerable by anybody willing to create a team first. It simply gets claimed soo
| Adding a member shares the team's vaults | client | Adding somebody wraps every team vault the adding machine can open to them, as part of the add rather than as a button to remember. Membership and a key are still two acts on two machines; the client just performs both. A vault this machine holds no key to is skipped and named. | | Adding a member shares the team's vaults | client | Adding somebody wraps every team vault the adding machine can open to them, as part of the add rather than as a button to remember. Membership and a key are still two acts on two machines; the client just performs both. A vault this machine holds no key to is skipped and named. |
| Removing a member rotates the vaults | server + client | `POST /api/v1/vaults/{id}/rekey` advances the generation and records the caller's new grant in one transaction — the server contributes the moment and no cryptography. The client then wraps the new key to the members who remain. Grants for earlier generations are kept, or the vault's stored items would become unreadable to everybody. See [ADR 0010](adr/0010-vault-key-rotation.md). | | Removing a member rotates the vaults | server + client | `POST /api/v1/vaults/{id}/rekey` advances the generation and records the caller's new grant in one transaction — the server contributes the moment and no cryptography. The client then wraps the new key to the members who remain. Grants for earlier generations are kept, or the vault's stored items would become unreadable to everybody. See [ADR 0010](adr/0010-vault-key-rotation.md). |
| Re-sealing a rotated vault's stored items | client | `VaultResealer`, the last step of a rotation: every item is opened and the same plaintext sealed again under the new key, in batches, against the version the server holds. The plaintext is never decoded, so an item written by a newer client crosses a rotation untouched. Resumable, because a vault at mixed generations is readable — a pass that stops half way leaves a working vault and is re-run. A change queued before the rotation is re-sealed by the push path instead, so nothing reaches the server under a superseded key at all. | | Re-sealing a rotated vault's stored items | client | `VaultResealer`, the last step of a rotation: every item is opened and the same plaintext sealed again under the new key, in batches, against the version the server holds. The plaintext is never decoded, so an item written by a newer client crosses a rotation untouched. Resumable, because a vault at mixed generations is readable — a pass that stops half way leaves a working vault and is re-run. A change queued before the rotation is re-sealed by the push path instead, so nothing reaches the server under a superseded key at all. |
| 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 rotate every team vault, which is a far larger act than the one being asked for. | | 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 rotate every team vault, 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. | | `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. | | 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 and every membership in one transaction — and is refused outright while the team owns any vault. |
| Design element | Layer | What it would take | What ships instead | | 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. | | `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 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. | | `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. | | Avatars | server | No picture is stored anywhere. | Omitted; the row shows a name and an address. |
| 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. | | Pending invites, the invitation mail, and **resend** | server | An outbound mail path this server has not got, and — the larger cost — a rule that turns an identity provider's claim about an address into team membership. | **Nothing pending exists, and the screen says why.** Only an account that already exists can be added; an address with no account is refused, naming the address and saying the person has to sign in here once. There was a `team_invitation` table doing the deferred version of this and it has been dropped. See [ADR 0009](adr/0009-team-access-model.md). |
| 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. | | 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. | | `SSO · OIDC · okta.dodotech.dev` | server | Per-team SSO. Authentication is one global JWT scheme bound to one authority. | Omitted. |
+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 ## 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 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 vault is in the way, ownership changes hands, and an address with no account is refused with its own code.
tokens the test mints itself. That last freedom is exactly what puts this phase here. **A test can mint a What is left needs two real accounts, a real sign-in, and in one case a clock — because the half of sharing
token asserting anything it likes, so it can prove the server's rule and can say nothing whatever about this product is built around happens on a machine rather than on the server, and no server test can reach
whether *your* identity provider sends the claim that rule depends on** — and an invitation that never it.
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 **Two accounts, and two profiles.** The dev realm ships `alice` and `bob`; a second DodoSSH profile means a
second DodoSSH profile means a second machine, a second OS user, or the same machine after signing out. second machine, a second OS user, or the same machine after signing out. Whichever account plays the
Whichever account plays the invitee **must not have signed in to this deployment before**most of what newcomer **must not have signed in to this deployment before**12.1 is about exactly that boundary.
follows is about what happens the first time it does.
### 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. 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** 2. Add `bob@example.com` as a Member, with Bob having never signed in here.
no "invitation emailed", no link to copy, no token anywhere on the screen. 3. **Pass:** it is refused. The status line names the address and says to ask them to sign in to this
3. **Pass:** the row appears as *pending*, carrying the address, the role and an expiry fourteen days out. server once and then add them. **Nothing on the screen should suggest anything is pending** — no
Bob is **not** in the members table, because he has no account here for a membership row to point at. 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. 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 5. **Pass, and this is the half that is easiest to lose:** Bob's vault list holds only his personal vault.
on either side. Back on Alice's machine, refresh: the invitation reads *accepted* rather than vanishing, Alice's members table is unchanged. Signing in with an address somebody typed earlier must join nothing
and Bob is now in the members table. at all.
6. **Pass, and this is the half that is easiest to lose:** the vault is in Bob's list **saying it is 6. Back on Alice's machine, add `bob@example.com` again.
waiting for a key**, and nothing in it is readable. Have Alice press SHARE KEY and Bob sync; now it 7. **Pass:** he is added, and — because Alice's machine holds the vault key and Bob has now published one
opens. This is where an invitation differs from an add and the difference is not a defect: adding an — the key is wrapped in the same step. Have Bob sync; the vault opens.
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.
**Failure means:** step 5 failing with everything else passing is almost always the `email_verified` claim **Failure means:** step 5 is the one to stop on. A vault appearing in Bob's list because he signed in with
— go to 12.2 rather than reading the invitation code, because the server is doing exactly what it should. an address is the deferred-membership path coming back, and it is the property this phase exists to check:
Step 6 opening the vault *without* Alice sharing a key would be the far more serious failure: nothing on access is granted to an account somebody named, never to an address. See ADR 0009.
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.
### 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. ### 12.2 Somebody who has signed in but not enrolled can still be added
Invite that address, then sign in as them.
**Pass:** they get an account and a personal vault and no shared one. The invitation stays *pending* on Have a third account sign in and stop — no passphrase, no enrollment. Then add its address to a vault.
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 vault appears while the address is unverified, the one security boundary **Pass:** accepted. Their row appears saying they hold no key, and SHARE KEY does not offer to wrap one to
invitations have is not being enforced, and anybody able to obtain a token asserting a colleague's address them. The directory returns nothing for that address, which is correct: it lists accounts that have
can walk into their vault. Stop there. If it stays pending after verifying, the claim is not reaching the published a key, and this one has not.
**access** token — check the provider's mappers, and set `Oidc:EmailVerifiedClaim` if it sends the claim
under some other name.
### 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. ### 12.3 An address already in the team is refused, and says which
**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
With Bob in the vault, add `bob@example.com` to it again. 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 **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 **Failure means:** the two refusals reading alike leaves somebody checking what they typed when the answer
hour on his next request rather than at a sign-in, so give it that long before deciding it has not worked. is that the person is already in.
**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.
### 12.5 LAST ACTIVE is a real time, and a coarse one · **needs a couple of hours** ### 12.5 LAST ACTIVE is a real time, and a coarse one · **needs a couple of hours**
@@ -17,51 +17,27 @@ public interface ICurrentUserContext
Task<UserAccount> GetOrProvisionAsync(CancellationToken cancellationToken); Task<UserAccount> GetOrProvisionAsync(CancellationToken cancellationToken);
} }
/// <summary>
/// Turns pending team invitations addressed to a verified email into memberships.
/// </summary>
/// <remarks>
/// <para>
/// Declared here, beside its only caller, and implemented in <c>Features/Teams</c>. The direction is
/// deliberate: sign-in is what an invitation waits for, so the sign-in path names the shape it needs
/// and the teams feature supplies it — rather than <see cref="ICurrentUserContext"/>, which every
/// endpoint in the server depends on, growing a reference into one feature's folder.
/// </para>
/// </remarks>
public interface ITeamInvitationClaim
{
/// <summary>
/// Claims every live invitation addressed to <paramref name="email"/> for this account.
/// </summary>
/// <param name="user">The account signing in.</param>
/// <param name="email">The address the token asserted, or null if it asserted none.</param>
/// <param name="emailVerified">
/// Whether the provider marked that address verified. False refuses the claim outright and is the
/// whole of what stops an invitation being taken by anybody able to assert somebody else's
/// address.
/// </param>
/// <param name="cancellationToken">Cancellation.</param>
/// <returns>How many invitations became memberships.</returns>
Task<int> ClaimAsync(
UserAccount user,
string? email,
bool emailVerified,
CancellationToken cancellationToken);
}
/// <summary> /// <summary>
/// Request-scoped caller identity with just-in-time provisioning. /// Request-scoped caller identity with just-in-time provisioning.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para>
/// Identity is keyed on <c>(issuer, subject)</c>, never on email. Matching an existing account by /// Identity is keyed on <c>(issuer, subject)</c>, never on email. Matching an existing account by
/// email means anyone who can obtain a token bearing a victim's email address — from any configured /// email means anyone who can obtain a token bearing a victim's email address — from any configured
/// provider — inherits that victim's vaults, so it is opt-in configuration and off by default. /// provider — inherits that victim's vaults, so it is opt-in configuration and off by default.
/// </para>
/// <para>
/// <b>Nothing here reads the email claim for authorization, and there is deliberately no hook left for
/// anything that would.</b> This class used to claim pending team invitations on the way past, which
/// made a provider's assertion about an address into a decision about who joins a team; invitations are
/// gone and membership is granted only to an account somebody named — see
/// <c>docs/adr/0009-team-access-model.md</c>. The address is still recorded, for display.
/// </para>
/// </remarks> /// </remarks>
internal sealed class CurrentUserContext( internal sealed class CurrentUserContext(
IHttpContextAccessor accessor, IHttpContextAccessor accessor,
DodoDbContext database, DodoDbContext database,
IOptions<Setup.OidcOptions> oidcOptions, IOptions<Setup.OidcOptions> oidcOptions,
ITeamInvitationClaim invitations,
TimeProvider clock) TimeProvider clock)
: ICurrentUserContext : ICurrentUserContext
{ {
@@ -73,8 +49,7 @@ internal sealed class CurrentUserContext(
/// UPDATE on the hot path of every authenticated call and — because <c>user_account</c> carries /// UPDATE on the hot path of every authenticated call and — because <c>user_account</c> carries
/// the xmin concurrency token — would start losing races between a user's own overlapping /// the xmin concurrency token — would start losing races between a user's own overlapping
/// requests. Writing it never is what made the old "last active" column impossible to offer /// requests. Writing it never is what made the old "last active" column impossible to offer
/// honestly. An hour answers the question a colleague actually asks, which is "this week or not", /// honestly. An hour answers the question a colleague actually asks, which is "this week or not".
/// and it is also the window on which a pending invitation is swept for.
/// </remarks> /// </remarks>
private static readonly TimeSpan LastSeenWindow = TimeSpan.FromHours(1); private static readonly TimeSpan LastSeenWindow = TimeSpan.FromHours(1);
@@ -101,7 +76,6 @@ internal sealed class CurrentUserContext(
var options = oidcOptions.Value; var options = oidcOptions.Value;
var email = principal.FindFirstValue(options.EmailClaim); var email = principal.FindFirstValue(options.EmailClaim);
var displayName = principal.FindFirstValue(options.NameClaim); var displayName = principal.FindFirstValue(options.NameClaim);
var emailVerified = IsVerified(principal, options.EmailVerifiedClaim);
var existing = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false); var existing = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false);
@@ -110,52 +84,28 @@ internal sealed class CurrentUserContext(
cached = await ProvisionAsync(issuer, subject, email, displayName, cancellationToken) cached = await ProvisionAsync(issuer, subject, email, displayName, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
// A first sign-in is exactly what an invitation is waiting for, so it is claimed at once
// rather than on the next hourly sweep — which would leave somebody staring at a team
// list that does not yet contain the team they were told they had been added to.
await invitations
.ClaimAsync(cached, email, emailVerified, cancellationToken)
.ConfigureAwait(false);
return cached; return cached;
} }
cached = existing; cached = existing;
await RefreshLastSeenAsync(existing, email, emailVerified, cancellationToken) await RefreshLastSeenAsync(existing, cancellationToken).ConfigureAwait(false);
.ConfigureAwait(false);
return cached; return cached;
} }
/// <summary> /// <summary>
/// Records that this account is active, and sweeps for invitations it can now claim. /// Records that this account is active.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para>
/// The two are one operation because they want the same rate. Both are housekeeping nobody is
/// waiting on, and doing them together costs one extra round trip per account per hour rather
/// than two.
/// </para>
/// <para>
/// The sweep is what makes claiming recoverable rather than one-shot. A claim that failed at
/// provisioning — or an invitation issued in the window between an account being created and this
/// person next signing in — is picked up here instead of being stranded for ever.
/// </para>
/// <para>
/// <c>ExecuteUpdateAsync</c> rather than the change tracker, and the predicate rather than a /// <c>ExecuteUpdateAsync</c> rather than the change tracker, and the predicate rather than a
/// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping /// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping
/// requests from one user would each read the row, each set the timestamp, and the second would /// requests from one user would each read the row, each set the timestamp, and the second would
/// fail on a version that had moved under it. This writes at most one row and cannot conflict. /// fail on a version that had moved under it. This writes at most one row and cannot conflict.
/// The tracked entity is deliberately left alone — a value up to an hour stale in memory changes /// The tracked entity is deliberately left alone — a value up to an hour stale in memory changes
/// nothing, and marking it modified would enlist the user row in whatever the request saves next. /// nothing, and marking it modified would enlist the user row in whatever the request saves next.
/// </para>
/// </remarks> /// </remarks>
private async Task RefreshLastSeenAsync( private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
UserAccount user,
string? email,
bool emailVerified,
CancellationToken cancellationToken)
{ {
var now = clock.GetUtcNow(); var now = clock.GetUtcNow();
@@ -171,20 +121,8 @@ internal sealed class CurrentUserContext(
setters => setters.SetProperty(u => u.LastSeenAtUtc, now), setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
await invitations
.ClaimAsync(user, email, emailVerified, cancellationToken)
.ConfigureAwait(false);
} }
/// <remarks>
/// A JWT boolean arrives as the string "true", so this parses rather than compares against a
/// constant. Anything else — absent, "false", or a value this does not understand — is false,
/// because the failure that matters is treating an unverified address as verified.
/// </remarks>
private static bool IsVerified(ClaimsPrincipal principal, string claimType) =>
bool.TryParse(principal.FindFirstValue(claimType), out var verified) && verified;
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) => private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
database.Users.SingleOrDefaultAsync( database.Users.SingleOrDefaultAsync(
u => u.Issuer == issuer && u.Subject == subject && u.DeletedAtUtc == null, u => u.Issuer == issuer && u.Subject == subject && u.DeletedAtUtc == null,
@@ -495,173 +495,6 @@ internal sealed class RemoveTeamMemberEndpoint(ICurrentUserContext currentUser,
} }
} }
/// <summary>Lists a team's invitations.</summary>
/// <remarks>
/// Readable by every member, as the members list is: whoever is about to be handed a vault key needs
/// to see who else is on their way in. Accepted and withdrawn invitations are included so the screen
/// can say an invitation was taken up rather than letting it vanish and read as never sent.
/// </remarks>
internal sealed class ListTeamInvitationsEndpoint(
ICurrentUserContext currentUser,
TeamService teams,
TeamInvitationService invitations)
: EndpointWithoutRequest<Results<Ok<IReadOnlyList<TeamInvitationSummary>>, NotFound>>
{
/// <inheritdoc />
public override void Configure()
{
Get("/api/v1/teams/{teamId:guid}/invitations");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("ListTeamInvitations")
.WithSummary("Lists a team's invitations.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<Ok<IReadOnlyList<TeamInvitationSummary>>, NotFound>> ExecuteAsync(
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(await invitations.ListAsync(teamId, ct).ConfigureAwait(false));
}
}
/// <summary>Invites an address to a team.</summary>
/// <remarks>
/// Authenticated rather than Enrolled, and pointedly so. Every other write that ends in somebody
/// reading a vault needs a key of the caller's own; this one does not, because an invitation grants
/// membership and membership is not readability. Requiring enrollment here would also be requiring it
/// of the wrong person — the invitee is the one with no key, and they have no account yet either.
/// </remarks>
internal sealed class CreateTeamInvitationEndpoint(
ICurrentUserContext currentUser,
TeamService teams,
TeamInvitationService invitations)
: Endpoint<CreateTeamInvitationRequest, Results<Ok<TeamInvitationSummary>, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Post("/api/v1/teams/{teamId:guid}/invitations");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("CreateTeamInvitation")
.WithSummary("Invites an email address to a team.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<Ok<TeamInvitationSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
CreateTeamInvitationRequest req,
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
if (!access.CanAdminister)
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"Only an admin or the owner of this team can invite people to it.");
}
try
{
var invitation = await invitations
.CreateAsync(user, teamId, req, ct)
.ConfigureAwait(false);
return TypedResults.Ok(invitation);
}
catch (TeamInvitationInvalidException exception)
{
return Problems.Coded(
StatusCodes.Status400BadRequest,
ProblemCodes.InvalidTeamInvitation,
exception.Message);
}
}
}
/// <summary>Withdraws an invitation that has not been taken up.</summary>
/// <remarks>
/// 404 for an invitation that is not there, is not this team's, or has already been claimed — the
/// same answer for all three, and for the reason revoking a device grant gives: a caller driving
/// towards "that invitation will not let anybody in" can treat 404 as having arrived. A claimed one
/// is a membership now, and removing a member is a different operation with different consequences.
/// </remarks>
internal sealed class RevokeTeamInvitationEndpoint(
ICurrentUserContext currentUser,
TeamService teams,
TeamInvitationService invitations)
: EndpointWithoutRequest<Results<NoContent, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Delete("/api/v1/teams/{teamId:guid}/invitations/{invitationId:guid}");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("RevokeTeamInvitation")
.WithSummary("Withdraws an invitation that has not been taken up.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var invitationId = Route<Guid>("invitationId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
if (!access.CanAdminister)
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"Only an admin or the owner of this team can withdraw an invitation.");
}
var revoked = await invitations
.RevokeAsync(user, teamId, invitationId, ct)
.ConfigureAwait(false);
return revoked ? TypedResults.NoContent() : TypedResults.NotFound();
}
}
/// <summary>Creates a vault owned by a team.</summary> /// <summary>Creates a vault owned by a team.</summary>
internal sealed class CreateTeamVaultEndpoint( internal sealed class CreateTeamVaultEndpoint(
ICurrentUserContext currentUser, ICurrentUserContext currentUser,
@@ -36,20 +36,19 @@ internal sealed class TeamNotEmptyException(string message) : Exception(message)
/// <summary>The address given to an add has no account on this server.</summary> /// <summary>The address given to an add has no account on this server.</summary>
/// <remarks> /// <remarks>
/// Separate from <see cref="TeamInvalidException"/> because the caller can act on it without being /// Separate from <see cref="TeamInvalidException"/> because it is the one refusal on this path that is
/// told to: there is nobody to add, so the address is invited instead. Folded into the general code it /// not about the request: the request was well formed and named somebody who is not here. Folded into
/// would be indistinguishable from a rejected role, and a client would have to guess which it was. /// the general code it would be indistinguishable from a rejected role, and a client wanting to say
/// "ask them to sign in first" would have to guess which of the two it had.
/// <para>
/// It is the end of the road rather than a step on it. Membership is only ever granted to an account
/// that exists — see <c>docs/adr/0009-team-access-model.md</c> — so there is nothing else for a client
/// to try, and the honest answer is to name the address and say what has to happen before it can be
/// added.
/// </para>
/// </remarks> /// </remarks>
internal sealed class NoSuchAccountException(string message) : Exception(message); internal sealed class NoSuchAccountException(string message) : Exception(message);
/// <summary>An invitation was rejected.</summary>
/// <remarks>
/// Separate from <see cref="TeamInvalidException"/> because its commonest cause has a different
/// remedy: an address that already has an account here should be added through the directory, which
/// is the path that shows the caller the public key they are about to trust.
/// </remarks>
internal sealed class TeamInvitationInvalidException(string message) : Exception(message);
/// <summary> /// <summary>
/// A vault key grant was rejected. /// A vault key grant was rejected.
/// </summary> /// </summary>
@@ -1,488 +0,0 @@
using DodoSSH.Api.Authorization;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace DodoSSH.Api.Features.Teams;
/// <summary>
/// Invitations to addresses that have no account here yet, and the sign-in path that claims them.
/// </summary>
/// <remarks>
/// <para>
/// <b>An invitation is a standing instruction, not a message and not a token.</b> 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 to sign in with this address joins this team as this role", and telling
/// them to sign in is the caller's job over a channel this server does not carry. That is a smaller
/// feature than the design drew, and it is the whole of what can be built honestly without a mail
/// path — a link nobody can deliver would be worse than none.
/// </para>
/// <para>
/// <b>Verification is the security boundary, and it is the only one.</b> Membership is authorization
/// (ADR 0009), so an invitation decides what the server will serve. Claiming one on an address the
/// identity provider has not marked verified would let anybody who can get a token asserting somebody
/// else's address walk into their team — which is precisely the attack
/// <c>OidcOptions.AllowEmailLinking</c> exists to refuse. So an unverified address claims nothing,
/// there is no setting that relaxes it, and the refusal is logged rather than silent.
/// </para>
/// <para>
/// What an invitation still cannot do is make anything readable. It creates a membership, and a
/// membership is not a key — somebody has to wrap the vault key to them afterwards, from a machine
/// that holds it. The split ADR 0009 describes is not weakened by this; the invitation simply moves
/// the first half of it earlier.
/// </para>
/// </remarks>
internal sealed class TeamInvitationService(
DodoDbContext database,
TimeProvider clock,
ILogger<TeamInvitationService> logger)
: ITeamInvitationClaim
{
/// <summary>Longest acceptable address. Matches the column, and RFC 5321's own limit.</summary>
private const int MaxEmailLength = 320;
/// <summary>
/// How long an invitation stays claimable.
/// </summary>
/// <remarks>
/// Fourteen days, and finite for a reason rather than as a default. An invitation that never
/// expired would be a standing offer against an address, and addresses are reassigned — a
/// company address handed to the next person to hold the job would let them into a team the
/// person who left was invited to. Fourteen days is long enough to survive a holiday and short
/// enough that a forgotten invitation lapses rather than waiting.
/// </remarks>
private static readonly TimeSpan Lifetime = TimeSpan.FromDays(14);
/// <summary>Lists a team's invitations, including the ones already dealt with.</summary>
/// <remarks>
/// Every member may read this, as with the members list and for the same reason: whoever is about
/// to be handed a vault key needs to see who else is on their way into the team. Accepted and
/// revoked rows are returned too, so the screen can show that an invitation was taken up rather
/// than having it silently vanish and read as never sent.
/// </remarks>
internal async Task<IReadOnlyList<TeamInvitationSummary>> ListAsync(
Guid teamId,
CancellationToken cancellationToken)
{
var invitations = await database.TeamInvitations
.Where(i => i.TeamId == teamId)
.OrderByDescending(i => i.CreatedAtUtc)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var now = clock.GetUtcNow();
return [.. invitations.Select(invitation => Describe(invitation, now))];
}
/// <summary>Invites an address to a team.</summary>
/// <remarks>
/// <para>
/// <b>An address that already has an account here is accepted rather than refused.</b> The obvious
/// alternative — refusing and pointing at the directory — would turn this endpoint into an oracle
/// for which addresses have accounts, answerable by anybody willing to create a team first. It
/// would also be answering a question the caller did not ask: they want that person in the team,
/// and whether the account exists yet only changes how soon it happens. An existing account picks
/// the invitation up on its next request, within the hour.
/// </para>
/// <para>
/// Idempotent on the client-chosen id, as team and vault creation are: the same id, team and
/// address returns the existing invitation rather than a second one. A different address under an
/// id already in use is refused rather than reinterpreted.
/// </para>
/// </remarks>
internal async Task<TeamInvitationSummary> CreateAsync(
UserAccount actor,
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var email = RequireEmail(request.Email);
var role = RequireInvitableRole(request.Role);
if (request.InvitationId == Guid.Empty)
{
throw new TeamInvitationInvalidException(
"An invitation id is required. Generate a UUIDv7 on the client.");
}
var now = clock.GetUtcNow();
var existing = await database.TeamInvitations
.SingleOrDefaultAsync(i => i.Id == request.InvitationId, cancellationToken)
.ConfigureAwait(false);
if (existing is not null)
{
return ResolveExisting(existing, teamId, email, now);
}
await RefuseIfAlreadyAMemberAsync(teamId, email, cancellationToken).ConfigureAwait(false);
var invitation = new TeamInvitation
{
Id = request.InvitationId,
TeamId = teamId,
Email = email,
Role = role,
InvitedByUserId = actor.Id,
CreatedAtUtc = now,
ExpiresAtUtc = now + Lifetime,
};
database.TeamInvitations.Add(invitation);
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
{
// The partial unique index on (team, email) among live invitations. Reported as its own
// sentence because it is the one failure the caller could not see coming from their own
// input — somebody else may have invited the same person a minute earlier.
throw new TeamInvitationInvalidException(
"There is already an invitation to that address for this team. Withdraw it first if "
+ "you want to invite them at a different role.");
}
TeamLog.InvitationIssued(logger, invitation.Id, teamId, role, actor.Id);
return Describe(invitation, now);
}
/// <remarks>
/// A retry is the same id against the same team and address. Anything else under an id already in
/// use is refused rather than reinterpreted: returning a differently-addressed invitation would
/// tell a client its invite went to somebody it did not.
/// </remarks>
private static TeamInvitationSummary ResolveExisting(
TeamInvitation existing,
Guid teamId,
string email,
DateTimeOffset now)
{
var isRetry = existing.TeamId == teamId
&& string.Equals(existing.Email, email, StringComparison.OrdinalIgnoreCase);
return isRetry
? Describe(existing, now)
: throw new TeamInvitationInvalidException(
"That invitation id is already in use. Generate a new UUIDv7 and retry.");
}
private static TeamRole RequireInvitableRole(TeamMemberRole role)
{
var domain = ToDomain(role);
return domain is TeamRole.Unspecified or TeamRole.Owner
? throw new TeamInvitationInvalidException(
"Invite somebody as a viewer, member or admin. Ownership is sole and is handed over "
+ "deliberately, never conferred by an address signing in.")
: domain;
}
/// <summary>Withdraws an invitation that has not been taken up.</summary>
/// <returns>Whether there was a live invitation to withdraw.</returns>
/// <remarks>
/// An invitation that has already been claimed is <em>not</em> withdrawable, and answering false
/// rather than unpicking it is the honest outcome: it is a membership now, and removing a member
/// is a different operation with different consequences — it revokes their vault key grants and
/// flags every team vault for rekey.
/// </remarks>
internal async Task<bool> RevokeAsync(
UserAccount actor,
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken)
{
var invitation = await database.TeamInvitations
.SingleOrDefaultAsync(
i => i.Id == invitationId
&& i.TeamId == teamId
&& i.AcceptedAtUtc == null
&& i.RevokedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
if (invitation is null)
{
return false;
}
invitation.RevokedAtUtc = clock.GetUtcNow();
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
TeamLog.InvitationRevoked(logger, invitation.Id, teamId, actor.Id);
return true;
}
/// <inheritdoc />
public async Task<int> ClaimAsync(
UserAccount user,
string? email,
bool emailVerified,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(user);
if (string.IsNullOrWhiteSpace(email))
{
return 0;
}
var now = clock.GetUtcNow();
var pending = await database.TeamInvitations
.Where(i => i.Email == email
&& i.AcceptedAtUtc == null
&& i.RevokedAtUtc == null
&& i.ExpiresAtUtc > now)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (pending.Count == 0)
{
return 0;
}
if (!emailVerified)
{
// Logged rather than silent, and this is the only signal an operator gets that their
// provider is not sending the claim. Without it, invitations would simply never work and
// there would be nothing anywhere saying why.
TeamLog.InvitationNotClaimedUnverified(logger, pending.Count, user.Id);
return 0;
}
return await ApplyAsync(user, pending, now, cancellationToken).ConfigureAwait(false);
}
/// <summary>Turns each claimable invitation into an active membership.</summary>
private async Task<int> ApplyAsync(
UserAccount user,
List<TeamInvitation> pending,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var teamIds = pending.Select(i => i.TeamId).ToArray();
// Archived teams are excluded here as well as at archive time. An invitation issued moments
// before an archive can still be in flight, and joining a team nobody can see is worse than
// an invitation that quietly lapses.
var liveTeamIds = await database.Teams
.Where(t => teamIds.Contains(t.Id) && t.DeletedAtUtc == null)
.Select(t => t.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var live = liveTeamIds.ToHashSet();
var memberships = await database.TeamMemberships
.Where(m => teamIds.Contains(m.TeamId) && m.UserId == user.Id && m.DeletedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var claimed = 0;
foreach (var invitation in pending.Where(i => live.Contains(i.TeamId)))
{
invitation.AcceptedAtUtc = now;
invitation.AcceptedByUserId = user.Id;
if (Join(user, invitation, memberships.Find(m => m.TeamId == invitation.TeamId), now))
{
claimed++;
TeamLog.InvitationClaimed(
logger, user.Id, invitation.Id, invitation.TeamId, invitation.Role);
}
}
return await SaveClaimAsync(claimed, cancellationToken).ConfigureAwait(false);
}
/// <summary>Adds or reactivates the membership an invitation asks for.</summary>
/// <returns>Whether the membership changed. False means they were already an active member.</returns>
private bool Join(
UserAccount user,
TeamInvitation invitation,
TeamMembership? membership,
DateTimeOffset now)
{
if (membership is null)
{
database.TeamMemberships.Add(new TeamMembership
{
Id = Guid.CreateVersion7(),
TeamId = invitation.TeamId,
UserId = user.Id,
Role = invitation.Role,
Status = MembershipStatus.Active,
InvitedByUserId = invitation.InvitedByUserId,
JoinedAtUtc = now,
CreatedAtUtc = now,
});
return true;
}
if (membership.Status == MembershipStatus.Active)
{
// Already in the team — the invitation is satisfied rather than applied. It must not
// change a role somebody set deliberately in the meantime, which is what re-applying an
// invitation issued weeks ago would silently do.
return false;
}
// Removed earlier and invited again. The row is reactivated rather than duplicated, exactly
// as TeamService.AddMemberAsync does, so historic audit entries stay resolvable to one
// membership. Their revoked key grants are not restored — those were wrapped to a generation
// the vault has since been flagged to leave behind.
membership.Role = invitation.Role;
membership.Status = MembershipStatus.Active;
membership.JoinedAtUtc = now;
return true;
}
/// <remarks>
/// Its own SaveChanges, never folded into the caller's. <c>CurrentUserContext.ProvisionAsync</c>
/// catches a unique violation and re-reads the account by (issuer, subject); a claim sharing that
/// call would put violations from this table inside a filter written for exactly one race, and
/// its rethrow would stop being correct.
/// </remarks>
private async Task<int> SaveClaimAsync(int claimed, CancellationToken cancellationToken)
{
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
{
// Two of this account's requests claiming at once. One wins; the other finds nothing
// left to do on the next sweep. Swallowed rather than surfaced because this runs inside
// the authorization middleware, where a throw is a 500 on a request that was otherwise
// fine — and because the outcome the caller wanted has happened either way.
foreach (var entry in database.ChangeTracker.Entries<TeamMembership>().ToList())
{
entry.State = EntityState.Detached;
}
return 0;
}
return claimed;
}
/// <remarks>
/// Refused only for an account that is <em>already in this team</em> — a fact about a team the
/// caller can see, so naming it leaks nothing. Whether an address has an account at all is
/// deliberately not answered here; see <see cref="CreateAsync"/>.
/// </remarks>
private async Task RefuseIfAlreadyAMemberAsync(
Guid teamId,
string email,
CancellationToken cancellationToken)
{
var isMember = await database.TeamMemberships
.Where(m => m.TeamId == teamId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null)
.Join(
database.Users.Where(u => u.Email == email && u.DeletedAtUtc == null),
m => m.UserId,
u => u.Id,
(m, u) => m.Id)
.AnyAsync(cancellationToken)
.ConfigureAwait(false);
if (isMember)
{
throw new TeamInvitationInvalidException(
"That address already belongs to a member of this team. Change their role instead.");
}
}
/// <summary>Derives what has become of an invitation from its timestamps.</summary>
/// <remarks>
/// Computed rather than stored, which is why <see cref="TeamInvitationState"/> has no domain twin.
/// Expiry is a fact about the clock: a stored state would need a sweeper to keep it true, and an
/// invitation that read Pending because nothing had run yet would be a lie the interface repeats.
/// </remarks>
private static TeamInvitationSummary Describe(TeamInvitation invitation, DateTimeOffset now)
{
var state = invitation switch
{
{ AcceptedAtUtc: not null } => TeamInvitationState.Accepted,
{ RevokedAtUtc: not null } => TeamInvitationState.Revoked,
_ when invitation.ExpiresAtUtc <= now => TeamInvitationState.Expired,
_ => TeamInvitationState.Pending,
};
return new TeamInvitationSummary(
invitation.Id,
invitation.Email,
ToContract(invitation.Role),
state,
invitation.InvitedByUserId,
invitation.CreatedAtUtc,
invitation.ExpiresAtUtc,
invitation.AcceptedAtUtc);
}
/// <remarks>
/// Deliberately shallow. This checks the shape the column and the claim path need — one at-sign
/// with something either side, no spaces, and inside the length the column holds — and nothing
/// more. A stricter address grammar here would reject addresses that a real identity provider
/// will happily assert, and the only thing that ultimately decides whether an address is that
/// person's is the provider marking it verified.
/// </remarks>
private static string RequireEmail(string? value)
{
var email = (value ?? string.Empty).Trim();
var at = email.IndexOf('@', StringComparison.Ordinal);
var acceptable = email.Length is > 2 and <= MaxEmailLength
&& at > 0
&& at == email.LastIndexOf('@')
&& at < email.Length - 1
&& !email.Any(char.IsWhiteSpace);
return acceptable
? email
: throw new TeamInvitationInvalidException(
"That does not look like an email address. Invite the address they sign in with.");
}
private static TeamRole ToDomain(TeamMemberRole role) => role switch
{
TeamMemberRole.Viewer => TeamRole.Viewer,
TeamMemberRole.Member => TeamRole.Member,
TeamMemberRole.Admin => TeamRole.Admin,
TeamMemberRole.Owner => TeamRole.Owner,
_ => TeamRole.Unspecified,
};
private static TeamMemberRole ToContract(TeamRole role) => role switch
{
TeamRole.Viewer => TeamMemberRole.Viewer,
TeamRole.Member => TeamMemberRole.Member,
TeamRole.Admin => TeamMemberRole.Admin,
TeamRole.Owner => TeamMemberRole.Owner,
_ => TeamMemberRole.Unspecified,
};
private static bool IsUniqueViolation(DbUpdateException exception) =>
string.Equals(
(exception.InnerException as PostgresException)?.SqlState,
PostgresErrorCodes.UniqueViolation,
StringComparison.Ordinal);
}
-45
View File
@@ -134,49 +134,4 @@ internal static partial class TeamLog
+ "The former owner is now an admin.")] + "The former owner is now an admin.")]
internal static partial void OwnershipTransferred( internal static partial void OwnershipTransferred(
ILogger logger, Guid teamId, Guid formerOwnerId, Guid newOwnerId); ILogger logger, Guid teamId, Guid formerOwnerId, Guid newOwnerId);
/// <remarks>
/// The invitation id, never the address. TeamLog's rule is ids and outcomes only, and an email is
/// exactly the kind of personal detail a log aggregator would then keep for its whole retention.
/// </remarks>
[LoggerMessage(
EventId = 2111,
Level = LogLevel.Information,
Message = "Issued invitation {InvitationId} to team {TeamId} as {Role}, by {ActorId}.")]
internal static partial void InvitationIssued(
ILogger logger, Guid invitationId, Guid teamId, Domain.TeamRole role, Guid actorId);
[LoggerMessage(
EventId = 2112,
Level = LogLevel.Information,
Message = "Revoked invitation {InvitationId} to team {TeamId}, by {ActorId}.")]
internal static partial void InvitationRevoked(
ILogger logger, Guid invitationId, Guid teamId, Guid actorId);
[LoggerMessage(
EventId = 2113,
Level = LogLevel.Information,
Message = "User {UserId} claimed invitation {InvitationId} and joined team {TeamId} as {Role}.")]
internal static partial void InvitationClaimed(
ILogger logger, Guid userId, Guid invitationId, Guid teamId, Domain.TeamRole role);
/// <remarks>
/// <para>
/// Warning, and the one log line an operator will need when invitations appear not to work at all.
/// A provider that does not assert <c>email_verified</c> leaves every invitation pending for ever
/// with nothing else to show for it, and this is the only place that difference is visible.
/// </para>
/// <para>
/// It names the count and the account, never the address — the address is the thing being refused
/// as untrustworthy, and writing it to a log would be keeping a claim the server just rejected.
/// </para>
/// </remarks>
[LoggerMessage(
EventId = 2114,
Level = LogLevel.Warning,
Message = "Left {InvitationCount} invitation(s) unclaimed for user {UserId}: the access token "
+ "does not assert that their email address is verified. Check the identity provider "
+ "sends the email_verified claim.")]
internal static partial void InvitationNotClaimedUnverified(
ILogger logger, int invitationCount, Guid userId);
} }
+5 -18
View File
@@ -277,20 +277,6 @@ internal sealed class TeamService(
membership.DeletedAtUtc = now; membership.DeletedAtUtc = now;
} }
// Pending invitations go too. An invitation that outlived its team would become a
// membership of something nobody can see, on a sign-in weeks later.
var invitations = await database.TeamInvitations
.Where(i => i.TeamId == team.Id
&& i.AcceptedAtUtc == null
&& i.RevokedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var invitation in invitations)
{
invitation.RevokedAtUtc = now;
}
team.DeletedAtUtc = now; team.DeletedAtUtc = now;
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
@@ -577,8 +563,9 @@ internal sealed class TeamService(
/// Both misses are specific, and neither is a new oracle. An id confirms nothing the caller did not /// Both misses are specific, and neither is a new oracle. An id confirms nothing the caller did not
/// already know from the lookup that produced it. An address is answered only for an admin or owner /// already know from the lookup that produced it. An address is answered only for an admin or owner
/// of the team the add names — checked by the endpoint before this runs — and is the same fact the /// of the team the add names — checked by the endpoint before this runs — and is the same fact the
/// member list would show them a moment later. It carries its own code so the caller can invite the /// member list would show them a moment later. It carries its own code so the caller can say what
/// address instead of reporting a failure at somebody who simply is not here yet. /// has to happen next — that person signing in here once — rather than reporting a failure at
/// somebody who simply is not here yet.
/// </para> /// </para>
/// </remarks> /// </remarks>
private async Task<UserAccount> ResolveTargetAsync( private async Task<UserAccount> ResolveTargetAsync(
@@ -614,8 +601,8 @@ internal sealed class TeamService(
.ConfigureAwait(false) .ConfigureAwait(false)
?? throw new NoSuchAccountException( ?? throw new NoSuchAccountException(
"No account here uses that address yet. Invite it instead — they join when they " "No account here uses that address yet. Ask them to sign in to this server once, "
+ "first sign in."); + "which is what creates the account, and then add them.");
} }
/// <remarks> /// <remarks>
+3 -10
View File
@@ -37,21 +37,14 @@ builder.Services.AddScoped<DeviceService>();
builder.Services.AddScoped<DirectoryService>(); builder.Services.AddScoped<DirectoryService>();
builder.Services.AddScoped<KeyLogService>(); builder.Services.AddScoped<KeyLogService>();
builder.Services.AddScoped<TeamService>(); builder.Services.AddScoped<TeamService>();
builder.Services.AddScoped<TeamInvitationService>();
// Registered twice on purpose, resolving to the same scoped instance: the endpoints take the
// concrete service, and CurrentUserContext takes only the claim it needs, so the sign-in path does
// not gain a reference to the whole of a feature it calls one method on.
builder.Services.AddScoped<ITeamInvitationClaim>(
provider => provider.GetRequiredService<TeamInvitationService>());
builder.Services.AddScoped<VaultGrantService>(); builder.Services.AddScoped<VaultGrantService>();
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>(); builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>(); builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
// A singleton, because the sockets it holds outlive the requests that opened them. Registered twice // A singleton, because the sockets it holds outlive the requests that opened them. Registered twice
// resolving to the same instance, for the reason the invitation claim above is: the endpoint needs the // resolving to the same instance: the endpoint needs the whole hub — admit, remove, count — while the
// whole hub — admit, remove, count — while the write paths that announce a change need only the two // write paths that announce a change need only the two methods that announce one, and should not gain
// methods that announce one, and should not gain a reference to connection management to get them. // a reference to connection management to get them.
builder.Services.AddSingleton<VaultEventHub>(); builder.Services.AddSingleton<VaultEventHub>();
builder.Services.AddSingleton<IVaultEventPublisher>( builder.Services.AddSingleton<IVaultEventPublisher>(
provider => provider.GetRequiredService<VaultEventHub>()); provider => provider.GetRequiredService<VaultEventHub>());
+7 -20
View File
@@ -62,28 +62,15 @@ public sealed class OidcOptions
public string EmailClaim { get; set; } = "email"; public string EmailClaim { get; set; } = "email";
/// <summary>Claim type holding the user's display name.</summary> /// <summary>Claim type holding the user's display name.</summary>
public string NameClaim { get; set; } = "name";
/// <summary>
/// Claim type asserting that the provider has verified the user's email.
/// </summary>
/// <remarks> /// <remarks>
/// <para> /// The email and the display name are read for display and for the directory's address lookup, and
/// Read for exactly one purpose: deciding whether a pending team invitation addressed to that /// for nothing that decides access. There was an <c>EmailVerifiedClaim</c> beside these, read by
/// email may be claimed. Nothing else in this server trusts the email claim for anything, and /// exactly one feature — claiming a team invitation addressed to that address — and it went with
/// <see cref="AllowEmailLinking"/> records why — a token from any configured provider carrying a /// the invitations. Nothing in this server now trusts the email claim to grant anything, which is
/// victim's address must not confer access to anything of theirs. An invitation is access, so it /// the property <see cref="AllowEmailLinking"/> spends its whole doc comment defending; re-adding a
/// needs the same bar. /// setting here would be the first step back towards a token's address being a way in.
/// </para>
/// <para>
/// <b>Absence is a refusal, not a default.</b> A provider that does not send this claim leaves
/// every invitation pending for ever, which is visible on the teams screen and diagnosable in the
/// log. There is deliberately no option to trust an unverified address instead: 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.
/// </para>
/// </remarks> /// </remarks>
public string EmailVerifiedClaim { get; set; } = "email_verified"; public string NameClaim { get; set; } = "name";
} }
/// <summary>Schema management.</summary> /// <summary>Schema management.</summary>
@@ -54,9 +54,6 @@ internal static class EndpointRegistration
typeof(AddTeamMemberEndpoint), typeof(AddTeamMemberEndpoint),
typeof(ChangeTeamMemberRoleEndpoint), typeof(ChangeTeamMemberRoleEndpoint),
typeof(RemoveTeamMemberEndpoint), typeof(RemoveTeamMemberEndpoint),
typeof(ListTeamInvitationsEndpoint),
typeof(CreateTeamInvitationEndpoint),
typeof(RevokeTeamInvitationEndpoint),
typeof(CreateTeamVaultEndpoint), typeof(CreateTeamVaultEndpoint),
typeof(RenameVaultEndpoint), typeof(RenameVaultEndpoint),
typeof(DeleteVaultEndpoint), typeof(DeleteVaultEndpoint),
@@ -129,8 +129,8 @@
<!-- <!--
Vaults, which the v2 design has no row for — it is a shipped screen the design had no slot for Vaults, which the v2 design has no row for — it is a shipped screen the design had no slot for
rather than a drawn one with nothing behind it. It is on the phone because an invitation is rather than a drawn one with nothing behind it. It is on the phone because a vault arrives
claimed by signing in, and somebody being invited is at least as likely to be holding a phone. without being asked for, and somebody it arrives for is at least as likely to be holding a phone.
◎ rather than a glyph of its own. The desktop rail already draws this destination with it, and ◎ rather than a glyph of its own. The desktop rail already draws this destination with it, and
two heads giving one destination two marks is how a user learns the wrong one. two heads giving one destination two marks is how a user learns the wrong one.
@@ -10,14 +10,11 @@
VAULTS, under MORE — and the one screen behind that hub the v2 phone design never drew. VAULTS, under MORE — and the one screen behind that hub the v2 phone design never drew.
It is the reverse of every other entry in docs/design-import-gaps.md: a shipped screen the design had It is the reverse of every other entry in docs/design-import-gaps.md: a shipped screen the design had
no slot for, rather than a drawn screen with nothing behind it. It is on the phone because an no slot for, rather than a drawn screen with nothing behind it. It is on the phone because a vault
invitation is claimed by *signing in*, and the person being invited is at least as likely to be arrives without being asked for — somebody wraps its key to you from their machine — and the person it
holding a phone as sitting at a desktop — a vault the server has just put somebody into, visible only arrives for is at least as likely to be holding a phone as sitting at a desktop. A vault the server has
on a head they may never have installed, is a membership they cannot see. just put somebody into, visible only on a head they may never have installed, is a membership they
cannot see.
That argument is also why the invited list is drawn here and not treated as an administrator's detail:
the people on it are the ones who cannot yet see the vault, and the row says out loud that no mail was
sent.
── IT LISTED TEAMS UNTIL THE SCREEN STOPPED BEING ABOUT THEM. ─────────────────────────────────────── ── IT LISTED TEAMS UNTIL THE SCREEN STOPPED BEING ABOUT THEM. ───────────────────────────────────────
The rows are vaults now, and the members under one are the people that vault is shared with. Nothing The rows are vaults now, and the members under one are the people that vault is shared with. Nothing
@@ -39,20 +36,20 @@
◆ **SHARE KEY is drawn and nothing that takes something away is.** That is a decision rather than a ◆ **SHARE KEY is drawn and nothing that takes something away is.** That is a decision rather than a
subset. Wrapping a vault key is the one act on this screen a server cannot perform at all — it needs a subset. Wrapping a vault key is the one act on this screen a server cannot perform at all — it needs a
machine that already holds the key, and this phone is one — so a vaults screen that could only be read machine that already holds the key, and this phone is one — so a vaults screen that could only be read
would leave the product's central claim undemonstrated on the head most people carry. REMOVE, WITHDRAW would leave the product's central claim undemonstrated on the head most people carry. REMOVE and
KEY and WITHDRAW INVITATION are the other half of that, and each of them acts on the first press: the WITHDRAW KEY are the other half of that, and both act on the first press: the view model's
view model's armed-confirmation state covers handing a vault over and nothing else. The desktop guards armed-confirmation state covers handing a vault over and nothing else. The desktop guards
them with a tooltip instead, which is a control a touch screen has no way to show. An irreversible them with a tooltip instead, which is a control a touch screen has no way to show. An irreversible
revocation under a thumb with its explanation missing is the wrong trade, so all three stay on the revocation under a thumb with its explanation missing is the wrong trade, so both stay on the
desktop — where the sentence beside them is visible. Handing a vault over is not drawn either, for a desktop — where the sentence beside them is visible. Handing a vault over is not drawn either, for a
plainer reason: it decides who controls the vault, which is not a thing to do while walking. Nor is plainer reason: it decides who controls the vault, which is not a thing to do while walking. Nor is
renaming, which is a keyboard on a screen that is otherwise all reading. renaming, which is a keyboard on a screen that is otherwise all reading.
**ADD is not drawn either**, and it is the operation this screen least needs. It is an address typed **ADD is not drawn either**, and it is the operation this screen least needs. It is an address typed
into a box, a directory lookup, a role picker, and a paragraph beside it saying what adding somebody into a box, a directory lookup, a role picker, and a paragraph beside it saying what adding somebody
did *not* do — and since invitations arrived the ordinary way into a vault is one the server claims at did *not* do — five controls for the one act on this screen that a colleague at a desktop is already
sign-in, which is what put this screen on the phone at all. Making a vault is here, because it is one doing. Making a vault is here, because it is one field and because it is what a person carrying a
field and because it is what a person carrying a phone can usefully start. phone can usefully start.
**The key-holder list is not drawn.** It is a third list, and what the phone can answer about a vault **The key-holder list is not drawn.** It is a third list, and what the phone can answer about a vault
is the more useful half of the same question and is on the vault's own row: whether *this* machine can is the more useful half of the same question and is on the vault's own row: whether *this* machine can
@@ -61,7 +58,7 @@
**↻ and `+` both, because this screen has more reason to re-read than any other.** Who is in a vault is **↻ and `+` both, because this screen has more reason to re-read than any other.** Who is in a vault is
not cached — it is read from the server on arrival and again at the end of every command — so the one not cached — it is read from the server on arrival and again at the end of every command — so the one
thing a member cannot otherwise see is a change somebody else just made: a vault key wrapped to them thing a member cannot otherwise see is a change somebody else just made: a vault key wrapped to them
from a colleague's desktop, or a vault they have this moment been invited into. On the desktop the from a colleague's desktop, or a vault they have this moment been added to. On the desktop the
re-read is leaving the rail and coming back, which is one click. Here it is a trip out to MORE and re-read is leaving the rail and coming back, which is one click. Here it is a trip out to MORE and
back, so the button earns its place. It binds to a real command rather than to ShowScreen(Vaults), back, so the button earns its place. It binds to a real command rather than to ShowScreen(Vaults),
which would set Screen to the value it already holds, raise nothing and reload nothing. which would set Screen to the value it already holds, raise nothing and reload nothing.
@@ -246,53 +243,6 @@
<TextBlock Classes="body" Margin="18,10,18,0" <TextBlock Classes="body" Margin="18,10,18,0"
Text="Being in a vault is what lets the server hand somebody its rows. It is not what lets them read one: a vault key can only be wrapped by a machine that already holds it, which is what sharing below does." /> Text="Being in a vault is what lets the server hand somebody its rows. It is not what lets them read one: a vault key can only be wrapped by a machine that already holds it, which is what sharing below does." />
<!-- ============ ◆ who has been asked and has not arrived ============ -->
<!--
The section this screen exists for, and the one the desktop had nothing to draw until
invitations were built. Read-only here: withdrawing one is a control that acts on the first
press, which is the line drawn at the top of this file.
So these are cards rather than the flat rows above them, and the shape is the difference: a
row that fills when you touch it is one of several you are choosing between, and there is
nothing to choose here. An ItemsControl rather than a ListBox for the same reason — a list
with a selection nothing reads would be a control offering something it cannot do.
Gated on the view model's own count rather than left to stand over an empty list, because a
vault with nobody outstanding is the ordinary case and a permanent empty heading would make
it look like a section that had failed to load.
The waiting row carries the whole mechanism in its own sentence — no mail was sent, and they
join when they first sign in here. That is the sentence somebody has to read, because every
other product's version of this word means an email is on its way.
-->
<StackPanel IsVisible="{Binding HasInvitations}">
<TextBlock Classes="section" Text="INVITED" Margin="18,18,18,4" />
<ItemsControl ItemsSource="{Binding Invitations}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:VaultInvitationRowViewModel">
<Border Classes="card" Margin="12,3">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Email}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource TextDim}" Text="{Binding State}"
IsVisible="{Binding !IsPending}" />
<TextBlock Classes="detail" FontSize="10" TextWrapping="Wrap"
Foreground="{StaticResource WarnText}" Text="{Binding State}"
IsVisible="{Binding IsPending}" />
</StackPanel>
<Border Grid.Column="1" Classes="tag outline" Margin="8,0,0,0">
<TextBlock Text="{Binding Role}" />
</Border>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
@@ -123,30 +123,6 @@ public interface ITeamApi
/// </returns> /// </returns>
Task<bool> RemoveTeamMemberAsync(Guid teamId, Guid userId, CancellationToken cancellationToken); Task<bool> RemoveTeamMemberAsync(Guid teamId, Guid userId, CancellationToken cancellationToken);
/// <summary>Lists a team's invitations, including the ones already dealt with.</summary>
Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken);
/// <summary>Invites an email address to a team.</summary>
Task<TeamInvitationSummary> CreateTeamInvitationAsync(
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Withdraws an invitation that has not been taken up.
/// </summary>
/// <returns>
/// Whether there was a live invitation to withdraw. False covers one that was never there and one
/// already claimed — a claimed invitation is a membership now, and removing a member is a different
/// operation with different consequences.
/// </returns>
Task<bool> RevokeTeamInvitationAsync(
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken);
/// <summary>Creates a vault owned by a team, with the creator's key grant.</summary> /// <summary>Creates a vault owned by a team, with the creator's key grant.</summary>
Task<VaultSummary> CreateTeamVaultAsync( Task<VaultSummary> CreateTeamVaultAsync(
Guid teamId, Guid teamId,
@@ -506,39 +482,6 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}"), string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}"),
cancellationToken); cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Get,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations"),
null,
DodoSshJsonContext.Default.IReadOnlyListTeamInvitationSummary,
cancellationToken);
/// <inheritdoc />
public Task<TeamInvitationSummary> CreateTeamInvitationAsync(
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations"),
JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamInvitationRequest),
DodoSshJsonContext.Default.TeamInvitationSummary,
cancellationToken);
/// <inheritdoc />
public Task<bool> RevokeTeamInvitationAsync(
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(
CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations/{invitationId}"),
cancellationToken);
/// <inheritdoc /> /// <inheritdoc />
public Task<VaultSummary> CreateTeamVaultAsync( public Task<VaultSummary> CreateTeamVaultAsync(
Guid teamId, Guid teamId,
+10 -43
View File
@@ -12,7 +12,7 @@
The left column used to list teams; a team owned vaults, and sharing meant creating a team, then a The left column used to list teams; a team owned vaults, and sharing meant creating a team, then a
vault in it, then wrapping a key. Two of those three steps were about a concept nobody came here for. vault in it, then wrapping a key. Two of those three steps were about a concept nobody came here for.
So the rows are vaults now: naming one makes the membership list that carries it, and everything on So the rows are vaults now: naming one makes the membership list that carries it, and everything on
the right — members, invitations, key holders — is that vault's. The server still authorises against the right — members, key holders — is that vault's. The server still authorises against
a team, because that is what VaultAccessService resolves; what went is the requirement that a person a team, because that is what VaultAccessService resolves; what went is the requirement that a person
know it exists. The one case where it is still visible is a membership list carrying several vaults, know it exists. The one case where it is still visible is a membership list carrying several vaults,
which this screen cannot make and will not hide: see SharedMembershipWarning. which this screen cannot make and will not hide: see SharedMembershipWarning.
@@ -25,11 +25,12 @@
than a checkbox on the member row. than a checkbox on the member row.
What the design asked for and is still not here: two-factor state (no such concept exists anywhere in What the design asked for and is still not here: two-factor state (no such concept exists anywhere in
this product) and avatars (no picture is stored anywhere). Nothing is sent for an invitation — there this product) and avatars (no picture is stored anywhere). There is no INVITED list either, and that
is no outbound mail path and no token, so an invitation is a standing instruction that the next one is a decision rather than a gap — an address is not a way into a vault, so only an account that
account signing in with that address joins, and there is consequently nothing to resend. Last-active already exists can be added and there is nothing pending to draw. See the ADD box below, which says
is recorded at most once per account per hour, so it is drawn coarsely. Nor is there a way to delete a what to do about somebody who has not signed in here yet. Last-active is recorded at most once per
vault: the server has no such call, and the screen says so rather than offering a button that refuses. account per hour, so it is drawn coarsely. Nor is there a way to delete a vault: the server has no
such call, and the screen says so rather than offering a button that refuses.
--> -->
<Grid ColumnDefinitions="268,*"> <Grid ColumnDefinitions="268,*">
@@ -261,11 +262,11 @@
</StackPanel> </StackPanel>
<Grid ColumnDefinitions="*,Auto,Auto" IsVisible="{Binding CanAdministerSelected}"> <Grid ColumnDefinitions="*,Auto,Auto" IsVisible="{Binding CanAdministerSelected}">
<TextBox Grid.Column="0" PlaceholderText="colleague@example.com" Text="{Binding InviteEmail}" <TextBox Grid.Column="0" PlaceholderText="colleague@example.com"
Margin="0,0,6,0" /> Text="{Binding NewMemberEmail}" Margin="0,0,6,0" />
<Button Grid.Column="1" Classes="accent" Content="ADD" <Button Grid.Column="1" Classes="accent" Content="ADD"
Command="{Binding AddMemberCommand}" IsEnabled="{Binding !IsBusy}" Command="{Binding AddMemberCommand}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Adds the account with this address, or invites the address if there is no account here yet. Nothing is sent either way — tell them yourself." /> ToolTip.Tip="Adds the account that signs in with this address. An address with no account here is refused and says so — ask them to sign in to this server once, which is what creates the account, and then add them." />
<Button Grid.Column="2" Classes="danger" Content="REMOVE" Margin="6,0,0,0" <Button Grid.Column="2" Classes="danger" Content="REMOVE" Margin="6,0,0,0"
Command="{Binding RemoveMemberCommand}" IsEnabled="{Binding !IsBusy}" Command="{Binding RemoveMemberCommand}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Removes the selected member and withdraws every key they hold to this vault. It blocks future reads only — anything already on their machine stays there, so rotate the credentials that matter." /> ToolTip.Tip="Removes the selected member and withdraws every key they hold to this vault. It blocks future reads only — anything already on their machine stays there, so rotate the credentials that matter." />
@@ -291,40 +292,6 @@
Text="Adding somebody lets the server serve them this vault. It does not let them read it: a vault key can only be wrapped by a machine that already holds it, which is what SHARE KEY below does." /> Text="Adding somebody lets the server serve them this vault. It does not let them read it: a vault key can only be wrapped by a machine that already holds it, which is what SHARE KEY below does." />
</StackPanel> </StackPanel>
<!--
Invitations, drawn only when there are any. An empty INVITED heading on every vault would be
a permanent reminder of a feature most people never use.
-->
<StackPanel Spacing="8" IsVisible="{Binding HasInvitations}">
<Border Height="1" Background="{StaticResource BorderSubtle}" />
<TextBlock Classes="label" Text="INVITED" />
<ListBox ItemsSource="{Binding Invitations}" SelectedItem="{Binding SelectedInvitation}"
Background="Transparent" BorderThickness="0" MaxHeight="160">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultInvitationRowViewModel">
<Grid ColumnDefinitions="*,Auto" Margin="0,3">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding Email}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="hint" FontSize="11" Text="{Binding State}"
TextWrapping="Wrap" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Role}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="10,0,0,0" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Button Classes="danger" Content="WITHDRAW INVITATION" HorizontalAlignment="Left"
Command="{Binding RevokeInvitationCommand}" IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanAdministerSelected}"
ToolTip.Tip="Signing in with that address will no longer put them in this vault. An invitation already taken up is a membership — remove the member instead." />
</StackPanel>
<Border Height="1" Background="{StaticResource BorderSubtle}" /> <Border Height="1" Background="{StaticResource BorderSubtle}" />
<!-- Who holds the key --> <!-- Who holds the key -->
@@ -195,35 +195,6 @@ internal sealed record VaultGrantRowViewModel(VaultGrantSummary Grant, uint Vaul
Grant.State == VaultGrantState.Active && Grant.KeyGeneration >= VaultGeneration; Grant.State == VaultGrantState.Active && Grant.KeyGeneration >= VaultGeneration;
} }
/// <summary>One invitation, as a row under the members it will join.</summary>
internal sealed record VaultInvitationRowViewModel(TeamInvitationSummary Invitation)
{
internal Guid InvitationId => Invitation.InvitationId;
internal string Email => Invitation.Email;
internal string Role => Invitation.Role.ToString().ToUpperInvariant();
/// <summary>
/// What has become of it, said as a sentence rather than a status word.
/// </summary>
/// <remarks>
/// The pending case has to carry the whole mechanism, because there is nothing else on this screen
/// that could: nothing was sent, so somebody reading "invited" would reasonably wait for an email
/// that is never coming.
/// </remarks>
internal string State => Invitation.State switch
{
TeamInvitationState.Accepted => "joined",
TeamInvitationState.Revoked => "withdrawn",
TeamInvitationState.Expired => "expired — invite them again if they still need it",
_ => "waiting — they join when they first sign in here. Nothing was sent; tell them yourself.",
};
/// <summary>Whether this invitation can still be withdrawn.</summary>
internal bool IsPending => Invitation.State == TeamInvitationState.Pending;
}
/// <summary> /// <summary>
/// A destructive vault operation, armed and waiting to be confirmed. /// A destructive vault operation, armed and waiting to be confirmed.
/// </summary> /// </summary>
@@ -286,7 +257,7 @@ internal enum VaultActionKind
/// </para> /// </para>
/// <para> /// <para>
/// The vault list is read from the session and works with no network. Everything under it — members, /// The vault list is read from the session and works with no network. Everything under it — members,
/// invitations, key holders — is read from the server on selection and after each change, because /// key holders — is read from the server on selection and after each change, because
/// membership is not vault content and has no local mirror. /// membership is not vault content and has no local mirror.
/// </para> /// </para>
/// </remarks> /// </remarks>
@@ -329,18 +300,12 @@ internal sealed partial class VaultsViewModel(
/// </remarks> /// </remarks>
internal ObservableCollection<VaultGrantRowViewModel> Grants { get; } = []; internal ObservableCollection<VaultGrantRowViewModel> Grants { get; } = [];
/// <summary>Invitations to addresses that are not accounts here yet.</summary>
internal ObservableCollection<VaultInvitationRowViewModel> Invitations { get; } = [];
[ObservableProperty] [ObservableProperty]
private VaultRowViewModel? selectedVault; private VaultRowViewModel? selectedVault;
[ObservableProperty] [ObservableProperty]
private VaultMemberRowViewModel? selectedMember; private VaultMemberRowViewModel? selectedMember;
[ObservableProperty]
private VaultInvitationRowViewModel? selectedInvitation;
[ObservableProperty] [ObservableProperty]
private string status = string.Empty; private string status = string.Empty;
@@ -377,10 +342,10 @@ internal sealed partial class VaultsViewModel(
// ---- Adding somebody ---- // ---- Adding somebody ----
[ObservableProperty] [ObservableProperty]
private string inviteEmail = string.Empty; private string newMemberEmail = string.Empty;
/// <summary> /// <summary>
/// The role a newly added or invited account gets. /// The role a newly added account gets.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Member by default, which is the role somebody adding a colleague almost always means. Viewer /// Member by default, which is the role somebody adding a colleague almost always means. Viewer
@@ -461,9 +426,6 @@ internal sealed partial class VaultsViewModel(
/// </remarks> /// </remarks>
internal bool ShowsVaultActions => !IsConfirming; internal bool ShowsVaultActions => !IsConfirming;
/// <summary>Whether the selected vault has any invitation worth drawing a list for.</summary>
internal bool HasInvitations => Invitations.Count > 0;
/// <summary> /// <summary>
/// The warning a vault sharing its membership list with others has to carry. /// The warning a vault sharing its membership list with others has to carry.
/// </summary> /// </summary>
@@ -530,7 +492,6 @@ internal sealed partial class VaultsViewModel(
if (session() is not { } open) if (session() is not { } open)
{ {
Members.Clear(); Members.Clear();
Invitations.Clear();
Grants.Clear(); Grants.Clear();
RaiseState(); RaiseState();
@@ -548,8 +509,7 @@ internal sealed partial class VaultsViewModel(
// The assignment reselects the same vault through a new row object, so the selection handler // The assignment reselects the same vault through a new row object, so the selection handler
// would start its own read of the very lists this method is about to read — two reads clearing // would start its own read of the very lists this method is about to read — two reads clearing
// and then appending into the same collections, which draws every member, invitation and key // and then appending into the same collections, which draws every member and key holder twice. Suppressed rather than deduplicated, because the read below is awaited and the
// holder twice. Suppressed rather than deduplicated, because the read below is awaited and the
// handler's is not: this is the one that has to be the reload's. // handler's is not: this is the one that has to be the reload's.
isReselecting = true; isReselecting = true;
@@ -680,8 +640,8 @@ internal sealed partial class VaultsViewModel(
/// <para> /// <para>
/// <b>A vault always belongs to a team, and this is what keeps that from being the user's problem.</b> /// <b>A vault always belongs to a team, and this is what keeps that from being the user's problem.</b>
/// Naming a vault is enough: the team is derived from the name, created with this account as its owner, /// Naming a vault is enough: the team is derived from the name, created with this account as its owner,
/// and the vault goes into it. What that buys is the rest of this screen — members, roles, invitations /// and the vault goes into it. What that buys is the rest of this screen — members, roles and key
/// and key holders all hang off it, so they are all there the moment the vault is. /// holders all hang off it, so they are all there the moment the vault is.
/// </para> /// </para>
/// <para> /// <para>
/// <b>Two calls, and the first can succeed alone.</b> When it does, the membership list is kept rather /// <b>Two calls, and the first can succeed alone.</b> When it does, the membership list is kept rather
@@ -923,10 +883,16 @@ internal sealed partial class VaultsViewModel(
/// <para> /// <para>
/// <b>A directory miss is not an absent account, and treating it as one was a bug worth naming.</b> /// <b>A directory miss is not an absent account, and treating it as one was a bug worth naming.</b>
/// The directory returns only accounts that have published a key, so everybody between their first /// The directory returns only accounts that have published a key, so everybody between their first
/// sign-in and their enrollment is missing from it. Falling straight through to an invitation told /// sign-in and their enrollment is missing from it. Reporting that miss as "no account here" told
/// somebody who was standing right there that they had no account here, left the members list /// somebody who was standing right there that they were not, and left the members list unchanged. So
/// unchanged, and made them wait for a sweep that runs at most hourly. So the miss is retried as an /// the miss is retried as an add by address, and only a server saying there is no such account is
/// add by address, and only a server that says there is no such account reaches the invitation. /// taken as an answer.
/// </para>
/// <para>
/// <b>And that answer is the end of it.</b> There is nothing to fall through to: a membership is
/// granted to an account, so somebody who has never signed in here cannot be added yet, and the
/// remedy belongs to them rather than to the person at this screen. Saying so plainly is the whole
/// of what this command can do about it — see <c>docs/adr/0009-team-access-model.md</c>.
/// </para> /// </para>
/// </remarks> /// </remarks>
[RelayCommand] [RelayCommand]
@@ -940,7 +906,7 @@ internal sealed partial class VaultsViewModel(
return; return;
} }
var email = InviteEmail.Trim(); var email = NewMemberEmail.Trim();
if (email.Length == 0) if (email.Length == 0)
{ {
@@ -948,7 +914,7 @@ internal sealed partial class VaultsViewModel(
return; return;
} }
await RunAsync(() => AddOrInviteAsync(server, teamId, email, cancellationToken)) await RunAsync(() => AddAsync(server, teamId, email, cancellationToken))
.ConfigureAwait(true); .ConfigureAwait(true);
} }
@@ -961,7 +927,7 @@ internal sealed partial class VaultsViewModel(
: "Select a vault on the left first — somebody is added to one vault, not to all."; : "Select a vault on the left first — somebody is added to one vault, not to all.";
/// <summary>The calls behind <see cref="AddMemberAsync"/>, once its arguments are known good.</summary> /// <summary>The calls behind <see cref="AddMemberAsync"/>, once its arguments are known good.</summary>
private async Task AddOrInviteAsync( private async Task AddAsync(
IVaultServer server, IVaultServer server,
Guid teamId, Guid teamId,
string email, string email,
@@ -985,13 +951,17 @@ internal sealed partial class VaultsViewModel(
catch (DodoSshApiException exception) catch (DodoSshApiException exception)
when (string.Equals(exception.Code, ProblemCodes.NoSuchAccount, StringComparison.Ordinal)) when (string.Equals(exception.Code, ProblemCodes.NoSuchAccount, StringComparison.Ordinal))
{ {
// The address really is unknown here, which only the server can say. This is the one // The address really is unknown here, which only the server can say. Reported rather than
// route to an invitation, and it is now a fact rather than an inference from silence. // rethrown, because it is the answer rather than a failure — and the sentence has to carry
await InviteAsync(server, teamId, email, cancellationToken).ConfigureAwait(true); // what happens next, or somebody retypes the address expecting a different outcome.
Status = $"No account on this server uses '{email}'. Ask them to sign in here once, "
+ "which is what creates the account, and then add them. Nothing is held for them in "
+ "the meantime — an address is not a way into a vault.";
return; return;
} }
InviteEmail = string.Empty; NewMemberEmail = string.Empty;
// Before the reload, so the vault list this screen redraws already shows what they can open. The // Before the reload, so the vault list this screen redraws already shows what they can open. The
// sharing is what makes the membership worth anything, and doing it here rather than leaving a // sharing is what makes the membership worth anything, and doing it here rather than leaving a
@@ -1127,73 +1097,7 @@ internal sealed partial class VaultsViewModel(
: $"Added {who}. {shared}"; : $"Added {who}. {shared}";
} }
/// <summary> /// <summary>Picks the role a newly added account will get.</summary>
/// Invites an address the directory does not know.
/// </summary>
/// <remarks>
/// <para>
/// Reached by falling through from <see cref="AddMemberAsync"/> rather than from a second button,
/// because the person typing an address does not know or care which of the two applies — that is a
/// fact about the server's account table, not about what they are trying to do. Which one happened
/// is reported afterwards, because the difference decides what they have to do next.
/// </para>
/// <para>
/// The message has to carry the whole mechanism. Nothing is sent — this server has no outbound
/// mail — so somebody who reads "invited" and waits has been misled by an interface that knew
/// better.
/// </para>
/// </remarks>
private async Task InviteAsync(
IVaultServer server,
Guid teamId,
string email,
CancellationToken cancellationToken)
{
var invitation = await server.Teams
.CreateTeamInvitationAsync(
teamId,
new CreateTeamInvitationRequest(Guid.CreateVersion7(), email, NewMemberRole),
cancellationToken)
.ConfigureAwait(true);
InviteEmail = string.Empty;
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = $"No account here has the address '{email}' yet, so it has been invited instead. "
+ $"They join this vault as {invitation.Role.ToString().ToLowerInvariant()} the first time "
+ "they sign in. Nothing was sent — this server cannot send mail, so tell them yourself — "
+ "and their identity provider has to confirm the address is theirs.";
}
/// <summary>Withdraws an invitation that has not been taken up.</summary>
[RelayCommand]
private async Task RevokeInvitationAsync(CancellationToken cancellationToken)
{
if (connection() is not { } server
|| SelectedVault?.TeamId is not { } teamId
|| SelectedInvitation is not { } invitation)
{
return;
}
await RunAsync(async () =>
{
var revoked = await server.Teams
.RevokeTeamInvitationAsync(teamId, invitation.InvitationId, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = revoked
? $"Withdrew the invitation to {invitation.Email}. Signing in will no longer put them "
+ "in this vault."
: $"The invitation to {invitation.Email} was already taken up or withdrawn. If they "
+ "are a member now, remove them instead.";
}).ConfigureAwait(true);
}
/// <summary>Picks the role a newly added or invited account will get.</summary>
[RelayCommand] [RelayCommand]
private void ChooseNewMemberRole(TeamMemberRole role) => NewMemberRole = role; private void ChooseNewMemberRole(TeamMemberRole role) => NewMemberRole = role;
@@ -1695,17 +1599,14 @@ internal sealed partial class VaultsViewModel(
} }
} }
/// <summary>Reads the selected vault's members, invitations and key holders.</summary> /// <summary>Reads the selected vault's members and key holders.</summary>
private async Task LoadSelectedAsync(CancellationToken cancellationToken) private async Task LoadSelectedAsync(CancellationToken cancellationToken)
{ {
var generation = ++selectionGeneration; var generation = ++selectionGeneration;
Members.Clear(); Members.Clear();
Invitations.Clear();
Grants.Clear(); Grants.Clear();
OnPropertyChanged(nameof(HasInvitations));
if (connection() is not { } server || SelectedVault is not { } vault) if (connection() is not { } server || SelectedVault is not { } vault)
{ {
return; return;
@@ -1735,24 +1636,6 @@ internal sealed partial class VaultsViewModel(
{ {
Members.Add(new VaultMemberRowViewModel(member, member.UserId == selfId)); Members.Add(new VaultMemberRowViewModel(member, member.UserId == selfId));
} }
var invitations = await server.Teams
.ListTeamInvitationsAsync(teamId, cancellationToken)
.ConfigureAwait(true);
if (generation != selectionGeneration)
{
return;
}
foreach (var invitation in invitations)
{
Invitations.Add(new VaultInvitationRowViewModel(invitation));
}
SelectedInvitation = Invitations.FirstOrDefault(row => row.IsPending);
OnPropertyChanged(nameof(HasInvitations));
} }
/// <summary>Tells the shell that the set of vaults, or one of their names, has moved.</summary> /// <summary>Tells the shell that the set of vaults, or one of their names, has moved.</summary>
@@ -1768,7 +1651,6 @@ internal sealed partial class VaultsViewModel(
OnPropertyChanged(nameof(SelectedIsShared)); OnPropertyChanged(nameof(SelectedIsShared));
OnPropertyChanged(nameof(SelectedIsPersonal)); OnPropertyChanged(nameof(SelectedIsPersonal));
OnPropertyChanged(nameof(CanDeleteSelected)); OnPropertyChanged(nameof(CanDeleteSelected));
OnPropertyChanged(nameof(HasInvitations));
OnPropertyChanged(nameof(SharedMembershipWarning)); OnPropertyChanged(nameof(SharedMembershipWarning));
OnPropertyChanged(nameof(HasSharedMembershipWarning)); OnPropertyChanged(nameof(HasSharedMembershipWarning));
OnPropertyChanged(nameof(IsOnline)); OnPropertyChanged(nameof(IsOnline));
@@ -49,9 +49,6 @@ namespace DodoSSH.Contracts;
[JsonSerializable(typeof(IReadOnlyList<TeamMemberSummary>))] [JsonSerializable(typeof(IReadOnlyList<TeamMemberSummary>))]
[JsonSerializable(typeof(AddTeamMemberRequest))] [JsonSerializable(typeof(AddTeamMemberRequest))]
[JsonSerializable(typeof(ChangeTeamMemberRoleRequest))] [JsonSerializable(typeof(ChangeTeamMemberRoleRequest))]
[JsonSerializable(typeof(CreateTeamInvitationRequest))]
[JsonSerializable(typeof(TeamInvitationSummary))]
[JsonSerializable(typeof(IReadOnlyList<TeamInvitationSummary>))]
[JsonSerializable(typeof(CreateTeamVaultRequest))] [JsonSerializable(typeof(CreateTeamVaultRequest))]
[JsonSerializable(typeof(UpdateVaultRequest))] [JsonSerializable(typeof(UpdateVaultRequest))]
[JsonSerializable(typeof(IssueVaultGrantRequest))] [JsonSerializable(typeof(IssueVaultGrantRequest))]
+12 -18
View File
@@ -135,30 +135,24 @@ public static class ProblemCodes
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Its own code rather than folded into <see cref="InvalidTeam"/> because it is the one add failure /// Its own code rather than folded into <see cref="InvalidTeam"/> because it is the one add failure
/// with a remedy the client can take unprompted: there is nobody to add, so invite the address /// that is not about the request: the request was fine and named somebody who is not here. A client
/// instead. A client that could not tell this apart from a rejected role would have to either /// that could not tell this apart from a rejected role would have to say "check what you typed"
/// invite on every failure or never. /// about an address that was typed correctly.
/// </para> /// </para>
/// <para> /// <para>
/// It answers whether an address has an account here, which <c>CreateTeamInvitationRequest</c> /// <b>Nothing follows it.</b> This used to be the signal to invite the address instead; there are
/// deliberately does not. The exposure is bounded by the same authorization the add already needs — /// no invitations, and a membership is only ever granted to an account somebody named, so the whole
/// only an admin or owner of the team reaches it — and it is what the caller learns anyway the /// of what a client can do with this is say that the person has to sign in here once. See
/// moment the account appears in the member list. /// <see cref="AddTeamMemberRequest"/>.
/// </para>
/// <para>
/// It answers whether an address has an account here, which is a real disclosure and a bounded one:
/// only an admin or owner of the team the add names reaches it — the endpoint checks that first —
/// and it is the same fact the member list would show them a moment later.
/// </para> /// </para>
/// </remarks> /// </remarks>
public const string NoSuchAccount = "no-such-account"; public const string NoSuchAccount = "no-such-account";
/// <summary>
/// An invitation was rejected: a malformed address, an unknown or ownership role, an expiry the
/// server will not issue, or an address that already has an account here.
/// </summary>
/// <remarks>
/// Separate from <see cref="InvalidTeam"/> because the most common cause has its own remedy that a
/// client can act on — an address that already has an account should be added through the
/// directory instead, which is the path that shows the caller the key they are about to trust.
/// </remarks>
public const string InvalidTeamInvitation = "invalid-team-invitation";
/// <summary> /// <summary>
/// A vault key grant was rejected: a fingerprint or wrap of the wrong size, a generation that is /// A vault key grant was rejected: a fingerprint or wrap of the wrong size, a generation that is
/// not the vault's current one, or a recipient who cannot reach the vault in the first place. /// not the vault's current one, or a recipient who cannot reach the vault in the first place.
@@ -10,7 +10,6 @@ const DodoSSH.Contracts.ProblemCodes.InvalidCursor = "invalid-cursor" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidDeviceRegistration = "invalid-device-registration" -> string! const DodoSSH.Contracts.ProblemCodes.InvalidDeviceRegistration = "invalid-device-registration" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidEnrollment = "invalid-enrollment" -> string! const DodoSSH.Contracts.ProblemCodes.InvalidEnrollment = "invalid-enrollment" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidTeam = "invalid-team" -> string! const DodoSSH.Contracts.ProblemCodes.InvalidTeam = "invalid-team" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidTeamInvitation = "invalid-team-invitation" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidVaultGrant = "invalid-vault-grant" -> string! const DodoSSH.Contracts.ProblemCodes.InvalidVaultGrant = "invalid-vault-grant" -> string!
const DodoSSH.Contracts.ProblemCodes.LastTeamOwner = "last-team-owner" -> string! const DodoSSH.Contracts.ProblemCodes.LastTeamOwner = "last-team-owner" -> string!
const DodoSSH.Contracts.ProblemCodes.MalformedRequest = "malformed-request" -> string! const DodoSSH.Contracts.ProblemCodes.MalformedRequest = "malformed-request" -> string!
@@ -51,17 +50,6 @@ DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Deconstruct(out DodoSSH.Contracts.
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Equals(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? other) -> bool DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Equals(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? other) -> bool
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Role.get -> DodoSSH.Contracts.TeamMemberRole DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Role.init -> void DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Role.init -> void
DodoSSH.Contracts.CreateTeamInvitationRequest
DodoSSH.Contracts.CreateTeamInvitationRequest.<Clone>$() -> DodoSSH.Contracts.CreateTeamInvitationRequest!
DodoSSH.Contracts.CreateTeamInvitationRequest.CreateTeamInvitationRequest(System.Guid InvitationId, string! Email, DodoSSH.Contracts.TeamMemberRole Role) -> void
DodoSSH.Contracts.CreateTeamInvitationRequest.Deconstruct(out System.Guid InvitationId, out string! Email, out DodoSSH.Contracts.TeamMemberRole Role) -> void
DodoSSH.Contracts.CreateTeamInvitationRequest.Email.get -> string!
DodoSSH.Contracts.CreateTeamInvitationRequest.Email.init -> void
DodoSSH.Contracts.CreateTeamInvitationRequest.Equals(DodoSSH.Contracts.CreateTeamInvitationRequest? other) -> bool
DodoSSH.Contracts.CreateTeamInvitationRequest.InvitationId.get -> System.Guid
DodoSSH.Contracts.CreateTeamInvitationRequest.InvitationId.init -> void
DodoSSH.Contracts.CreateTeamInvitationRequest.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.CreateTeamInvitationRequest.Role.init -> void
DodoSSH.Contracts.CreateTeamRequest DodoSSH.Contracts.CreateTeamRequest
DodoSSH.Contracts.CreateTeamRequest.<Clone>$() -> DodoSSH.Contracts.CreateTeamRequest! DodoSSH.Contracts.CreateTeamRequest.<Clone>$() -> DodoSSH.Contracts.CreateTeamRequest!
DodoSSH.Contracts.CreateTeamRequest.CreateTeamRequest(System.Guid TeamId, string! Name, string! Slug, string? Description) -> void DodoSSH.Contracts.CreateTeamRequest.CreateTeamRequest(System.Guid TeamId, string! Name, string! Slug, string? Description) -> void
@@ -594,33 +582,6 @@ DodoSSH.Contracts.SyncPushResult.Status.init -> void
DodoSSH.Contracts.SyncPushResult.SyncPushResult(System.Guid OperationId, DodoSSH.Contracts.SyncOperationStatus Status, int? Version, long? ChangeSequence, DodoSSH.Contracts.SyncChange? ServerEntity, string? Detail) -> void DodoSSH.Contracts.SyncPushResult.SyncPushResult(System.Guid OperationId, DodoSSH.Contracts.SyncOperationStatus Status, int? Version, long? ChangeSequence, DodoSSH.Contracts.SyncChange? ServerEntity, string? Detail) -> void
DodoSSH.Contracts.SyncPushResult.Version.get -> int? DodoSSH.Contracts.SyncPushResult.Version.get -> int?
DodoSSH.Contracts.SyncPushResult.Version.init -> void DodoSSH.Contracts.SyncPushResult.Version.init -> void
DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Accepted = 2 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Expired = 4 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Pending = 1 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Revoked = 3 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationState.Unspecified = 0 -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationSummary
DodoSSH.Contracts.TeamInvitationSummary.<Clone>$() -> DodoSSH.Contracts.TeamInvitationSummary!
DodoSSH.Contracts.TeamInvitationSummary.AcceptedAt.get -> System.DateTimeOffset?
DodoSSH.Contracts.TeamInvitationSummary.AcceptedAt.init -> void
DodoSSH.Contracts.TeamInvitationSummary.CreatedAt.get -> System.DateTimeOffset
DodoSSH.Contracts.TeamInvitationSummary.CreatedAt.init -> void
DodoSSH.Contracts.TeamInvitationSummary.Deconstruct(out System.Guid InvitationId, out string! Email, out DodoSSH.Contracts.TeamMemberRole Role, out DodoSSH.Contracts.TeamInvitationState State, out System.Guid InvitedByUserId, out System.DateTimeOffset CreatedAt, out System.DateTimeOffset ExpiresAt, out System.DateTimeOffset? AcceptedAt) -> void
DodoSSH.Contracts.TeamInvitationSummary.Email.get -> string!
DodoSSH.Contracts.TeamInvitationSummary.Email.init -> void
DodoSSH.Contracts.TeamInvitationSummary.Equals(DodoSSH.Contracts.TeamInvitationSummary? other) -> bool
DodoSSH.Contracts.TeamInvitationSummary.ExpiresAt.get -> System.DateTimeOffset
DodoSSH.Contracts.TeamInvitationSummary.ExpiresAt.init -> void
DodoSSH.Contracts.TeamInvitationSummary.InvitationId.get -> System.Guid
DodoSSH.Contracts.TeamInvitationSummary.InvitationId.init -> void
DodoSSH.Contracts.TeamInvitationSummary.InvitedByUserId.get -> System.Guid
DodoSSH.Contracts.TeamInvitationSummary.InvitedByUserId.init -> void
DodoSSH.Contracts.TeamInvitationSummary.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamInvitationSummary.Role.init -> void
DodoSSH.Contracts.TeamInvitationSummary.State.get -> DodoSSH.Contracts.TeamInvitationState
DodoSSH.Contracts.TeamInvitationSummary.State.init -> void
DodoSSH.Contracts.TeamInvitationSummary.TeamInvitationSummary(System.Guid InvitationId, string! Email, DodoSSH.Contracts.TeamMemberRole Role, DodoSSH.Contracts.TeamInvitationState State, System.Guid InvitedByUserId, System.DateTimeOffset CreatedAt, System.DateTimeOffset ExpiresAt, System.DateTimeOffset? AcceptedAt) -> void
DodoSSH.Contracts.TeamMemberRole DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberRole.Admin = 30 -> DodoSSH.Contracts.TeamMemberRole DodoSSH.Contracts.TeamMemberRole.Admin = 30 -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberRole.Member = 20 -> DodoSSH.Contracts.TeamMemberRole DodoSSH.Contracts.TeamMemberRole.Member = 20 -> DodoSSH.Contracts.TeamMemberRole
@@ -793,9 +754,6 @@ override DodoSSH.Contracts.AddTeamMemberRequest.ToString() -> string!
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Equals(object? obj) -> bool override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.GetHashCode() -> int override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.GetHashCode() -> int
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.ToString() -> string! override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.ToString() -> string!
override DodoSSH.Contracts.CreateTeamInvitationRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.CreateTeamInvitationRequest.GetHashCode() -> int
override DodoSSH.Contracts.CreateTeamInvitationRequest.ToString() -> string!
override DodoSSH.Contracts.CreateTeamRequest.Equals(object? obj) -> bool override DodoSSH.Contracts.CreateTeamRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.CreateTeamRequest.GetHashCode() -> int override DodoSSH.Contracts.CreateTeamRequest.GetHashCode() -> int
override DodoSSH.Contracts.CreateTeamRequest.ToString() -> string! override DodoSSH.Contracts.CreateTeamRequest.ToString() -> string!
@@ -889,9 +847,6 @@ override DodoSSH.Contracts.SyncPushResponse.ToString() -> string!
override DodoSSH.Contracts.SyncPushResult.Equals(object? obj) -> bool override DodoSSH.Contracts.SyncPushResult.Equals(object? obj) -> bool
override DodoSSH.Contracts.SyncPushResult.GetHashCode() -> int override DodoSSH.Contracts.SyncPushResult.GetHashCode() -> int
override DodoSSH.Contracts.SyncPushResult.ToString() -> string! override DodoSSH.Contracts.SyncPushResult.ToString() -> string!
override DodoSSH.Contracts.TeamInvitationSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.TeamInvitationSummary.GetHashCode() -> int
override DodoSSH.Contracts.TeamInvitationSummary.ToString() -> string!
override DodoSSH.Contracts.TeamMemberSummary.Equals(object? obj) -> bool override DodoSSH.Contracts.TeamMemberSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.TeamMemberSummary.GetHashCode() -> int override DodoSSH.Contracts.TeamMemberSummary.GetHashCode() -> int
override DodoSSH.Contracts.TeamMemberSummary.ToString() -> string! override DodoSSH.Contracts.TeamMemberSummary.ToString() -> string!
@@ -926,8 +881,6 @@ static DodoSSH.Contracts.AddTeamMemberRequest.operator !=(DodoSSH.Contracts.AddT
static DodoSSH.Contracts.AddTeamMemberRequest.operator ==(DodoSSH.Contracts.AddTeamMemberRequest? left, DodoSSH.Contracts.AddTeamMemberRequest? right) -> bool static DodoSSH.Contracts.AddTeamMemberRequest.operator ==(DodoSSH.Contracts.AddTeamMemberRequest? left, DodoSSH.Contracts.AddTeamMemberRequest? right) -> bool
static DodoSSH.Contracts.ChangeTeamMemberRoleRequest.operator !=(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? left, DodoSSH.Contracts.ChangeTeamMemberRoleRequest? right) -> bool static DodoSSH.Contracts.ChangeTeamMemberRoleRequest.operator !=(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? left, DodoSSH.Contracts.ChangeTeamMemberRoleRequest? right) -> bool
static DodoSSH.Contracts.ChangeTeamMemberRoleRequest.operator ==(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? left, DodoSSH.Contracts.ChangeTeamMemberRoleRequest? right) -> bool static DodoSSH.Contracts.ChangeTeamMemberRoleRequest.operator ==(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? left, DodoSSH.Contracts.ChangeTeamMemberRoleRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamInvitationRequest.operator !=(DodoSSH.Contracts.CreateTeamInvitationRequest? left, DodoSSH.Contracts.CreateTeamInvitationRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamInvitationRequest.operator ==(DodoSSH.Contracts.CreateTeamInvitationRequest? left, DodoSSH.Contracts.CreateTeamInvitationRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamRequest.operator !=(DodoSSH.Contracts.CreateTeamRequest? left, DodoSSH.Contracts.CreateTeamRequest? right) -> bool static DodoSSH.Contracts.CreateTeamRequest.operator !=(DodoSSH.Contracts.CreateTeamRequest? left, DodoSSH.Contracts.CreateTeamRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamRequest.operator ==(DodoSSH.Contracts.CreateTeamRequest? left, DodoSSH.Contracts.CreateTeamRequest? right) -> bool static DodoSSH.Contracts.CreateTeamRequest.operator ==(DodoSSH.Contracts.CreateTeamRequest? left, DodoSSH.Contracts.CreateTeamRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamVaultRequest.operator !=(DodoSSH.Contracts.CreateTeamVaultRequest? left, DodoSSH.Contracts.CreateTeamVaultRequest? right) -> bool static DodoSSH.Contracts.CreateTeamVaultRequest.operator !=(DodoSSH.Contracts.CreateTeamVaultRequest? left, DodoSSH.Contracts.CreateTeamVaultRequest? right) -> bool
@@ -993,8 +946,6 @@ static DodoSSH.Contracts.SyncPushResponse.operator !=(DodoSSH.Contracts.SyncPush
static DodoSSH.Contracts.SyncPushResponse.operator ==(DodoSSH.Contracts.SyncPushResponse? left, DodoSSH.Contracts.SyncPushResponse? right) -> bool static DodoSSH.Contracts.SyncPushResponse.operator ==(DodoSSH.Contracts.SyncPushResponse? left, DodoSSH.Contracts.SyncPushResponse? right) -> bool
static DodoSSH.Contracts.SyncPushResult.operator !=(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool static DodoSSH.Contracts.SyncPushResult.operator !=(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool
static DodoSSH.Contracts.SyncPushResult.operator ==(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool static DodoSSH.Contracts.SyncPushResult.operator ==(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool
static DodoSSH.Contracts.TeamInvitationSummary.operator !=(DodoSSH.Contracts.TeamInvitationSummary? left, DodoSSH.Contracts.TeamInvitationSummary? right) -> bool
static DodoSSH.Contracts.TeamInvitationSummary.operator ==(DodoSSH.Contracts.TeamInvitationSummary? left, DodoSSH.Contracts.TeamInvitationSummary? right) -> bool
static DodoSSH.Contracts.TeamMemberSummary.operator !=(DodoSSH.Contracts.TeamMemberSummary? left, DodoSSH.Contracts.TeamMemberSummary? right) -> bool static DodoSSH.Contracts.TeamMemberSummary.operator !=(DodoSSH.Contracts.TeamMemberSummary? left, DodoSSH.Contracts.TeamMemberSummary? right) -> bool
static DodoSSH.Contracts.TeamMemberSummary.operator ==(DodoSSH.Contracts.TeamMemberSummary? left, DodoSSH.Contracts.TeamMemberSummary? right) -> bool static DodoSSH.Contracts.TeamMemberSummary.operator ==(DodoSSH.Contracts.TeamMemberSummary? left, DodoSSH.Contracts.TeamMemberSummary? right) -> bool
static DodoSSH.Contracts.TeamSummary.operator !=(DodoSSH.Contracts.TeamSummary? left, DodoSSH.Contracts.TeamSummary? right) -> bool static DodoSSH.Contracts.TeamSummary.operator !=(DodoSSH.Contracts.TeamSummary? left, DodoSSH.Contracts.TeamSummary? right) -> bool
+19 -102
View File
@@ -50,16 +50,15 @@ public enum TeamMemberStatus
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Still nothing writes this, and invitations shipping is the reason rather than an exception to /// <b>Nothing writes this, and nothing in this server can.</b> A membership names an account —
/// it. A membership names an account: <c>team_membership.user_id</c> is not nullable and carries a /// <c>team_membership.user_id</c> is not nullable and carries a foreign key — so a row in this
/// foreign key, so somebody who has never signed in has nothing for that row to point at. An /// state would have to point at somebody who has never signed in. There was a separate invitation
/// invitation is therefore its own record against an <em>address</em> /// record that stood in for exactly that, and it is gone: an address is not a way into a team, and
/// (<see cref="TeamInvitationSummary"/>), and it becomes a membership at /// only an account that exists can be added. See <c>docs/adr/0009-team-access-model.md</c>.
/// <see cref="Active"/> the moment an account with that address first signs in.
/// </para> /// </para>
/// <para> /// <para>
/// Retained because the column exists and a client must not fail on a value a later server may /// Retained because the column exists and holds this value in nobody's database, and because a
/// send — a server that grew a second invitation model would use it. /// client must not fail on a value a later server may send.
/// </para> /// </para>
/// </remarks> /// </remarks>
Invited = 1, Invited = 1,
@@ -224,8 +223,16 @@ public sealed record TeamMemberSummary(
/// is invisible there. It is still an account, and it can still be a member: membership is server-side /// is invisible there. It is still an account, and it can still be a member: membership is server-side
/// authorization and grants nothing readable, which is why <see cref="TeamMemberSummary.IsEnrolled"/> /// authorization and grants nothing readable, which is why <see cref="TeamMemberSummary.IsEnrolled"/>
/// exists to say that a member has no key yet. Without this field such a person could not be added at /// exists to say that a member has no key yet. Without this field such a person could not be added at
/// all, and a caller reading the directory's silence as "no account here" would invite an address that /// all, and a caller reading the directory's silence as "no account here" would report an absence to
/// already has one. /// somebody who is standing right there.
/// </para>
/// <para>
/// <b>This is the only way into a team.</b> There is no invitation and no address-based path — a
/// membership is granted to an account that already exists, named by somebody who can see it. That
/// rules out the shape where a team is joined by whoever turns up holding a token asserting an
/// address, which is the same attack <c>OidcOptions.AllowEmailLinking</c> refuses one door along. The
/// cost is stated rather than hidden: somebody who has never signed in here cannot be added yet, and
/// the refusal says so. See <c>docs/adr/0009-team-access-model.md</c>.
/// </para> /// </para>
/// <para> /// <para>
/// No key is verified on this path, and none needs to be: nothing is wrapped by adding somebody. The /// No key is verified on this path, and none needs to be: nothing is wrapped by adding somebody. The
@@ -241,7 +248,8 @@ public sealed record TeamMemberSummary(
/// <param name="Email"> /// <param name="Email">
/// The address to resolve, used only when <see cref="UserId"/> is <see cref="Guid.Empty"/>. Matched /// The address to resolve, used only when <see cref="UserId"/> is <see cref="Guid.Empty"/>. Matched
/// case-insensitively, exactly as the directory matches. An address with no account here is refused /// case-insensitively, exactly as the directory matches. An address with no account here is refused
/// with <see cref="ProblemCodes.NoSuchAccount"/> so the caller can offer an invitation instead. /// with <see cref="ProblemCodes.NoSuchAccount"/>, which is the end of the road rather than a step on
/// it: the remedy is that person signing in once, and it belongs to them rather than to the caller.
/// </param> /// </param>
public sealed record AddTeamMemberRequest( public sealed record AddTeamMemberRequest(
Guid UserId, Guid UserId,
@@ -252,97 +260,6 @@ public sealed record AddTeamMemberRequest(
/// <param name="Role">The new role.</param> /// <param name="Role">The new role.</param>
public sealed record ChangeTeamMemberRoleRequest(TeamMemberRole Role); public sealed record ChangeTeamMemberRoleRequest(TeamMemberRole Role);
/// <summary>What has become of an invitation.</summary>
/// <remarks>
/// Derived from the invitation's own timestamps rather than stored, so — unlike every other enum in
/// this file — it has no <c>DodoSSH.Domain</c> twin and no numbering to keep in step. That is the
/// point of computing it: <see cref="Expired"/> is a fact about the clock, and a stored state would
/// have to be swept by something that remembered to run.
/// </remarks>
public enum TeamInvitationState
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>Waiting. It becomes a membership when an account with this address signs in.</summary>
Pending = 1,
/// <summary>Taken up. The address signed in and is now a member.</summary>
Accepted = 2,
/// <summary>Withdrawn before it was taken up.</summary>
Revoked = 3,
/// <summary>Its lifetime ran out. It will not become a membership.</summary>
Expired = 4,
}
/// <summary>Invites an address that has no account here yet.</summary>
/// <remarks>
/// <para>
/// <b>By email, where <see cref="AddTeamMemberRequest"/> is by user id.</b> Adding a member resolves an
/// address through the directory first, so the caller sees the public key they are about to wrap a
/// vault to. An invitation cannot do that — there may be no account and therefore no key — so it grants
/// nothing readable and is never a step towards sharing.
/// </para>
/// <para>
/// <b>An address that already has an account is accepted rather than refused</b>, and only an address
/// already belonging to a member of this team is turned away. Refusing on the strength of an account
/// existing would make this endpoint an oracle for which addresses have accounts here, answerable by
/// anybody willing to create a team first — and it would be answering a question the caller did not
/// ask. Whether the account exists changes only how soon the invitation is taken up: an existing one
/// picks it up on its next request.
/// </para>
/// <para>
/// <b>There is no token and nothing is sent.</b> This server has no outbound mail path, so the
/// invitation is not a link: it is a standing instruction that the next account to sign in with this
/// address joins the team. Telling them to sign in is the caller's job, over a channel this server
/// does not carry. That also means the address has to be one the identity provider will assert and
/// mark verified — an unverified email is refused at claim time, because an invitation that anybody
/// could take by naming somebody else's address is a way in.
/// </para>
/// </remarks>
/// <param name="InvitationId">
/// Client-generated UUIDv7, for the reason a team id is client-generated: a create whose response was
/// lost can be re-sent verbatim rather than leaving two invitations to the same address.
/// </param>
/// <param name="Email">The address to invite. Matched case-insensitively.</param>
/// <param name="Role">
/// Role to grant on arrival. May not be <see cref="TeamMemberRole.Owner"/> — ownership is sole and is
/// handed over deliberately, never conferred by an address signing in.
/// </param>
public sealed record CreateTeamInvitationRequest(
Guid InvitationId,
string Email,
TeamMemberRole Role);
/// <summary>One invitation, as the teams interface sees it.</summary>
/// <remarks>
/// The address is in plaintext here, as it is on <see cref="TeamMemberSummary"/>. It is readable by
/// the team's members, who are the people it concerns; the server stores it in plaintext either way
/// and docs/crypto.md §10 already records that membership metadata is not encrypted.
/// </remarks>
/// <param name="InvitationId">The invitation.</param>
/// <param name="Email">The address invited.</param>
/// <param name="Role">The role it will grant.</param>
/// <param name="State">What has become of it.</param>
/// <param name="InvitedByUserId">Who issued it.</param>
/// <param name="CreatedAt">When it was issued.</param>
/// <param name="ExpiresAt">
/// When it stops being claimable. An invitation that never expired would be a standing offer on an
/// address somebody may hand on or lose.
/// </param>
/// <param name="AcceptedAt">When an account with this address signed in and took it up, if one has.</param>
public sealed record TeamInvitationSummary(
Guid InvitationId,
string Email,
TeamMemberRole Role,
TeamInvitationState State,
Guid InvitedByUserId,
DateTimeOffset CreatedAt,
DateTimeOffset ExpiresAt,
DateTimeOffset? AcceptedAt);
/// <summary> /// <summary>
/// Creates a vault owned by a team, with its key already wrapped to the creator. /// Creates a vault owned by a team, with its key already wrapped to the creator.
/// </summary> /// </summary>
+1 -1
View File
@@ -66,7 +66,7 @@ public sealed record Argon2Profile
public static Argon2Profile PassphraseHigh { get; } = new(512, 4); public static Argon2Profile PassphraseHigh { get; } = new(512, 4);
/// <summary> /// <summary>
/// Profile for 128-bit random secrets — recovery codes and invite secrets. /// Profile for 128-bit random secrets — recovery codes.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// KDF hardening is nearly irrelevant for a full-entropy random secret; this is /// KDF hardening is nearly irrelevant for a full-entropy random secret; this is
+10 -66
View File
@@ -33,9 +33,6 @@ public sealed class Team
/// <summary>Members.</summary> /// <summary>Members.</summary>
public ICollection<TeamMembership> Memberships { get; } = []; public ICollection<TeamMembership> Memberships { get; } = [];
/// <summary>Invitations to addresses that have no account here yet.</summary>
public ICollection<TeamInvitation> Invitations { get; } = [];
} }
/// <summary> /// <summary>
@@ -68,10 +65,18 @@ public sealed class TeamMembership
/// <summary>Membership state.</summary> /// <summary>Membership state.</summary>
public MembershipStatus Status { get; set; } public MembershipStatus Status { get; set; }
/// <summary>Who invited them.</summary> /// <summary>
/// Which admin added them.
/// </summary>
/// <remarks>
/// Named for the invitations that used to be the other way in. They are gone — an account is added
/// by somebody who names it, and there is no path that creates a membership out of a token's email
/// claim — so this now records only that, and the column keeps its name rather than costing a
/// migration to rename a field nothing reads but an audit trail.
/// </remarks>
public Guid? InvitedByUserId { get; set; } public Guid? InvitedByUserId { get; set; }
/// <summary>When the invitation was accepted.</summary> /// <summary>When the membership became active.</summary>
public DateTimeOffset? JoinedAtUtc { get; set; } public DateTimeOffset? JoinedAtUtc { get; set; }
/// <summary>Creation timestamp.</summary> /// <summary>Creation timestamp.</summary>
@@ -81,64 +86,3 @@ public sealed class TeamMembership
public DateTimeOffset? DeletedAtUtc { get; set; } public DateTimeOffset? DeletedAtUtc { get; set; }
} }
/// <summary>
/// A standing offer of membership to an email address that has no account here yet.
/// </summary>
/// <remarks>
/// <para>
/// <b>Its own table rather than a <see cref="TeamMembership"/> with
/// <see cref="MembershipStatus.Invited"/>.</b> A membership names an account —
/// <c>team_membership.user_id</c> is not nullable and carries a foreign key to
/// <see cref="UserAccount"/> — so an invitee who has never signed in has nothing for that row to
/// point at. Widening that column would make the unique index on (team, user) meaningless, because
/// PostgreSQL counts every NULL as distinct, and would silently change what every
/// <c>m.UserId == user.Id</c> query in the server means.
/// </para>
/// <para>
/// <b>There is no token.</b> Nothing is sent, because this server has no outbound mail path; the row
/// is an instruction to the sign-in path rather than a secret somebody presents. That is why it is
/// keyed on the address and why the address has to be one the identity provider marks verified before
/// the claim is honoured — an unclaimable invitation is an inconvenience, but one claimable by
/// anybody who can assert an address is a way into the team.
/// </para>
/// <para>
/// Revoked and accepted rows are retained rather than deleted, as <see cref="VaultKeyGrant"/> is and
/// for the same reason: the uniqueness that matters is among <em>pending</em> invitations, and the
/// history of who invited whom stays resolvable.
/// </para>
/// </remarks>
public sealed class TeamInvitation
{
/// <summary>Primary key.</summary>
public Guid Id { get; set; }
/// <summary>The team.</summary>
public Guid TeamId { get; set; }
/// <summary>The team.</summary>
public Team? Team { get; set; }
/// <summary>The address invited. Case-insensitive.</summary>
public string Email { get; set; } = string.Empty;
/// <summary>Role the membership will carry when it is claimed.</summary>
public TeamRole Role { get; set; }
/// <summary>Who issued it.</summary>
public Guid InvitedByUserId { get; set; }
/// <summary>Creation timestamp.</summary>
public DateTimeOffset CreatedAtUtc { get; set; }
/// <summary>When it stops being claimable.</summary>
public DateTimeOffset ExpiresAtUtc { get; set; }
/// <summary>When an account with this address signed in and took it up.</summary>
public DateTimeOffset? AcceptedAtUtc { get; set; }
/// <summary>Which account took it up.</summary>
public Guid? AcceptedByUserId { get; set; }
/// <summary>Revocation timestamp.</summary>
public DateTimeOffset? RevokedAtUtc { get; set; }
}
@@ -62,47 +62,6 @@ public sealed class TeamMembershipConfiguration : IEntityTypeConfiguration<TeamM
} }
} }
/// <summary>Maps <see cref="TeamInvitation"/>.</summary>
public sealed class TeamInvitationConfiguration : IEntityTypeConfiguration<TeamInvitation>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<TeamInvitation> builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.ToTable("team_invitation");
builder.HasKey(i => i.Id);
builder.Property(i => i.Id).ValueGeneratedNever();
builder.UseXminConcurrencyToken();
// citext, matching user_account.email: the claim at sign-in compares an address the identity
// provider chose the casing of against one a person typed, and lower() on both sides of that
// is a rule somebody eventually forgets on one side.
builder.Property(i => i.Email).HasColumnType("citext").HasMaxLength(320).IsRequired();
builder.Property(i => i.Role).HasConversion<int>();
builder.HasOne(i => i.Team)
.WithMany(t => t.Invitations)
.HasForeignKey(i => i.TeamId)
.OnDelete(DeleteBehavior.Cascade);
// One live invitation per address per team. Filtered on the two tombstones rather than on a
// deletion marker, as vault_key_grant is: an accepted or withdrawn invitation is kept, and
// re-inviting an address whose invitation lapsed has to be possible.
//
// Expiry is deliberately not in this predicate. A partial index predicate must be IMMUTABLE,
// so now() cannot appear in one; an expired invitation therefore still holds the slot, and
// the service treats replacing one as a revoke-and-reissue rather than a second insert.
builder.HasIndex(i => new { i.TeamId, i.Email })
.IsUnique()
.HasFilter("accepted_at_utc IS NULL AND revoked_at_utc IS NULL");
// The claim at sign-in knows the address and nothing else — it is looking for every team that
// invited this person, across all of them — so the address is the hot direction here.
builder.HasIndex(i => i.Email);
}
}
/// <summary>Maps <see cref="Vault"/>.</summary> /// <summary>Maps <see cref="Vault"/>.</summary>
public sealed class VaultConfiguration : IEntityTypeConfiguration<Vault> public sealed class VaultConfiguration : IEntityTypeConfiguration<Vault>
{ {
@@ -45,9 +45,6 @@ public class DodoDbContext(DbContextOptions<DodoDbContext> options) : DbContext(
/// <summary>Team memberships.</summary> /// <summary>Team memberships.</summary>
public DbSet<TeamMembership> TeamMemberships => Set<TeamMembership>(); public DbSet<TeamMembership> TeamMemberships => Set<TeamMembership>();
/// <summary>Invitations to addresses with no account here yet.</summary>
public DbSet<TeamInvitation> TeamInvitations => Set<TeamInvitation>();
/// <summary>Vaults.</summary> /// <summary>Vaults.</summary>
public DbSet<Vault> Vaults => Set<Vault>(); public DbSet<Vault> Vaults => Set<Vault>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DodoSSH.Infrastructure.Migrations
{
/// <summary>
/// Removes the invitation table. Membership is now only ever granted to an account that exists.
/// </summary>
/// <remarks>
/// <para>
/// <b>Pending invitations are dropped rather than converted, and that is the decision rather than
/// the omission.</b> Converting one would mean creating a membership because an address matched —
/// which is the exact property this change exists to remove, and the same one
/// <c>OidcOptions.AllowEmailLinking</c> refuses one door along. In practice almost nothing is lost:
/// an invitation to an address that already had an account here was claimed within the hour by the
/// sign-in sweep, so what is left in this table is offers to people who never arrived, and there
/// was never an account to make a membership out of.
/// </para>
/// <para>
/// The Down is a faithful rebuild of the empty table and nothing else. It cannot bring the rows
/// back, and a migration that pretended otherwise would be worse than one that says so.
/// </para>
/// </remarks>
public partial class DropTeamInvitation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "team_invitation",
schema: "dodo");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "team_invitation",
schema: "dodo",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
team_id = table.Column<Guid>(type: "uuid", nullable: false),
accepted_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
accepted_by_user_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
email = table.Column<string>(type: "citext", maxLength: 320, nullable: false),
expires_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
invited_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
revoked_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
role = table.Column<int>(type: "integer", nullable: false),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_team_invitation", x => x.id);
table.ForeignKey(
name: "fk_team_invitation_team_team_id",
column: x => x.team_id,
principalSchema: "dodo",
principalTable: "team",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_team_invitation_email",
schema: "dodo",
table: "team_invitation",
column: "email");
migrationBuilder.CreateIndex(
name: "ix_team_invitation_team_id_email",
schema: "dodo",
table: "team_invitation",
columns: new[] { "team_id", "email" },
unique: true,
filter: "accepted_at_utc IS NULL AND revoked_at_utc IS NULL");
}
}
}
@@ -316,70 +316,6 @@ namespace DodoSSH.Infrastructure.Migrations
b.ToTable("team", "dodo"); b.ToTable("team", "dodo");
}); });
modelBuilder.Entity("DodoSSH.Domain.TeamInvitation", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset?>("AcceptedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("accepted_at_utc");
b.Property<Guid?>("AcceptedByUserId")
.HasColumnType("uuid")
.HasColumnName("accepted_by_user_id");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at_utc");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(320)
.HasColumnType("citext")
.HasColumnName("email");
b.Property<DateTimeOffset>("ExpiresAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at_utc");
b.Property<Guid>("InvitedByUserId")
.HasColumnType("uuid")
.HasColumnName("invited_by_user_id");
b.Property<DateTimeOffset?>("RevokedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at_utc");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<Guid>("TeamId")
.HasColumnType("uuid")
.HasColumnName("team_id");
b.Property<uint>("xmin")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.HasKey("Id")
.HasName("pk_team_invitation");
b.HasIndex("Email")
.HasDatabaseName("ix_team_invitation_email");
b.HasIndex("TeamId", "Email")
.IsUnique()
.HasDatabaseName("ix_team_invitation_team_id_email")
.HasFilter("accepted_at_utc IS NULL AND revoked_at_utc IS NULL");
b.ToTable("team_invitation", "dodo");
});
modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b => modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1644,18 +1580,6 @@ namespace DodoSSH.Infrastructure.Migrations
b.Navigation("Vault"); b.Navigation("Vault");
}); });
modelBuilder.Entity("DodoSSH.Domain.TeamInvitation", b =>
{
b.HasOne("DodoSSH.Domain.Team", "Team")
.WithMany("Invitations")
.HasForeignKey("TeamId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_team_invitation_team_team_id");
b.Navigation("Team");
});
modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b => modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b =>
{ {
b.HasOne("DodoSSH.Domain.Team", "Team") b.HasOne("DodoSSH.Domain.Team", "Team")
@@ -1858,8 +1782,6 @@ namespace DodoSSH.Infrastructure.Migrations
modelBuilder.Entity("DodoSSH.Domain.Team", b => modelBuilder.Entity("DodoSSH.Domain.Team", b =>
{ {
b.Navigation("Invitations");
b.Navigation("Memberships"); b.Navigation("Memberships");
}); });
+7 -6
View File
@@ -84,17 +84,18 @@ public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
/// <summary>Creates a client carrying a valid token for the given subject.</summary> /// <summary>Creates a client carrying a valid token for the given subject.</summary>
/// <remarks> /// <remarks>
/// <paramref name="emailVerified"/> defaults to true, which is what a provider asserts about an /// There is no verified-address knob here, and its absence is worth a sentence. It used to exist so
/// address it has checked and what every ordinary sign-in means. Passing false mints a token that /// a test could present the one shape the server refused — an address the provider had not vouched
/// carries the address and no <c>email_verified</c> claim — the shape a team invitation has to /// for, offered against a pending team invitation. Nothing in the server reads
/// refuse, and the only way a test can present it. /// <c>email_verified</c> now, because nothing decides access from an address at all, so a parameter
/// here would be one that changes no outcome.
/// </remarks> /// </remarks>
public HttpClient CreateClientFor(string subject, string? email = null, bool emailVerified = true) public HttpClient CreateClientFor(string subject, string? email = null)
{ {
var client = CreateClient(); var client = CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", "Bearer",
IdentityProvider.MintToken(subject, email, emailVerified: emailVerified)); IdentityProvider.MintToken(subject, email));
return client; return client;
} }
@@ -89,13 +89,10 @@ public sealed class EndpointInventoryTests(ApiFixture fixture)
"DELETE /api/v1/teams/{teamId:guid} name=ArchiveTeam tags=Teams policies=Authenticated anon=False", "DELETE /api/v1/teams/{teamId:guid} name=ArchiveTeam tags=Teams policies=Authenticated anon=False",
"POST /api/v1/teams/{teamId:guid}/owner name=TransferTeamOwnership tags=Teams policies=Authenticated anon=False", "POST /api/v1/teams/{teamId:guid}/owner name=TransferTeamOwnership tags=Teams policies=Authenticated anon=False",
// Authenticated, and pointedly not Enrolled. An invitation names an address that may have no // There are deliberately no invitation routes here, and their absence is the assertion. A team
// account at all and certainly holds no key; gating these on Enrolled would be demanding a key // was once joinable by an address the server had never seen, claimed at sign-in from the token's
// of the one participant the feature exists for. Membership is not readability — somebody still // email claim; membership is now only ever granted to an account somebody named. If three
// has to wrap the vault key afterwards — so no key is involved on either side. // /invitations entries reappear in this list, that property has been given back.
"GET /api/v1/teams/{teamId:guid}/invitations name=ListTeamInvitations tags=Teams policies=Authenticated anon=False",
"POST /api/v1/teams/{teamId:guid}/invitations name=CreateTeamInvitation tags=Teams policies=Authenticated anon=False",
"DELETE /api/v1/teams/{teamId:guid}/invitations/{invitationId:guid} name=RevokeTeamInvitation tags=Teams policies=Authenticated anon=False",
// Enrolled, because both end in a vault key being wrapped: creating a team means creating a vault // Enrolled, because both end in a vault key being wrapped: creating a team means creating a vault
// in it, and neither is reachable without a key of one's own. // in it, and neither is reachable without a key of one's own.
@@ -67,22 +67,19 @@ public sealed class StubIdentityProvider : IDisposable
/// <param name="audience">Override the audience, to test rejection.</param> /// <param name="audience">Override the audience, to test rejection.</param>
/// <param name="issuer">Override the issuer, to test rejection.</param> /// <param name="issuer">Override the issuer, to test rejection.</param>
/// <param name="expires">Override expiry, to test rejection.</param> /// <param name="expires">Override expiry, to test rejection.</param>
/// <param name="emailVerified"> /// <remarks>
/// Whether the token asserts <c>email_verified</c> over <paramref name="email"/>. True by /// <c>email_verified</c> travels beside an address because a real provider sends it, and for no
/// default, because that is what a provider says about an address it has checked and every /// other reason: the server reads it nowhere. It did once — to decide whether a pending team
/// existing caller means a genuine sign-in. False omits the claim outright rather than sending /// invitation addressed to that address could be claimed — and there are no invitations. Keeping it
/// <c>false</c>: an absent claim is what a provider that was never configured to send one /// in the token keeps this stub honest about the shape of a real one.
/// produces, and it is the case the server must not read as verified. The claim is emitted only /// </remarks>
/// alongside an address, since on its own it asserts nothing about anybody.
/// </param>
public string MintToken( public string MintToken(
string subject, string subject,
string? email = null, string? email = null,
string? name = null, string? name = null,
string? audience = null, string? audience = null,
string? issuer = null, string? issuer = null,
DateTime? expires = null, DateTime? expires = null)
bool emailVerified = true)
{ {
var now = TimeProvider.System.GetUtcNow().UtcDateTime; var now = TimeProvider.System.GetUtcNow().UtcDateTime;
@@ -95,17 +92,13 @@ public sealed class StubIdentityProvider : IDisposable
{ {
claims.Add(new System.Security.Claims.Claim("email", email)); claims.Add(new System.Security.Claims.Claim("email", email));
if (emailVerified) // Boolean, so the handler writes a JSON boolean rather than a quoted string, which is what
{ // a real provider sends.
// Boolean, so the handler writes a JSON boolean rather than a quoted string. A real
// provider sends one, and the server parses rather than compares — so a test that
// sent a string would agree with an implementation that only handled strings.
claims.Add(new System.Security.Claims.Claim( claims.Add(new System.Security.Claims.Claim(
"email_verified", "email_verified",
"true", "true",
System.Security.Claims.ClaimValueTypes.Boolean)); System.Security.Claims.ClaimValueTypes.Boolean));
} }
}
if (name is not null) if (name is not null)
{ {
+34 -295
View File
@@ -5,7 +5,7 @@ using DodoSSH.Contracts;
namespace DodoSSH.Api.Tests; namespace DodoSSH.Api.Tests;
/// <summary> /// <summary>
/// Teams, membership, invitations and the vault key grants that make a team vault readable. /// Teams, membership and the vault key grants that make a team vault readable.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
@@ -24,9 +24,9 @@ namespace DodoSSH.Api.Tests;
/// <para> /// <para>
/// Two boundaries in here are load-bearing beyond their own endpoint, and both are of that invisible /// Two boundaries in here are load-bearing beyond their own endpoint, and both are of that invisible
/// kind. An admin who can archive a team or hand it away is an admin who can take it from the person /// kind. An admin who can archive a team or hand it away is an admin who can take it from the person
/// who promoted them, and nothing about the response would say so. An invitation claimed on an address /// who promoted them, and nothing about the response would say so. A team that could be joined by
/// the identity provider never vouched for is a way into somebody else's team, and every other part of /// whoever turns up holding a token asserting an address is a team anybody who can obtain such a token
/// that request succeeds. Neither has a symptom; each has a test. /// is in, and every other part of that request succeeds. Neither has a symptom; each has a test.
/// </para> /// </para>
/// </remarks> /// </remarks>
[Collection(ApiCollection.Name)] [Collection(ApiCollection.Name)]
@@ -709,7 +709,7 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
/// An account exists from its owner's first authenticated request and publishes no key until they /// An account exists from its owner's first authenticated request and publishes no key until they
/// enroll, and the directory omits it for that whole window — deliberately, because an entry exists /// enroll, and the directory omits it for that whole window — deliberately, because an entry exists
/// to be wrapped to. So the lookup is asserted empty first: that is not a missing account, and a /// to be wrapped to. So the lookup is asserted empty first: that is not a missing account, and a
/// caller that read it as one would invite an address that already has one. /// caller that read it as one would report an absence to somebody who is standing right there.
/// </para> /// </para>
/// <para> /// <para>
/// Adding by address is what covers the gap, and the member it produces says <c>IsEnrolled</c> /// Adding by address is what covers the gap, and the member it produces says <c>IsEnrolled</c>
@@ -749,8 +749,8 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
/// <remarks> /// <remarks>
/// The other side of it. An address with no account is refused under its own code rather than the /// The other side of it. An address with no account is refused under its own code rather than the
/// general one, because the caller can act on it unprompted — there is nobody to add, so invite /// general one, because it is the one refusal on this path that is not about the request — a code
/// them — and a code shared with a rejected role would leave them guessing which had happened. /// shared with a rejected role would have the caller checking what they typed.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task AddingAnAddressWithNoAccount_IsRefusedWithItsOwnCode() public async Task AddingAnAddressWithNoAccount_IsRefusedWithItsOwnCode()
@@ -928,263 +928,35 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
self.LastActiveAt.Value.ShouldBeGreaterThan(TimeProvider.System.GetUtcNow().AddHours(-1)); self.LastActiveAt.Value.ShouldBeGreaterThan(TimeProvider.System.GetUtcNow().AddHours(-1));
} }
// ---- Invitations ----
/// <remarks> /// <remarks>
/// The expiry is asserted rather than merely present. An invitation that never lapsed would be a /// <b>The refusal is the end of the road, and this is what pins that.</b> A team was once joinable
/// standing offer against an address, and company addresses are handed to the next person to hold /// by an address the server had never seen: an invitation row waited, and the next account to sign
/// the job — so the person who inherits the mailbox would inherit the team. /// in with that address became a member on the strength of its token's email claim. Adding somebody
/// who is not here is now refused outright, and — the half that would be easy to lose — signing in
/// afterwards joins nothing. Without the second act this test would pass against a server that had
/// merely renamed the deferred path.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task InvitingAnAddress_ListsItAsPendingWithItsRoleAndAnExpiry() public async Task AnAddressRefusedForHavingNoAccount_JoinsNothingWhenItLaterSignsIn()
{ {
var owner = await EnrolledClientAsync("invite-owner"); var owner = await EnrolledClientAsync("no-deferred-owner", "ndowner@example.com");
var team = await CreateTeamAsync(owner, "No deferred joins");
var address = NewAddress(); var address = NewAddress();
var team = await CreateTeamAsync(owner, "Invitations"); var refused = await owner.PostContractAsync(
var created = await InviteAsync(owner, team.TeamId, address, TeamMemberRole.Admin); MembersUrl(team.TeamId),
new AddTeamMemberRequest(Guid.Empty, TeamMemberRole.Member, address));
var listed = await FindInvitationAsync(owner, team.TeamId, created.InvitationId); await ShouldBeProblemAsync(refused, HttpStatusCode.NotFound, ProblemCodes.NoSuchAccount);
listed.Email.ShouldBe(address); var (arrival, userId) = await SignInAsync(address);
listed.Role.ShouldBe(TeamMemberRole.Admin);
listed.State.ShouldBe(TeamInvitationState.Pending);
listed.AcceptedAt.ShouldBeNull();
(listed.ExpiresAt - listed.CreatedAt).ShouldBe(TimeSpan.FromDays(14));
}
/// <remarks> (await ReadAsync<IReadOnlyList<TeamSummary>>(arrival, TeamsUrl))
/// The same body twice, as a client whose response was lost would send it — the shape team and .ShouldNotContain(row => row.TeamId == team.TeamId);
/// vault creation already have. Two invitations to one address would show the same person twice on
/// the teams screen and take two revocations to withdraw.
/// </remarks>
[Fact]
public async Task RepeatingAnInvitation_ReturnsTheSameOneRatherThanASecond()
{
var owner = await EnrolledClientAsync("invite-retry-owner");
var address = NewAddress();
var team = await CreateTeamAsync(owner, "Retried invitations"); (await ReadAsync<IReadOnlyList<TeamMemberSummary>>(owner, MembersUrl(team.TeamId)))
.ShouldNotContain(row => row.UserId == userId);
var request = new CreateTeamInvitationRequest(
Guid.CreateVersion7(), address, TeamMemberRole.Member);
var first = await PostAsync<CreateTeamInvitationRequest, TeamInvitationSummary>(
owner, InvitationsUrl(team.TeamId), request);
var second = await PostAsync<CreateTeamInvitationRequest, TeamInvitationSummary>(
owner, InvitationsUrl(team.TeamId), request);
second.InvitationId.ShouldBe(first.InvitationId);
var listed = await ReadAsync<IReadOnlyList<TeamInvitationSummary>>(
owner, InvitationsUrl(team.TeamId));
// Case-insensitively, because the column is citext and two addresses differing only in case
// are one address — a second row under a different casing would still be a second invitation.
listed.Count(row => string.Equals(row.Email, address, StringComparison.OrdinalIgnoreCase))
.ShouldBe(1);
}
[Fact]
public async Task ASecondInvitationToTheSameAddress_IsRefused()
{
var owner = await EnrolledClientAsync("invite-duplicate-owner");
var address = NewAddress();
var team = await CreateTeamAsync(owner, "Duplicate invitations");
await InviteAsync(owner, team.TeamId, address);
// A different id, so this is a second invitation rather than a retry of the first.
var response = await owner.PostContractAsync(
InvitationsUrl(team.TeamId),
new CreateTeamInvitationRequest(Guid.CreateVersion7(), address, TeamMemberRole.Admin));
await ShouldBeProblemAsync(
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation);
}
/// <remarks>
/// Ownership is sole and is handed over deliberately. An invitation that conferred it would let an
/// address typed once take the team the moment somebody signed in with it — and the invitee is by
/// definition somebody nobody here has met.
/// </remarks>
[Fact]
public async Task InvitingSomebodyAsOwner_IsRefused()
{
var owner = await EnrolledClientAsync("invite-owner-role-owner");
var team = await CreateTeamAsync(owner, "Not for sale");
var response = await owner.PostContractAsync(
InvitationsUrl(team.TeamId),
new CreateTeamInvitationRequest(
Guid.CreateVersion7(), NewAddress(), TeamMemberRole.Owner));
await ShouldBeProblemAsync(
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation);
}
[Fact]
public async Task AMalformedAddress_IsRefused()
{
var owner = await EnrolledClientAsync("invite-malformed-owner");
var team = await CreateTeamAsync(owner, "Shapes");
var response = await owner.PostContractAsync(
InvitationsUrl(team.TeamId),
new CreateTeamInvitationRequest(
Guid.CreateVersion7(), "not an address", TeamMemberRole.Member));
await ShouldBeProblemAsync(
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation);
}
/// <remarks>
/// A withdrawn invitation stays in the listing rather than vanishing, so the screen can show that it
/// was withdrawn rather than letting it read as never sent. Withdrawing it twice is 404, for the
/// reason revoking a device grant gives: a caller driving towards "that invitation will not let
/// anybody in" can treat 404 as having arrived.
/// </remarks>
[Fact]
public async Task RevokingAnInvitation_MarksItRevokedAndCannotBeDoneTwice()
{
var owner = await EnrolledClientAsync("invite-revoke-owner");
var team = await CreateTeamAsync(owner, "Withdrawals");
var invitation = await InviteAsync(owner, team.TeamId, NewAddress());
var revoked = await DeleteAsync(
owner, InvitationUrl(team.TeamId, invitation.InvitationId));
revoked.StatusCode.ShouldBe(HttpStatusCode.NoContent);
var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId);
listed.State.ShouldBe(TeamInvitationState.Revoked);
var again = await DeleteAsync(owner, InvitationUrl(team.TeamId, invitation.InvitationId));
again.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
/// <remarks>
/// An invitation is a fact about the team, so any member may read the list — whoever is about to be
/// handed a vault key needs to see who else is on their way in — but only an admin may write one.
/// </remarks>
[Fact]
public async Task APlainMember_MayReadInvitationsAndMayNotIssueThem()
{
var owner = await EnrolledClientAsync("invite-reader-owner", "irowner@example.com");
var member = await EnrolledClientAsync("invite-reader-member", "irmember@example.com");
var team = await CreateTeamAsync(owner, "Readable invitations");
var entry = await LookupAsync(owner, "irmember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var invitation = await InviteAsync(owner, team.TeamId, NewAddress());
var listed = await ReadAsync<IReadOnlyList<TeamInvitationSummary>>(
member, InvitationsUrl(team.TeamId));
listed.ShouldContain(row => row.InvitationId == invitation.InvitationId);
var refused = await member.PostContractAsync(
InvitationsUrl(team.TeamId),
new CreateTeamInvitationRequest(
Guid.CreateVersion7(), NewAddress(), TeamMemberRole.Member));
await ShouldBeProblemAsync(refused, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
}
// ---- Claiming an invitation at sign-in ----
/// <remarks>
/// The heart of the feature, and the only path by which an invitation becomes anything. There is no
/// token and no mail: the row says "the next account to sign in with this address joins this team",
/// and just-in-time provisioning is what reads it. What it creates is a membership and not a key —
/// the vault key still has to be wrapped to them from a machine that holds one.
/// </remarks>
[Fact]
public async Task SigningInWithAnInvitedAddress_JoinsTheTeamAndMarksTheInvitationAccepted()
{
var owner = await EnrolledClientAsync("claim-owner");
var address = NewAddress();
var team = await CreateTeamAsync(owner, "Claimed");
var invitation = await InviteAsync(owner, team.TeamId, address, TeamMemberRole.Member);
var (invitee, inviteeUserId) = await SignInAsync(address);
(await ReadAsync<IReadOnlyList<TeamSummary>>(invitee, TeamsUrl))
.Where(row => row.TeamId == team.TeamId)
.ShouldHaveSingleItem()
.Role.ShouldBe(TeamMemberRole.Member);
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
owner, MembersUrl(team.TeamId));
var joined = members.Where(row => row.UserId == inviteeUserId).ShouldHaveSingleItem();
joined.Status.ShouldBe(TeamMemberStatus.Active);
joined.Role.ShouldBe(TeamMemberRole.Member);
joined.IsEnrolled.ShouldBeFalse("membership is authorization, and they hold no key yet");
var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId);
listed.State.ShouldBe(TeamInvitationState.Accepted);
listed.AcceptedAt.ShouldNotBeNull();
}
/// <remarks>
/// <b>The security test this feature stands on.</b> An invitation is authorization: claiming one is
/// what decides that this server will serve somebody a team's vaults. An address the identity
/// provider has not vouched for is an address anybody able to obtain a token can name, so an
/// unverified one must confer nothing — that is the same attack <c>AllowEmailLinking</c> exists to
/// refuse, arriving by a different door. The failure would be silent by construction: the account is
/// provisioned either way and every other part of the request succeeds, so nothing but this would
/// notice that the wrong person had walked into the team.
/// </remarks>
[Fact]
public async Task AnAddressTheProviderHasNotVerified_ClaimsNothing()
{
var owner = await EnrolledClientAsync("unverified-owner");
var address = NewAddress();
var team = await CreateTeamAsync(owner, "Verified only");
var invitation = await InviteAsync(owner, team.TeamId, address);
var (impostor, _) = await SignInAsync(address, emailVerified: false);
(await ReadAsync<IReadOnlyList<TeamSummary>>(impostor, TeamsUrl)).ShouldBeEmpty();
var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId);
listed.State.ShouldBe(TeamInvitationState.Pending);
listed.AcceptedAt.ShouldBeNull();
}
/// <remarks>
/// A withdrawn invitation is withdrawn, which the claim path has to honour independently — it reads
/// the invitation table directly rather than going back through the endpoint that refused.
/// </remarks>
[Fact]
public async Task SigningInAfterAnInvitationWasWithdrawn_JoinsNothing()
{
var owner = await EnrolledClientAsync("withdrawn-owner");
var address = NewAddress();
var team = await CreateTeamAsync(owner, "Withdrawn");
var invitation = await InviteAsync(owner, team.TeamId, address);
await DeleteAsync(owner, InvitationUrl(team.TeamId, invitation.InvitationId));
var (invitee, _) = await SignInAsync(address);
(await ReadAsync<IReadOnlyList<TeamSummary>>(invitee, TeamsUrl)).ShouldBeEmpty();
} }
// ---- Vault key grants ---- // ---- Vault key grants ----
@@ -1531,17 +1303,12 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
private static string MemberRoleUrl(Guid teamId, Guid userId) => private static string MemberRoleUrl(Guid teamId, Guid userId) =>
$"{MemberUrl(teamId, userId)}/role"; $"{MemberUrl(teamId, userId)}/role";
private static string InvitationsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/invitations";
private static string InvitationUrl(Guid teamId, Guid invitationId) =>
$"{InvitationsUrl(teamId)}/{invitationId}";
private static string TeamVaultsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/vaults"; private static string TeamVaultsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/vaults";
private static string VaultUrl(Guid vaultId) => $"/api/v1/vaults/{vaultId}"; private static string VaultUrl(Guid vaultId) => $"/api/v1/vaults/{vaultId}";
/// <summary>An address no account holds, uniquified because the container is shared.</summary> /// <summary>An address no account holds, uniquified because the container is shared.</summary>
private static string NewAddress() => $"invitee-{Guid.CreateVersion7():N}@example.com"; private static string NewAddress() => $"newcomer-{Guid.CreateVersion7():N}@example.com";
private async Task<HttpClient> EnrolledClientAsync(string subject, string? email = null) private async Task<HttpClient> EnrolledClientAsync(string subject, string? email = null)
{ {
@@ -1565,18 +1332,16 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
return client; return client;
} }
/// <summary>Signs a brand-new account in, which is what claims any invitation to its address.</summary> /// <summary>Signs a brand-new account in, without enrolling it.</summary>
/// <remarks> /// <remarks>
/// No enrollment, because an invitee has no key and the claim path must not need one — requiring it /// No enrollment, because the account this stands for is somebody between their first sign-in and
/// would be requiring it of exactly the person who cannot yet supply it. Driving <c>/me</c> is what /// setting a machine up — the one the directory cannot answer for and who can still be added by
/// runs just-in-time provisioning, and provisioning is where the claim happens. /// address. Driving <c>/me</c> is what runs just-in-time provisioning, which is what creates the
/// account at all.
/// </remarks> /// </remarks>
private async Task<(HttpClient Client, Guid UserId)> SignInAsync( private async Task<(HttpClient Client, Guid UserId)> SignInAsync(string email)
string email,
bool emailVerified = true)
{ {
var client = fixture.CreateClientFor( var client = fixture.CreateClientFor($"newcomer-{Guid.CreateVersion7():N}", email);
$"invitee-{Guid.CreateVersion7():N}", email, emailVerified);
var me = await ReadAsync<MeResponse>(client, MeUrl); var me = await ReadAsync<MeResponse>(client, MeUrl);
@@ -1675,32 +1440,6 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
private static Task<TeamInvitationSummary> InviteAsync(
HttpClient client,
Guid teamId,
string email,
TeamMemberRole role = TeamMemberRole.Member) =>
PostAsync<CreateTeamInvitationRequest, TeamInvitationSummary>(
client,
InvitationsUrl(teamId),
new CreateTeamInvitationRequest(Guid.CreateVersion7(), email, role));
/// <remarks>
/// Filtered by id rather than taken from a position in the list, because the container is shared and
/// every other class's invitations are in the same table. A count over the whole listing would pass
/// or fail depending on what else ran.
/// </remarks>
private static async Task<TeamInvitationSummary> FindInvitationAsync(
HttpClient client,
Guid teamId,
Guid invitationId)
{
var listed = await ReadAsync<IReadOnlyList<TeamInvitationSummary>>(
client, InvitationsUrl(teamId));
return listed.Where(row => row.InvitationId == invitationId).ShouldHaveSingleItem();
}
private static async Task<TResponse> PostAsync<TRequest, TResponse>( private static async Task<TResponse> PostAsync<TRequest, TResponse>(
HttpClient client, HttpClient client,
string url, string url,
@@ -1706,7 +1706,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
OnTheVaultsScreenAsync( OnTheVaultsScreenAsync(
vaults => { }, vaults => { },
window => LayoutHarness.Unreachable(window) window => LayoutHarness.Unreachable(window)
.ShouldBeEmpty("the vaults screen with members, invitations and key holders")); .ShouldBeEmpty("the vaults screen with members and key holders"));
/// <remarks> /// <remarks>
/// The rename form is drawn in place, above the members list, and pushes everything below it down. /// The rename form is drawn in place, above the members list, and pushes everything below it down.
@@ -12,7 +12,7 @@ namespace DodoSSH.Client.App.Layout.Tests;
/// <remarks> /// <remarks>
/// <para> /// <para>
/// The vaults screen draws its list from the session and everything under it from the server: who is in a /// The vaults screen draws its list from the session and everything under it from the server: who is in a
/// vault, who has been invited, and who holds a key are all read on open, and the suite's /// vault and who holds a key are both read on open, and the suite's
/// <c>FakeAccountServer</c> implements <see cref="IAccountApi"/> and nothing else. Rather than teach that /// <c>FakeAccountServer</c> implements <see cref="IAccountApi"/> and nothing else. Rather than teach that
/// fake five more interfaces for one screen, this serves fixed rows and refuses everything a layout test /// fake five more interfaces for one screen, this serves fixed rows and refuses everything a layout test
/// has no business calling. /// has no business calling.
@@ -124,25 +124,6 @@ internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
LastActiveAt: null), LastActiveAt: null),
]); ]);
/// <inheritdoc />
public Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<TeamInvitationSummary>>(
[
// Pending, because its sentence is the long one — it has to carry the whole mechanism,
// since nothing was sent and there is nothing else on the screen that could say so.
new TeamInvitationSummary(
Guid.CreateVersion7(),
"wilhelmina.ashworth-blake@dodotech.example",
TeamMemberRole.Admin,
TeamInvitationState.Pending,
OwnerId,
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch.AddDays(14),
AcceptedAt: null),
]);
/// <inheritdoc /> /// <inheritdoc />
public Task<VaultGrantsResponse> ListVaultGrantsAsync( public Task<VaultGrantsResponse> ListVaultGrantsAsync(
Guid vaultId, Guid vaultId,
@@ -216,18 +197,6 @@ internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
Guid userId, Guid userId,
CancellationToken cancellationToken) => throw new NotSupportedException(); CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<TeamInvitationSummary> CreateTeamInvitationAsync(
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<bool> RevokeTeamInvitationAsync(
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <summary> /// <summary>
/// Accepts the vault, so a layout test can put a shared one into the session it is drawing. /// Accepts the vault, so a layout test can put a shared one into the session it is drawing.
/// </summary> /// </summary>
@@ -36,7 +36,6 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
grants = []; grants = [];
private readonly List<KeyLogRecord> keyLog = []; private readonly List<KeyLogRecord> keyLog = [];
private readonly List<DirectoryEntry> directory = []; private readonly List<DirectoryEntry> directory = [];
private readonly Dictionary<Guid, List<TeamInvitationSummary>> invitations = [];
/// <summary> /// <summary>
/// Every account on this fake server, enrolled or not. /// Every account on this fake server, enrolled or not.
@@ -345,7 +344,6 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
teams.RemoveAt(index); teams.RemoveAt(index);
members.Remove(teamId); members.Remove(teamId);
invitations.Remove(teamId);
return Task.FromResult(true); return Task.FromResult(true);
} }
@@ -464,69 +462,6 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
return Task.FromResult(member); return Task.FromResult(member);
} }
/// <inheritdoc />
public Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<TeamInvitationSummary>>(
invitations.TryGetValue(teamId, out var list) ? [.. list] : []);
/// <inheritdoc />
public Task<TeamInvitationSummary> CreateTeamInvitationAsync(
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken)
{
var list = invitations.GetValueOrDefault(teamId, []);
if (list.Exists(invitation =>
invitation.State == TeamInvitationState.Pending
&& string.Equals(invitation.Email, request.Email, StringComparison.OrdinalIgnoreCase)))
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.BadRequest,
ProblemCodes.InvalidTeamInvitation,
"There is already an invitation to that address for this team.");
}
var invited = new TeamInvitationSummary(
request.InvitationId,
request.Email,
request.Role,
TeamInvitationState.Pending,
UserId,
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch.AddDays(14),
AcceptedAt: null);
invitations[teamId] = [.. list, invited];
return Task.FromResult(invited);
}
/// <inheritdoc />
public Task<bool> RevokeTeamInvitationAsync(
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken)
{
var list = invitations.GetValueOrDefault(teamId, []);
var index = list.FindIndex(invitation =>
invitation.InvitationId == invitationId
&& invitation.State == TeamInvitationState.Pending);
if (index < 0)
{
return Task.FromResult(false);
}
// Kept and marked rather than removed, as the server keeps it: the screen has to be able to
// say an invitation was withdrawn rather than letting it vanish and read as never sent.
list[index] = list[index] with { State = TeamInvitationState.Revoked };
return Task.FromResult(true);
}
/// <inheritdoc /> /// <inheritdoc />
public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync( public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
Guid teamId, Guid teamId,
@@ -697,7 +632,6 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
{ {
teams.RemoveAll(team => team.TeamId == teamId); teams.RemoveAll(team => team.TeamId == teamId);
members.Remove(teamId); members.Remove(teamId);
invitations.Remove(teamId);
} }
return Task.FromResult(true); return Task.FromResult(true);
@@ -30,8 +30,8 @@ namespace DodoSSH.Client.App.Tests;
/// <para> /// <para>
/// It was <c>TeamSharingTests</c>, and the screen it drives stopped being about teams: a vault is what /// It was <c>TeamSharingTests</c>, and the screen it drives stopped being about teams: a vault is what
/// gets made and named, and the membership list behind it is made with it. The team is still what the /// gets made and named, and the membership list behind it is made with it. The team is still what the
/// server authorises against, which is why the assertions about roles, hand-over and invitations are all /// server authorises against, which is why the assertions about roles and hand-over are all still here —
/// still here — they are the same operations, reached through the vault they apply to. /// they are the same operations, reached through the vault they apply to.
/// </para> /// </para>
/// </remarks> /// </remarks>
public sealed class VaultSharingTests : IAsyncLifetime public sealed class VaultSharingTests : IAsyncLifetime
@@ -120,7 +120,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
var vaultId = vaults.SelectedVault!.VaultId; var vaultId = vaults.SelectedVault!.VaultId;
vaults.InviteEmail = "bob@example.com"; vaults.NewMemberEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null); await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.Members.Count.ShouldBe(2, vaults.Status); vaults.Members.Count.ShouldBe(2, vaults.Status);
@@ -148,7 +148,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
await CreateVaultAsync(vaults, "Platform secrets"); await CreateVaultAsync(vaults, "Platform secrets");
vaults.InviteEmail = "bob@example.com"; vaults.NewMemberEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null); await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague); vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
@@ -190,7 +190,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
foreach (var address in (string[])["bob@example.com", "carol@example.com"]) foreach (var address in (string[])["bob@example.com", "carol@example.com"])
{ {
vaults.InviteEmail = address; vaults.NewMemberEmail = address;
await vaults.AddMemberCommand.ExecuteAsync(null); await vaults.AddMemberCommand.ExecuteAsync(null);
} }
@@ -235,7 +235,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
var vaultId = vaults.SelectedVault!.VaultId; var vaultId = vaults.SelectedVault!.VaultId;
vaults.InviteEmail = "bob@example.com"; vaults.NewMemberEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null); await vaults.AddMemberCommand.ExecuteAsync(null);
// Removing them is what rotates the vault, so the next person to be added arrives at a vault // Removing them is what rotates the vault, so the next person to be added arrives at a vault
@@ -243,7 +243,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == first); vaults.SelectedMember = vaults.Members.Single(member => member.UserId == first);
await vaults.RemoveMemberCommand.ExecuteAsync(null); await vaults.RemoveMemberCommand.ExecuteAsync(null);
vaults.InviteEmail = "carol@example.com"; vaults.NewMemberEmail = "carol@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null); await vaults.AddMemberCommand.ExecuteAsync(null);
server.GenerationsGranted(vaultId, second).ShouldBe([1u, 2u], vaults.Status); server.GenerationsGranted(vaultId, second).ShouldBe([1u, 2u], vaults.Status);
@@ -275,7 +275,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
// that corrupted the log afterwards would be asserting about the second one only. // that corrupted the log afterwards would be asserting about the second one only.
server.CorruptKeyLog = true; server.CorruptKeyLog = true;
vaults.InviteEmail = "mallory@example.com"; vaults.NewMemberEmail = "mallory@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null); await vaults.AddMemberCommand.ExecuteAsync(null);
var vaultId = vaults.SelectedVault!.VaultId; var vaultId = vaults.SelectedVault!.VaultId;
@@ -412,7 +412,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
var vaultId = vaults.SelectedVault!.VaultId; var vaultId = vaults.SelectedVault!.VaultId;
vaults.InviteEmail = "bob@example.com"; vaults.NewMemberEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null); await vaults.AddMemberCommand.ExecuteAsync(null);
server.IssuedGrants.ShouldContainKey((vaultId, colleague)); server.IssuedGrants.ShouldContainKey((vaultId, colleague));
@@ -486,7 +486,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
vaults.SelectedVault = personal; vaults.SelectedVault = personal;
vaults.SelectedIsShared.ShouldBeFalse(); vaults.SelectedIsShared.ShouldBeFalse();
vaults.InviteEmail = "bob@example.com"; vaults.NewMemberEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null); await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.Members.ShouldBeEmpty(); vaults.Members.ShouldBeEmpty();
@@ -1185,7 +1185,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
await CreateVaultAsync(vaults, "Platform secrets"); await CreateVaultAsync(vaults, "Platform secrets");
vaults.InviteEmail = "bob@example.com"; vaults.NewMemberEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null); await vaults.AddMemberCommand.ExecuteAsync(null);
// Two, and the creator is the other: their own self-grant is what makes a vault they just made // Two, and the creator is the other: their own self-grant is what makes a vault they just made
@@ -1214,7 +1214,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
await CreateVaultAsync(vaults, "Platform secrets"); await CreateVaultAsync(vaults, "Platform secrets");
vaults.InviteEmail = "bob@example.com"; vaults.NewMemberEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null); await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague); vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
@@ -1240,7 +1240,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
await CreateVaultAsync(vaults, "Platform secrets"); await CreateVaultAsync(vaults, "Platform secrets");
vaults.InviteEmail = "bob@example.com"; vaults.NewMemberEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null); await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague); vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
@@ -1273,7 +1273,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
await CreateVaultAsync(vaults, "Platform secrets"); await CreateVaultAsync(vaults, "Platform secrets");
vaults.InviteEmail = "bob@example.com"; vaults.NewMemberEmail = "bob@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null); await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague); vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
@@ -1324,17 +1324,19 @@ public sealed class VaultSharingTests : IAsyncLifetime
/// <remarks> /// <remarks>
/// <para> /// <para>
/// The address the directory does not know used to be a dead end — the screen said they had to sign /// <b>An address with no account is a refusal, and the sentence has to say what to do about it.</b>
/// in first and stopped. It invites them instead, from the same button, because which of the two /// This used to issue an invitation from the same button — a standing instruction that the next
/// applies is a fact about the server's account table rather than about what the user is doing. /// account signing in with that address joined the vault. It does not any more: an address is not a
/// way in, and only an account somebody named can be added.
/// </para> /// </para>
/// <para> /// <para>
/// The status assertion is the point of the test. Nothing is sent, and an interface that said /// The status assertion is the point of the test. "No such account" on its own is a dead end that
/// "invited" without saying that would leave somebody waiting for an email that is never coming. /// reads as a typo, so what is pinned is that the message names the address and says the remedy —
/// they sign in here once — and that nothing is held for them in the meantime.
/// </para> /// </para>
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task AddingAnAddressWithNoAccount_InvitesItAndSaysNothingWasSent() public async Task AddingAnAddressWithNoAccount_IsRefusedAndSaysWhatHasToHappenFirst()
{ {
await UnlockedAsync(); await UnlockedAsync();
@@ -1342,18 +1344,14 @@ public sealed class VaultSharingTests : IAsyncLifetime
await CreateVaultAsync(vaults, "Platform secrets"); await CreateVaultAsync(vaults, "Platform secrets");
vaults.InviteEmail = "newcomer@example.com"; vaults.NewMemberEmail = "newcomer@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null); await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited"); vaults.Members.ShouldHaveSingleItem("nobody joined — there was nobody to add");
var invitation = vaults.Invitations.ShouldHaveSingleItem(); vaults.Status.ShouldContain("newcomer@example.com");
vaults.Status.ShouldContain("sign in here once");
invitation.Email.ShouldBe("newcomer@example.com"); vaults.Status.ShouldContain("Nothing is held for them");
invitation.IsPending.ShouldBeTrue();
invitation.State.ShouldContain("Nothing was sent");
vaults.Status.ShouldContain("cannot send mail");
} }
/// <remarks> /// <remarks>
@@ -1361,13 +1359,12 @@ public sealed class VaultSharingTests : IAsyncLifetime
/// The regression this whole path was rewritten for. An account exists from its owner's first /// The regression this whole path was rewritten for. An account exists from its owner's first
/// authenticated request and publishes no key until they choose a passphrase on their own machine, /// authenticated request and publishes no key until they choose a passphrase on their own machine,
/// and the directory omits it for that entire window — an entry exists to be wrapped to, and this /// and the directory omits it for that entire window — an entry exists to be wrapped to, and this
/// one has nothing to wrap. Reading that silence as "there is no such account" meant ADD quietly /// one has nothing to wrap. Reading that silence as "there is no such account" meant ADD refused
/// issued an invitation instead: the members list did not change, the screen said they had no /// somebody who was standing right there.
/// account here, and they only actually joined on the next hourly sweep.
/// </para> /// </para>
/// <para> /// <para>
/// So the assertion is that they are a <em>member</em>, not an invitation, and that the row says /// So the assertion is that they are a member, and that the row says what is true of them — no key,
/// what is true of them — no key, so nothing can be shared with them yet. /// so nothing can be shared with them yet.
/// </para> /// </para>
/// </remarks> /// </remarks>
[Fact] [Fact]
@@ -1380,11 +1377,9 @@ public sealed class VaultSharingTests : IAsyncLifetime
await CreateVaultAsync(vaults, "Platform secrets"); await CreateVaultAsync(vaults, "Platform secrets");
vaults.InviteEmail = "carol@example.com"; vaults.NewMemberEmail = "carol@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null); await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.Invitations.ShouldBeEmpty("they have an account here, so there is nothing to invite");
vaults.Members.Count.ShouldBe(2, vaults.Status); vaults.Members.Count.ShouldBe(2, vaults.Status);
var member = vaults.Members.Single(row => row.UserId == colleague); var member = vaults.Members.Single(row => row.UserId == colleague);
@@ -1399,12 +1394,13 @@ public sealed class VaultSharingTests : IAsyncLifetime
} }
/// <remarks> /// <remarks>
/// The other half of the pair above: an address with no account at all still falls through to an /// The refusal does not clear the box, and that is the half worth pinning separately. A message
/// invitation. It is the server that decides which, so this proves the fall-through survived being /// telling somebody to come back once that person has signed in is a message they act on later — with
/// moved behind it rather than being replaced by an error. /// the address gone they would have to find it again, and the natural reading of an emptied box is
/// that the add went through.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task AddingAnAddressWithNoAccount_StillInvitesRatherThanFailing() public async Task AnAddressThatWasRefused_IsStillInTheBox()
{ {
await UnlockedAsync(); await UnlockedAsync();
@@ -1412,36 +1408,10 @@ public sealed class VaultSharingTests : IAsyncLifetime
await CreateVaultAsync(vaults, "Platform secrets"); await CreateVaultAsync(vaults, "Platform secrets");
vaults.InviteEmail = "stranger@example.com"; vaults.NewMemberEmail = "stranger@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null); await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited"); vaults.NewMemberEmail.ShouldBe("stranger@example.com");
vaults.Invitations.ShouldHaveSingleItem().Email.ShouldBe("stranger@example.com");
}
/// <remarks>
/// A withdrawn invitation stays on the list saying it was withdrawn, rather than vanishing. One that
/// disappeared would read as never having been sent, which is the same thing the screen looks like
/// before anybody does anything.
/// </remarks>
[Fact]
public async Task WithdrawingAnInvitation_LeavesItListedAsWithdrawn()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
vaults.InviteEmail = "newcomer@example.com";
await vaults.AddMemberCommand.ExecuteAsync(null);
vaults.SelectedInvitation = vaults.Invitations.ShouldHaveSingleItem();
await vaults.RevokeInvitationCommand.ExecuteAsync(null);
vaults.Invitations.ShouldHaveSingleItem().State.ShouldBe("withdrawn");
vaults.Status.ShouldContain("Withdrew the invitation");
} }
/// <remarks> /// <remarks>
@@ -468,108 +468,6 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
UpdatedAtUtc = Now, UpdatedAtUtc = Now,
}; };
// ---- Team invitations ----
/// <remarks>
/// One live invitation per address per team. Without the index two admins acting a minute apart
/// would each leave a row, and the claim at sign-in would apply both — quietly overwriting whichever
/// role was decided second with whichever was written first.
/// </remarks>
[Fact]
public async Task Invitation_IsUniquePerTeamAndAddress()
{
await using var context = fixture.CreateContext();
var user = await SeedUserAsync(context);
var team = SeedTeam(context, user.Id);
var email = $"invite{Guid.CreateVersion7():N}@example.com";
context.TeamInvitations.Add(NewInvitation(team.Id, email, user.Id));
await context.SaveChangesAsync();
context.TeamInvitations.Add(NewInvitation(team.Id, email, user.Id));
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
exception.InnerException.ShouldBeOfType<PostgresException>()
.SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation);
}
/// <remarks>
/// The citext proof, and it is load-bearing rather than tidy: the address in an invitation is typed
/// by a person and the one on the token is chosen by the identity provider, so a column that
/// compared them case-sensitively would let <c>Alice@</c> and <c>alice@</c> be two invitations and
/// would make the claim at sign-in miss the one that was actually sent.
/// </remarks>
[Fact]
public async Task Invitation_IsCaseInsensitivelyUniquePerTeam()
{
await using var context = fixture.CreateContext();
var user = await SeedUserAsync(context);
var team = SeedTeam(context, user.Id);
var email = $"Invite{Guid.CreateVersion7():N}@Example.com";
context.TeamInvitations.Add(NewInvitation(team.Id, email.ToUpperInvariant(), user.Id));
await context.SaveChangesAsync();
context.TeamInvitations.Add(NewInvitation(team.Id, email.ToLowerInvariant(), user.Id));
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
exception.InnerException.ShouldBeOfType<PostgresException>()
.SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation);
}
/// <remarks>
/// The other half of the filter. Withdrawing an invitation and issuing a fresh one — at a different
/// role, say — has to be possible, so the uniqueness is among live rows rather than all of them, and
/// the withdrawn row stays for the history.
/// </remarks>
[Fact]
public async Task Invitation_MayBeReissuedAfterItIsRevoked()
{
await using var context = fixture.CreateContext();
var user = await SeedUserAsync(context);
var team = SeedTeam(context, user.Id);
var email = $"invite{Guid.CreateVersion7():N}@example.com";
var first = NewInvitation(team.Id, email, user.Id);
context.TeamInvitations.Add(first);
await context.SaveChangesAsync();
first.RevokedAtUtc = Now;
await context.SaveChangesAsync();
context.TeamInvitations.Add(NewInvitation(team.Id, email, user.Id));
await Should.NotThrowAsync(() => context.SaveChangesAsync());
}
/// <remarks>
/// An accepted invitation frees the slot too, which is what lets somebody removed from a team be
/// invited back. The claim marks the old row accepted rather than deleting it, so without this the
/// second invitation would collide with a row that has already done its job.
/// </remarks>
[Fact]
public async Task Invitation_MayBeReissuedAfterItIsAccepted()
{
await using var context = fixture.CreateContext();
var user = await SeedUserAsync(context);
var team = SeedTeam(context, user.Id);
var email = $"invite{Guid.CreateVersion7():N}@example.com";
var first = NewInvitation(team.Id, email, user.Id);
context.TeamInvitations.Add(first);
await context.SaveChangesAsync();
first.AcceptedAtUtc = Now;
first.AcceptedByUserId = user.Id;
await context.SaveChangesAsync();
context.TeamInvitations.Add(NewInvitation(team.Id, email, user.Id));
await Should.NotThrowAsync(() => context.SaveChangesAsync());
}
private static async Task<UserAccount> SeedUserAsync(DodoDbContext context) private static async Task<UserAccount> SeedUserAsync(DodoDbContext context)
{ {
var user = NewUser("https://idp.example", Guid.CreateVersion7().ToString()); var user = NewUser("https://idp.example", Guid.CreateVersion7().ToString());
@@ -670,15 +568,4 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
ActorUserId = Guid.CreateVersion7(), ActorUserId = Guid.CreateVersion7(),
OccurredAtUtc = Now, OccurredAtUtc = Now,
}; };
private static TeamInvitation NewInvitation(Guid teamId, string email, Guid invitedBy) => new()
{
Id = Guid.CreateVersion7(),
TeamId = teamId,
Email = email,
Role = TeamRole.Member,
InvitedByUserId = invitedBy,
CreatedAtUtc = Now,
ExpiresAtUtc = Now.AddDays(14),
};
} }