309 lines
14 KiB
Markdown
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.
|