Files
OmniOilPersonal/openspec/changes/notify-approved-access-requests/design.md

309 lines
14 KiB
Markdown

# 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.