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

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_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:

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

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

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.