Skip to content

feat(teams): revocable, expiring, multi-use team join grants - #9

Merged
kipavy merged 3 commits into
mainfrom
feat/team-join-grants
Aug 25, 2026
Merged

feat(teams): revocable, expiring, multi-use team join grants#9
kipavy merged 3 commits into
mainfrom
feat/team-join-grants

Conversation

@kipavy

@kipavy kipavy commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

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 the team_members event, and why that is verified here rather than assumed.

Migration number used: 038 (migrations/038_team_join_grants.sql). 037 was the highest on main; the full suite passes against a database migrated from scratch.

Contract

Implemented verbatim:

POST   /v1/teams/:team_id/grants      {role, max_uses, expires_in_secs}
       -> {id, secret, role, max_uses, uses, expires_at}     secret returned ONCE
GET    /v1/teams/:team_id/grants      -> [{id, role, max_uses, uses, expires_at, created_by}]
DELETE /v1/teams/:team_id/grants/:id  -> 204, sets revoked_at
POST   /v1/grants/:id/preview {secret}
       -> {team_name, role, inviter_handle}
POST   /v1/grants/:id/redeem  {secret, public_key}
       -> {team_id, team_name, role}

Every field name and shape matches. Additions the contract did not specify are listed under "Deviations" below.

How the rules are met

  • Secrets. Minted server-side as 32 random bytes, base64url, 43 characters. Only sha256(secret) is stored; a test asserts the column equals sha256(convert_to(secret,'UTF8')) and that no read path can return the secret. CreateGrantResponse has a hand-written Debug that redacts it, so a stray {:?} cannot log it.
  • Gating. Create, list and revoke go through one require_grant_admin helper wrapping PERM_INVITE_MEMBERS — the same bit POST /v1/teams/:team_id/invite uses. Revoke is scoped to (id, team_id), so a grant id alone cannot be revoked from another team.
  • Identity comes from the token. RedeemGrantRequest has only secret and public_key. Tests post bodies carrying role: "owner" and user_id: <someone else> and assert the joiner is the bearer and the role is the one baked in at creation.
  • One transaction. Redemption does SELECT ... FOR UPDATE on the grant row, then a conditional UPDATE ... 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. A CHECK (uses <= max_uses) backs it at the schema level. The concurrent-last-use test ran 25 consecutive times with no failures.
  • Already a member. Checked inside the same locked transaction: a success, not an error, and not a consumed use. A test proves the single use is still available to a real joiner afterwards.
  • The SSE event fires on this path. Redemption calls notify_membership_changed plus notify_team_members_changed. A test subscribes to the notifier and asserts team_members:<team_id> reaches the existing key-holder and the joiner. Confirmed again over real HTTP: /v1/sync/stream delivered team_members:<team_id> to the host during a live redemption.
  • Revocation on the live path. Re-checked against the freshly locked row at redemption, not against anything resolved earlier. Live run: after DELETE, a third account presenting the same valid secret got 410 and did not become a member.
  • Kind-specific resolution. Every lookup is scoped to team_join_grants and to a caller-supplied grant id. No "find any grant by secret" helper exists and none is shared with session_grants. The only thing shared is the pure hash_secret sha256, noted in a comment.
  • No account_id. It appears in this change only in a comment explaining why it is absent and in two negative test assertions.
  • Audit rows confirmed in the database. join_grant.created, join_grant.revoked, and member.joined with {"via":"join_grant"}. These are .awaited rather than tokio::spawned, so a write failure cannot pass silently. A test asserts the exact three rows are present; a live run then queried audit_logs directly and found all three, with the correct target_id and metadata.

Deviations and additions

The contract did not pin these down; the client should know about them.

  1. Error codes. 404 for a wrong id or wrong secret (indistinguishable). 410 Gone for revoked or expired, 409 Conflict for 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. 402 on the seat cap.
  2. owner cannot be granted. role must be one of manager, editor, member, connect-only; anything else is 400. A link that mints owners is a privilege-escalation primitive, and the create gate is held by managers who are not owners.
  3. Clamps rather than rejections. max_uses clamps to [1, 500] (default 1); expires_in_secs clamps to [60, 2592000] (default 7 days). An unattended link always expires.
  4. The seat cap is enforced at redemption. Otherwise a link is a way around the cap that invite_member enforces. A user already holding one of that owner's seats is exempt. This factored the owner and seat lookups out of invite_member into shared helpers.
  5. public_key is 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 with PUT /v1/auth/public-key. If the redeemer ends up with no key at all, redemption is refused with 400 rather than creating a member no key-holder can ever wrap for.
  6. Rate limits. New per-user budgets: 30 mints/hour, 20 preview-or-redeem/hour. Kept separate from the session-code limiters so exhausting one feature cannot lock a user out of the other.
  7. GET /grants returns live grants only — revoked and expired rows are history, not offers.

Testing

  • 296 tests pass, including 21 new ones covering role override, expiry, revocation, max_uses exhaustion, 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 warnings is clean.
  • Live end-to-end against a throwaway Postgres and a locally built server on port 18080 — all five endpoints, then direct SQL against the database to confirm the audit rows, uses = 1 after two redemptions by the same user, the stored hash, the assigned role, and the roster public key. The shipped compose.yml was not used: its container_name values collide with the live containers on this machine.

Noted, not fixed

routes::auth::register logs account_id at 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_recency pinned 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 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 from activity_counts; the never-seen assertion now applies the same predicate to exactly the four users the test creates.
  • chore(clippy) — the runners' stable reached 1.98, whose clippy flags create_checkout and claim_handle under result_large_err. Both are pre-existing and fail on main too; no run has landed since the toolchain moved, so nothing had caught it. Response in the Err slot is deliberate in those two (typed JSON error bodies, and email_not_verified_response so a client can tell that refusal apart), and the lint's suggested boxing would cost the IntoResponse impl 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.

kipavy added 3 commits August 25, 2026 16:18
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.
@kipavy
kipavy merged commit ec770f9 into main Aug 25, 2026
2 checks passed
@kipavy
kipavy deleted the feat/team-join-grants branch August 25, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant