feat(email-intake): admin CRUD for tenant intake tokens (PMS-450 phase 2) #342

Merged
YousifShkara merged 1 commit from feat/PMS-450-phase2-token-admin into main 2026-06-23 11:50:10 +02:00
Owner

Adds the operator-facing surface that turns Phase 1 from "the operator SQL-inserts a token hash and crosses their fingers" into "the operator clicks New Token, copies the plaintext into their mail gateway, and revokes it later from the same UI". Three endpoints under /api/v1/intake-tokens, all admin-gated:

  • GET /api/v1/intake-tokens -> list (active + revoked) so an operator can audit
  • POST /api/v1/intake-tokens -> mint a fresh bearer
  • DELETE /api/v1/intake-tokens/{id} -> revoke (soft-delete via revoked_at)

Security posture matches what an MSP operator expects from a webhook credential surface:

  • the plaintext bearer is on the create response EXACTLY ONCE (the new CreatedIntakeTokenResponse), with the metadata-only IntakeTokenResponse shape used by every other read. The list endpoint deliberately does not carry the plaintext;
  • only the SHA-256 hash hits the DB (matches the Phase 1 lookup path; the existing idx_tenant_intake_tokens_hash UNIQUE keeps double-mint a constraint violation, not a silent shadow);
  • 32 random bytes URL-safe base64 = ~43 chars of bearer with 256 bits of entropy. Same minting pattern mokosh-bootstrap.rs uses for OIDC confidential clients (PMS-122);
  • revoke is a soft-delete (revoked_at = COALESCE(revoked_at, NOW())) so the audit trail of "who used this token when" stays intact, and re-revoke is idempotent (the COALESCE pins the first timestamp);
  • admin-only via RequireAdmin paired with RequireAuth so non-admin attempts are 403/401, never 200.

Token rotation == revoke + mint a fresh one; there is no update endpoint. That is the standard webhook-credential pattern: once a bearer is in a mail gateway's config, the only safe rotation is "issue new, swap, revoke old".

The integration test under tests/intake_token_admin.rs pins the full round trip: tech POST fails, admin POST succeeds, the DB carries the SHA-256 hash and no plaintext, the minted token immediately works against /email-intake, list returns metadata only (no token field), tech cannot list, revoke flips revoked_at, the revoked token immediately fails against /email-intake with 401, re-revoke is idempotent (timestamp unchanged), and the revoked row stays visible to the list endpoint.

Phase 2 follow-ups still parked under PMS-450:

  • tenant_settings.email_intake_default_company_id so a missing-sender intake auto-creates a contact instead of 422'ing;
  • turn a recognised reply (matched via the references array) into a ticket comment on the existing ticket rather than just returning its id;
  • an email_intake_log audit table that captures every raw payload (headers + body) so a malformed sender can be debugged post-hoc.

#PMS-450

Adds the operator-facing surface that turns Phase 1 from "the operator SQL-inserts a token hash and crosses their fingers" into "the operator clicks New Token, copies the plaintext into their mail gateway, and revokes it later from the same UI". Three endpoints under `/api/v1/intake-tokens`, all admin-gated: - GET /api/v1/intake-tokens -> list (active + revoked) so an operator can audit - POST /api/v1/intake-tokens -> mint a fresh bearer - DELETE /api/v1/intake-tokens/{id} -> revoke (soft-delete via `revoked_at`) Security posture matches what an MSP operator expects from a webhook credential surface: - the plaintext bearer is on the create response EXACTLY ONCE (the new `CreatedIntakeTokenResponse`), with the metadata-only `IntakeTokenResponse` shape used by every other read. The list endpoint deliberately does not carry the plaintext; - only the SHA-256 hash hits the DB (matches the Phase 1 lookup path; the existing `idx_tenant_intake_tokens_hash` UNIQUE keeps double-mint a constraint violation, not a silent shadow); - 32 random bytes URL-safe base64 = ~43 chars of bearer with 256 bits of entropy. Same minting pattern mokosh-bootstrap.rs uses for OIDC confidential clients (PMS-122); - revoke is a soft-delete (`revoked_at = COALESCE(revoked_at, NOW())`) so the audit trail of "who used this token when" stays intact, and re-revoke is idempotent (the COALESCE pins the first timestamp); - admin-only via `RequireAdmin` paired with `RequireAuth` so non-admin attempts are 403/401, never 200. Token rotation == revoke + mint a fresh one; there is no update endpoint. That is the standard webhook-credential pattern: once a bearer is in a mail gateway's config, the only safe rotation is "issue new, swap, revoke old". The integration test under `tests/intake_token_admin.rs` pins the full round trip: tech POST fails, admin POST succeeds, the DB carries the SHA-256 hash and no plaintext, the minted token immediately works against `/email-intake`, list returns metadata only (no `token` field), tech cannot list, revoke flips `revoked_at`, the revoked token immediately fails against `/email-intake` with 401, re-revoke is idempotent (timestamp unchanged), and the revoked row stays visible to the list endpoint. Phase 2 follow-ups still parked under PMS-450: - `tenant_settings.email_intake_default_company_id` so a missing-sender intake auto-creates a contact instead of 422'ing; - turn a recognised reply (matched via the `references` array) into a ticket comment on the existing ticket rather than just returning its id; - an `email_intake_log` audit table that captures every raw payload (headers + body) so a malformed sender can be debugged post-hoc. #PMS-450
feat(email-intake): admin CRUD for tenant intake tokens (PMS-450 phase 2)
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 1m15s
Check / fmt + clippy + build + tests (pull_request) Successful in 4m34s
Integration / integration tests (pull_request) Successful in 4m56s
Create release / Create release from merged PR (pull_request) Successful in 1s
0b55f25402
Adds the operator-facing surface that turns Phase 1 from "the operator SQL-inserts a token hash and crosses their fingers" into "the operator clicks New Token, copies the plaintext into their mail gateway, and revokes it later from the same UI". Three endpoints under `/api/v1/intake-tokens`, all admin-gated:

  - GET  /api/v1/intake-tokens          -> list (active + revoked) so an operator can audit
  - POST /api/v1/intake-tokens          -> mint a fresh bearer
  - DELETE /api/v1/intake-tokens/{id}   -> revoke (soft-delete via `revoked_at`)

Security posture matches what an MSP operator expects from a webhook credential surface:

  - the plaintext bearer is on the create response EXACTLY ONCE (the new `CreatedIntakeTokenResponse`), with the metadata-only `IntakeTokenResponse` shape used by every other read. The list endpoint deliberately does not carry the plaintext;
  - only the SHA-256 hash hits the DB (matches the Phase 1 lookup path; the existing `idx_tenant_intake_tokens_hash` UNIQUE keeps double-mint a constraint violation, not a silent shadow);
  - 32 random bytes URL-safe base64 = ~43 chars of bearer with 256 bits of entropy. Same minting pattern mokosh-bootstrap.rs uses for OIDC confidential clients (PMS-122);
  - revoke is a soft-delete (`revoked_at = COALESCE(revoked_at, NOW())`) so the audit trail of "who used this token when" stays intact, and re-revoke is idempotent (the COALESCE pins the first timestamp);
  - admin-only via `RequireAdmin` paired with `RequireAuth` so non-admin attempts are 403/401, never 200.

Token rotation == revoke + mint a fresh one; there is no update endpoint. That is the standard webhook-credential pattern: once a bearer is in a mail gateway's config, the only safe rotation is "issue new, swap, revoke old".

The integration test under `tests/intake_token_admin.rs` pins the full round trip: tech POST fails, admin POST succeeds, the DB carries the SHA-256 hash and no plaintext, the minted token immediately works against `/email-intake`, list returns metadata only (no `token` field), tech cannot list, revoke flips `revoked_at`, the revoked token immediately fails against `/email-intake` with 401, re-revoke is idempotent (timestamp unchanged), and the revoked row stays visible to the list endpoint.

Phase 2 follow-ups still parked under PMS-450:
- `tenant_settings.email_intake_default_company_id` so a missing-sender intake auto-creates a contact instead of 422'ing;
- turn a recognised reply (matched via the `references` array) into a ticket comment on the existing ticket rather than just returning its id;
- an `email_intake_log` audit table that captures every raw payload (headers + body) so a malformed sender can be debugged post-hoc.

#PMS-450
YousifShkara deleted branch feat/PMS-450-phase2-token-admin 2026-06-23 11:50:10 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!342
No description provided.