# Design: Provision Approved Access Requests ## Technical Approach Turn approval into a durable provisioning transaction followed by a recoverable email side effect. The backend owns all state transitions: approve request, create `edge_projects`, create/link customer `admin`, create `user_project_access`, create `subscriptions(status='trial')`, create activation invitation hash, then attempt SMTP delivery. Console reflects provisioning/email state; Admin owns `/activate` because customers must never land in provider Console. ## Current State - `access_requests` has review fields but no provisioning linkage. - `approve_access_request` only updates `status='approved'` and writes audit. - Customer identity/access currently uses `users`, `roles`, `edge_projects`, and `user_project_access`. - Customer admin role is `admin`; provider Console roles are `platform_admin` and `platform_operator`. - `frontend-admin` routes include `/login` and `/setup`, but no `/activate`. - `frontend-admin` `apiClient` bypasses `/auth/login`, `/auth/register`, `/auth/request-access`, `/auth/refresh`, and `/auth/setup`; activation endpoints must be added to the bypass list. - `subscriptions` already supports `status='trial'` and `trial_ends_at`, but `get_or_create_subscription()` currently auto-creates missing subscriptions as `active`. Approval must create a trial subscription before billing usage reads it. - `notifier.rs` already sends SMTP email for alarms via `lettre` and `SMTP_*` env vars. ## Architecture Decisions | ID | Decision | Rationale | Alternative Rejected | |----|----------|-----------|----------------------| | ADR-1 | Approval auto-creates an `edge_projects` row. | User chose automatic provisioning; current schema has no tenant table. | Approval modal requiring operator-selected project. | | ADR-2 | Request contact becomes/links to customer role `admin`. | Customer must enter `frontend-admin`; provider roles are internal only. | Creating `platform_admin` from customer request. | | ADR-3 | Existing email links existing user to the new project. | Prevent duplicate accounts and preserve active users. | Blocking approval on duplicate email. | | ADR-4 | New users are inserted inactive until activation. | Avoid temporary passwords and email credential leaks. | Sending generated password by email. | | ADR-5 | Activation token is opaque and hash-only at rest. | Simple revocation and no JWT leakage. | JWT invitation token. | | ADR-6 | Activation lives at `app.omnioil.app/activate`. | Customer app owns customer account activation. | Console or landing activation. | | ADR-7 | SMTP failure does not rollback provisioning. | Approval/provisioning is durable; email is recoverable. | Transaction rollback on SMTP failure. | | ADR-8 | Resend revokes unconsumed prior tokens. | Prevent multiple valid links in the wild. | Multiple parallel valid tokens. | | ADR-9 | Approved projects start with 7-day trial subscription. | Existing billing table supports trial; payment enforcement is later. | Implement full billing/payments now. | ## Data Model ### Migration Add provisioning linkage to `access_requests`: ```sql ALTER TABLE access_requests ADD COLUMN provisioned_user_id UUID REFERENCES users(id) ON DELETE SET NULL, ADD COLUMN provisioned_project_id UUID REFERENCES edge_projects(id) ON DELETE SET NULL, ADD COLUMN provisioned_at TIMESTAMPTZ; ``` Create activation invitations: ```sql CREATE TABLE access_request_invitations ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), access_request_id UUID NOT NULL REFERENCES access_requests(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, project_id UUID REFERENCES edge_projects(id) ON DELETE SET NULL, token_hash TEXT NOT NULL UNIQUE, email_status VARCHAR(30) NOT NULL DEFAULT 'pending' CHECK (email_status IN ('pending', 'sent', 'failed')), email_sent_at TIMESTAMPTZ, email_error TEXT, expires_at TIMESTAMPTZ NOT NULL, consumed_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, revoked_reason TEXT, created_by UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_access_request_invitations_request_latest ON access_request_invitations(access_request_id, created_at DESC); CREATE INDEX idx_access_request_invitations_token_hash ON access_request_invitations(token_hash) WHERE consumed_at IS NULL AND revoked_at IS NULL; ``` No `activation_status` column is added initially. Pending activation is represented by `users.is_active = false` plus an unconsumed invitation. This keeps schema churn low. ### Project Defaults The request form does not collect all `edge_projects` required fields. Approval uses deterministic defaults: | `edge_projects` field | Source | |-----------------------|--------| | `name` | Sanitized `access_requests.company`; if conflict, append short request id suffix. | | `client` | `access_requests.company` | | `operator_name` | `access_requests.company` | | `contract_number` | `PENDING-{access_request.id short}` | | `description` | `message` or `Access request from {full_name}` | | `metadata` | JSON with `source='access_request'`, request id, nit, contact, phone, position. | The project remains operationally minimal. Assets/devices/variables are out of scope. ### Trial Subscription Inside the approval transaction, insert: ```sql INSERT INTO subscriptions (project_id, tier, status, trial_ends_at, notes) VALUES ($project_id, 'starter', 'trial', NOW() + INTERVAL '7 days', 'Created from approved access request') ON CONFLICT (project_id) DO NOTHING; ``` Because `get_or_create_subscription()` currently creates `active` subscriptions by default, approval must create the trial row before any billing read path touches the project. ## Token Design - Generate 32 random bytes with a cryptographically secure RNG. - Encode raw token as base64url without padding for the email link. - Store `hex(SHA-256(raw_token))` or equivalent stable SHA-256 digest in `token_hash`. - Never log the raw token. - Never return raw token from API except internally to the email construction path. - `expires_at = now + 7 days`. - `consumed_at` marks successful activation. - `revoked_at`/`revoked_reason` marks resend invalidation. Activation validation checks token hash, then rejects if expired, consumed, revoked, request not approved, or user/project no longer exists. ## Backend Design ### New Modules - `handlers/invitations.rs`: public activation endpoint and provider resend endpoint, unless keeping access-request resend in `handlers/access_requests.rs` is simpler. - `mailer.rs`: generic SMTP helper with `send_email(to, subject, body)` reused by alarm notifier later or wrapped for approval emails. - `access_request_provisioning.rs` or private functions in `access_requests.rs`: provisioning transaction helpers. ### Endpoint Changes | Method | Path | Auth | Purpose | |--------|------|------|---------| | `POST` | `/api/platform/access-requests/{id}/approve` | `PlatformClaims` | Approve, provision, create trial, create/send activation. | | `POST` | `/api/platform/access-requests/{id}/invitation/resend` | `PlatformClaims` | Revoke previous unconsumed tokens, create/send new activation token. | | `POST` | `/api/auth/invitations/activate` | Public | Validate token, set password, activate user. | | `GET` | `/api/auth/invitations/validate?token=...` | Public | Optional but recommended for frontend preflight display. | ### Approval Transaction ```text BEGIN lock access_requests row FOR UPDATE require status IN ('pending', 'in_review') create edge_projects from request data find existing user by lower(email) if user exists: use existing user id do not change password or is_active else: create user role=admin, is_active=false, password_hash=random unusable hash insert/reactivate user_project_access user/project project_role='owner' insert subscriptions project_id status='trial' trial_ends_at=now+7d update access_requests status='approved', provisioned_user_id, provisioned_project_id, reviewed_by, reviewed_at, provisioned_at revoke any older unconsumed invitations for request create invitation row with token_hash, expires_at=now+7d audit approval/provisioning records COMMIT send email if user is inactive/pending activation or send access-ready notification if existing active user update latest invitation email_status sent/failed audit email sent/failed return approved row with provisioning/invitation summary ``` Use a generated unusable password hash for inactive users because `users.password_hash` is currently `NOT NULL`. Design implementation should prefer a random high-entropy value hashed with the existing Argon2 helper, not a fixed sentinel. ### Existing User Handling - Existing active user: link project; send “access ready” email; do not create activation token requirement for login. An invitation row may still be created for email tracking, but activation should not be required. - Existing inactive user: link project; create activation invitation so they can set password/activate. - Existing platform role user with same email: block approval with a conflict unless design explicitly supports dual customer/provider identity. Default: block and surface operator error. This avoids accidentally granting customer access to provider identity. ### Resend Flow ```text BEGIN lock request row require request status='approved' require provisioned_user_id exists revoke unconsumed invitations for request create new invitation with token_hash expires_at=now+7d audit resend COMMIT send activation/access email update email_status sent/failed ``` If the linked user is already active, resend sends access-ready notification rather than password activation. ### Activation Flow ```text POST /api/auth/invitations/activate body: { token, password } hash token BEGIN select invitation + user + request FOR UPDATE require not expired, not consumed, not revoked require request.status='approved' hash submitted password update users set password_hash, is_active=true, updated_at=NOW() update invitation consumed_at=NOW() audit activation COMMIT return { status: 'activated' } ``` Password validation should reuse existing policy, but raise the floor to at least 8 characters if compatible with current frontend schemas. Do not auto-login after activation in this change; redirect to `/login`. ## Frontend Design ### Console - Extend `AccessRequest` type with provisioning fields: - `provisioned_user_id` - `provisioned_project_id` - `provisioned_at` - `invitation_email_status` - `invitation_email_sent_at` - `invitation_expires_at` - `invitation_consumed_at` - Detail panel shows project/admin/invitation status after approval. - Approve button can remain single-click because product decision is automatic project creation. - If approval fails due project name conflict edge case not handled by suffix, show backend error toast. - Show resend as primary only when email failed or invitation expired/unconsumed; secondary otherwise. ### Admin Activation - Add route `/activate` before protected app routes. - Add `features/auth/pages/ActivatePage.tsx`. - Add `activateInvitationApi` and optional `validateInvitationApi`. - Add `/auth/invitations` to `AUTH_BYPASS_ENDPOINTS`. - Page states: - loading token validation - invalid/expired/revoked token - password form - success with link to `/login` - Do not store activation token in global auth state. ## Email Design ### Activation Email Subject: `Tu acceso a OmniOil fue aprobado` Body includes: - Company name. - Activation link. - Expiration: 7 days. - Security note: link is single-use and no password was sent. - Support contact. ### Existing Active User Email Subject: `Nuevo proyecto habilitado en OmniOil` Body includes: - Company/project name. - Login link: `https://app.omnioil.app/login`. - Note that existing credentials remain unchanged. ## Sequence ```mermaid sequenceDiagram participant Console participant API participant DB participant SMTP participant AdminApp Console->>API: POST /api/platform/access-requests/{id}/approve API->>DB: BEGIN + lock request API->>DB: create project, user/link, access, trial, invitation hash API->>DB: update request approved + audit API->>DB: COMMIT API->>SMTP: send activation/access email SMTP-->>API: accepted or failed API->>DB: update email_status + audit API-->>Console: approved + provisioning status Customer->>AdminApp: GET /activate?token=raw AdminApp->>API: POST /api/auth/invitations/activate API->>DB: validate hash, set password, activate, consume token API-->>AdminApp: activated AdminApp-->>Customer: redirect/link to /login ``` ## Testing Strategy - Backend unit/integration tests: - new user approval provisions project/user/access/trial/invitation. - existing active user links project and does not reset password. - closed request cannot be approved. - token hash stored, raw token absent from persisted fields. - expired/consumed/revoked token activation fails. - resend revokes previous unconsumed tokens. - SMTP failure keeps approved/provisioned state with failed email status. - Frontend Console tests: - approved detail shows provisioning state. - failed invitation shows resend action. - Frontend Admin tests: - `/activate` renders password form for valid token. - invalid/expired token state is shown. - successful activation redirects or links to login. ## Rollout Notes - Add `INVITATION_BASE_URL=https://app.omnioil.app` to deployment env. - Reuse `SMTP_*` env vars; if unset, dev may stub/log email but production should report failed status clearly. - Deploy backend before frontend activation route is linked in emails. - No destructive migration. Rollback leaves inactive users/invitations and trial subscriptions in DB for manual cleanup.