feat(teams): revocable, expiring, multi-use team join grants - #9
Merged
Conversation
Adds the server half of VoltiusApp/voltius#68: a grant object that lets a link confer *membership* in a team. It cannot confer vault access — the team vault key is wrapped per member with X25519, so there is no recipient public key at link-creation time. The key follows separately once an online key-holder runs the client's reconcileTeamVaultKeys, which is why redemption fires the team_members SSE event. POST /v1/teams/:team_id/grants {role, max_uses, expires_in_secs} GET /v1/teams/:team_id/grants DELETE /v1/teams/:team_id/grants/:id POST /v1/grants/:id/preview {secret} POST /v1/grants/:id/redeem {secret, public_key} Only sha256 of the secret is stored; the secret is minted server-side and returned exactly once. Create, list and revoke sit behind PERM_INVITE_MEMBERS, the same gate as POST /v1/teams/:team_id/invite. Preview and redeem are authenticated as the caller, and the caller's own token decides who joins — the request body carries no identity and no role. Redemption locks the grant row and validates it in one transaction, so two clients racing the last use cannot both succeed. Revoked and expired are re-checked against the freshly locked row on the live path; nothing caches a resolved grant. Redeeming while already a member is a success that consumes no use. Resolution is scoped to this table and to a caller-supplied grant id, with no lookup helper shared with terminal session grants. Audit rows are written for create, revoke and redeem, awaited rather than spawned so a failure cannot pass silently. Factors the team-owner and seat-cap lookups out of invite_member so grant redemption enforces the same cap rather than opening a way around it.
`activity_counts_bucket_users_by_recency` asserted a whole-table delta on `never_seen`. LAST_SEEN_LOCK serializes everything that writes `last_seen_on`, but every `seed_user` in the suite inserts a row with the column NULL without holding that lock, and each one lands in that bucket — so any test seeding a user concurrently inflated the delta. Latent since the count shipped; the join-grant tests seed enough users to make it fire. The two active-window deltas stay on `activity_counts` (a freshly seeded user is NULL, so it moves neither); the never-seen assertion now applies the same predicate to exactly the four users this test creates.
Rust stable reached 1.98 on the CI runners, whose clippy flags `create_checkout` and `claim_handle` under `result_large_err`. Both are pre-existing and unrelated to this branch; they fail `main` too, and only went unnoticed because no run has landed since the toolchain moved. `Response` in the Err slot is deliberate — these two answer with a typed JSON error body rather than a bare status, and `email_not_verified_response` exists so a client can tell that refusal from every other 403. The lint's suggested fix, boxing the response, would cost the `IntoResponse` impl axum requires of a handler's error type, so the allow is scoped to these two functions.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Server half of VoltiusApp/voltius#68. Reasoning and hard rules come from VoltiusApp/voltius#144 Part 4.
A grant confers membership, never vault access. The team vault key is wrapped per member with X25519, so there is no recipient public key when a link is created and no link can ever carry the key. The key follows separately once an online key-holder runs the client's
reconcileTeamVaultKeys— which is why redemption has to fire theteam_membersevent, and why that is verified here rather than assumed.Migration number used: 038 (
migrations/038_team_join_grants.sql). 037 was the highest onmain; the full suite passes against a database migrated from scratch.Contract
Implemented verbatim:
Every field name and shape matches. Additions the contract did not specify are listed under "Deviations" below.
How the rules are met
sha256(secret)is stored; a test asserts the column equalssha256(convert_to(secret,'UTF8'))and that no read path can return the secret.CreateGrantResponsehas a hand-writtenDebugthat redacts it, so a stray{:?}cannot log it.require_grant_adminhelper wrappingPERM_INVITE_MEMBERS— the same bitPOST /v1/teams/:team_id/inviteuses. Revoke is scoped to(id, team_id), so a grant id alone cannot be revoked from another team.RedeemGrantRequesthas onlysecretandpublic_key. Tests post bodies carryingrole: "owner"anduser_id: <someone else>and assert the joiner is the bearer and the role is the one baked in at creation.SELECT ... FOR UPDATEon the grant row, then a conditionalUPDATE ... SET uses = uses + 1 WHERE revoked_at IS NULL AND expires_at > now() AND uses < max_uses. The lock serializes redemptions; the loser re-reads after the winner commits and is refused. ACHECK (uses <= max_uses)backs it at the schema level. The concurrent-last-use test ran 25 consecutive times with no failures.notify_membership_changedplusnotify_team_members_changed. A test subscribes to the notifier and assertsteam_members:<team_id>reaches the existing key-holder and the joiner. Confirmed again over real HTTP:/v1/sync/streamdeliveredteam_members:<team_id>to the host during a live redemption.DELETE, a third account presenting the same valid secret got410and did not become a member.team_join_grantsand to a caller-supplied grant id. No "find any grant by secret" helper exists and none is shared withsession_grants. The only thing shared is the purehash_secretsha256, noted in a comment.account_id. It appears in this change only in a comment explaining why it is absent and in two negative test assertions.join_grant.created,join_grant.revoked, andmember.joinedwith{"via":"join_grant"}. These are.awaited rather thantokio::spawned, so a write failure cannot pass silently. A test asserts the exact three rows are present; a live run then queriedaudit_logsdirectly and found all three, with the correcttarget_idand metadata.Deviations and additions
The contract did not pin these down; the client should know about them.
404for a wrong id or wrong secret (indistinguishable).410 Gonefor revoked or expired,409 Conflictfor exhausted — both only reachable by someone who already presented the correct secret, so naming the reason leaks nothing and lets the client say something true.402on the seat cap.ownercannot be granted.rolemust be one ofmanager,editor,member,connect-only; anything else is400. A link that mints owners is a privilege-escalation primitive, and the create gate is held by managers who are not owners.max_usesclamps to[1, 500](default 1);expires_in_secsclamps to[60, 2592000](default 7 days). An unattended link always expires.invite_memberenforces. A user already holding one of that owner's seats is exempt. This factored the owner and seat lookups out ofinvite_memberinto shared helpers.public_keyis optional and fills a gap, never overwrites. Set only when the user has none; overwriting a published key would orphan every vault key already wrapped to it, in this team and every other — rotation stays withPUT /v1/auth/public-key. If the redeemer ends up with no key at all, redemption is refused with400rather than creating a member no key-holder can ever wrap for.GET /grantsreturns live grants only — revoked and expired rows are history, not offers.Testing
max_usesexhaustion, the concurrent last-use race, the already-a-member no-op, gating, cross-team revoke, secret storage, key handling, seats, and audit.cargo clippy --all-targets -- -D warningsis clean.uses = 1after two redemptions by the same user, the stored hash, the assigned role, and the roster public key. The shippedcompose.ymlwas not used: itscontainer_namevalues collide with the live containers on this machine.Noted, not fixed
routes::auth::registerlogsaccount_idat INFO. Pre-existing and outside this change, but it is the same value #144 says must never be exposed.Two commits that are not the feature
CI needed both to go green. Neither touches grant behaviour, and both are separable if you would rather they landed on their own.
test(last_seen)—activity_counts_bucket_users_by_recencypinned a whole-table delta onnever_seen.LAST_SEEN_LOCKserializes everything that writeslast_seen_on, but everyseed_userin the suite inserts a row with that column NULL without holding the lock, and each one lands in that bucket. Latent since the count shipped; the 21 new tests here seed enough users to make it fire. The two active-window deltas still come fromactivity_counts; the never-seen assertion now applies the same predicate to exactly the four users the test creates.chore(clippy)— the runners'stablereached 1.98, whose clippy flagscreate_checkoutandclaim_handleunderresult_large_err. Both are pre-existing and fail onmaintoo; no run has landed since the toolchain moved, so nothing had caught it.Responsein the Err slot is deliberate in those two (typed JSON error bodies, andemail_not_verified_responseso a client can tell that refusal apart), and the lint's suggested boxing would cost theIntoResponseimpl axum requires — so the allow is scoped to those two functions rather than applied crate-wide.Local clippy here is 1.97, which is why the lint passed locally and failed in CI.