Skip to content

feat(enrollment): enrolment tokens and join requests for Business teams - #128

Merged
kipavy merged 10 commits into
mainfrom
feat/enrollment-tokens
Nov 9, 2026
Merged

kipavy merged 10 commits into
mainfrom
feat/enrollment-tokens

Conversation

@kipavy

@kipavy kipavy commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Adds enrolment tokens for Business teams: one company-wide token lets new machines and people sign up and ask to join a team. Joins are admitted automatically only for a proven email in an allowed domain with a free seat; everything else waits as a join request an admin approves or denies. Part of VoltiusApp/voltius#757 ("Server: enrolment tokens — Business"). The client part (admin UI) follows in a separate PR; voltius-cli enroll and the first-launch screen come later.

What changes

  • Migration 068: users.email_proven_at, team_enrollment_tokens (one live token per team, secret stored as SHA-256, at least one guard: allowed domains or admin approval), team_join_requests (one pending request per user and team). A trigger clears email_proven_at whenever users.email changes, so the rule holds for any code path, including an older binary after a rollback.
  • Proven email: only the verify-email link sets email_proven_at. Accounts marked verified by migration 023 or by register's auto-verify (servers without email delivery) are not proven. Resend now sends a link to any unproven account, capped at 5 an hour per user. /v1/meta gains email_delivery.
  • Admin routes (PERM_INVITE_MEMBERS, role checks shared with join grants): POST/GET/DELETE /v1/teams/:id/enrollment-token to create (rotate), read and revoke; GET /v1/teams/:id/join-requests, POST …/:rid/approve, POST …/:rid/deny. Create and approve are Business-gated; read, revoke and deny are not.
  • Public routes: POST /v1/enrollment/resolve (own per-IP bucket, 60/min) and POST /v1/auth/register with an optional enrollment field. A valid token bypasses closed registration and the per-IP limits and is charged to a per-token budget of 500/day instead, so a fleet rollout from one office address works. A bad token is charged to the per-IP limits as before. Register with a token redeems it in the same call and returns enrollment.status.
  • Authed redeem: POST /v1/enrollment/redeem. A redeemer without a public key is refused, as join grants do. Email verification re-runs admission for that user's waiting requests.
  • Events: a new join_requests:<team> event reaches only members who can invite; admission sends the existing membership events once.
  • Register's registration-flag and rate-limit checks moved from middleware into the handler, in the same order, so it can see the token. Requests without a token behave as before.
  • Error codes: ENROLLMENT_NEEDS_GUARD, ENROLLMENT_NEEDS_APPROVAL (domain-only token on a server without email delivery), ENROLLMENT_UNAVAILABLE (402, lapsed team), ENROLLMENT_DOMAIN_MISMATCH (403); 404 unknown token or wrong secret (indistinguishable), 410 revoked or expired.

Rollback

An older binary ignores the new tables. A rollback below this change keeps working; tokens and pending requests simply wait.

Tests

cargo test: 910 passed. cargo clippy --all-targets -- -D warnings clean. New tests cover the admission table, rotation, lapsed teams, the 50-machine resolve + register rollout from one IP, approve/verify and double-redeem races, keyless redeemers, the email-change trigger, and that every foreign key to users has an ON DELETE rule.

Known gaps, left for later

  • Seat counting can overshoot by one when two teams of the same owner admit at the same moment (same as join grants today).
  • A few admission paths still take a second pool connection while holding a transaction (same pattern as join grants).

kipavy added 10 commits November 9, 2026 09:18
A malformed body is now rejected after the registration flag and IP buckets run, so a closed server answers 403 and an over-limit IP 429. Also drops the check_user_budget forwarder, formats the limiter key only on failure, restores method and path on the auth rate-limit warning, and adds token-budget and dead-token tests.
- 068: token created_by is nullable with ON DELETE SET NULL so a creator can
  delete their account; a trigger clears email_proven_at on any email change
  (update_email no longer does it itself); indexes on pending user_id and
  token_id.
- register charges the resolve bucket only when the token fails to load.
- join_requests notifications resolve permissions with one bulk query.
- redeem notifies when a re-redeem changes the reason, skips the created
  audit and notify for auto-admissions, and answers a concurrent second
  redeem with the settled state instead of 404.
- EnrollError passes statuses through; enrolment routes share one
  require_team_invites layer; create role defaults like grants; at most 50
  allowed domains; resend verification is capped at 5 an hour per user.
- Tests that read or set RESEND_API_KEY hold the env lock via DeliveryGuard.
@kipavy
kipavy merged commit e5af0d4 into main Nov 9, 2026
1 check passed
@kipavy
kipavy deleted the feat/enrollment-tokens branch November 9, 2026 11:51
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