Version: 1.3
Date: February 2026
Status: Pre-Production Security Review
AMAPI Commander is a serverless, multi-tenant SaaS application that provides an intelligent interface to Google's Android Management API (AMAPI) for enterprise mobility management. The platform combines a React-based frontend with Netlify serverless functions backend, OpenAI GPT-4.1 assistant integration, and multi-workspace support with role-based access control.
This whitepaper documents the technical implementation, security architecture, infrastructure design, and deployment model. Risk ratings use a 0–5 scale throughout: 0 (none), 1 (negligible), 2 (low), 3 (moderate), 4 (high), 5 (critical/guaranteed).
This project was built utilising GPT-5.3-Codex and Claude Opus 4.6. Security auditing completed by these two models & Gemini.
Key Capabilities:
Technology Stack:
gpt-4.1-mini)┌─────────────────────────────────────────────────────────────┐
│ Client (Browser) │
│ React SPA + TypeScript + Tailwind CSS │
└────────────────┬────────────────────────────────────────────┘
│ HTTPS (TLS 1.2+)
│ CSP, Security Headers
▼
┌─────────────────────────────────────────────────────────────┐
│ Netlify Edge / CDN Layer │
│ - Static asset delivery │
│ - Security headers injection │
│ - Request routing │
└────────────────┬────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ Netlify Serverless Functions (Node.js) │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ Auth Layer │ │ Workspace │ │ Assistant API │ │
│ │ - OAuth 2.0 │ │ Management │ │ - GPT-4.1 Proxy │ │
│ │ - Magic Link │ │ - RBAC │ │ - MCP Tools │ │
│ │ - Sessions │ │ - Invites │ │ - Background │ │
│ └──────────────┘ └──────────────┘ └─────────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ AMAPI Proxy │ │ Secret Mgmt │ │ Rate Limiting │ │
│ │ - Caching │ │ - AES-256 │ │ - Per-IP │ │
│ │ - Token Mgmt │ │ - Encryption │ │ - Per-endpoint │ │
│ └──────────────┘ └──────────────┘ └─────────────────┘ │
└────────────────┬───────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ Netlify Blobs Storage │
│ - Workspaces - Sessions - Job Results │
│ - Memberships - Invites - Cache Data │
│ - Encrypted Secrets │
└────────────────┬───────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ External Services │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ Google │ │ OpenAI │ │ Resend │ │
│ │ - OAuth │ │ - GPT-4.1 │ │ - Email │ │
│ │ - AMAPI │ │ - Chat API │ │ - Magic Links │ │
│ │ - Tokeninfo │ │ - Realtime │ │ - Invites │ │
│ └──────────────┘ └──────────────┘ └─────────────────┘ │
└────────────────────────────────────────────────────────────┘
Authentication Flow (OAuth 2.0):
/auth/google/startS256) and stores the PKCE verifier server-side (keyed by OAuth nonce)/auth/google/callback with authorisation codeMagic Link Authentication Flow:
/auth/magic-link/start/auth/magic-link/verify (GET returns confirmation form; POST consumes token)Assistant Query Flow:
/assistant/chatBackground Sync Flow:
/assistant/fleet/refresh/assistant/chat/status for completion/assistant/chat/resultVoice Realtime Flow:
/assistant/realtime-sessionQuery Planning Flow:
query-planner.js performs pattern-based intent classification (e.g. enterprise_count, enterprise_device_counts, enterprise_app_presence)The application implements a workspace-based multi-tenancy model where each workspace represents an isolated tenant with its own:
Workspace Roles:
owner role, or delete workspaces)For platform-level administration, the system supports bootstrap admins identified by email addresses in MULTI_TENANT_BOOTSTRAP_EMAILS. Bootstrap admins can:
Security Considerations:
Storage Isolation: All Netlify Blobs keys include workspace ID prefixes:
workspaces/{workspaceId}/config.json
workspaces/{workspaceId}/secrets.enc.json
workspaces/{workspaceId}/members.json
workspaces/{workspaceId}/audit-log.json
workspaces/{workspaceId}/users/{emailHash}/oauth.enc.json
workspaces/{workspaceId}/invites/{inviteHash}.json
users/{emailHash}/workspaces.json
sessions/{sessionId}.json
Cache Isolation: In-memory caches (token validation, fleet data) use composite keys scoped by workspace:
assistant-cache/workspace/{workspaceId}/project/{projectId}/enterprises.json
assistant-cache/workspace/{workspaceId}/project/{projectId}/device/{deviceName}.json
assistant-cache/workspace/{workspaceId}/project/{projectId}/tool/{toolName}/{argsHash}.json
Session Scoping: Each session includes:
id: Session identifier (UUID v4)email: Authenticated user emailworkspaceId: Currently active workspacecreatedAt: Session creation timestampexpiresAt: Session expiry timestampThe projectId is not stored in the session directly; it is resolved from the active workspace's configuration at request time.
Users can be members of multiple workspaces and switch between them via /workspace/select.
Per-user chat history is stored with workspace scoping:
workspaces/{workspaceId}/users/{emailHash}/chat-history.json
MULTI_TENANT_CHAT_HISTORY_RETENTION_DAYS)/workspace/chat-historyEach workspace maintains a rolling audit trail:
GET /workspace/audit-log (100-entry read limit)workspaces/{workspaceId}/audit-log.jsonState-changing workspace operations (delete, invite, member addition/removal, invite consumption) are serialised using per-workspace Promise-chain locks to prevent race conditions during concurrent requests. Each operation awaits the completion of any preceding operation on the same workspace before proceeding.
Authorisation Code Flow:
crypto.randomBytes(18).toString('base64url')){nonce, projectId, returnTo, issuedAt}OAUTH_STATE_SECRETS256 code challenge (verifier persisted server-side, keyed by nonce/workspace scope)code_challengereturnTo passes path traversal sanitisationcode_verifier for tokens via Google OAuth APItokeninfo endpointSecurity Controls:
OAUTH_STATE_SECRET is unsetToken Generation:
// Two concatenated UUIDs (stripped of hyphens) = 64 hex characters = 32 bytes entropy
const token = randomUUID().replace(/-/g, '') + randomUUID().replace(/-/g, '');
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
Storage:
{
email: "user@example.com",
tokenHash: "abcd1234...", // SHA-256 hash (only hash stored, not raw token)
expiresAt: Date.now() + 900_000, // 15 minutes
usedAt: null // Token is deleted on consumption; field exists for schema
}
Security Controls:
!isProductionRuntime() && MULTI_TENANT_MAGIC_LINK_DEBUGCookie Configuration:
mcp_mt_session=<sessionId>;
Path=/;
HttpOnly;
Secure;
SameSite=Lax;
Max-Age=2592000 // 30 days
Session Payload (stored server-side in Netlify Blobs):
{
id: "uuid-v4",
email: "user@example.com",
workspaceId: "ws_abc123",
createdAt: 1234567890,
expiresAt: 1237259890
}
Security Controls:
__cookie_session__) used in frontend state instead of real tokensAlgorithm: AES-256-GCM (Galois/Counter Mode)
Key Derivation:
WORKSPACE_SECRET_MASTER_KEYAPP_MASTER_KEY → OAUTH_TOKEN_ENCRYPTION_KEY (consolidation recommended)workspace-secret:workspace:{workspaceId}Envelope Format:
v1.{iv_base64url}.{tag_base64url}.{ciphertext_base64url}
Where:
v1: Version identifier for future key rotationiv: 96-bit (12-byte) random initialisation vectortag: 128-bit (16-byte) GCM authentication tagciphertext: Encrypted plaintext secretEncrypted Secrets:
Security Controls:
*Set boolean flags, never encrypted envelopesSecrets are stored in Netlify Blobs with workspace scoping:
// Workspace-level secrets
// Key: workspaces/{workspaceId}/secrets.enc.json
{
openaiApiKeyEnc: "v1.iv.tag.ciphertext",
openaiApiKeySet: true,
googleClientIdEnc: "v1.iv.tag.ciphertext",
googleClientIdSet: true,
googleClientSecretEnc: "v1.iv.tag.ciphertext",
googleClientSecretSet: true,
updatedAt: 1234567890
}
// User-workspace OAuth secrets
// Key: workspaces/{workspaceId}/users/{emailHash}/oauth.enc.json
{
googleRefreshTokenEnc: "v1.iv.tag.ciphertext",
googleRefreshTokenSet: true,
updatedAt: 1234567890
}
Access Control:
*Set boolean flags (whether secret exists) plus updatedAtGoogle Cloud project IDs are user-controlled and appear in:
Validation (whitelist approach):
function sanitizeProjectId(value) {
const candidate = String(value || '').trim();
if (!candidate) return '';
if (candidate.length > 128) return '';
if (!/^[a-zA-Z0-9\-:.]+$/.test(candidate)) return '';
return candidate;
}
Rejects the entire value if it exceeds 128 characters or contains characters outside a-zA-Z0-9-:.. Applied to OAuth state parameters, session creation, AMAPI proxy requests, and GPT prompt context.
User-controlled values inserted into GPT system prompts are sanitised:
function sanitizePromptValue(value, maxLength = 180) {
const text = String(value || '')
.replace(/[`{}[\]<>]/g, ' ') // Remove template/injection chars
.replace(/\s+/g, ' ') // Collapse whitespace
.trim();
if (!text) return 'none';
return text.slice(0, Math.max(1, maxLength));
}
Applied to:
Limitations:
#, *, ---)OAuth and magic link flows accept returnTo parameter for post-authentication redirects:
function sanitizeReturnTo(value) {
let candidate = String(value || '').trim();
// Double-decode before validation so encoded protocol-relative paths are rejected.
for (let i = 0; i < 2; i += 1) {
try {
const decoded = decodeURIComponent(candidate);
if (decoded === candidate) break;
candidate = decoded.trim();
} catch { break; }
}
if (!candidate.startsWith('/')) return '/';
if (candidate.startsWith('//')) return '/';
if (candidate.startsWith('/\\')) return '/';
return candidate;
}
The app icon proxy fetches Play Store icons for Android applications:
Allowlists:
// Page fetches (HTML scraping for icon URL)
const PLAY_PAGE_ALLOWED_HOSTS = ['play.google.com'];
// Icon image fetches
const PLAY_ICON_ALLOWED_HOSTS = [
'play-lh.googleusercontent.com',
'lh3.googleusercontent.com',
'lh4.googleusercontent.com',
'lh5.googleusercontent.com',
'lh6.googleusercontent.com',
'lh7.googleusercontent.com',
'encrypted-tbn0.gstatic.com',
];
Redirect Handling:
fetch() calls use redirect: 'manual'resolveTrustedRedirect() validates every redirect hop against the relevant allowlistSame-Origin Enforcement:
function isSameOriginRequest(request) {
const method = String(request?.method || '').trim().toUpperCase();
const requiresOrigin = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
const origin = String(request?.headers?.get('origin') || '').trim();
if (!origin) return !requiresOrigin; // Requires Origin on mutating methods
try {
return origin === new URL(request.url).origin;
} catch {
return false;
}
}
Applied to all POST/PUT/PATCH/DELETE endpoints including workspace management operations, secret updates, and user invitations. Mutating requests without an Origin header are rejected.
Implementation:
x-nf-client-connection-ip or x-forwarded-for)ASSISTANT_HTTP_RATE_LIMIT_ENABLED=true (default disabled)Configured Limits (defaults):
| Endpoint | Bucket | Max Requests | Window | Always Active |
|---|---|---|---|---|
/assistant/chat |
assistant-chat |
2,000 | 60 s | No (opt-in) |
/assistant/chat/status |
assistant-chat-status |
1,200 | 60 s | No (opt-in) |
/assistant/chat/result |
assistant-chat-result |
1,200 | 60 s | No (opt-in) |
/assistant/realtime-session |
assistant-realtime-session |
600 | 60 s | No (opt-in) |
/auth/magic-link/start |
auth-magic-link-start |
120 | 60 s | Yes |
/auth/magic-link/verify |
auth-magic-link-verify |
180 | 60 s | Yes |
/auth/google/callback |
oauth-callback |
120 | 60 s | Yes |
/workspace/create |
workspace-create |
60 | 60 s | No (opt-in) |
/workspace/invite |
workspace-invite |
120 | 60 s | No (opt-in) |
/workspace/delete |
workspace-delete |
15 | 60 s | No (opt-in) |
/workspace/chat-history (read) |
chat-read |
240 | 60 s | No (opt-in) |
/workspace/chat-history (write) |
chat-write |
480 | 60 s | No (opt-in) |
/workspace/chat-history (delete) |
chat-delete |
60 | 60 s | No (opt-in) |
/feedback/submit |
feedback |
120 | 60 s | Yes |
IP Resolution:
function resolveClientIp(request) {
const netlifyIp = String(request?.headers?.get('x-nf-client-connection-ip') || '').trim();
if (netlifyIp) return netlifyIp;
const forwarded = String(request?.headers?.get('x-forwarded-for') || '').trim();
if (forwarded) return forwarded.split(',')[0].trim();
return 'unknown';
}
Limitations:
x-forwarded-for can be spoofed (Netlify's x-nf-client-connection-ip is trusted)Netlify Features Used:
Function Configuration:
[functions]
node_bundler = "esbuild" # Fast bundling, tree-shaking
Netlify Blobs Characteristics:
Usage Patterns:
// Workspace config (< 1 KB)
workspaces/{workspaceId}/config.json
// Members list (< 10 KB for 100 users)
workspaces/{workspaceId}/members.json
// Encrypted secrets (< 1 KB)
workspaces/{workspaceId}/secrets.enc.json
// Session data (< 500 bytes)
sessions/{sessionId}.json
// Fleet cache (1-5 MB for large enterprises)
assistant-cache/workspace/{workspaceId}/project/{projectId}/enterprises.json
assistant-cache/workspace/{workspaceId}/project/{projectId}/device/{deviceName}.json
// Async job results (variable, up to 5 MB)
assistant-jobs/{jobId}.json
// Chat history (per user, per workspace)
workspaces/{workspaceId}/users/{emailHash}/chat-history.json
// Workspace audit log (rolling buffer, < 500 entries)
workspaces/{workspaceId}/audit-log.json
Fail-Closed Behaviour:
In production with multi-tenant mode enabled, the system throws a hard error if the blob store is unavailable. The in-memory Map fallback is only available in non-production environments. This is controlled by shouldRequirePersistentStore() which checks NODE_ENV === 'production' or Netlify CONTEXT === 'production' when MULTI_TENANT_ENABLED is set and MULTI_TENANT_REQUIRE_BLOB_STORE is unset.
Configured via netlify.toml:
[headers.values]
Content-Security-Policy = "default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
font-src 'self' data:;
img-src 'self' data: https:;
connect-src 'self' https://api.openai.com;
media-src 'self' blob:;
worker-src 'self' blob:;
frame-ancestors 'none';
base-uri 'self';
form-action 'self' https://accounts.google.com;"
Strict-Transport-Security = "max-age=31536000"
X-Content-Type-Options = "nosniff"
X-Frame-Options = "DENY"
Referrer-Policy = "no-referrer"
Permissions-Policy = "microphone=(self), camera=(), geolocation=()"
Build Process:
npm run build → Vite builds React appnpm run check validates all function syntax (node --check)Environment Variables:
VITE_* prefixed only (MCP endpoint, rate limits)Logging:
platformLogger.ts (redacts secrets)emitSecurityAudit() and emitWorkspaceAudit()Sensitive Data Redaction:
function looksSensitiveKey(key) {
const normalised = String(key || '').toLowerCase();
return (
normalised.includes('token') ||
normalised.includes('secret') ||
normalised.includes('password') ||
normalised.includes('authorization') ||
normalised.includes('cookie')
);
}
Audit Logging: Security-sensitive workspace operations emit structured audit events to stdout:
APIs Used:
Authentication Methods:
User OAuth Tokens (per-workspace, per-user):
https://www.googleapis.com/auth/androidmanagement/oauth2/v3/tokeninfoService Account (optional, legacy):
GOOGLE_APPLICATION_CREDENTIALS pathhttps://www.googleapis.com/auth/androidmanagementAMAPI Endpoints (proxied via MCP):
GET /v1/enterprises — List enterprisesGET /v1/enterprises/{name} — Get enterprise detailsGET /v1/enterprises/{name}/devices — List devicesGET /v1/enterprises/{name}/devices/{name} — Get device detailsGET /v1/enterprises/{name}/policies — List policiesGET /v1/enterprises/{name}/webApps — List web appsGET /v1/enterprises/{name}/applications/{packageName} — Get app detailsRate Limiting:
Error Handling:
APIs Used:
Authentication:
Authorization: Bearer {decrypted_api_key}Model Configuration:
{
model: "gpt-4.1-mini", // Default (configurable via OPENAI_MODEL env var)
tools: [
{ type: "function", function: mcpToolDefinition }
],
temperature: 0.2, // Low temperature for deterministic data queries
max_tokens: 500 // Default per-completion limit
}
Function Calling (MCP Tools):
list_enterprisesget_enterpriselist_devicesget_devicelist_policiesget_policylist_web_appsget_web_appget_applicationEach tool call triggers server-side AMAPI proxy with:
Request Handling:
Cost Control:
Realtime API (Voice):
The platform supports voice-based fleet interaction via OpenAI's Realtime API:
POST /assistant/realtime-sessionask_fleet; when invoked, the backend assistant orchestrates MCP tool calls (e.g. list_devices, get_device) on behalf of the voice sessionSecurity Controls:
Permissions-Policy: microphone=(self) header and browser media permission promptprojectId (unlike /assistant/chat)Purpose:
Configuration:
{
apiKey: process.env.RESEND_API_KEY,
from: process.env.RESEND_FROM_EMAIL,
to: userEmail,
subject: "Sign in to AMAPI Commander",
html: emailTemplate
}
Security Considerations:
!isProductionRuntime() AND explicit debug flag is setUser Frontend Functions Google
| | | |
|--[Click "Sign In"]-->| | |
| |--[GET /auth/google/start]--> |
| | |--[Generate state] |
| | | HMAC-sign state |
| |<--[302 redirect]-----| |
| | |
|<--[Redirect to Google consent]---------------------------[OAuth]->|
| |
|--[Authorise]----------------------------------------------------->|
| |
|<--[302 redirect to /callback?code=xxx&state=yyy]------------------|
| |
|--[GET /auth/google/callback?code=xxx&state=yyy]--> |
| | |--[Validate state] |
| | |--[Exchange code]--->|
| | |<--[Access+Refresh]--|
| | |--[Validate token]-->|
| | |<--[Token info]------|
| | |--[Encrypt refresh] |
| | |--[Store in Blobs] |
| | |--[Create session] |
| |<--[Set-Cookie]-------| |
|<--[302 redirect /]---| | |
User Frontend Function OpenAI AMAPI
| | | | |
|--[Query]->| | | |
| |--[POST /assistant/chat]--> | |
| | |--[Validate session] |
| | |--[Decrypt OpenAI key] |
| | |--[POST /v1/chat/completions]--> |
| | | |--[Analyse query]|
| | | |--[tool_call: list_devices]
| | |<--[tool_calls]---| |
| | |--[Proxy to AMAPI]----------------->|
| | |<--[Device list]--------------------|
| | |--[Return tool result]--> |
| | | |--[Format answer]|
| | |<--[JSON response]| |
| |<--[JSON response]---------------| |
|<--[Display]| | | |
User Frontend Trigger Func Background Func AMAPI
| | | | |
|--[Refresh Fleet]--> | | |
| |--[POST /assistant/fleet/refresh]-> |
| | |--[Create job] |
| | |--[Store in Blobs] |
| |<--[Job ID]---| | |
| | |--[Trigger background]---> |
| | | (HMAC auth) | |
| | | |--[Fetch enterprises]->|
| | | |<--[Enterprise list]---|
| | | |--[Fetch devices]----->|
| | | |<--[Device list]-------|
| | | |--[Store results] |
| | | | (Blobs) |
| | | |--[Mark complete] |
| | |
|--[Poll status]--> |
| |--[GET /assistant/chat/status?jobId=xxx]--> |
| |<--[{status: "completed"}]--------------------- |
| | |
|--[Get results]--> |
| |--[GET /assistant/chat/result?jobId=xxx]--> |
| |<--[{devices: [...]}]------------------------- |
Device Deduplication:
Fleet sync results are processed through dedupeDevicesForReenrolment() which merges devices sharing the same name or previous names. When duplicates exist, the device with the most recent enrolment, compliance, or sync timestamp is retained. This prevents duplicate entries caused by device re-enrolment (factory reset and re-provision) and produces accurate fleet counts.
Device Summary Compaction:
Raw device payloads from AMAPI are compacted into summary objects for caching and assistant context injection. Summaries include: name, state, compliance status, ownership, battery level (extracted from the latest powerManagementEvent), model, and hardware info. This reduces cache size and LLM token consumption.
Risk ratings: 0 (none) → 5 (critical/guaranteed). Ratings reflect current state after security hardening.
| Threat | Vector | Mitigation | Risk |
|---|---|---|---|
| Spoofing | OAuth state forgery | HMAC-signed state; server fails hard (500) if secret unset | 1 |
| Magic link token theft | SHA-256 hash, single-use with mutex lock, confirmation form on GET | 2 | |
| Session cookie theft | HttpOnly, Secure, SameSite=Lax | 2 | |
| Tampering | Encrypted secret modification | GCM auth tag + workspace-bound AAD | 1 |
| Cache poisoning | Workspace-scoped cache keys | 2 | |
| Request parameter injection | Whitelist validation for project IDs; prompt sanitisation | 2 | |
| Repudiation | Insufficient audit trail | Structured audit events emitted for privileged operations | 2 |
| Information Disclosure | Debug token leakage | Gated behind non-production runtime + explicit debug flag | 1 |
| Encrypted envelope exposure | API returns only boolean flags, never ciphertext | 1 | |
| Error message leakage | Most errors mapped to generic messages; some upstream passthrough remains | 2 | |
| SSRF metadata access | Two-tier allowlist + redirect: 'manual' + per-hop validation |
1 | |
| Denial of Service | Rate limit bypass | Per-IP sliding window; auth endpoints always active; assistant endpoints opt-in | 3 |
| Unbounded Map growth | Not yet addressed (no max-size eviction) | 3 | |
| Large payload attacks | 1 MB body limit, 12K char message limit, 120K char tool result limit | 1 | |
| Elevation of Privilege | Admin → Owner escalation | Role hierarchy enforced (admins cannot assign owner role) | 1 |
| Workspace isolation bypass | HMAC-signed internal auth with workspace ID enforcement | 1 | |
| CSRF attacks | Same-origin check on all mutating endpoints; Origin header required on POST/PUT/PATCH/DELETE | 1 |
Scenario 1: OAuth State Forgery (CSRF)
OAUTH_STATE_SECRET is unset; HMAC-SHA256 with timing-safe comparisonScenario 2: Magic Link Interception
Scenario 3: Workspace Isolation Bypass
Scenario 4: SSRF to Cloud Metadata
http://169.254.169.254/redirect: 'manual' on all fetches; resolveTrustedRedirect() validates every hop against allowlist; HTTPS-onlyScenario 5: Prompt Injection for Data Exfiltration
sanitizePromptValue() strips template/injection characters; tool results capped at 120K charsScenario 6: Rate Limit Exhaustion / DoS
/assistant/chat with requestsScenario 7: URL-Encoded Open Redirect
returnTo=%2f%2fevil.comsanitizeReturnTo() double-decodes URL-encoded input before rejecting // and /\ prefixes%2f%2f bypass; requires a novel encoding vector to exploitGDPR (General Data Protection Regulation):
The following tables compare the current implementation against industry frameworks. These are included for benchmarking purposes — no certification is being pursued.
OWASP ASVS Level 2 Alignment:
| Control Area | Status | Notes |
|---|---|---|
| Architecture | Implemented | Threat model documented in this whitepaper |
| Authentication | Implemented | OAuth 2.0 + magic link; GET confirmation form; debug gating; fail-hard on missing secrets |
| Session Management | Mostly implemented | HttpOnly/Secure/SameSite; missing __Host- prefix and idle timeout |
| Access Control | Implemented | RBAC with role hierarchy enforcement; bootstrap admin audit trail |
| Input Validation | Mostly implemented | Project ID whitelist; prompt sanitisation (does not yet cover backticks/quotes) |
| Cryptography | Implemented | AES-256-GCM with AAD; key rotation support via version field (unused) |
| Error Handling | Mostly implemented | Generic errors for most paths; some upstream passthrough remains |
| Data Protection | Implemented | Encrypted secrets; API returns boolean flags only; no envelope exposure |
| Communications | Implemented | CSP deployed; HSTS configured (max-age=31536000) |
| Malicious Code | Implemented | CSP with strict script-src 'self'; no eval() or inline scripts |
| Business Logic | Implemented | Role hierarchy, invite-only mode, workspace isolation |
| Files | N/A | No file uploads |
| APIs | Implemented | CSRF same-origin checks with mandatory Origin on mutating methods; rate limiting (auth always-on, assistant opt-in) |
| Configuration | Mostly implemented | .env in .gitignore; master key fallback chain should be consolidated |
NIST Cybersecurity Framework Alignment:
| Function | Status | Notes |
|---|---|---|
| Identify | Partial | Asset inventory via workspace model; threat model in this whitepaper; no formal risk register |
| Protect | Strong | Access control (RBAC), encryption (AES-256-GCM), security headers (CSP, X-Frame-Options), input validation |
| Detect | Basic | Structured audit events for privileged operations; no SIEM or anomaly detection |
| Respond | Documented | Incident response procedures defined (Section 8.2); no automated response playbooks |
| Recover | Minimal | No native backup/restore for Netlify Blobs; workspace configs are small and reconstructable |
UK Cyber Essentials Alignment:
Cyber Essentials defines five technical controls. The mapping below reflects how each applies to a serverless SaaS deployment (some controls map differently from traditional on-premises infrastructure).
| Control | Status | Notes |
|---|---|---|
| Firewalls / Boundary Controls | Mostly aligned | No traditional firewall — Netlify edge/CDN acts as boundary; CSP restricts client-side resource loading; frame-ancestors 'none' prevents framing; connect-src limits outbound API calls to self + api.openai.com |
| Secure Configuration | Mostly aligned | Debug modes gated behind non-production runtime; OAuth state signing fails hard when unconfigured; .env excluded from version control; default-deny CSP; HSTS deployed; no unnecessary services exposed. Gap: Vite dev server binds to 0.0.0.0 |
| User Access Control | Partially aligned | RBAC with three-tier role hierarchy (owner/admin/member); bootstrap admin restricted to named email addresses; admin cannot escalate to owner; principle of least privilege for workspace secrets. Gap: no multi-factor authentication (MFA) — Cyber Essentials requires MFA for cloud service accounts |
| Malware Protection | N/A (serverless) | No endpoint to protect — serverless functions run in ephemeral containers; CSP script-src 'self' prevents injection of external scripts; no file upload functionality |
| Patch Management | Partial | Dependencies managed via npm with pinned versions; node --check validates function syntax on build. Gap: no automated dependency vulnerability scanning (e.g. npm audit, Dependabot, Snyk); no documented patching cadence |
Key gaps for Cyber Essentials certification:
Required Environment Variables (Production):
OAUTH_STATE_SECRET (256-bit random, base64)WORKSPACE_SECRET_MASTER_KEY (256-bit random, hex)RESEND_API_KEY and RESEND_FROM_EMAILMULTI_TENANT_BOOTSTRAP_EMAILS (comma-separated)ASSISTANT_HTTP_RATE_LIMIT_ENABLED=trueMULTI_TENANT_MAGIC_LINK_DEBUG is unset or falseMULTI_TENANT_INVITE_DEBUG is unset or falseProduction Deployment:
npm run build && npm run check)Security Event Classification:
Response Procedures:
P0/P1 Incidents:
P2/P3 Incidents:
Required (Production):
# OAuth Configuration
GOOGLE_OAUTH_CLIENT_ID=xxx.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=GOCSPX-xxx
OAUTH_STATE_SECRET=<256-bit-random-base64>
# Encryption
WORKSPACE_SECRET_MASTER_KEY=<256-bit-random-hex>
# Email
RESEND_API_KEY=re_xxx
RESEND_FROM_EMAIL=noreply@example.com
# Multi-Tenant
MULTI_TENANT_ENABLED=true
MULTI_TENANT_BOOTSTRAP_EMAILS=admin@example.com
# Security
ASSISTANT_HTTP_RATE_LIMIT_ENABLED=true
MULTI_TENANT_MAGIC_LINK_DEBUG=false
MULTI_TENANT_INVITE_DEBUG=false
Optional (Tuning):
# Rate Limits
ASSISTANT_CHAT_RATE_LIMIT_MAX=2000
ASSISTANT_CHAT_RATE_LIMIT_WINDOW_MS=60000
ASSISTANT_CHAT_STATUS_RATE_LIMIT_MAX=1200
ASSISTANT_CHAT_RESULT_RATE_LIMIT_MAX=1200
ASSISTANT_REALTIME_RATE_LIMIT_MAX=600
MULTI_TENANT_MAGIC_VERIFY_RATE_LIMIT_MAX=180
OAUTH_CALLBACK_RATE_LIMIT_MAX=120
# Session TTLs
MULTI_TENANT_SESSION_TTL_MS=2592000000 # 30 days
MULTI_TENANT_MAGIC_LINK_TTL_MS=900000 # 15 min
# Model
OPENAI_MODEL=gpt-4.1-mini
# AMAPI
AMAPI_MCP_ENDPOINT=https://androidmanagement.googleapis.com/mcp
AMAPI_SCOPES=https://www.googleapis.com/auth/androidmanagement
# Feature Flags
MULTI_TENANT_INVITE_ONLY=false
GOOGLE_OAUTH_INCLUDE_GRANTED_SCOPES=false
AUTH_VALIDATE_TOKENS=true
Authentication:
GET /auth/google/start — Initiate OAuth flowGET /auth/google/callback — OAuth callback (rate-limited: 120/min)GET /auth/session — Check current session status (returns user info + workspace context)POST /auth/magic-link/start — Request magic link (rate-limited: 120/min)GET /auth/magic-link/verify — Display confirmation formPOST /auth/magic-link/verify — Consume magic link token (rate-limited: 180/min)POST /auth/logout — Legacy OAuth cookie logout (revokes tokens, clears cookies)POST /auth/logout-session — Multi-tenant session logoutWorkspace Management:
POST /workspace/create — Create new workspace (rate-limited: 60/min)GET /workspace/config — Get active workspace config and membership listPOST /workspace/select — Switch active workspacePOST /workspace/delete — Delete workspace and purge associated workspace-scoped data (owner/bootstrap-admin only; rate-limited: 15/min)GET /workspace/users — List workspace membersPOST /workspace/invite — Invite user to workspace (rate-limited: 120/min)POST /workspace/user/remove — Remove user from workspacePOST /workspace/secrets/openai — Set OpenAI API keyPOST /workspace/secrets/google-client — Set Google OAuth client credentialsPOST /workspace/google-oauth/start — Start workspace OAuth setupGET /workspace/google-oauth/callback — Complete workspace OAuth callbackPOST /workspace/google-oauth/disconnect — Disconnect workspace OAuthGET /workspace/audit-log — Retrieve workspace audit entries (owner/admin only; 100-entry read limit)GET/POST/DELETE /workspace/chat-history — Read, write, or delete per-user chat sessions (rate-limited: 240/480/60 per min respectively)Assistant API:
POST /assistant/chat — Query GPT assistant (rate-limited: 2,000/min, opt-in)GET /assistant/chat/status — Get asynchronous job status (rate-limited: 1,200/min, opt-in)GET /assistant/chat/result — Get asynchronous job result (rate-limited: 1,200/min, opt-in)GET /assistant/chat/logs — Get assistant logsGET /assistant/runtime-limits — Get effective rate limitsPOST /assistant/realtime-session — Create OpenAI Realtime API session (rate-limited: 600/min, opt-in)Fleet Data:
GET /assistant/fleet/enterprises — List enterprises (cached)GET /assistant/fleet/enterprise?name=X — Get enterprise detailsGET /assistant/fleet/devices?enterpriseName=X — List devices (with re-enrolment deduplication)GET /assistant/fleet/device?name=X — Get device detailsPOST /assistant/fleet/refresh — Trigger background fleet syncGET /assistant/fleet/policies?enterpriseName=X — List policiesGET /assistant/fleet/policy?name=X — Get policy detailsGET /assistant/fleet/web-apps?enterpriseName=X — List web appsGET /assistant/fleet/web-app?name=X — Get web app detailsGET /assistant/fleet/application?packageName=X — Get application detailsScheduled:
assistant-refresh-scheduled — Cron-triggered function that refreshes fleet data for active workspaces in configurable batch sizesUtilities:
POST /mcp — Model Context Protocol endpoint (tool listing and execution)GET /assistant/app-icon?packageName=X — Proxy Play Store icon (SSRF-protected)POST /feedback/submit — Submit user feedback (sends email; rate-limited: 120/min)GET /app/config — Get application configuration (returns projectId and cacheDefaultEnabled)| Code | Meaning | Typical Cause |
|---|---|---|
| 400 | Bad Request | Invalid parameters, malformed JSON |
| 401 | Unauthorised | No session, invalid token, expired token |
| 403 | Forbidden | Insufficient permissions, CSRF failure |
| 404 | Not Found | Resource does not exist, wrong workspace |
| 405 | Method Not Allowed | Wrong HTTP method (GET vs POST) |
| 409 | Conflict | Duplicate workspace, existing membership |
| 413 | Payload Too Large | Workspace mutation body exceeds MULTI_TENANT_WORKSPACE_MUTATION_MAX_REQUEST_BYTES (default 100 KB) |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Uncaught exception, configuration error (e.g. missing OAuth state secret) |
| 502 | Bad Gateway | External API failure (Google, OpenAI) |
| 503 | Service Unavailable | Blob storage down, quota exceeded |
AMAPI Commander demonstrates a modern serverless SaaS architecture with comprehensive security controls including OAuth 2.0 authentication, AES-256-GCM encryption, multi-tenant isolation, and defence-in-depth strategies. The platform has undergone three rounds of security auditing with all critical and high-priority, and most lower-recommended items addressed.
Current Readiness:
max-age=31536000)This whitepaper serves as both technical documentation for developers and security guidance for operators. It should be updated as the architecture evolves and new security controls are implemented.
Are you in need of further help, or would you like to raise a feature request? You can: