Found while live-testing #70 (PR #186) with two real accounts against an isolated server.
What happens
A member holding only the built-in connect-only role opens the team vault they were just invited to and sees:
Access revoked — You no longer have access to this team vault. Contact the team owner.
They were never revoked. They are in team_members, a key-holder has wrapped the vault key for them, and the row exists in team_vault_keys.
Why
GET /v1/teams/:team_id/vault-key (server/src/routes/team_sync.rs) requires PERM_VIEW_SECRETS:
crate::permissions::require_all_team_permissions(
&pool, team_id, auth.0,
&[crate::permissions::PERM_VIEW_SECRETS],
).await?;
connect-only is 28676 in BUILTIN_ROLES = CONNECT | START_TERMINAL_SESSION | JOIN_TERMINAL_SESSION | VIEW_TERMINAL_SESSIONS. No VIEW_SECRETS, so the route returns 403, and getTeamVaultKey (src/services/teamVaultSync.ts:119) maps 403 to "forbidden" — the revocation state. From the client's side a role denial and a revocation are indistinguishable.
Note the asymmetry: reconcileTeamVaultKeys happily wraps and uploads a key for a connect-only member (put_vault_keys only validates team membership), so the server stores a key that the same server then refuses to hand back.
When it bites
Exactly at first access. fetchTeamData tries listTeamObjects first; once the vault holds objects that path serves them and the vault opens for a connect-only member anyway. It is the empty team vault — the one a joiner meets right after the owner converts a private vault and invites them — that falls through to the key route and 403s.
So the very first thing a connect-only invitee sees is a false revocation notice, and the "land them on the connections they can use" behaviour from #70 has nothing to land on.
Reproduction
- Owner A converts a private vault into a team vault (no hosts pushed yet).
- A invites B with initial role
connect-only only.
- B accepts, A comes online so the key is wrapped (
Vault keys upserted key_count=2).
- B selects the vault → "Access revoked". Server log:
GET .../vault-key → 403.
- A adds one host to the team vault → B reloads → the vault opens normally.
The decision this needs
Should a connect-only member hold a wrapped vault key at all?
- Yes — they need the vault contents to connect to anything, and they already receive one in practice. Then
get_my_vault_key should gate on PERM_CONNECT || PERM_VIEW_SECRETS rather than VIEW_SECRETS alone, and the credential-hiding stays where it already is (team_vault_secrets / the VIEW_SECRETS checks on the secret payloads).
- No — then
reconcileTeamVaultKeys should stop wrapping keys for members who may never fetch them, and the client needs a real state for "your role cannot unlock this vault" instead of borrowing the revocation copy.
Either way the client should stop rendering a bare 403 as "Access revoked" when the user is still a listed member. No copy was invented for it in PR #186 because the answer changes what the copy should say.
Related: #70, #41, #68.
Found while live-testing #70 (PR #186) with two real accounts against an isolated server.
What happens
A member holding only the built-in
connect-onlyrole opens the team vault they were just invited to and sees:They were never revoked. They are in
team_members, a key-holder has wrapped the vault key for them, and the row exists inteam_vault_keys.Why
GET /v1/teams/:team_id/vault-key(server/src/routes/team_sync.rs) requiresPERM_VIEW_SECRETS:connect-onlyis28676inBUILTIN_ROLES=CONNECT | START_TERMINAL_SESSION | JOIN_TERMINAL_SESSION | VIEW_TERMINAL_SESSIONS. NoVIEW_SECRETS, so the route returns 403, andgetTeamVaultKey(src/services/teamVaultSync.ts:119) maps 403 to"forbidden"— the revocation state. From the client's side a role denial and a revocation are indistinguishable.Note the asymmetry:
reconcileTeamVaultKeyshappily wraps and uploads a key for a connect-only member (put_vault_keysonly validates team membership), so the server stores a key that the same server then refuses to hand back.When it bites
Exactly at first access.
fetchTeamDatatrieslistTeamObjectsfirst; once the vault holds objects that path serves them and the vault opens for a connect-only member anyway. It is the empty team vault — the one a joiner meets right after the owner converts a private vault and invites them — that falls through to the key route and 403s.So the very first thing a connect-only invitee sees is a false revocation notice, and the "land them on the connections they can use" behaviour from #70 has nothing to land on.
Reproduction
connect-onlyonly.Vault keys upserted key_count=2).GET .../vault-key → 403.The decision this needs
Should a
connect-onlymember hold a wrapped vault key at all?get_my_vault_keyshould gate onPERM_CONNECT || PERM_VIEW_SECRETSrather thanVIEW_SECRETSalone, and the credential-hiding stays where it already is (team_vault_secrets/ theVIEW_SECRETSchecks on the secret payloads).reconcileTeamVaultKeysshould stop wrapping keys for members who may never fetch them, and the client needs a real state for "your role cannot unlock this vault" instead of borrowing the revocation copy.Either way the client should stop rendering a bare 403 as "Access revoked" when the user is still a listed member. No copy was invented for it in PR #186 because the answer changes what the copy should say.
Related: #70, #41, #68.