> ## Documentation Index
> Fetch the complete documentation index at: https://docs-terra.withunify.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Security

> Authentication, authorization, input validation, rate limiting, and webhook security

# Security

> Terra uses defense-in-depth: multiple layers of protection with fail-secure defaults.

***

## Authentication

Sessions are encrypted JWT cookies via WorkOS OAuth. The app **refuses to start** without `WORKOS_COOKIE_PASSWORD`:

```typescript theme={null}
// src/middleware.ts — fail-secure on missing config
if (!cookiePassword) {
  throw new Error("WORKOS_COOKIE_PASSWORD is required");
}
```

If decryption fails or the role is unrecognized, the user defaults to `"applicant"` — the lowest privilege:

```typescript theme={null}
// src/middleware.ts — role validation (inline checks, fail-secure)
if (
  !role ||
  (role !== "super_admin" &&
    role !== "admin" &&
    role !== "editor" &&
    role !== "viewer" &&
    role !== "user" &&
    role !== "applicant")
) {
  session.user.role = "applicant"; // Fail-secure: unknown role gets minimum access
}
```

CSP headers use a per-request nonce (16 random bytes) for script tags. `frame-ancestors: 'self'` prevents clickjacking.

**Tests:** 25 cases in `src/lib/__tests__/auth-guards.test.ts`

```bash theme={null}
pnpm --dir apps/terra test -- -t "Auth Guards"
```

<Accordion title="What the tests prove">
  * Missing or malformed sessions throw `UNAUTHENTICATED` - Non-admin roles get
    `UNAUTHORIZED` on admin-only functions - `admin` cannot call
    `super_admin`-only functions - Nonexistent forms return `NOT_FOUND` -
    `withAdminAuth` / `withFormAuth` wrappers block unauthorized actions before
    they execute
</Accordion>

**Try to break it:** Tamper with the `wos-session` cookie in the browser — decryption should fail and redirect to `/login`. Navigate to `/admin` while logged out — should redirect.

***

## Authorization (RBAC)

Two layers checked together: **global role** sets the ceiling, **form role** sets the grant.

```mermaid theme={null}
flowchart LR
    subgraph Global["Global Role (user_profiles)"]
        SA[super_admin] --> Full[Full Access]
        A[admin] --> Full
        E[editor] --> Scoped[Assigned Forms Only]
        V[viewer] --> RO[Read-Only — Even If Form Owner]
    end

    subgraph Form["Form Role (program_members)"]
        Owner --> Edit & Manage
        Editor --> Edit
        Viewer --> View
    end
```

The critical invariant: a global `viewer` **cannot** edit a form even if they hold `owner` at the form level.

```typescript theme={null}
// src/app/actions/team.ts — canEditForm()
const systemRole = await getSystemRole(userId);

if (systemRole === "super_admin" || systemRole === "admin") return true;

const formRole = await getUserFormRole(userId, formId);

// Global viewer caps everything to read-only
if (systemRole === "viewer") return false;

// Global editor can edit assigned forms
if (systemRole === "editor") return true;

return formRole === "owner" || formRole === "editor";
```

**Tests:** 38 cases across 4 files

```bash theme={null}
pnpm --dir apps/terra test -- -t "Team Permissions"
pnpm --dir apps/terra test -- -t "Permission System"
pnpm --dir apps/terra test -- -t "submission-management"
pnpm --dir apps/terra test -- -t "system action authorization"
```

<Accordion title="What the tests prove">
  * Super admins bypass all form-level checks - Global `viewer` overrides form
    `owner` (read-only cap) - Global `editor` overrides form `viewer` (can edit
    assigned forms) - Viewers cannot bulk-modify submissions - Scoped users with
    no assigned forms see nothing - Non-admins cannot list users, invite admins,
    or change roles - Last owner of a form cannot be removed or demoted
</Accordion>

**Try to break it:** Log in as a viewer, call a mutation server action directly (e.g., `bulkMarkAsTest`). It should return `{ success: false }`. Try removing the last owner of a form — the system should reject it.

<Warning>
  **Migration boundary:** `canEditForm()` has a backwards-compat fallback that
  returns `true` when the `program_members` table doesn't exist. Review
  `team.ts` lines 110-122 to determine if this is still needed.
</Warning>

***

## Input Validation

Three layers protect file paths:

```typescript theme={null}
// Layer 1: Sanitize the filename
const safe = cleanFileName("../../evil.exe"); // → "evil.exe"

// Layer 2: Build a safe storage path
const path = buildSafeStoragePath(formId, fileId, safe);
// → "form-123/file-456/evil.exe"

// Layer 3: Final validation (throws on traversal, null bytes, hidden files)
sanitizeStoragePath(path);
```

Redirect safety prevents open redirects after login:

```typescript theme={null}
// src/lib/security.ts — blocks //evil.com, javascript:, data:, encoded variants
getSafeRedirectPath("//evil.com"); // → "/"
getSafeRedirectPath("javascript:alert"); // → "/"

// Defense-in-depth: same-origin check catches anything getSafeRedirectPath misses
const dest = createSafeInternalRedirect(path, baseUrl);
if (dest.origin !== origin) redirect("/"); // CRITICAL log if this ever fires
```

**Tests:** 72 cases in `src/lib/__tests__/security.test.ts`, 15 in `files-security.test.ts`, 11 in `import-security.test.ts`

```bash theme={null}
pnpm --dir apps/terra test -- -t "Path Traversal"
pnpm --dir apps/terra test -- -t "Open Redirect"
pnpm --dir apps/terra test -- -t "File Security"
pnpm --dir apps/terra test -- -t "Import Security"
```

<Accordion title="What the tests prove">
  * Path traversal: `../`, `..\\`, null bytes, URL-encoded, double-encoded, mixed slashes, bucket escape
  * Open redirect: `//evil.com`, `javascript:`, `data:`, backslash, encoded variants all return `/`
  * File upload: `.exe` rejected, MIME mismatch caught, SVG with `<script>` detected, PDF with JS detected
  * File operations: Cross-form file access blocked, path traversal on delete blocked, super\_admin bypass works
  * Import: User ID comes from session (not request), cannot impersonate another user, max 8 images / 10MB PDF
</Accordion>

***

## Rate Limiting

```typescript theme={null}
// src/lib/rate-limit.ts
const LIMITS = {
  formSubmission: { limit: 30, window: 60, key: "per-IP" },
  statusLookup: { limit: 10, window: 60, key: "per-IP" }, // Stricter: prevents enumeration
  webhook: { limit: 500, window: 60, key: "global" },
};
```

If Redis (Upstash) is unavailable:

* **Production:** Fail closed — deny all requests
* **Development:** Fail open — allow all requests

```bash theme={null}
pnpm --dir apps/terra test -- -t "Rate Limiting"
```

<Accordion title="What the tests prove (20+ cases)">
  * Rate limit values documented: 30/min submissions, 10/min status, 500/min
    webhooks - Production fails closed when Redis is down - Development fails open
  * Status lookups are stricter than submissions (enumeration prevention) -
    Client IP extracted correctly from `x-forwarded-for`, `cf-connecting-ip`,
    `x-real-ip`
</Accordion>

***

## Webhook Security

Outbound webhooks are signed with HMAC-SHA256 and include a timestamp for replay prevention:

```typescript theme={null}
// Headers sent with every webhook delivery
{
  "X-Terra-Signature": hmac("sha256", secret, body),
  "X-Terra-Event": "submission.created",
  "X-Terra-Timestamp": "1704000000"
}

// Receiver verifies: |now - timestamp| <= 5 minutes
```

Webhook failures are fire-and-forget — they don't block the submission. The async queue retries up to 5 times with exponential backoff.

```bash theme={null}
pnpm --dir apps/terra test -- -t "Webhook"
pnpm --dir apps/terra test -- -t "webhook-security"
```

<Accordion title="What the tests prove (22 cases)">
  * Auth enforced on all webhook operations (get, save, regenerate, delete) -
    Invalid URLs rejected - Secrets are 64 chars (32 bytes hex), regeneration
    produces a different secret - HMAC signatures are deterministic (same input →
    same output) - Different payloads/secrets produce different signatures -
    Unsigned webhooks blocked in production - Timestamp freshness enforced (5-min
    window) - Webhook failure (500, network error) doesn't throw — just logs
</Accordion>

***

<CardGroup cols={2}>
  <Card title="Data Protection" icon="lock" href="/review/data-protection">
    PII encryption and audit logging
  </Card>

  <Card title="Test Coverage" icon="vial" href="/review/test-coverage">
    Full test inventory
  </Card>
</CardGroup>
