Application Security — Comprehensive Reference
Table of Contents
- Security Architecture Overview
- Authentication
- Authorization (RBAC)
- Organization Isolation
- Encryption & Data Protection
- Middleware Security Stack
- Input Validation & Request Limits
- Rate Limiting
- Security Headers
- CORS Policy
- Audit Trail
- Error Handling & Monitoring
- Data Retention & Deletion
- Secrets Management
- Infrastructure Security
- Vulnerability Management
- Incident Response
- Backup & Disaster Recovery
1. Security Architecture Overview
Prism implements defense-in-depth with security controls at every layer of the stack:
┌─────────────────────────────────────────────────────────┐
│ Client (Browser) │
│ Clerk.js SDK → JWT acquisition, session management │
└───────────────────────┬─────────────────────────────────┘
│ HTTPS (TLS 1.2+)
┌───────────────────────▼─────────────────────────────────┐
│ Cloudflare Edge (TLS termination) │
│ WAF · DDoS mitigation · HSTS · Bot management │
│ CF-Connecting-IP header injection │
└───────────────────────┬─────────────────────────────────┘
│ Internal (Cloudflare Tunnel)
┌───────────────────────▼─────────────────────────────────┐
│ Go Backend (Chi Router) │
│ │
│ ┌─ Middleware Stack (13 layers) ───────────────────┐ │
│ │ 1. Sentry panic capture │ │
│ │ 2. Chi Recoverer │ │
│ │ 3. Gzip compression │ │
│ │ 4. CORS enforcement │ │
│ │ 5. Structured logging │ │
│ │ 6. Prometheus metrics │ │
│ │ 7. Audit logging (mutation tracking) │ │
│ │ 8. Request body size limits │ │
│ │ 9. Security headers │ │
│ │ 10. Rate limiting (Redis / in-memory) │ │
│ │ 11. Real IP extraction (CF-Connecting-IP) │ │
│ │ 12. Request ID injection │ │
│ │ 13. Auth (Clerk JWT / API Key) + Scope check │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ Handler → Service → Repository → Database │
└───────────────────────┬─────────────────────────────────┘
│ TLS (connection pooling via pgx)
┌───────────────────────▼─────────────────────────────────┐
│ PostgreSQL 15 + PostGIS │
│ Row-Level Security (RLS) on all org-scoped tables │
│ Org consistency triggers on join tables │
│ Encrypted at rest (managed platform) │
└─────────────────────────────────────────────────────────┘
Security Layers Summary
| Layer | System | Controls |
|---|---|---|
| Edge / Transport | Cloudflare | TLS 1.2+ termination, WAF, DDoS, HSTS, bot mitigation |
| User Authentication | Clerk | SSO, MFA, session management, JWT issuance |
| Service Authentication | Prism API Keys | SHA-256 hashed, scoped, expiring machine tokens |
| Authorization | Backend middleware | 30+ scope-based permissions with role bundles |
| Org Isolation | Backend + Database RLS | Multi-tenant data separation at every layer |
| Data Protection | Platform encryption | Encrypted at rest (Supabase Postgres, Upstash Redis) |
| Secrets | Infisical | Centralized secret management, auto-sync to platforms |
| Observability | Sentry + Prometheus | Error capture, metrics, request tracing |
2. Authentication
Prism supports two authentication methods — Clerk JWT (user sessions) and API Keys (machine-to-machine). Every API request must authenticate via one of these methods.
2.1 Clerk JWT Authentication (User Sessions)
Flow:
- User authenticates via Clerk.js SDK in the browser (SSO, social login, or email — MFA required for production access)
- Clerk issues a short-lived JWT containing user identity, org context, and permissions
- Frontend sends
Authorization: Bearer <token>on every API request - Backend validates the JWT and extracts auth context
Implementation: go-backend/internal/middleware/auth.go, go-backend/internal/auth/clerk.go
JWT Validation Process
The ClerkValidator performs multi-step validation:
Token received
│
├─ 1. Parse JWT header → extract `kid` (key ID)
│
├─ 2. Fetch signing key from JWKS endpoint
│ └─ Cached with 1-hour TTL (background refresh)
│ └─ SSRF protection on JWKS fetch (see §2.3)
│
├─ 3. Verify RSA signature (RS256 / RS384 / RS512 only)
│ └─ HMAC algorithms explicitly rejected
│
├─ 4. Validate standard claims:
│ ├─ `exp` — must be present and not expired
│ ├─ `iss` — must match configured CLERK_ISSUER
│ └─ `aud` — must match CLERK_AUDIENCE (if configured)
│
└─ 5. Extract Prism-specific claims:
├─ `sub` → user ID
├─ `org_id` → organization ID
└─ `org_permissions` → permission array
JWT Claims Structure
{
"sub": "user_abc123",
"org_id": "org_xyz789",
"org_permissions": [
"org:buildings:read",
"org:buildings:write",
"org:comps:read",
"org:tims:read",
"org:tims:write"
],
"iss": "https://clerk.property-prism.com",
"aud": "prism-api",
"exp": 1712000000,
"iat": 1711996400
}
Scope Normalization
Clerk permissions use org: prefix format. The backend normalizes these to internal scope format:
org:buildings:read→buildings:readorg:tims:write→tims:writeorg:api_keys:manage→api_keys:manage
Strict Scope Mode
STRICT_CLERK_SCOPES=true must be set in production.
| Mode | Behavior |
|---|---|
| Strict (production) | Missing or empty org_permissions = zero scopes. Deny-by-default. |
| Relaxed (dev only) | Missing permissions may fall back to default grant for development convenience. |
Wildcard Scope Detection
If a JWT contains a wildcard scope (*), the middleware:
- Logs a structured error with the user ID and org ID
- Does not grant elevated access — wildcard scopes are not honored
2.2 API Key Authentication (Machine-to-Machine)
For scripts, integrations, and BI tools that cannot use browser-based JWT auth.
Flow:
- Org admin creates an API key via
POST /api/v1/api-keys(requiresapi_keys:managescope) - Backend generates a cryptographically random key, stores only the SHA-256 hash
- Raw key is returned once in the creation response — never stored or retrievable again
- Client sends the raw key via
X-API-Keyheader on subsequent requests - Backend hashes the provided key with SHA-256, looks up the hash in the database
Implementation: go-backend/internal/middleware/auth.go
API Key Validation Process
X-API-Key header received
│
├─ 1. Length check: reject if > 512 bytes (guard against abuse)
│
├─ 2. SHA-256 hash the raw key
│
├─ 3. Database lookup by hash
│ └─ Returns: key record with scopes, org_id, expires_at, revoked_at
│
├─ 4. Expiration check: reject if past expires_at
│
├─ 5. Revocation check: reject if revoked_at is set
│
├─ 6. Org active check: verify the org is still active
│
├─ 7. Set auth context (org ID, scopes, auth method = "api_key")
│
└─ 8. Async update of last_used_at timestamp (non-blocking)
API Key Security Properties
| Property | Detail |
|---|---|
| Storage | SHA-256 one-way hash only — raw key never stored |
| Scope restrictions | api_keys:manage and operations:write are excluded from API key delegation (admin-only scopes cannot be delegated) |
| Organization-scoped | Each key belongs to exactly one org |
| Expiring | Optional expires_at date |
| Revocable | Immediate invalidation via revoked_at timestamp |
| Safe view | api_keys_safe database view excludes key_hash column; direct table SELECT is revoked for the authenticated role |
Scope Delegation Validation
The ValidAPIKeyScopes allowlist explicitly defines which scopes can be assigned to API keys. This prevents privilege escalation — an admin cannot create an API key with api_keys:manage scope, which would allow the key to create more keys.
Excluded from API key delegation:
api_keys:manageoperations:write
2.3 SSRF Protection (JWKS Fetch)
The JWKS endpoint fetch includes multiple SSRF defenses:
| Defense | Implementation |
|---|---|
| HTTPS only | Rejects http:// URLs |
| IP rejection | Blocks private IPs (10.*, 172.16-31.*, 192.168.*), loopback (127.*, ::1), link-local |
| Localhost rejection | Blocks localhost hostname |
| Hostname allowlist | Only fetches from .clerk.accounts.dev, .clerk.com, api.clerk.com, property-prism.com |
| Redirect limit | Maximum 3 redirects, each must be same-host |
| Custom HTTP client | Uses CheckRedirect function with same SSRF checks on each redirect |
Configuration:
var defaultJWKSAllowedHostSuffixes = []string{
".clerk.accounts.dev", ".clerk.com", "clerk.accounts.dev",
"clerk.com", "api.clerk.com", "property-prism.com",
}
2.4 JWKS Key Caching
| Parameter | Value |
|---|---|
| Cache TTL | 1 hour |
| Refresh | Background refresh before expiry |
| Key types | RSA only (RS256, RS384, RS512) |
| HMAC rejection | Explicitly rejected to prevent algorithm confusion attacks |
3. Authorization (RBAC)
3.1 Role Definitions
| Role | Description | Scope Set |
|---|---|---|
| Viewer | Read-only stakeholders | All *:read scopes |
| Member | Standard operational users | All *:read + *:write + export |
| Admin | Org administrators | All member scopes + api_keys:manage, audit:read, export:admin |
3.2 Complete Scope Inventory
Defined in go-backend/internal/domain/auth.go:
Read Scopes (Viewer+):
| Scope | Resource |
|---|---|
buildings:read | Building records |
comps:read | Comp lease records |
tims:read | TIM (Tenant-in-Market) records |
tenants:read | Tenant lookup records |
owners:read | Owner lookup records |
contacts:read | Contact records |
brokerage_firms:read | Brokerage firm records |
reports:read | Report records |
building_parks:read | Building park records |
key_points:read | Key point (port/airport/interchange) records |
Write Scopes (Member+):
| Scope | Resource |
|---|---|
buildings:write | Create/update/delete buildings |
comps:write | Create/update/delete comps |
tims:write | Create/update/delete TIMs |
tenants:write | Create/update/delete tenants |
owners:write | Create/update/delete owners |
contacts:write | Create/update/delete contacts |
brokerage_firms:write | Create/update/delete brokerage firms |
reports:write | Create/update/delete reports |
building_parks:write | Create/update/delete building parks |
key_points:write | Create/update/delete key points |
export | Data export access |
Admin Scopes:
| Scope | Resource |
|---|---|
api_keys:manage | Create, list, revoke API keys |
audit:read | View audit log |
export:admin | BI export management (credential lifecycle) |
operations:write | Operations queue state management |
3.3 Enforcement Points
Authorization is enforced at four levels:
-
Route-level middleware (
RequireScope) — checks the auth context has the required scope before the handler executes. Applied to every protected route group. -
Admin middleware — additional gate for
/admin/*routes that requires admin-level scopes. -
Frontend UI gates (
AuthGate,AdminRouteGate) — hides UI elements the user cannot access. These are convenience-only, not authoritative — the backend enforces the real check. -
Database RLS — row-level policies enforce org isolation even if application-layer checks were somehow bypassed (defense-in-depth).
4. Organization Isolation
4.1 Multi-Tenant Architecture
Prism is a multi-tenant SaaS application. Each organization's data is fully isolated through a layered enforcement model:
JWT claims (org_id)
│
├─ Backend middleware extracts org_id → sets auth context
│
├─ Service layer passes org_id to all repository calls
│
├─ Repository layer includes org_id in all SQL WHERE clauses
│
└─ Database RLS enforces org_id check at the row level
4.2 Data Classification
| Classification | Tables | Isolation |
|---|---|---|
| Public | buildings, geography_*, lookups_*, key_points, labor_block_groups | No org filter; requires authentication but visible to all orgs |
| Org-Scoped | comps, tims, contacts, owners, tenants, brokerage_firms, reports, audit_log, api_keys, import_jobs, etc. | organization_id column + RLS policies |
| System | organizations, user_org_memberships | RLS policies based on membership |
4.3 Row-Level Security (RLS) Implementation
All tables in public schema have RLS enabled, including public datasets (defense-in-depth).
RLS Helper Functions
Defined in database/PRISM_vNext_FINAL.sql:
| Function | Purpose | Used By |
|---|---|---|
current_user_id_text() | Returns the current session's user ID | All RLS policies |
can_org_read(org_id) | User is a member of the specified org | SELECT policies |
can_org_write(org_id) | User is an active member of the specified org | INSERT/UPDATE/DELETE policies |
is_org_admin(org_id) | User has admin role in the specified org | Admin-only operations (org settings, memberships) |
RLS Policy Pattern
Standard org-scoped tables follow this pattern:
-- SELECT: any org member can read
CREATE POLICY tenant_select ON public.tenant_lookup
FOR SELECT USING (public.can_org_read(organization_id));
-- INSERT: active members can create
CREATE POLICY tenant_insert ON public.tenant_lookup
FOR INSERT WITH CHECK (public.can_org_write(organization_id));
-- UPDATE: active members can modify
CREATE POLICY tenant_update ON public.tenant_lookup
FOR UPDATE USING (public.can_org_write(organization_id))
WITH CHECK (public.can_org_write(organization_id));
-- DELETE: active members can remove
CREATE POLICY tenant_delete ON public.tenant_lookup
FOR DELETE USING (public.can_org_write(organization_id));
Special RLS Policies
| Table | Policy | Rationale |
|---|---|---|
organizations | org_no_delete USING (false) | Organizations cannot be deleted via SQL (offboarding is a manual admin process) |
audit_log | INSERT only for org members; SELECT for admins; no UPDATE/DELETE | Append-only audit trail |
api_keys | Direct SELECT revoked; only accessible via api_keys_safe view | Prevents key hash exposure |
broker_details | RLS via JOIN to parent contacts table | Child table inherits parent org scope |
4.4 Cross-Org Consistency Triggers
Database triggers enforce referential integrity across org boundaries on join tables:
| Trigger | Tables | Enforcement |
|---|---|---|
tim_comp_org_consistency() | tim_comp_leases | TIM and comp must belong to the same org |
tim_building_interest | tim_building_interest | TIM must exist in the requesting org |
report_*_org_consistency() | Report association tables | Report and associated entities must share org |
These triggers prevent an application bug from accidentally associating records across org boundaries.
4.5 BI Export Isolation
The export schema (prism_export) provides org-filtered views for BI tools:
- Each org gets a dedicated BI database role (e.g.,
prism_bi_org_123) - The BI role has
SELECTonly on export views - Export views are pre-filtered by
organization_id - BI credential lifecycle is managed via one-time token exchange (see §5.5)
5. Encryption & Data Protection
5.1 Transport Encryption (TLS)
| Segment | Encryption | Details |
|---|---|---|
| Client → Cloudflare | TLS 1.2+ (terminated at edge) | Managed by Cloudflare; automatic certificate provisioning |
| Cloudflare → Backend | Cloudflare Tunnel (encrypted) | Internal traffic never traverses public internet |
| Backend → PostgreSQL | TLS (pgx connection) | Managed by Supabase/RDS; sslmode=require |
| Backend → Redis | TLS | Managed by Upstash; TLS enforced on connection |
HSTS Policy: Strict-Transport-Security: max-age=31536000; includeSubDomains
- 1-year HSTS max-age ensures browsers always use HTTPS after first visit
includeSubDomainscovers all subdomains
5.2 Data at Rest Encryption
| System | Encryption | Provider |
|---|---|---|
| PostgreSQL | AES-256 at rest | Supabase managed (or RDS with AWS KMS) |
| Redis | AES-256 at rest | Upstash managed |
| Application server | Ephemeral (no persistent disk) | Render managed containers |
| Backups | Encrypted with platform encryption | Supabase / RDS automated backups |
Prism does not implement application-level field encryption — encryption at rest is delegated to the managed database and cache platforms, which is appropriate for the data classification (commercial real estate intelligence, no PII beyond contact names/emails).
5.3 JWT Cryptographic Verification
| Property | Value |
|---|---|
| Algorithm | RSA (RS256, RS384, RS512) |
| Key management | Clerk-managed JWKS endpoint with automatic key rotation |
| HMAC rejection | HMAC algorithms (HS256, HS384, HS512) are explicitly rejected to prevent algorithm confusion attacks |
| Key caching | 1-hour TTL with background refresh |
| Signature verification | Every request — no caching of verification results |
5.4 API Key Hashing
| Property | Value |
|---|---|
| Algorithm | SHA-256 (one-way hash) |
| Raw key exposure | Shown exactly once at creation time, never stored |
| Database storage | Only the key_hash column is stored; the api_keys_safe view excludes it |
| Lookup | Hash the provided key → match against key_hash column |
SHA-256 is appropriate here because API keys are high-entropy random values (not user-chosen passwords), so brute-force/dictionary attacks are not feasible.
5.5 BI Credential One-Time Token Exchange
BI credential creation uses a secure token exchange pattern to avoid transmitting database credentials in standard API responses:
Admin creates BI credentials
│
├─ 1. Backend generates credentials (DB role + password)
├─ 2. Backend generates a 256-bit cryptographically random token
├─ 3. Token stored in Redis with 5-minute TTL
├─ 4. API returns the token (not the credentials)
│
└─ Client exchanges token for credentials
├─ 5. Client calls exchange endpoint with token
├─ 6. Backend validates token, checks org ID matches
├─ 7. Backend returns credentials
└─ 8. Token is immediately deleted (one-time use)
Token generation: go-backend/internal/pkg/token/token.go
// 32 bytes (256 bits) of crypto/rand, URL-safe base64 encoded
func GenerateBICredentialToken() (string, error) {
return GenerateSecureToken(32)
}
Security properties:
- 256-bit cryptographic randomness (
crypto/rand) - 5-minute TTL — auto-expires if not exchanged
- One-time use — deleted immediately after successful exchange
- Org ID validation — token can only be exchanged by the org that created it
5.6 Secret Storage
- No secrets in source code —
.envfiles are gitignored,.env.examplehas only placeholders - Infisical is the single source of truth for all production secrets
- No long-lived keys in frontend — only
VITE_*(public) variables are in frontend builds - GitHub push protection blocks commits containing detected secret patterns
See Secrets Management for the full secret inventory and rotation playbooks.
6. Middleware Security Stack
The backend applies a 13-layer middleware stack to every request, in this order:
Implementation: go-backend/cmd/api/main.go
| Order | Middleware | Source | Purpose |
|---|---|---|---|
| 1 | sentryhttp.Handle | Sentry SDK | Captures panics before Chi recoverer |
| 2 | middleware.Recoverer | Chi | Catches panics, returns 500 instead of crashing |
| 3 | middleware.Compress(5) | Chi | Gzip compression (level 5 balanced) |
| 4 | cors.Handler | go-chi/cors | CORS enforcement with explicit origin allowlist |
| 5 | loggingMiddleware | Custom | Structured request logging (zap) |
| 6 | PrometheusMiddleware | Custom | HTTP request metrics (duration, status, method) |
| 7 | AuditLogger.Middleware | Custom | Audit logging for sensitive mutations |
| 8 | DefaultMaxBody | Custom | 1MB default request body size limit |
| 9 | SecurityHeaders | Custom | HSTS, X-Frame-Options, X-Content-Type-Options, etc. |
| 10 | RateLimitByIP | Custom | Redis sliding-window rate limiting |
| 11 | RealIP | Custom | Extract CF-Connecting-IP (Cloudflare real IP) |
| 12 | InjectRequestID | Custom | Generate unique request ID for tracing |
| 13 | AuthMiddleware | Custom | JWT / API Key validation + scope enforcement |
Protected route groups add additional middleware:
RequireScope(scope)— enforces specific permission scopes per route groupBulkMaxBody()/UploadMaxBody()— larger body limits for bulk/upload routes
7. Input Validation & Request Limits
7.1 Request Body Size Limits
Implementation: go-backend/internal/middleware/request_limit.go
| Context | Limit | Applied To |
|---|---|---|
| Default | 1 MB | All routes (global middleware) |
| Bulk operations | 10 MB | Bulk create/update endpoints |
| File upload | 50 MB | CSV import uploads |
| Export | 100 KB | Export configuration payloads |
Requests exceeding the limit receive 413 Request Entity Too Large.
7.2 Domain Model Validation
Every domain entity has a Validate() method that enforces business rules before database operations:
Implementation: go-backend/internal/domain/*.go
| Entity | Validations |
|---|---|
Building | Required fields (name, property type, class), enum value checks, numeric range checks |
Contact | Required fields, exactly one company association (owner OR tenant OR brokerage firm) |
CompLease | Required fields, date range validity, numeric range checks |
TIM | Required fields, status enum check |
TIMBuildingInterest | Interest type enum check (interest, backup, toured, proposed, offered, rejected) |
KeyPoint | Required fields, valid coordinates |
BuildingPark | Required name |
ContactActivity | Required fields, valid activity type |
UserOrgMembership | Required user ID, org ID, valid role |
Validation is called in the service layer before any repository operation. Invalid input returns 400 Bad Request with structured field-level errors.
7.3 SQL Injection Prevention
All database queries use parameterized queries via pgx:
// All user input is passed as parameters ($1, $2, ...), never interpolated
rows, err := pool.Query(ctx,
`SELECT * FROM buildings WHERE building_id = $1 AND organization_id = $2`,
buildingID, orgID,
)
No raw string concatenation is used in SQL queries.
7.4 API Key Length Guard
API keys provided in the X-API-Key header are rejected if longer than 512 bytes, preventing abuse via oversized inputs before hashing.
8. Rate Limiting
8.1 Algorithm
Implementation: go-backend/internal/middleware/rate_limit_redis.go
| Property | Value |
|---|---|
| Algorithm | Sliding window (Redis INCR + EXPIRE) |
| Fallback | In-memory token bucket if Redis unavailable |
| Key | Per client IP (or per org/API key for authenticated requests) |
| Redis timeout | 50ms — if Redis doesn't respond in 50ms, request is allowed (fail-open for availability) |
8.2 Configuration
| Env Var | Default | Description |
|---|---|---|
RATE_LIMIT_PER_SECOND | Configurable | Requests per second per key |
RATE_LIMIT_BURST | Configurable | Burst capacity |
TRUST_CLOUDFLARE_HEADERS | false | When true, uses CF-Connecting-IP for real client IP |
8.3 Real IP Extraction
Implementation: go-backend/internal/middleware/real_ip.go
When TRUST_CLOUDFLARE_HEADERS=true:
- Reads
CF-Connecting-IPheader (single IP, set by Cloudflare) - Falls back to
X-Real-IP→X-Forwarded-For→RemoteAddr
The CF-Connecting-IP header cannot be spoofed by clients when traffic routes through Cloudflare, as Cloudflare overwrites it. This must only be trusted when Cloudflare is the edge proxy.
9. Security Headers
Implementation: go-backend/internal/middleware/security_headers.go
| Header | Value | Purpose |
|---|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains | Force HTTPS for 1 year, all subdomains |
X-Content-Type-Options | nosniff | Prevent MIME type sniffing |
X-Frame-Options | DENY | Prevent clickjacking — no framing allowed |
X-XSS-Protection | 0 | Disable legacy XSS auditor (rely on CSP instead) |
Referrer-Policy | no-referrer | Don't leak URLs in referrer headers |
Cache-Control | no-store | Prevent caching of API responses (all responses contain org-scoped data) |
Permissions-Policy | Restrictive | Disables unused browser features (camera, microphone, geolocation, etc.) |
10. CORS Policy
Implementation: go-backend/cmd/api/main.go
cors.Handler(cors.Options{
AllowedOrigins: cfg.AllowedOrigins, // Explicit per-environment list
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-API-Key"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300, // Preflight cache: 5 minutes
})
| Property | Detail |
|---|---|
| Origin list | Configured via ALLOWED_ORIGINS env var; no wildcards in production |
| Credentials | AllowCredentials: true — cookies and auth headers are allowed |
| Preflight cache | 5-minute max-age reduces preflight requests |
| Startup guard | Application refuses to start if ALLOWED_ORIGINS is empty |
11. Audit Trail
11.1 Audit Log
Implementation: go-backend/internal/middleware/audit.go
The audit middleware captures all data mutations on sensitive resources:
Audited Resources
| Route Pattern | Resource |
|---|---|
/api/v1/buildings* | Building CRUD |
/api/v1/comps* | Comp lease CRUD |
/api/v1/tims* | TIM CRUD (including sub-resources) |
/api/v1/api-keys* | API key lifecycle |
/api/v1/memberships* | Org membership changes |
/api/v1/import-jobs* | Import job lifecycle |
/api/v1/export* | Export and BI credential operations |
/api/v1/operations* | Operations queue state changes |
/api/v1/admin* | Admin operations |
Audit Log Fields
| Field | Description |
|---|---|
actor_user_id | User who performed the action |
organization_id | Org context |
action | HTTP method + path |
table_name | Target resource type |
record_id | Target record ID (if applicable) |
changes | Changed fields with old/new values |
ip_address | Client IP (via CF-Connecting-IP) |
user_agent | Client user agent |
metadata | Additional context (JSONB) |
Sensitive Field Redaction
For API key operations, the audit middleware uses an allowlist — only these fields are logged from request bodies:
namescopesexpires_in_days
Key hashes and raw key values are never written to the audit log.
Audit Log Integrity
- Append-only: RLS policy allows INSERT for org members but denies UPDATE and DELETE
- Admin-only read: Only users with
audit:readscope can query the log - Retention: 7 years (SOC 2 compliance) — enforced by
pg_cronscheduled job
12. Error Handling & Monitoring
12.1 Error Capture
Implementation: go-backend/internal/middleware/error_capture.go
| Behavior | Detail |
|---|---|
| Async persistence | Error details written to error_log table asynchronously (non-blocking) |
| 5xx → Sentry | Server errors are reported to Sentry with full request context |
| Skipped codes | 401 and 404 are not persisted (noise reduction) |
| Request ID | Every error response includes request_id for correlation |
12.2 Request ID Injection
Implementation: go-backend/internal/middleware/request_id_inject.go
Every JSON error response body (4xx and 5xx) is automatically augmented with a request_id field. This ID is propagated to:
- Structured log entries (zap)
- Error log table entries
- Sentry error reports
- Frontend error display (user can report the ID for support)
12.3 Error Response Format
All API errors follow a standardized format:
{
"error": "Forbidden",
"message": "insufficient scope: buildings:write required",
"request_id": "req_abc123def456"
}
Validation errors include field-level detail:
{
"error": "Bad Request",
"message": "validation failed",
"field_errors": {
"name": "required",
"property_type": "must be one of: industrial, flex, office"
},
"request_id": "req_abc123def456"
}
Internal details (stack traces, SQL errors) are never exposed to clients — they are captured in Sentry and the error log.
12.4 Monitoring Stack
| System | Purpose | Coverage |
|---|---|---|
| Sentry | Error tracking, performance monitoring | All 5xx errors, panics, slow transactions |
| Prometheus | Metrics collection | HTTP request duration/status, DB pool stats, cache hit rates |
| Grafana | Metrics visualization | Dashboards for API latency, error rates, resource usage |
| Alertmanager | Alert routing | Configurable alerts for error spikes, performance degradation |
12.5 Debug Endpoint Protection
/debug/pprof endpoints (goroutine stacks, heap dumps) are only enabled in development mode (ENV=development). They are disabled in staging and production to prevent information leakage.
13. Data Retention & Deletion
13.1 Automated Retention Policies
Implementation: database/migrations/000018_add_data_retention_policies.up.sql
Data retention is enforced via pg_cron scheduled jobs that run daily at staggered times:
| Table | Retention Period | Schedule | Rationale |
|---|---|---|---|
error_log | 90 days | Daily at 03:00 UTC | Operational data — 90 days sufficient for debugging |
import_jobs | 90 days | Daily at 03:10 UTC | Import history — records imported data is in permanent tables |
contact_activities | 2 years | Daily at 03:20 UTC | Business activity data — 2-year lookback window |
audit_log | 7 years | Daily at 03:30 UTC | SOC 2 compliance requirement |
Purge SQL
-- Runs daily, staggered to avoid concurrent load
DELETE FROM public.error_log WHERE occurred_at < now() - INTERVAL '90 days';
DELETE FROM public.import_jobs WHERE created_at < now() - INTERVAL '90 days';
DELETE FROM public.contact_activities WHERE created_at < now() - INTERVAL '2 years';
DELETE FROM public.audit_log WHERE occurred_at < now() - INTERVAL '7 years';
13.2 Organization Offboarding (Full Purge)
When an organization cancels their account, all org-scoped data is deleted in FK-safe order:
- Junction/child tables first: contact_activities, contact_owner_roles, broker assignments, building metrics/addresses/electrical, comp_leases, import_jobs
- Core entity tables: contacts, buildings, owner_lookup, tenant_lookup, brokerage_firm_lookup
- Auth/config tables: api_keys, user_org_memberships
- Audit log: Anonymized (not deleted) — actor_user_id set to
'deleted-org', details redacted - Error log: Deleted
- Organization record: Deleted last
Post-deletion checklist:
- Confirm zero rows for the org across all tables
- Revoke any BI database roles created for the org
- Remove org from Clerk
- Log the deletion with requester, date, and ticket number
13.3 Individual User Removal
When a single user leaves an org that remains active:
- Delete
user_org_membershipsrow for the user + org - Deactivate/remove user in Clerk
Business data stays with the org. Contacts, buildings, comps, activities, and import history belong to the organization, not individual users. The created_by column is retained as an internal audit trail.
13.4 Redis Cache Expiration
| Cache | TTL | Purpose |
|---|---|---|
| JWKS keys | 1 hour | Clerk signing key cache |
| Rate limit windows | Sliding window per config | Request counting |
| BI credential tokens | 5 minutes | One-time credential exchange tokens |
| General cache entries | Varies by resource | Query result caching |
Redis is a cache, not durable storage. Total Redis loss causes performance degradation but no data loss.
14. Secrets Management
14.1 Architecture
| Principle | Implementation |
|---|---|
| Single source of truth | Infisical manages all production secrets |
| Auto-sync | Infisical syncs to Render (backend) and Cloudflare Pages (frontend) |
| No manual edits | Platform dashboards are never edited directly for secret values |
| No secrets in code | .env files gitignored; .env.example has placeholders only |
| Push protection | GitHub secret scanning blocks commits containing detected patterns |
14.2 Secret Inventory
| Secret | Sensitive | Rotation Cadence |
|---|---|---|
DATABASE_URL | Yes (embedded password) | 90 days or on compromise |
REDIS_URL | Yes (embedded password) | 90 days or on compromise |
CLERK_SECRET_KEY | Yes | On staff change or compromise |
SENTRY_AUTH_TOKEN | Yes | On compromise |
CLERK_ISSUER | No | On Clerk tenant change |
CLERK_JWKS_URL | No | On Clerk tenant change |
All VITE_* vars | No (public by nature) | — |
14.3 Rotation Process
- Rotate the credential in the upstream service
- Update the value in Infisical (prod environment)
- Infisical auto-syncs to deployment platform
- Redeploy the affected service
- Verify health (
/readyfor backend, auth flow for frontend) - Infisical version history serves as the rotation log
See Secrets Management for detailed per-secret rotation playbooks.
14.4 Break-Glass Access
Emergency direct database access:
- Must be documented, audited, and the credentials rotated immediately after use
- Uses
postgressuperuser role — break-glass only - Normal application access uses
prism_approle (no SUPERUSER, no BYPASSRLS)
15. Infrastructure Security
15.1 Network Architecture
Internet → Cloudflare (WAF + TLS) → Cloudflare Tunnel → Render (Backend :8080)
→ Supabase (Postgres)
→ Upstash (Redis)
- Backend port
:8080is not publicly accessible — only reachable via Cloudflare tunnel - Database is managed by Supabase with network-level access controls
- Redis is managed by Upstash with TLS-only connections
15.2 Database Role Model
| Role | Privileges | Used By |
|---|---|---|
postgres (superuser) | Full access | Break-glass emergency only |
prism_app | USAGE on public, CRUD via RLS | Application process |
authenticated | Read public, org-scoped CRUD via RLS | Supabase auth context |
service_role | Execute refresh/snapshot functions | Scheduled jobs, export refresh |
Key constraint: prism_app has no SUPERUSER and no BYPASSRLS — all application access goes through RLS policies.
15.3 Platform Access Control
| System | Admin Access | Standard Access |
|---|---|---|
| GitHub | Engineering lead (Owner/Admin) | Active engineers (Write) |
| Infisical | Backend lead / Ops lead | Engineers (read-only prod) |
| Supabase/RDS | Backend lead (break-glass only) | API process only (prism_app role) |
| Redis | Backend lead | — |
| Cloudflare | Ops lead | — |
| Sentry | Backend lead | Active engineers (Member) |
| Render | Ops lead | — |
| Clerk Dashboard | Backend lead | — |
15.4 CI/CD Security
| Control | Implementation |
|---|---|
| Branch protection | main branch requires PR review |
| Automated testing | GitHub Actions runs on every PR (tests, lint, typecheck, vet) |
| PostGIS test container | Integration tests run against real Postgres with full schema |
| Race detection | go test -race in CI catches data races |
| Swagger drift check | make api-docs-check fails CI if generated docs are stale |
| Secret scanning | GitHub push protection blocks secret patterns pre-commit |
| Dependency scanning | Dependabot monitors Go modules and npm packages |
| Static analysis | CodeQL runs in CI pipeline |
16. Vulnerability Management
16.1 Dependency Scanning
| Tool | Scope | Frequency |
|---|---|---|
| Dependabot | Go modules, npm packages | Continuous (PRs on new CVEs) |
| CodeQL | Go, TypeScript static analysis | Per PR |
| GitHub secret scanning | All file types | Continuous |
16.2 Response SLAs
| Severity | Response Time | Actions |
|---|---|---|
| Critical (CVSS 9.0+) | 24 hours | Immediate patch, emergency deploy |
| High (CVSS 7.0-8.9) | 72 hours | Prioritized patch |
| Medium (CVSS 4.0-6.9) | 2 weeks | Scheduled update |
| Low (CVSS < 4.0) | Next maintenance cycle | Batched with other updates |
See Vulnerability Management for full procedures.
17. Incident Response
17.1 Severity Levels
| Severity | Response Target | Example |
|---|---|---|
| P1 — Critical | < 15 minutes | Production down, confirmed data breach, auth bypass |
| P2 — High | < 1 hour | Elevated 5xx rate, auth failures, critical CVE |
| P3 — Medium | < 4 hours | Single endpoint issue, staging problem |
| P4 — Low | Next business day | Non-critical dep alert, doc drift |
17.2 Detection Sources
| Source | Detects |
|---|---|
| Sentry alerts | Error spikes, performance degradation |
| Render metrics | CPU, memory, container restarts |
| GitHub Dependabot | New CVEs in dependencies |
| GitHub secret scanning | Leaked credentials |
| Audit log anomalies | Unusual mutation patterns |
| External reports | User-reported issues |
17.3 Response Process
- Detect — alert triggers or report received
- Triage — assign severity, notify on-call
- Contain — isolate affected systems (revoke keys, block IPs, disable features)
- Investigate — correlate request IDs, audit logs, error logs, Sentry events
- Remediate — deploy fix, rotate compromised credentials
- Recover — verify system health, confirm data integrity
- Postmortem — document timeline, root cause, preventive actions
See Incident Response for full procedures.
18. Backup & Disaster Recovery
18.1 Objectives
| Objective | Target |
|---|---|
| RPO (Recovery Point Objective) | 24 hours (daily backup minimum) |
| RTO (Recovery Time Objective) | 4 hours |
| Uptime target | 99.5% monthly |
18.2 Backup Strategy
| Data | Backup Method | Retention |
|---|---|---|
| PostgreSQL | Supabase daily automated (PITR on Pro) | 7-30 days |
| Redis | Not backed up (cache only) | N/A |
| Import staging files | Not backed up (transient) | N/A |
Pre-migration: Manual snapshot required before every production database migration.
18.3 Degraded Mode Behavior
| Dependency Down | Impact |
|---|---|
| Redis | Cache misses fall through to DB; all endpoints work with degraded performance |
| Database | API returns 503; /health returns 200, /ready returns 503 |
| Clerk JWKS | Tolerated during cache TTL (1 hour); extended outage blocks all user auth |
| Cloudflare | API unreachable from internet; process continues running internally |
18.4 Restore Drills
Restore drills must be performed quarterly. Drill records are stored in docs/audit/restore-drills/.
See Backup & Disaster Recovery for full restore procedures.
Related Documents
- Security Architecture Overview — high-level security summary
- Access Control Policy — roles, permissions, joiner/mover/leaver
- Secrets Management — secret inventory, Infisical setup, rotation playbooks
- Incident Response — severity levels, triage, containment, postmortem
- Vulnerability Management — dependency scanning, CVE response
- Backup & DR — backup strategy, restore procedures
- Data Deletion Policy — org offboarding, user removal procedures
- Security Audit (2026-04-01) — latest security audit
Source Code References
| Topic | File |
|---|---|
| Auth middleware (JWT + API Key) | go-backend/internal/middleware/auth.go |
| Clerk JWT validator | go-backend/internal/auth/clerk.go |
| Auth context utilities | go-backend/internal/auth/context.go |
| Scope definitions | go-backend/internal/domain/auth.go |
| Security headers | go-backend/internal/middleware/security_headers.go |
| Rate limiting (Redis) | go-backend/internal/middleware/rate_limit_redis.go |
| Audit logging | go-backend/internal/middleware/audit.go |
| Error capture | go-backend/internal/middleware/error_capture.go |
| Request ID injection | go-backend/internal/middleware/request_id_inject.go |
| Real IP extraction | go-backend/internal/middleware/real_ip.go |
| Body size limits | go-backend/internal/middleware/request_limit.go |
| BI credential token | go-backend/internal/pkg/token/token.go |
| Middleware stack (main) | go-backend/cmd/api/main.go |
| RLS policies | database/PRISM_vNext_FINAL.sql (section 14) |
| Data retention jobs | database/migrations/000018_add_data_retention_policies.up.sql |