Access Control Evidence

Evidence of RBAC implementation, authentication mechanisms, privilege management, and audit logging

Public

Status: pre-launch. This evidence reflects implemented code and deployed infrastructure. Provii is not yet serving end-user production traffic, so production operational metrics and audit history are not yet available.

Access Control Evidence

Control Domain: UC-052 through UC-061 (Access Control) Date: 2026-02-14 Repositories Analysed:

  • admin-portal/ (primary. all access control implementation)
  • provii-management/ (HMAC-authenticated service binding for admin operations)

Executive Summary

Scope

This document provides evidence for 10 unified access control requirements (UC-052 through UC-061) across the Provii platform’s admin portal. Evidence was collected from the admin-portal and provii-management repositories, which manage platform administration.

Key Findings

Strengths:

  • Modern OAuth 2.0 + OIDC authentication via Logto identity provider
  • 3-tier RBAC hierarchy: viewer < admin < super_admin
  • Session storage in Durable Objects with AES-256-GCM encryption at rest
  • Session binding with IP + User-Agent SHA-256 hashing
  • Dual timeout enforcement (8h absolute, 30min idle)
  • PKCE (Proof Key for Code Exchange) in OAuth flows
  • WebAuthn step-up authentication for sensitive operations (YubiKey / biometric)
  • Wide-coverage audit logging via Cloudflare Workers Logs (shipped to Grafana Loki)
  • Rate limiting via KV-based distributed counters
  • CSRF protection with single-use encrypted OAuth state tokens

Gaps Identified:

  • UC-055 (Multi-Factor Authentication). MFA delegated to Logto; no application-level amr claim verification. MEDIUM gap
  • UC-059 (Access Reviews). No automated access review implementation. documented policy exists but no evidence of execution. LOW gap

Control Coverage Summary

  • Implemented. 8/10 controls (80%)
  • Partially Implemented. 2/10 controls (20%)

Detailed Findings by Control

UC-052: User Access Management

Requirement: Implement user identity management, access provisioning, and de-provisioning processes.

Status: ✅ Implemented

Evidence:

Authentication Framework

Logto OAuth 2.0 + OIDC Integration:

  • Location. admin-portal/src/utils/logto.ts (379 lines)

User Context Building (JIT Provisioning):

// admin-portal/src/utils/logto.ts:223-249
export async function buildAdminContext(
  jwt: LogtoJWTPayload,
  env: Env
): Promise<AdminUserContext | null> {
  if (!jwt.email) {
    return null;
  }

  // Extract admin portal role from Logto user roles
  const role = extractAdminRole(jwt);

  if (!role) {
    logger.warn(`[Admin] Unauthorized admin access attempt: ${jwt.email}`);
    return null;
  }

  return {
    user_id: jwt.sub,
    email: jwt.email,
    name: jwt.name,
    role,
    is_super_admin: role === 'super_admin',
    organizations: Array.isArray(jwt.organizations) ? jwt.organizations : [],
  };
}

Role Extraction from Logto JWT:

// admin-portal/src/utils/logto.ts:194-221
export function extractAdminRole(
  jwt: LogtoJWTPayload | LogtoUserInfo
): 'super_admin' | 'admin' | 'viewer' | null {
  const roles = (jwt as any).roles || [];

  // Look for admin_portal_* roles
  for (const role of roles) {
    if (typeof role === 'string' && role.startsWith('admin_portal_')) {
      const roleSuffix = role.substring('admin_portal_'.length);
      if (roleSuffix === 'super_admin' || roleSuffix === 'admin' || roleSuffix === 'viewer') {
        return roleSuffix;
      }
    }
  }

  return null; // No admin portal role found
}

Session Management

Session Creation (Durable Objects):

  • Location. admin-portal/src/utils/session.ts (368 lines)
  • Sessions stored in Durable Objects via ADMIN_PORTAL_SESSION_MANAGER binding
  • NOT in KV. Durable Objects provide transactional consistency
// admin-portal/src/utils/session.ts:89-111
export async function createAdminSession(
  env: Env,
  sessionId: string,
  session: AdminSession,
  accessToken: string,
  ipAddress: string,
  userAgent: string
): Promise<boolean> {
  // Hash IP and User-Agent for session binding (no salt, full SHA-256 hash)
  const ipHash = await hashValue(ipAddress);
  const uaHash = await hashValue(userAgent || 'unknown');

  const result = await sendRequest(env, {
    op: 'createSession',
    sessionId,
    sessionCiphertext: await encryptJson(env.ADMIN_PORTAL_SESSION_ENCRYPTION_KEY, session),
    accessToken,
    ipHash,
    userAgentHash: uaHash,
  });

  return Boolean(result);
}

Session De-provisioning (Terminate All):

// admin-portal/src/utils/session.ts:346-365
export async function terminateAllUserSessions(
  env: Env,
  userId: string,
  reason: string
): Promise<{ terminatedCount: number; terminatedSessions: string[] }> {
  const response = await sendRequest<{
    ok: boolean;
    terminatedCount: number;
    terminatedSessions: string[];
  }>(env, {
    op: 'terminateUserSessions',
    userId,
    reason,
  });

  return {
    terminatedCount: response?.terminatedCount ?? 0,
    terminatedSessions: response?.terminatedSessions ?? [],
  };
}

Access Control Policy Documentation:

  • Location. src/content/trust/security/access-control.md
  • Documents onboarding, access changes, and offboarding procedures
  • Specifies 24-hour best-effort target for access provisioning (no contractual SLA)
  • Requires background check, NDA, and security training before access grant

Implementation Status: FULL. Logto handles user lifecycle, admin-portal enforces session management via Durable Objects


UC-053: Privilege Management

Requirement: Implement least privilege access, role-based access control (RBAC), and privilege escalation prevention.

Status: ✅ Implemented

Evidence:

Role Definitions

3-Tier Role Hierarchy:

  • Location. admin-portal/src/types.ts:108
export interface AdminSession {
  // ...
  role: 'super_admin' | 'admin' | 'viewer';
  // ...
}

Role Hierarchy Enforcement (RBAC Middleware):

  • Location. admin-portal/src/middleware/rbac.ts (59 lines)
// admin-portal/src/middleware/rbac.ts:27-58
export function requireAdmin(minRole: 'viewer' | 'admin' | 'super_admin' = 'admin') {
  return async (c: Context<AppBindings>, next: () => Promise<void>) => {
    const session = c.get('session');

    if (!session) {
      return c.json({ error: 'Not authenticated' }, 401);
    }

    const roleHierarchy: Record<string, number> = {
      viewer: 0,
      admin: 1,
      super_admin: 2
    };

    const userRoleLevel = roleHierarchy[session.role as string] ?? -1;
    const requiredRoleLevel = roleHierarchy[minRole];

    if (userRoleLevel < requiredRoleLevel) {
      return c.json({
        error: 'Insufficient permissions',
        message: `This operation requires at least '${minRole}' role. You have '${session.role}'.`,
        required_role: minRole,
        current_role: session.role,
      }, 403);
    }

    await next();
  };
}

Privilege Hierarchy

RoleLevelAccess
viewer0Read-only access to metrics and dashboards
admin1Can modify most resources, manage configurations
super_admin2Full system access, can manage other admins, requires WebAuthn step-up for sensitive ops

Super Admin Step-Up Authentication

Combined Role + WebAuthn Middleware:

  • Location. admin-portal/src/middleware/webauthn-stepup.ts:123-162
// admin-portal/src/middleware/webauthn-stepup.ts:123-162
export const requireSuperAdminWithStepUp = async (
  c: Context<AppBindings>,
  next: () => Promise<void>
): Promise<Response | void> => {
  const session = c.get('session');

  if (session.role !== 'super_admin') {
    return c.json({
      error: 'Forbidden',
      message: 'This operation requires super_admin role',
    }, 403);
  }

  // Verify recent WebAuthn step-up for privileged operations
  const hasStepUp = await hasRecentStepUp(env, session.user_id);
  if (!hasStepUp) {
    return c.json({
      error: 'WebAuthn verification required',
      message: 'Super admin operations require WebAuthn re-authentication',
      require_step_up: true,
      step_up_url: '/auth/webauthn/stepup',
    }, 403);
  }

  await next();
};

Implementation Status: FULL. 3-tier RBAC hierarchy with middleware enforcement, super_admin step-up for sensitive operations


UC-054: User Authentication

Requirement: Implement strong authentication mechanisms including password policies, account lockout, and secure credential storage.

Status: ✅ Implemented

Evidence:

OAuth 2.0 + OIDC Authentication with PKCE

PKCE Challenge Generation:

  • Location. admin-portal/src/utils/logto.ts:90-120
// admin-portal/src/utils/logto.ts:90-120
export async function generatePKCEChallenge(): Promise<{
  code_verifier: string;
  code_challenge: string;
}> {
  // 32 bytes = 256 bits of entropy
  const verifierBytes = new Uint8Array(32);
  crypto.getRandomValues(verifierBytes);

  const code_verifier = btoa(String.fromCharCode(...verifierBytes))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');

  // SHA-256 hash of verifier
  const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(code_verifier));
  const code_challenge = btoa(String.fromCharCode(...new Uint8Array(hashBuffer)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');

  return { code_verifier, code_challenge };
}

Token Exchange with PKCE:

// admin-portal/src/utils/logto.ts:279-335
export async function exchangeCodeForTokens(
  code: string,
  redirectUri: string,
  env: Env,
  codeVerifier?: string
): Promise<{ access_token: string; id_token: string; refresh_token?: string } | null> {
  const config = getLogtoConfig(env);
  // Secrets Store binding for client secret
  const clientSecret = await env.LOGTO_APP_SECRET.get();

  // HTTP Basic Auth with LOGTO_APP_ID
  const credentials = btoa(`${config.appId}:${clientSecret}`);

  const bodyParams: Record<string, string> = {
    grant_type: 'authorization_code',
    code,
    redirect_uri: redirectUri,
  };
  if (codeVerifier) {
    bodyParams.code_verifier = codeVerifier;
  }

  const response = await fetch(config.tokenEndpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'Authorization': `Basic ${credentials}`,
    },
    body: new URLSearchParams(bodyParams),
    signal: AbortSignal.timeout(API_TIMEOUT),
  });
  // ...
}

OAuth State Protection (CSRF)

Encrypted State Storage in Durable Objects:

// admin-portal/src/utils/session.ts:148-169
export async function storeOAuthState(
  env: Env,
  tempSessionId: string,
  state: string,
  codeVerifier?: string
): Promise<void> {
  // Encrypt OAuth state before storage
  const encryptedState = await encryptJson(env.ADMIN_PORTAL_SESSION_ENCRYPTION_KEY, state);
  // Encrypt PKCE code_verifier server-side only
  const encryptedVerifier = codeVerifier
    ? await encryptJson(env.ADMIN_PORTAL_SESSION_ENCRYPTION_KEY, codeVerifier)
    : undefined;

  await sendRequest(env, {
    op: 'storeState',
    stateToken: tempSessionId,
    stateValue: encryptedState,
    codeVerifier: encryptedVerifier,
  });
}

OAuth state TTL: 10 minutes (set in Durable Object: Date.now() + 10 * 60 * 1000) Single-use: State deleted after validation in Durable Object

Rate Limiting (Account Lockout)

Login Rate Limiting:

  • Location. admin-portal/src/middleware/rate-limit.ts (199 lines)
// admin-portal/src/middleware/rate-limit.ts:100-114
export const rateLimitAuthLogin = rateLimit({
  limit: 5,
  windowSeconds: 300, // 5 minutes
  keyPrefix: 'auth-login',
  byIP: true,
  byUser: false
});

export const rateLimitAuthCallback = rateLimit({
  limit: 10,
  windowSeconds: 300, // 5 minutes
  keyPrefix: 'auth-callback',
  byIP: true,
  byUser: false
});
  1. KV-based distributed rate limiting using ADMIN_PORTAL_CONFIG_METADATA KV namespace
  2. Returns HTTP 429 with retry_after header for client-side backoff
  3. Sets X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset headers

Password Policy

Delegated to Logto:

  • Policy Reference. src/content/trust/security/access-control.md
  • Requirements:
  • Stored in password manager (mandatory)
  • Unique per service
  • Strong (password manager generated recommended)
  • Never shared, never in plaintext, never in code

ID Token Verification

// admin-portal/src/routes/auth.ts:186-207
// Verify JWT signature using JWKS endpoint
const jwt = await verifyLogtoJWT(tokens.id_token, env);
if (!jwt) {
  await auditLog(env, {
    action: 'admin_id_token_validation_failed',
    ip: clientIP,
    success: false,
    metadata: { reason: 'invalid_id_token_signature' },
  });
  return c.json({ error: 'Invalid ID token signature' }, 401);
}

// Validate required claims
if (!jwt.email) {
  return c.json({ error: 'ID token missing required email claim' }, 401);
}

Implementation Status: FULL. OAuth 2.0 with PKCE, encrypted state, rate limiting, JWT signature verification


UC-055: Multi-Factor Authentication (MFA)

Requirement: Enforce MFA for access to sensitive systems and administrative functions.

Status: 🔄 Partially Implemented (MEDIUM Gap)

Evidence:

MFA Policy

Documentation:

  • Location. src/content/trust/security/access-control.md

Required for all access to:

  • GitHub (code repository, CI/CD)
  • Cloudflare (production infrastructure)
  • Email accounts
  • Password managers
  • Any system with access to production secrets

MFA Methods (in order of preference):

  1. Hardware security keys (YubiKey)
  2. Authenticator apps (Authy, Google Authenticator)
  3. SMS (least preferred)

WebAuthn Implementation (Step-Up Authentication)

Step-up authentication IS implemented for sensitive admin operations:

  • Location. admin-portal/src/middleware/webauthn-stepup.ts (163 lines)
  • Routes. admin-portal/src/routes/auth.ts (lines 880-1046)

Step-up authentication requires YubiKey/biometric verification before sensitive operations. Valid for 5 minutes after verification.

// admin-portal/src/middleware/webauthn-stepup.ts:17
const STEP_UP_VALIDITY_MS = 5 * 60 * 1000; // 5 minutes

// admin-portal/src/middleware/webauthn-stepup.ts:73-115
export const requireWebAuthnStepUp = async (
  c: Context<AppBindings>,
  next: () => Promise<void>
): Promise<Response | void> => {
  const session = c.get('session');

  if (session.webauthn_verified) {
    await next();
    return;
  }

  const hasStepUp = await hasRecentStepUp(env, session.user_id);
  if (!hasStepUp) {
    return c.json({
      error: 'WebAuthn verification required',
      message: 'This sensitive operation requires WebAuthn re-authentication.',
      require_step_up: true,
      step_up_url: '/auth/webauthn/stepup',
      validity_seconds: STEP_UP_VALIDITY_MS / 1000,
    }, 403);
  }
  await next();
};

WebAuthn endpoints in routes/auth.ts:

  • POST /auth/webauthn/stepup. request step-up challenge (rate limited: 5/5min)
  • POST /auth/webauthn/stepup/verify. verify step-up response
  • GET /auth/webauthn/stepup/check. check current step-up status
  • POST /auth/webauthn/register/options. register new security key
  • POST /auth/webauthn/register/verify. verify registration
  • POST /auth/webauthn/assert/options. authenticate with security key
  • POST /auth/webauthn/assert/verify. verify authentication

Gap Analysis

What IS implemented:

  • WebAuthn step-up authentication for sensitive operations (YubiKey, biometric)
  • Recovery codes as WebAuthn fallback
  • Session regeneration after WebAuthn verification (prevents session fixation)
  • Step-up validity period (5 minutes)

What IS NOT implemented:

  1. No check for amr (Authentication Methods Reference) claim in Logto ID token
  2. No verification that MFA was completed during initial Logto login
  3. No conditional MFA based on risk signals (new device, unusual location)

Implementation Status: PARTIAL. WebAuthn step-up authentication fully implemented for sensitive operations, but no verification that MFA was used during initial Logto authentication


UC-056: Session Management

Requirement: Implement secure session management with timeout, secure token generation, and session binding.

Status: ✅ Implemented

Evidence:

Secure Token Generation

// admin-portal/src/utils/logto.ts:337-341
export async function generateState(): Promise<string> {
  const array = new Uint8Array(32);  // 256 bits of entropy
  crypto.getRandomValues(array);
  return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}

Used for both session tokens and OAuth state parameters.

Session Timeout (Dual Enforcement)

Absolute Timeout (8 hours):

// admin-portal/src/routes/auth.ts:252
expires_at: now + 8 * 60 * 60 * 1000, // 8 hours

Idle Timeout (30 minutes):

// admin-portal/src/routes/auth.ts:255
idle_timeout: 30 * 60 * 1000, // 30 minute idle timeout

Session expiry enforcement in Durable Object:

// admin-portal/src/durableObjects/sessionManager.ts:192-195
if (session.expires_at < Date.now()) {
  await this.state.storage.delete(`session:${sessionId}`);
  await this.cleanupChallenges(sessionId);
  return new Response('Expired', { status: 404 });
}

Session Binding (Hijacking Prevention)

IP + User-Agent Hashing (SHA-256, no salt, full 64-char hex):

// admin-portal/src/utils/session.ts:79-85
async function hashValue(value: string): Promise<string> {
  const encoder = new TextEncoder();
  const data = encoder.encode(value || '');
  const digest = await crypto.subtle.digest('SHA-256', data);
  const bytes = Array.from(new Uint8Array(digest));
  return bytes.map(byte => byte.toString(16).padStart(2, '0')).join('');
  // Returns full 64-character hex string (no truncation)
}

Session binding is enforced at the middleware level. IP and User-Agent hashes are stored alongside the encrypted session in the Durable Object and validated on each request.

Session Encryption at Rest

AES-256-GCM with Versioned Payload:

  • Location. admin-portal/src/utils/encryption.ts (152 lines)
// admin-portal/src/utils/encryption.ts:96-110
export async function encryptJson<T>(binding: SecretsStoreBinding, value: T): Promise<string> {
  const key = await getCryptoKey(binding);
  // Random 96-bit IV per encryption (NIST SP 800-38D)
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encoded = encoder.encode(JSON.stringify(value));
  const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded);

  const payload: EncryptionPayload = {
    v: 1,              // Version field for future key rotation
    iv: bytesToBase64(iv),
    ciphertext: bytesToBase64(ciphertext),
  };

  return JSON.stringify(payload);
}
  1. Encryption key sourced from Cloudflare Secrets Store binding (ADMIN_PORTAL_SESSION_ENCRYPTION_KEY)
  2. 256-bit key validation enforced: key must be exactly 32 bytes (encryption.ts:68-69)
  3. Decryption failure on corrupted/invalid sessions causes session deletion (no plaintext fallback)

Durable Object Session Storage

Session Manager Durable Object:

  • Location. admin-portal/src/durableObjects/sessionManager.ts (567 lines)

The SessionManager class implements all session lifecycle operations:

  • createSession. stores encrypted session with IP/UA hashes
  • getSession. decrypts, checks expiry, returns session or deletes if expired
  • deleteSession. removes session and associated WebAuthn challenges
  • listSessions. lists all sessions for a user (decrypts each)
  • terminateSession. deletes specific session by ID
  • terminateUserSessions. deletes all sessions for a user
  • storeState / validateState. OAuth CSRF state (10 min TTL, single-use)
  • storeChallenge / consumeChallenge. WebAuthn challenges (5 min TTL, single-use)
  • updateSession. partial session updates with re-encryption

__Host- Cookie Prefix:

// admin-portal/src/routes/auth.ts:280
const sessionCookie = `__Host-session=${sessionToken}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=28800`;

Logout with Clear-Site-Data:

// admin-portal/src/routes/auth.ts:830-834
c.header('Set-Cookie', '__Host-session=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0');
c.header('Clear-Site-Data', '"cache", "cookies", "storage"');

Implementation Status: FULL. Secure token generation, dual timeouts (8h/30min), session binding (IP+UA), AES-256-GCM encryption, Durable Object storage, secure cookies, session regeneration on WebAuthn


UC-057: Least Privilege

Requirement: Enforce least privilege principle. users granted minimum access necessary for job function.

Status: ✅ Implemented

Evidence:

Role-Based Least Privilege

3-Tier Hierarchy:

  • viewer (level 0): Read-only access to dashboards and metrics
  • admin (level 1): Can modify resources, manage configurations
  • super_admin (level 2): Full access, team management, requires WebAuthn step-up

The requireAdmin(minRole) middleware enforces minimum role level per endpoint. Routes specify the minimum required role:

// Usage example:
router.get('/data', requireAdmin('viewer'), handler);       // Any role can read
router.post('/config', requireAdmin('admin'), handler);     // Admin+ can modify
router.post('/users', requireAdmin('super_admin'), handler); // Super admin only

Default Role Assignment

Users without recognised Logto roles are denied access entirely:

// admin-portal/src/utils/logto.ts:218-221
// No admin portal role found
logger.warn('[Admin] No admin_portal role found in user roles');
return null; // Access denied. no default role granted

If buildAdminContext returns null, the user receives an “Access Denied” page (routes/auth.ts:219-239).

Documentation:

  • Location. src/content/trust/security/access-control.md
  • Principles: Least Privilege, Need-to-Know, Segregation of Duties, Zero Trust

Implementation Status: FULL. 3-tier role hierarchy, no default access, per-endpoint minimum role enforcement


UC-058: Segregation of Duties

Requirement: Implement segregation of duties for critical operations to prevent fraud and errors.

Status: ✅ Implemented

Evidence:

Role Separation

The 3-tier role system enforces separation:

  • viewer. Cannot modify anything. read-only access
  • admin. Can modify configurations but cannot manage users or perform super_admin operations
  • super_admin. Full access but sensitive operations require WebAuthn step-up, preventing automated or unattended execution

Approval Workflow (Maker-Checker Pattern)

Config Change Approval:

  • Location. admin-portal/src/types.ts:533-551
// admin-portal/src/types.ts:533-551
export interface ApprovalRequest {
  request_id: string;
  config_key: string;
  binding: string;
  value: any;
  message: string;
  diff: ConfigDiff;
  impact: ImpactAnalysis;
  created_at: number;
  created_by: string;
  created_by_email: string;
  expires_at: number;
  status: 'pending' | 'approved' | 'rejected' | 'expired' | 'cancelled';
  approved_at?: number;
  approved_by?: string;
  approved_by_email?: string;
  rejection_reason?: string;
  applied_commit_hash?: string;
}

The approval system requires a different user (approved_by) from the requester (created_by). implementing the maker-checker pattern.

Risk-Based Impact Analysis

// admin-portal/src/types.ts:297-310
export interface ImpactAnalysis {
  risk_level: RiskLevel;  // 'low' | 'medium' | 'high' | 'critical'
  blast_radius: {
    customers: number;
    issuers: number;
    estimated_requests_per_day: number;
    affected_services: string[];
  };
  breaking_changes: BreakingChange[];
  warnings: string[];
  similar_changes: SimilarChange[];
  estimated_downtime_seconds: number;
  reversibility: 'instant' | 'delayed' | 'partial' | 'irreversible';
}

Implementation Status: FULL. Role separation, approval workflows with maker-checker, risk-based impact analysis


UC-059: Access Reviews

Requirement: Conduct periodic access reviews to ensure access rights remain appropriate.

Status: 📋 Partially Implemented (LOW Gap)

Evidence:

Access Review Policy

Documentation:

  • Location. src/content/trust/security/access-control.md

Quarterly review process documented:

  1. Export GitHub organisation members
  2. Export Cloudflare account access lists
  3. Verify each account matches current team roster
  4. Check for unused accounts (>90 days inactive)
  5. Verify privileged access still required
  6. Remove or adjust as needed
  7. Document findings

Timeline: Within first week of each quarter

Event-Triggered Reviews: Upon team member departure, role change, security incident, or suspected compromise.

Session Listing Capability (Technical Foundation)

Active Session Enumeration:

// admin-portal/src/utils/session.ts:320-330
export async function listUserSessions(
  env: Env,
  userId: string
): Promise<UserSessionInfo[]> {
  const response = await sendRequest<{ sessions: UserSessionInfo[] }>(env, {
    op: 'listSessions',
    userId,
  });
  return response?.sessions ?? [];
}

Session Termination:

// admin-portal/src/utils/session.ts:332-344
export async function terminateSession(
  env: Env,
  sessionId: string,
  terminatedBy: string
): Promise<boolean> {
  const response = await sendRequest<{ ok: boolean }>(env, {
    op: 'terminateSession',
    sessionId,
    terminatedBy,
  });
  return Boolean(response?.ok);
}

Gap Analysis

Missing:

  1. No automated quarterly access review execution
  2. No review audit trail or documentation system
  3. No automated inactive user detection
  4. No GitHub/Cloudflare access review automation

Implementation Status: PARTIAL. Policy documented, technical foundation exists (session listing/termination), but no automated review execution or audit trail


UC-060: Privileged Access Management

Requirement: Implement controls for privileged accounts including monitoring, approval workflows, and just-in-time access.

Status: ✅ Implemented

Evidence:

Privileged Role Definition

super_admin Role (Highest Privilege):

// admin-portal/src/types.ts:108
role: 'super_admin' | 'admin' | 'viewer';

Super admin is the only role that can:

  • Manage other users and roles
  • Delete WebAuthn credentials
  • Perform operations protected by requireSuperAdminWithStepUp

WebAuthn Step-Up for Privileged Operations

Sensitive operations require recent WebAuthn verification (5-minute window):

// admin-portal/src/routes/auth.ts:1193
// Example: Credential deletion requires WebAuthn step-up
auth.post('/webauthn/credentials/delete-all', rateLimitAuth, requireWebAuthnStepUp, handler);

Step-up verification is stored in KV with automatic TTL expiration:

// admin-portal/src/middleware/webauthn-stepup.ts:48-64
export async function recordStepUpVerification(env: Env, userId: string): Promise<void> {
  const stepUpKey = `stepup:${userId}`;
  await env.ADMIN_PORTAL_CONFIG_METADATA.put(stepUpKey, JSON.stringify({
    verifiedAt: Date.now(),
    userId,
  }), {
    expirationTtl: Math.ceil(STEP_UP_VALIDITY_MS / 1000), // 5 minutes TTL
  });
}

Privileged Access Monitoring

Audit Logging:

  • Location. admin-portal/src/utils/api-client.ts:634-661
// admin-portal/src/utils/api-client.ts:634-661
export async function auditLog(
  env: Env,
  event: {
    action: string;
    user_id?: string;
    ip?: string;
    success: boolean;
    metadata?: Record<string, any>;
  }
): Promise<void> {
  const ipHash = event.ip ? await hashIP(env, event.ip) : 'unknown';
  const sanitizedMetadata = sanitizeMetadata(event.metadata);

  console.log(JSON.stringify({
    event_type: event.action,
    user_id: event.user_id || 'anonymous',
    ip_hash: ipHash,
    success: event.success,
    timestamp: Date.now(),
    metadata: sanitizedMetadata,
  }));
}

Logged Privileged Events (from routes/auth.ts):

  • admin_login_success. successful admin login with role
  • admin_unauthorized_access. denied access (no admin role)
  • admin_csrf_validation_failed. CSRF state validation failure
  • admin_id_token_validation_failed. JWT signature invalid
  • admin_webauthn_registered. new security key registered
  • admin_webauthn_verified. security key authentication succeeded
  • webauthn_stepup_success / webauthn_stepup_failed. step-up verification
  • recovery_codes_generated / recovery_code_consumed. recovery code lifecycle
  • admin_logout. admin logout
  • webauthn_credentials_deleted. all credentials deleted

Privileged Access Documentation

GitHub Admin Access:

  • Location. src/content/trust/security/access-control.md
  • Limited to the ISMS Owner (sole operator)
  • Required for: Repository settings, security settings, Actions secrets
  • Audited via GitHub audit log

Cloudflare Production Access:

  • Admin access: ISMS Owner only (sole operator)
  • Worker deployment: Via CI/CD (automated) or emergency manual by the ISMS Owner (also acting as Developer)
  • KV access: Only via Workers (no direct access)
  • Analytics: Read-only access via Cloudflare dashboard (ISMS Owner, also acting as Security Lead)

Implementation Status: FULL. super_admin role with WebAuthn step-up, audit logging, approval workflows, documented access policies


UC-061: Audit Logging of Access

Requirement: Implement audit logging of authentication events, authorisation decisions, and privileged actions.

Status: ✅ Implemented

Evidence:

Audit Logging Framework

Workers Logs Integration:

  • Location. admin-portal/src/utils/api-client.ts:634-661
  • Events emitted as structured JSON console.log lines and shipped via Cloudflare Workers Logs to Grafana Loki under the admin-portal labelset
  • IP addresses hashed before emission (privacy-preserving)
  • event_type field used as a Loki label for efficient LogQL filtering

Log Line Fields:

  • event_type. action (label)
  • user_id. user identifier or anonymous
  • ip_hash. hashed IP address
  • success. boolean
  • timestamp. milliseconds since epoch
  • metadata. sanitized event-specific fields

Logged Events by Category

Authentication Events (from routes/auth.ts):

  • admin_login_success. with role in metadata
  • admin_oauth_error. OAuth provider errors
  • admin_csrf_validation_failed. possible CSRF attack
  • admin_id_token_validation_failed. invalid JWT signature
  • admin_id_token_missing_email. malformed ID token
  • admin_session_persist_failed. session creation failure
  • admin_logout. with IP

WebAuthn Events:

  • admin_webauthn_registered. with credential_id, session_regenerated
  • admin_webauthn_verified. with credential_id, session_regenerated
  • webauthn_stepup_success. step-up for sensitive ops
  • webauthn_stepup_failed. failed step-up (triggers notification)
  • webauthn_credentials_deleted. all credentials removed

Recovery Code Events:

  • recovery_codes_generated. with count
  • recovery_code_consumed. with remaining count
  • recovery_code_attempt. failed attempt

Secure Logging (Designed to Reduce Secret Leakage)

Sanitization Framework:

  • Location. admin-portal/src/utils/secure-logger.ts (242 lines)
// admin-portal/src/utils/secure-logger.ts:23-173
export class SecureLogger {
  private sanitize(data: any): any {
    // Redacts: session cookies, Bearer tokens, emails, IPs,
    // credit card numbers, JWTs, AWS keys, DB connections,
    // authorization headers, SSNs, phone numbers

    // Log injection prevention: removes control characters, ANSI codes
    sanitized = sanitized.replace(/[\r\n\t\x00-\x1F\x7F]/g, ' ');
    sanitized = sanitized.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');

    // Truncates messages > 10,000 characters
    // Object keys matching sensitive patterns redacted entirely
    // ...
  }
}

Sensitive field names auto-redacted: password, secret, token, apikey, api_key, cookie, authorisation, bearer, email, ip, ip_address

Privacy-Preserving IP Hashing

// admin-portal/src/utils/api-client.ts:47-66
export async function hashPII(env: Env, value: string): Promise<string> {
  const salt = env.ADMIN_PORTAL_PII_SALT;
  const data = encoder.encode(value + salt);
  const hashBuffer = await crypto.subtle.digest('SHA-256', data);
  const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
  return hashHex.substring(0, 16); // Truncated for analytics
}

Log Storage and Retention

  1. Cloudflare Workers Logs (shipped to Grafana Loki). 90-day retention; critical security event logs are retained for up to 365 days
  2. Durable Object session data. 8-hour TTL (session lifetime)
  3. Step-up verification. 5-minute TTL in KV

Security Alert Notifications

Failed step-up attempts trigger notifications to super admins:

// admin-portal/src/routes/auth.ts:1011-1019
await notifySecurityAlert(env, {
  alert_type: 'Failed WebAuthn Step-Up',
  message: `User ${stored.session.email} failed WebAuthn step-up verification`,
  link: '/sessions',
});

Rate limit exceeded events also trigger notifications:

// admin-portal/src/middleware/rate-limit.ts:62-72
await notifyRateLimitExceeded(env, {
  client_id: identifier,
  endpoint: keyPrefix,
  tier: `${limit} req/${windowSeconds}s`,
});

Implementation Status: FULL. Wide-coverage audit logging via Workers Logs to Grafana Loki, IP hashing, secure logger with sanitization, log injection prevention, 90-day retention (365 days for critical security events), security alert notifications


Cross-Cutting Findings

Session Security

Best Practices Implemented:

  1. PKCE (Proof Key for Code Exchange). prevents authorisation code interception
  2. Session Binding (IP + User-Agent SHA-256 hashing). prevents session hijacking
  3. Encryption at Rest (AES-256-GCM with versioned payloads). protects session data in Durable Objects
  4. Dual Timeout (8h absolute + 30min idle). limits exposure window
  5. __Host- Cookie Prefix. requires Secure flag, prevents subdomain attacks
  6. CSRF Protection. encrypted single-use OAuth state tokens with 10-min TTL
  7. WebAuthn Step-Up. YubiKey/biometric for sensitive operations (5-min validity)
  8. Session Regeneration. new session token after WebAuthn verification (prevents fixation)
  9. Clear-Site-Data on Logout. clears cache, cookies, and storage

Rate Limiting

Pre-configured Rate Limiters:

  • Location. admin-portal/src/middleware/rate-limit.ts
EndpointLimitWindowKey
Auth Login5300sIP
Auth Callback10300sIP
WebAuthn Verify5300sIP
Auth (general)20300sIP
WebAuthn Step-Up5300sUser
CRM Leads6060sUser
Config3060sUser
Security2060sUser
Admin3060sUser
Credit10300sUser

Management API Authentication

provii-management is NOT empty. it provides centralised admin operations via a service binding (MANAGEMENT_API: Fetcher). The admin-portal communicates with provii-management via worker-to-worker service bindings, with HMAC authentication using a secret in Cloudflare Secrets Store.


Gaps and Recommendations

GAP-1: MFA Verification at Login (UC-055). MEDIUM Priority

Current State:

  • MFA policy documented and delegated to Logto
  • WebAuthn step-up authentication fully implemented for sensitive operations
  • No verification that MFA was completed during initial Logto login (amr claim not checked)

Recommendation:

  1. Verify amr (Authentication Methods Reference) claim in Logto ID token
  2. Store MFA verification status in session
  3. Add conditional MFA based on risk signals (new device, unusual location)

Impact: Medium. MFA may be enforced by Logto configuration, but application cannot verify it

Remediation Effort: Low. code changes to check AMR claim in ID token


GAP-2: Access Reviews. Automation (UC-059). LOW Priority

Current State:

  • Quarterly access review policy documented
  • Technical foundation exists (listUserSessions, terminateSession, terminateAllUserSessions)
  • No evidence of automated execution or audit trail

Recommendation:

  1. Implement scheduled access review reports (quarterly)
  2. Automate inactive user detection (>90 days)
  3. Create audit trail of review decisions
  4. Integrate with GitHub/Cloudflare APIs for reviews

Impact: Low. manual reviews can be performed, but automation improves consistency

Remediation Effort: Medium. requires scheduled job implementation


Control Compliance Matrix

Control IDControl NameStatusEvidence LocationGap
UC-052User Access Management✅ Implementedadmin-portal/src/utils/logto.ts:223-249
admin-portal/src/utils/session.ts:89-111
None
UC-053Privilege Management✅ Implementedadmin-portal/src/middleware/rbac.ts:27-58
admin-portal/src/middleware/webauthn-stepup.ts:123-162
None
UC-054User Authentication✅ Implementedadmin-portal/src/utils/logto.ts:90-120, 279-335
admin-portal/src/middleware/rate-limit.ts:100-114
None
UC-055Multi-Factor Auth🔄 Partialadmin-portal/src/middleware/webauthn-stepup.ts
admin-portal/src/routes/auth.ts:880-1046
No Logto amr claim verification (GAP-1)
UC-056Session Management✅ Implementedadmin-portal/src/utils/session.ts
admin-portal/src/durableObjects/sessionManager.ts
admin-portal/src/utils/encryption.ts
None
UC-057Least Privilege✅ Implementedadmin-portal/src/middleware/rbac.ts
admin-portal/src/utils/logto.ts:194-221
None
UC-058Segregation of Duties✅ Implementedadmin-portal/src/types.ts:533-551
admin-portal/src/types.ts:297-310
None
UC-059Access Reviews🔄 Partialsrc/content/trust/security/access-control.md
admin-portal/src/utils/session.ts:320-365
No automated reviews (GAP-2)
UC-060Privileged Access Mgmt✅ Implementedadmin-portal/src/middleware/webauthn-stepup.ts
admin-portal/src/utils/api-client.ts:634-661
None
UC-061Audit Logging✅ Implementedadmin-portal/src/utils/api-client.ts:634-661
admin-portal/src/utils/secure-logger.ts
None

Conclusion

The Provii admin portal demonstrates strong access control implementation with modern security practices. The 3-tier RBAC model (viewer/admin/super_admin) combined with WebAuthn step-up authentication provides both convenience for routine operations and strong assurance for sensitive ones.

Key Strengths:

  • Modern authentication (OAuth 2.0 + OIDC + PKCE via Logto)
  • Durable Object session storage with AES-256-GCM encryption
  • Session binding (IP + User-Agent hashing)
  • Dual timeouts (8h absolute + 30min idle)
  • WebAuthn step-up for sensitive operations (5-minute validity)
  • Privacy-preserving audit logging (IP hashing, PII redaction)
  • Rate limiting across all auth and API endpoints

Priority Remediation:

  1. GAP-1 (MFA): Add amr claim verification for Logto login (MEDIUM priority, LOW effort)
  2. GAP-2 (Access Reviews): Automate quarterly reviews (LOW priority, MEDIUM effort)

Overall Assessment: 8/10 controls fully implemented (80%), 2/10 partially implemented (20%). Strong foundation with minor gaps in MFA verification and access review automation.


End of Access Control Evidence Document