14 KiB
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_requestshas review fields but no provisioning linkage.approve_access_requestonly updatesstatus='approved'and writes audit.- Customer identity/access currently uses
users,roles,edge_projects, anduser_project_access. - Customer admin role is
admin; provider Console roles areplatform_adminandplatform_operator. frontend-adminroutes include/loginand/setup, but no/activate.frontend-adminapiClientbypasses/auth/login,/auth/register,/auth/request-access,/auth/refresh, and/auth/setup; activation endpoints must be added to the bypass list.subscriptionsalready supportsstatus='trial'andtrial_ends_at, butget_or_create_subscription()currently auto-creates missing subscriptions asactive. Approval must create a trial subscription before billing usage reads it.notifier.rsalready sends SMTP email for alarms vialettreandSMTP_*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:
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:
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:
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 intoken_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_atmarks successful activation.revoked_at/revoked_reasonmarks 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 inhandlers/access_requests.rsis simpler.mailer.rs: generic SMTP helper withsend_email(to, subject, body)reused by alarm notifier later or wrapped for approval emails.access_request_provisioning.rsor private functions inaccess_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
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
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
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
AccessRequesttype with provisioning fields:provisioned_user_idprovisioned_project_idprovisioned_atinvitation_email_statusinvitation_email_sent_atinvitation_expires_atinvitation_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
/activatebefore protected app routes. - Add
features/auth/pages/ActivatePage.tsx. - Add
activateInvitationApiand optionalvalidateInvitationApi. - Add
/auth/invitationstoAUTH_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
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:
/activaterenders 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.appto 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.