Public Access
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:
@@ -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
|
||||
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
|
||||
somebody who has never signed in here has nothing for that row to point at. `MembershipStatus.Invited`
|
||||
has existed since the first migration and is still never written — not as an oversight, but because a
|
||||
membership waiting for a person is the one shape this model cannot store. An invitation is therefore its
|
||||
own record, `team_invitation`, held against an **address**, and it becomes an ordinary active membership
|
||||
the moment an account with that address signs in.
|
||||
somebody who has never signed in has nothing for that row to point at. `MembershipStatus.Invited` has
|
||||
existed since the first migration and has never been written, because a membership waiting for a person
|
||||
is the one shape this model cannot store. So an invitation was its own record, `team_invitation`, held
|
||||
against an **address**, and it became an ordinary active membership the moment an account with that
|
||||
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
|
||||
step towards sharing, because there is no account and so no public key to wrap a vault to. It moves the
|
||||
first half of the split earlier and leaves the second half exactly where it was.
|
||||
**That last clause was the whole feature, and it is why the feature is gone.** Everything else in this
|
||||
model decides access from something a person did: an admin naming an account, a key holder wrapping a
|
||||
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
|
||||
of the security boundary. Membership is authorisation, so an invitation that could be taken by anybody
|
||||
able to obtain a token asserting somebody else's address is a way into a team — the same attack
|
||||
`OidcOptions.AllowEmailLinking` exists to refuse, arriving by another door and deserving the same bar.
|
||||
An unverified or absent claim claims nothing and logs a warning, which is the only signal an operator
|
||||
gets that their provider is not sending it. There is deliberately no setting to trust an unverified
|
||||
address: a flag that exists is a flag somebody turns on for the afternoon their provider is
|
||||
misconfigured, and this is the one it must not be possible to turn on.
|
||||
- **Nothing is sent, and the product says so rather than implying a mail path it has not got.** There is
|
||||
no token and no link — the row is a standing instruction, and telling the invitee to go and sign in
|
||||
happens over a channel this server does not carry. A link nobody can deliver would be worse than none.
|
||||
The compensation, such as it is, is real: an invitation that is not a bearer credential is one that
|
||||
cannot be forwarded, intercepted or replayed.
|
||||
- **An address that already has an account here is accepted rather than refused.** Refusing and pointing
|
||||
at the directory would have been tidier, and would have turned the endpoint into an oracle for which
|
||||
addresses have accounts on this deployment, answerable by anybody willing to create a team first. Only
|
||||
an address already belonging to a member of *this* team is refused, and that is a fact the caller can
|
||||
already read off the members table, so naming it leaks nothing.
|
||||
Three consequences, all of them stated rather than mitigated:
|
||||
|
||||
- **Somebody who has never signed in here cannot be added yet.** The remedy is theirs: they sign in
|
||||
once, which is what creates the account, and then they can be added. Both clients say exactly that
|
||||
when the refusal comes back. This is a real loss of convenience, and it is the price of not having a
|
||||
path where an address is a credential.
|
||||
- **Nothing reads the email claim for authorisation any more**, anywhere in the server.
|
||||
`OidcOptions.EmailVerifiedClaim` was read by the claim path and by nothing else, so it went with it —
|
||||
a setting that changes no outcome is worse than no setting. The address is still recorded and still
|
||||
resolved by the directory, both for display and for naming an account that exists.
|
||||
- **Pending invitations were dropped rather than converted.** Converting one would have meant 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 was left
|
||||
was offers to people who never arrived. See the `DropTeamInvitation` migration.
|
||||
|
||||
The oracle question the old design worried about — whether this endpoint tells a caller which addresses
|
||||
have accounts here — is answered rather than avoided. `NoSuchAccount` does say so, to an admin or owner
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
edge — an address that has been invited is on the graph before its owner has ever been here — which is
|
||||
the same class of fact and worth naming rather than leaving to be noticed.
|
||||
and is not made worse here, but it is now a graph rather than a list. Every node on it is an account
|
||||
that exists: there is no longer an edge to an address whose owner has never been here, which is what
|
||||
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
|
||||
Ed25519 signature names who issued it — detectable and attributable, which is the most that is
|
||||
|
||||
Reference in New Issue
Block a user