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
amrclaim 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_MANAGERbinding - 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
| Role | Level | Access |
|---|---|---|
viewer | 0 | Read-only access to metrics and dashboards |
admin | 1 | Can modify most resources, manage configurations |
super_admin | 2 | Full 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
});
- KV-based distributed rate limiting using
ADMIN_PORTAL_CONFIG_METADATAKV namespace - Returns HTTP 429 with
retry_afterheader for client-side backoff - Sets
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Resetheaders
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):
- Hardware security keys (YubiKey)
- Authenticator apps (Authy, Google Authenticator)
- 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 responseGET /auth/webauthn/stepup/check. check current step-up statusPOST /auth/webauthn/register/options. register new security keyPOST /auth/webauthn/register/verify. verify registrationPOST /auth/webauthn/assert/options. authenticate with security keyPOST /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:
- No check for
amr(Authentication Methods Reference) claim in Logto ID token - No verification that MFA was completed during initial Logto login
- 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);
}
- Encryption key sourced from Cloudflare Secrets Store binding (
ADMIN_PORTAL_SESSION_ENCRYPTION_KEY) - 256-bit key validation enforced: key must be exactly 32 bytes (
encryption.ts:68-69) - 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 hashesgetSession. decrypts, checks expiry, returns session or deletes if expireddeleteSession. removes session and associated WebAuthn challengeslistSessions. lists all sessions for a user (decrypts each)terminateSession. deletes specific session by IDterminateUserSessions. deletes all sessions for a userstoreState/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
Secure Cookie Attributes
__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:
- Export GitHub organisation members
- Export Cloudflare account access lists
- Verify each account matches current team roster
- Check for unused accounts (>90 days inactive)
- Verify privileged access still required
- Remove or adjust as needed
- 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:
- No automated quarterly access review execution
- No review audit trail or documentation system
- No automated inactive user detection
- 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 roleadmin_unauthorized_access. denied access (no admin role)admin_csrf_validation_failed. CSRF state validation failureadmin_id_token_validation_failed. JWT signature invalidadmin_webauthn_registered. new security key registeredadmin_webauthn_verified. security key authentication succeededwebauthn_stepup_success/webauthn_stepup_failed. step-up verificationrecovery_codes_generated/recovery_code_consumed. recovery code lifecycleadmin_logout. admin logoutwebauthn_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.loglines and shipped via Cloudflare Workers Logs to Grafana Loki under theadmin-portallabelset - IP addresses hashed before emission (privacy-preserving)
event_typefield used as a Loki label for efficient LogQL filtering
Log Line Fields:
event_type. action (label)user_id. user identifier oranonymousip_hash. hashed IP addresssuccess. booleantimestamp. milliseconds since epochmetadata. sanitized event-specific fields
Logged Events by Category
Authentication Events (from routes/auth.ts):
admin_login_success. with role in metadataadmin_oauth_error. OAuth provider errorsadmin_csrf_validation_failed. possible CSRF attackadmin_id_token_validation_failed. invalid JWT signatureadmin_id_token_missing_email. malformed ID tokenadmin_session_persist_failed. session creation failureadmin_logout. with IP
WebAuthn Events:
admin_webauthn_registered. with credential_id, session_regeneratedadmin_webauthn_verified. with credential_id, session_regeneratedwebauthn_stepup_success. step-up for sensitive opswebauthn_stepup_failed. failed step-up (triggers notification)webauthn_credentials_deleted. all credentials removed
Recovery Code Events:
recovery_codes_generated. with countrecovery_code_consumed. with remaining countrecovery_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
- Cloudflare Workers Logs (shipped to Grafana Loki). 90-day retention; critical security event logs are retained for up to 365 days
- Durable Object session data. 8-hour TTL (session lifetime)
- 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:
- PKCE (Proof Key for Code Exchange). prevents authorisation code interception
- Session Binding (IP + User-Agent SHA-256 hashing). prevents session hijacking
- Encryption at Rest (AES-256-GCM with versioned payloads). protects session data in Durable Objects
- Dual Timeout (8h absolute + 30min idle). limits exposure window
__Host-Cookie Prefix. requires Secure flag, prevents subdomain attacks- CSRF Protection. encrypted single-use OAuth state tokens with 10-min TTL
- WebAuthn Step-Up. YubiKey/biometric for sensitive operations (5-min validity)
- Session Regeneration. new session token after WebAuthn verification (prevents fixation)
- Clear-Site-Data on Logout. clears cache, cookies, and storage
Rate Limiting
Pre-configured Rate Limiters:
- Location.
admin-portal/src/middleware/rate-limit.ts
| Endpoint | Limit | Window | Key |
|---|---|---|---|
| Auth Login | 5 | 300s | IP |
| Auth Callback | 10 | 300s | IP |
| WebAuthn Verify | 5 | 300s | IP |
| Auth (general) | 20 | 300s | IP |
| WebAuthn Step-Up | 5 | 300s | User |
| CRM Leads | 60 | 60s | User |
| Config | 30 | 60s | User |
| Security | 20 | 60s | User |
| Admin | 30 | 60s | User |
| Credit | 10 | 300s | User |
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 (
amrclaim not checked)
Recommendation:
- Verify
amr(Authentication Methods Reference) claim in Logto ID token - Store MFA verification status in session
- 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:
- Implement scheduled access review reports (quarterly)
- Automate inactive user detection (>90 days)
- Create audit trail of review decisions
- 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 ID | Control Name | Status | Evidence Location | Gap |
|---|---|---|---|---|
| UC-052 | User Access Management | ✅ Implemented | admin-portal/src/utils/logto.ts:223-249admin-portal/src/utils/session.ts:89-111 | None |
| UC-053 | Privilege Management | ✅ Implemented | admin-portal/src/middleware/rbac.ts:27-58admin-portal/src/middleware/webauthn-stepup.ts:123-162 | None |
| UC-054 | User Authentication | ✅ Implemented | admin-portal/src/utils/logto.ts:90-120, 279-335admin-portal/src/middleware/rate-limit.ts:100-114 | None |
| UC-055 | Multi-Factor Auth | 🔄 Partial | admin-portal/src/middleware/webauthn-stepup.tsadmin-portal/src/routes/auth.ts:880-1046 | No Logto amr claim verification (GAP-1) |
| UC-056 | Session Management | ✅ Implemented | admin-portal/src/utils/session.tsadmin-portal/src/durableObjects/sessionManager.tsadmin-portal/src/utils/encryption.ts | None |
| UC-057 | Least Privilege | ✅ Implemented | admin-portal/src/middleware/rbac.tsadmin-portal/src/utils/logto.ts:194-221 | None |
| UC-058 | Segregation of Duties | ✅ Implemented | admin-portal/src/types.ts:533-551admin-portal/src/types.ts:297-310 | None |
| UC-059 | Access Reviews | 🔄 Partial | src/content/trust/security/access-control.mdadmin-portal/src/utils/session.ts:320-365 | No automated reviews (GAP-2) |
| UC-060 | Privileged Access Mgmt | ✅ Implemented | admin-portal/src/middleware/webauthn-stepup.tsadmin-portal/src/utils/api-client.ts:634-661 | None |
| UC-061 | Audit Logging | ✅ Implemented | admin-portal/src/utils/api-client.ts:634-661admin-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:
- GAP-1 (MFA): Add
amrclaim verification for Logto login (MEDIUM priority, LOW effort) - 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