{"repo": "abhiram162105/skillopt", "files": {"docs/prd/enterprise/PRD_SECURITY_AUTHZ.md": {"6edf47c5c4365f20fe0c7a7bff494253067b9991": {"t": 1785596428.0744436, "n": 44227, "h": "ff0360a558ff645e", "c": "---\ntitle: \"JuPTR — Enterprise Security, Auth & Multi-Tenancy PRD\"\nlayer: \"enterprise_security-authz\"\ndoc_type: \"prd\"\ndate: \"2026-06-05\"\nparent: \"docs/prd/PRD_BACKEND.md\"\nowns:\n - \"server/security/* (auth middleware, JWT, RBAC, CSRF, rate limit, headers)\"\n - \"server/security/secrets.py (vault client, rotation hooks)\"\n - \"server/security/audit.py (structured audit log emitter)\"\n - \"server/security/tenancy.py (tenant_id propagation, RLS guards)\"\n - \"server/security/sandbox.py (gVisor/Firecracker code-runner wrapper)\"\n - \"infra/iam/*.tf (Terraform: WAF, KMS, Secrets Manager, S3 policies)\"\n - \"ops/keys/ (key rotation lambdas, CRL, JWKS)\"\naudience:\n - \"LLM agents implementing enterprise hardening of JuPTR\"\n - \"security & platform engineers\"\n - \"SOC2 / ISO27001 auditors reviewing the design\"\nstatus: \"draft v1 — addendum\"\ndepends_on:\n - \"docs/prd/PRD_BACKEND.md\"\n - \"docs/prd/PRD_ORCHESTRATION.md\"\n - \"docs/prd/PRD_CORE_ML_SKILLOPT.md\"\n - \"docs/prd/PRD_FRONTEND.md\"\n---\n\n# JuPTR — Enterprise Security, Auth & Multi-Tenancy PRD\n\n> This addendum converts the single-user hackathon JuPTR into a multi-tenant, zero-trust,\n> SOC2-track platform without rewriting the pipeline. Every route the backend exposes (`PRD_BACKEND\n> §4`) becomes guarded by **OIDC-issued JWTs**, every orchestration call (`PRD_ORCHESTRATION §2`)\n> receives a verified `tenant_id`, every secret the core-ML layer consumes (`PRD_CORE_ML §3`) flows\n> from a centralized vault, and every code execution inside the code verifier\n> (`PRD_CORE_ML §3.3`) runs inside a hardened gVisor sandbox with no network and a 256 MB cap.\n> The five non-goals in `PRD_BACKEND §1` (\"auth, accounts, multi-user\") are explicitly\n> **promoted to goals** here. This document does not relitigate the pipeline — it bolts onto it.\n\n---\n\n## 1. Goals & non-goals\n\n### Goals\n1. **Zero-trust ingress.** No endpoint, SSE stream, or admin tool is reachable without a valid,\n short-lived JWT bound to a `tenant_id` and a set of RBAC roles.\n2. **Hard multi-tenancy.** A tenant cannot read, list, modify, stream, or even infer the existence\n of another tenant's runs, artifacts, probes, audit logs, or secrets. Enforced at five layers:\n middleware, orchestrator, filesystem prefix, S3 bucket policy, Postgres row-level security.\n3. **Secrets out of code, env, and logs.** Anthropic/OpenAI/JWT keys live in AWS Secrets Manager\n (or HashiCorp Vault), rotate every 90 days, and never appear in a response body or stdout.\n4. **Audit everything material.** Every state transition (`PRD_ORCHESTRATION §2.1`), every admin\n action, every secret access emits a structured JSON event to an immutable audit pipeline\n retained 1 year hot + 7 years cold.\n5. **Sandbox untrusted code.** LLM-generated Python in `CodeVerifier.score()` runs inside gVisor\n (`runsc`) with `--network=none`, `--memory=256m`, `--cpu=1000m`, a hardened seccomp filter, and\n AST-level static rejection of `exec`, `eval`, `__import__`, `socket`, `subprocess`, `open`.\n6. **Defense-in-depth at the API edge.** Strict CORS, CSRF on stateful mutations, CSP / HSTS /\n X-Frame-Options, rate limits per tenant, HMAC request signing, prompt-injection guardrails.\n7. **Compliance-ready.** SOC2 CC6 + CC7 controls implementable, GDPR right-to-access and\n right-to-delete endpoints, SBOM + signed images per release.\n\n### Non-goals\n- Replacing the orchestrator's pipeline semantics. Gate, training, eval, ship logic is untouched —\n we wrap it, we don't rewrite it.\n- Building our own IdP. We federate to Okta / Entra / Auth0 via OIDC; on-prem deployments may\n bring SAML2.0, also federated, not hosted.\n- HSM-backed signing for general data. JWT signing keys live in AWS KMS (FIPS 140-2 L2);\n per-record HSM signing is out of scope.\n- Customer-managed keys (CMK / BYOK) for tenant data encryption — **P3**, tracked separately.\n\n---\n\n## 2. Threat model\n\nThe adversary model assumed by this PRD:\n\n| Actor | Capability | In scope |\n|---|---|---|\n| **External anonymous** | Can hit any public endpoint with arbitrary HTTP. | Yes — block at edge. |\n| **Authenticated tenant A** | Holds a valid JWT for tenant A; tries to read tenant B. | Yes — RLS + middleware. |\n| **Compromised tenant user** | Has stolen access token; uses it to exfiltrate runs / spam training. | Yes — short-lived JWT + revocation list + rate limits. |\n| **Malicious prompt author** | Submits prompts crafted to (a) jailbreak intake LLM, (b) emit code that escapes the sandbox, (c) leak internal state via error messages. | Yes — input validation, locked system prompt, gVisor, error sanitization. |\n| **Compromised dependency** | Supply-chain attack on a transitive Python/npm package. | Yes — pinned hashes, SBOM, image signing. |\n| **Compromised CI worker** | Pushes a malicious image. | Partially — cosign verify on deploy. |\n| **Compromised cloud account** | Full AWS root. | Out of scope; covered by AWS IAM hardening and a separate operational runbook. |\n\n**Top 5 attacks we explicitly prevent:**\n\n1. Cross-tenant data read via guessed `run_id`.\n2. API key exfiltration via stack-trace error response.\n3. Sandbox escape from `CodeVerifier` to host filesystem or outbound network.\n4. Prompt-injection that causes the intake LLM to emit a `TaskSpec` with arbitrary shell payload\n the orchestrator later evaluates.\n5. Replay / token-theft after logout (revocation list bypass).\n\n---\n\n## 3. Identity, authentication, JWT lifecycle\n\n### 3.1 Requirement\n\nAll API access (every route in `PRD_BACKEND §4`, every SSE stream in §4.6, every admin route added\nin §11) is authenticated via short-lived OIDC-issued JWTs. The backend is **not** the IdP; it is a\nresource server. Identity is federated to a customer-chosen OIDC provider (Okta, Entra, Auth0,\nGoogle Workspace) or, for on-prem, SAML2.0 via `python-saml`.\n\n### 3.2 Token shapes\n\n```jsonc\n// Access token — JWT, RS256, 15-min expiry, distributed verifier\n{\n \"iss\": \"https://auth.juptr.io\", // or tenant-specific IdP\n \"sub\": \"user_01HXYZ...\", // stable user id\n \"aud\": \"juptr-api\",\n \"iat\": 1717628400,\n \"exp\": 1717629300, // iat + 900s\n \"jti\": \"8a3f...\", // for revocation lookup\n \"tenant_id\": \"acme-001\", // REQUIRED, lower-case kebab, ≤ 64 chars\n \"roles\": [\"trainer\", \"auditor\"], // subset of {admin, trainer, auditor, read_only}\n \"scope\": \"runs:read runs:write skills:ship\",\n \"mfa\": true // required for any role containing \"admin\"\n}\n```\n\n```jsonc\n// Refresh token — opaque 256-bit URL-safe, stored hashed in Postgres\n{\n \"token_id\": \"rt_01HXYZ...\",\n \"hash_sha256\": \"b64(...)\",\n \"user_id\": \"user_01HXYZ...\",\n \"tenant_id\": \"acme-001\",\n \"issued_at\": \"2026-06-05T22:10:00Z\",\n \"expires_at\": \"2026-06-12T22:10:00Z\", // 7 days\n \"revoked\": false,\n \"user_agent_hash\": \"...\",\n \"ip_first_seen\": \"203.0.113.4\"\n}\n```\n\n### 3.3 Endpoints\n\n| Method | Path | Purpose | Auth |\n|---|---|---|---|\n| `GET` | `/.well-known/openid-configuration` | OIDC discovery (proxied if upstream). | none |\n| `GET` | `/.well-known/jwks.json` | Public RS256 keys (current + previous for rollover). | none |\n| `POST` | `/auth/token` | OAuth2 authz-code exchange → `{access_token, refresh_token, expires_in: 900}` | client_secret |\n| `POST` | `/auth/refresh` | Refresh-cookie → new access token. Rotates refresh token. | refresh cookie |\n| `POST` | `/auth/logout` | Marks refresh token `revoked=true`, adds `jti` to in-memory + Redis blacklist. | bearer |\n| `POST` | `/auth/mfa/challenge` | Triggers TOTP/WebAuthn challenge. | bearer (un-elevated) |\n| `POST` | `/auth/mfa/verify` | Returns an `mfa=true` token (5-min elevation). | bearer + code |\n\n### 3.4 Implementation\n\n- **Library**: `authlib` for OIDC client + JWT signing; `python-jose` only as fallback verifier.\n- **Signing key**: RS256 private key in `arn:aws:kms:.../alias/juptr-jwt-signer`; backend never\n holds plaintext — uses `kms:Sign` API. JWKS endpoint serves public keys cached 1h.\n- **Verification**: FastAPI dependency `Depends(require_user)` runs on every protected route:\n\n```python\n# server/security/auth.py\nfrom fastapi import Depends, HTTPException, Request\nfrom jose import jwt\nfrom typing import Annotated\n\nclass UserCtx(BaseModel):\n user_id: str\n tenant_id: str\n roles: frozenset[str]\n mfa: bool\n jti: str\n\nasync def require_user(request: Request) -> UserCtx:\n token = _bearer(request)\n if await blacklist.contains(token_jti(token)):\n raise HTTPException(401, \"token_revoked\")\n try:\n claims = jwt.decode(\n token, JWKS.current(), algorithms=[\"RS256\"],\n audience=\"juptr-api\", issuer=ISSUER,\n options={\"require\": [\"exp\", \"iat\", \"sub\", \"tenant_id\"]},\n )\n except jwt.JWTError as e:\n raise HTTPException(401, \"invalid_token\")\n ctx = UserCtx(\n user_id=claims[\"sub\"], tenant_id=claims[\"tenant_id\"],\n roles=frozenset(claims.get(\"roles\", [])),\n mfa=bool(claims.get(\"mfa\", False)), jti=claims[\"jti\"],\n )\n request.state.user = ctx\n return ctx\n\ndef require_role(*needed: str):\n async def _dep(u: Annotated[UserCtx, Depends(require_user)]):\n if not (set(needed) & u.roles):\n raise HTTPException(403, \"forbidden\")\n return u\n return _dep\n\ndef require_mfa(u: Annotated[UserCtx, Depends(require_user)]):\n if not u.mfa:\n raise HTTPException(403, \"mfa_required\")\n return u\n```\n\n- **Frontend storage**: access token in `sessionStorage` (cleared on tab close); refresh token in\n `Set-Cookie: __Host-rt=...; HttpOnly; Secure; SameSite=Strict; Path=/auth`.\n- **Logout**: client deletes `sessionStorage.juptr_at`; server adds `jti` to a Redis blacklist\n TTL=900s (the access-token lifetime) and marks the refresh row `revoked=true`.\n\n### 3.5 RBAC role matrix\n\n| Role | runs:read | runs:write | skills:ship | admin:keys | admin:audit | admin:tenants |\n|---|---|---|---|---|---|---|\n| `read_only` | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |\n| `trainer` | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |\n| `auditor` | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ |\n| `admin` (mfa) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n\n### 3.6 Acceptance criteria\n- A request without `Authorization: Bearer` to any `/api/*` route returns **401**.\n- A request whose token expired ≤ 30s ago returns **401** `{error:\"token_expired\"}`.\n- A trainer for tenant A reading `GET /api/runs/{tenant_B_run_id}` returns **404** (not 403 — we\n do not leak existence).\n- Logout: a token used after `POST /auth/logout` returns **401** within 2s globally.\n- All admin endpoints require `mfa=true` and reject elevation older than 5 min.\n\n---\n\n## 4. Multi-tenant isolation\n\n### 4.1 Requirement\n\n`tenant_id` is a first-class field at every layer:\n\n- **TaskSpec**: `tenant_id: str` is required (modify `orchestrator/spec.py`).\n- **Filesystem**: runs live at `runs/{tenant_id}/{run_id}/` — no exceptions.\n- **S3 / artifact store**: `s3://juptr-artifacts-{region}/{tenant_id}/{run_id}/...` with a bucket\n policy denying cross-prefix reads at the IAM level.\n- **Postgres run index**: row-level security policy keyed on `tenant_id = current_setting('app.tenant_id')`.\n- **In-memory event bus** (`server/events.py`): subscriber queues are `Dict[Tuple[tenant_id, run_id], Queue]`.\n\n### 4.2 Middleware injection\n\n```python\n# server/security/tenancy.py\n@app.middleware(\"http\")\nasync def inject_tenant(request: Request, call_next):\n user: UserCtx | None = getattr(request.state, \"user\", None)\n if user is not None:\n request.state.tenant_id = user.tenant_id\n # bind to async-local for orchestrator + db\n ctx_tenant_id.set(user.tenant_id)\n await db.execute(f\"SET LOCAL app.tenant_id = '{user.tenant_id}'\")\n return await call_next(request)\n```\n\n### 4.3 Orchestrator changes\n\nEvery public function in `orchestrator.py` gains a `tenant_id: str` first positional argument and\nasserts `manifest[\"tenant_id\"] == tenant_id` before any I/O:\n\n```python\n# orchestrator.py\ndef verify(tenant_id: str, run_id: str, labels: dict[str, str]) -> GateResult:\n m = manifest.read(tenant_id, run_id) # raises NotFound if tenant mismatch\n _assert_tenant_owns(m, tenant_id)\n ...\n```\n\n`manifest.read(tenant_id, run_id)` resolves the path to `runs/{tenant_id}/{run_id}/manifest.json`\nand returns `NotFound` (mapped to HTTP 404 in backend) if any segment is absent — preserving the\n\"don't leak existence\" invariant.\n\n### 4.4 Postgres RLS\n\n```sql\n-- migrations/0002_rls.sql\nCREATE TABLE runs (\n run_id text PRIMARY KEY,\n tenant_id text NOT NULL,\n user_id text NOT NULL,\n stage text NOT NULL,\n task_class text,\n task_id text,\n lift numeric,\n created_at timestamptz DEFAULT now()\n);\nALTER TABLE runs ENABLE ROW LEVEL SECURITY;\nCREATE POLICY runs_tenant_isolation ON runs\n USING (tenant_id = current_setting('app.tenant_id'));\nCREATE POLICY runs_tenant_isolation_ins ON runs\n FOR INSERT WITH CHECK (tenant_id = current_setting('app.tenant_id'));\n```\n\nThe audit log table gets a parallel policy, plus an `INSERT`-only grant for the application role\nand `SELECT`-only for the auditor role.\n\n### 4.5 S3 bucket policy (Terraform excerpt)\n\n```hcl\n# infra/iam/s3_artifacts.tf\ndata \"aws_iam_policy_document\" \"tenant_isolation\" {\n statement {\n sid = \"DenyCrossTenant\"\n effect = \"Deny\"\n actions = [\"s3:GetObject\", \"s3:PutObject\", \"s3:ListBucket\"]\n resources = [\"${aws_s3_bucket.artifacts.arn}/*\"]\n condition {\n test = \"StringNotLike\"\n variable = \"s3:prefix\"\n values = [\"$${aws:PrincipalTag/tenant_id}/*\"]\n }\n }\n}\n```\n\n### 4.6 Acceptance criteria\n- A run created by tenant A is invisible to tenant B in `GET /api/runs`, `GET /api/runs/{id}`,\n `GET /api/runs/{id}/events`, and direct S3 access.\n- Removing the `tenant_id` middleware in tests causes **every** integration test in\n `tests/security/test_isolation.py` to fail.\n- The orchestrator refuses to write to any path that does not begin with `runs/{tenant_id}/`.\n- Postgres RLS verified by an automated test that sets `app.tenant_id='b'` and asserts\n `SELECT * FROM runs WHERE run_id = 'created_by_a'` returns zero rows.\n\n---\n\n## 5. Secrets management & rotation\n\n### 5.1 Requirement\n\n`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, JWT RS256 private key, Postgres\npassword, S3 KMS key ARN, Redis password, HMAC signing secret per tenant — all stored in **AWS\nSecrets Manager** (default) or **HashiCorp Vault** (on-prem). Rotated every **90 days**.\nPer-tenant API-key overrides supported (tenant brings own Anthropic account).\n\n### 5.2 Naming convention\n\n```\n/juptr/{environment}/global/anthropic-api-key # platform default\n/juptr/{environment}/global/openai-api-key\n/juptr/{environment}/global/jwt-signing-key # KMS alias\n/juptr/{environment}/tenant/{tenant_id}/anthropic-api-key # optional override\n/juptr/{environment}/tenant/{tenant_id}/openai-api-key\n/juptr/{environment}/tenant/{tenant_id}/hmac-secret # request signing\n```\n\n### 5.3 Client\n\n```python\n# server/security/secrets.py\nimport boto3, time\nfrom threading import RLock\n\nclass SecretsClient:\n def __init__(self, region: str, env: str, ttl_s: int = 3600):\n self._sm = boto3.client(\"secretsmanager\", region_name=region)\n self._env, self._ttl, self._cache, self._lock = env, ttl_s, {}, RLock()\n\n def get(self, name: str, tenant_id: str | None = None) -> str:\n key = f\"/juptr/{self._env}/{'tenant/'+tenant_id if tenant_id else 'global'}/{name}\"\n with self._lock:\n hit = self._cache.get(key)\n if hit and hit[1] > time.time():\n return hit[0]\n resp = self._sm.get_secret_value(SecretId=key, VersionStage=\"AWSCURRENT\")\n val = resp[\"SecretString\"]\n with self._lock:\n self._cache[key] = (val, time.time() + self._ttl)\n audit.emit(\"secret_access\", {\"key\": key, \"version\": resp[\"VersionId\"]})\n return val\n\n def invalidate(self, name: str, tenant_id: str | None = None):\n with self._lock:\n self._cache.pop(self._fqdn(name, tenant_id), None)\n```\n\nThe core-ML layer (`PRD_CORE_ML §3`) accepts a `key_id: str` parameter (e.g.\n`\"anthropic-api-key\"`) instead of a raw key. The model factory resolves via `SecretsClient` at\ncall time, never at module import.\n\n### 5.4 Rotation\n\n- AWS Secrets Manager native rotation lambda (`AWSCURRENT` / `AWSPENDING` / `AWSPREVIOUS`).\n- On rotation event, an SNS message hits `/internal/secrets/invalidate` (mTLS only, VPC-private),\n which calls `SecretsClient.invalidate(...)`.\n- If a request in flight uses an `AWSPREVIOUS` key and Anthropic 401s, the model client retries\n **once** with `AWSCURRENT` and emits an audit event `secret_rotation_observed`.\n- A daily cron asserts no secret is older than 90 days; older → page on-call.\n\n### 5.5 `/health/secrets` (admin-only)\n\n```jsonc\n{\n \"anthropic-api-key\": { \"age_days\": 12, \"next_rotation\": \"2026-09-03T00:00:00Z\" },\n \"openai-api-key\": { \"age_days\": 87, \"next_rotation\": \"2026-06-08T00:00:00Z\",\n \"warn\": \"rotation_due_within_7d\" },\n \"jwt-signing-key\": { \"age_days\": 30, \"next_rotation\": \"2026-08-05T00:00:00Z\" }\n}\n```\n\n### 5.6 Acceptance criteria\n- `grep -r \"sk-ant-\" server/` and `grep -r \"ANTHROPIC_API_KEY\" server/security/` after refactor\n return no source-code matches outside `secrets.py` test fixtures.\n- Killing the secrets-manager IAM permission causes the backend to **fail to start** (loud crash,\n no fallback).\n- Rotating a key during a live training run produces zero failed model calls (verified by\n `tests/security/test_rotation_smoke.py`).\n\n---\n\n## 6. Audit logging & compliance trail\n\n### 6.1 Requirement\n\nEvery material action emits one immutable JSON event. \"Material\" = anything in\n`PRD_ORCHESTRATION §2.1` (every stage transition), plus auth events, secret access, admin\noperations, deletion requests.\n\n### 6.2 Event schema\n\n```jsonc\n{\n \"event_id\": \"01HXYZ...\", // ULID, monotonic\n \"event_type\": \"stage_transition\", // see §6.3\n \"timestamp\": \"2026-06-05T22:10:15.123Z\", // UTC, RFC3339, ms precision\n \"tenant_id\": \"acme-001\",\n \"user_id\": \"user_01HXYZ...\",\n \"run_id\": \"2026-06-05T22-10_extraction_gst_invoice\",\n \"request_id\": \"req_01HXYZ...\",\n \"ip\": \"203.0.113.4\",\n \"user_agent\": \"Mozilla/5.0 ...\",\n \"action\": \"verify\",\n \"result\": \"success\", // success | failure | denied\n \"from_stage\": \"data\",\n \"to_stage\": \"gated\",\n \"details\": { \"agreement\": 0.92, \"kappa\": 0.84 },\n \"schema_version\": 1\n}\n```\n\n### 6.3 Event types\n\n| event_type | Emitter | Sensitive |\n|---|---|---|\n| `auth_login` / `auth_logout` / `auth_refresh` / `auth_failed` | `/auth/*` | yes |\n| `mfa_challenge` / `mfa_success` / `mfa_failure` | `/auth/mfa/*` | yes |\n| `stage_transition` | `orchestrator.py` | no |\n| `gate_failed` | `orchestrator.verify` | no — alert on this |\n| `train_started` / `train_completed` / `train_failed` | `runner.train` | no |\n| `secret_access` / `secret_rotation_observed` | `secrets.py` | yes — separate retention |\n| `tenant_data_export` / `tenant_data_delete_requested` / `tenant_data_delete_completed` | admin routes | yes |\n| `rate_limit_breach` / `csrf_failure` / `sandbox_violation` | middleware / sandbox | yes — alert |\n| `code_exec` | `CodeVerifier.score` | yes — alert on `sandbox_violation` |\n\n### 6.4 Pipeline\n\n```\norchestrator.py ──┐\nbackend routes ───┼──► loguru sink ──► stdout (JSON) ──► vector.dev ──► Loki (hot, 30d)\nauth/admin ───────┘ │\n ├──► Postgres `audit_events` (1y hot, indexed)\n └──► S3 Glacier (1y → 7y, Object Lock COMPLIANCE mode)\n```\n\n- **Library**: `loguru` Python-side, structured JSON, one line per event.\n- **Sink**: `vector.dev` sidecar tails stdout, fans out.\n- **Immutability**: S3 Object Lock in **COMPLIANCE** mode for the 7-year tier (cannot be deleted\n even by root). Postgres uses an append-only role + a trigger blocking `UPDATE`/`DELETE`.\n\n### 6.5 Search API\n\n```\nGET /admin/audit?tenant_id=acme-001&run_id=...&action=verify&from=2026-06-01&to=2026-06-30\nAuthorization: Bearer ... (role=auditor or admin)\n```\n\nReturns paginated events. Auditor for tenant A can only see tenant A's events (RLS).\n\n### 6.6 Acceptance criteria\n- Every state transition listed in `PRD_ORCHESTRATION §2.1` emits exactly one audit event;\n asserted by `tests/security/test_audit_coverage.py` running a full pipeline and grepping.\n- No audit event contains a raw API key (`grep \"sk-\"` on a day's logs returns 0).\n- A SOC2 auditor can reconstruct any run's full action history from `audit_events` alone.\n- Attempting to `UPDATE audit_events SET ...` as the app role returns a permission error.\n\n---\n\n## 7. Hardened code-runner sandbox\n\n### 7.1 Requirement\n\n`CodeVerifier.score()` in `PRD_CORE_ML §3.3` currently runs untrusted LLM-generated Python in a\nsubprocess with env-var network blocking. Promote to **gVisor `runsc`** (production-grade\ncontainer sandbox) with `--network=none`, `--memory=256m`, `--cpu=1000m`, `--rootless`. Add static\nAST screening *before* execution.\n\n### 7.2 Architecture\n\n```\n┌──────────────────────────────────────────────────────────────────┐\n│ server.process (FastAPI worker, uid=10001) │\n│ │\n│ CodeVerifier.score(pred, item): │\n│ 1. ast.parse(pred) → walk → reject if banned-node found │\n│ 2. write pred to /tmp/runner/{uuid}/main.py (mode 0400) │\n│ 3. exec: runsc run --rootless --network=none │\n│ --cpu=1000m --memory=256m --pids=64 │\n│ --readonly --bind /tmp/runner/{uuid}:/work:ro │\n│ --bundle /opt/juptr/runner-bundle │\n│ -- timeout 10s python -I /work/main.py │\n│ 4. capture stdout/stderr, exit_code │\n│ 5. exit_code map: 0→1.0 1→0.0 124→0.0(timeout) │\n│ 137→0.0(oomkill) *→audit \"sandbox_error\" │\n│ 6. emit audit \"code_exec\" with hash(pred), wall_ms, rss_kb │\n└──────────────────────────────────────────────────────────────────┘\n```\n\n### 7.3 AST screening\n\n```python\n# server/security/sandbox.py\nBANNED_NAMES = {\"eval\", \"exec\", \"__import__\", \"compile\", \"open\"}\nBANNED_MODULES = {\"socket\", \"subprocess\", \"os\", \"sys\", \"ctypes\",\n \"multiprocessing\", \"threading\", \"http\", \"urllib\",\n \"requests\", \"pickle\", \"marshal\", \"shelve\"}\n\ndef screen(src: str) -> None:\n try:\n tree = ast.parse(src)\n except SyntaxError as e:\n raise SandboxRejected(f\"syntax_error:{e.msg}\")\n for node in ast.walk(tree):\n if isinstance(node, ast.Name) and node.id in BANNED_NAMES:\n raise SandboxRejected(f\"banned_name:{node.id}\")\n if isinstance(node, (ast.Import, ast.ImportFrom)):\n for n in (node.names or []):\n top = n.name.split(\".\")[0]\n if top in BANNED_MODULES:\n raise SandboxRejected(f\"banned_module:{top}\")\n if isinstance(node, ast.Attribute) and node.attr.startswith(\"__\"):\n raise SandboxRejected(f\"dunder_access:{node.attr}\")\n```\n\n### 7.4 Seccomp filter (defense in depth, even inside gVisor)\n\n```yaml\n# infra/sandbox/seccomp.json (abridged)\ndefaultAction: SCMP_ACT_ERRNO # EPERM by default\ndefaultErrnoRet: 1 # EPERM\nsyscalls:\n - names: [read, write, close, exit, exit_group, brk, mmap, munmap,\n rt_sigaction, rt_sigprocmask, futex, getrandom, fstat,\n lseek, openat, newfstatat, pread64]\n action: SCMP_ACT_ALLOW\n - names: [socket, connect, sendto, recvfrom, accept, bind, listen]\n action: SCMP_ACT_ERRNO # explicit deny on network\n```\n\n### 7.5 Rate limit\n\n`max 10 code_execs / minute / tenant`, enforced via Redis token bucket. Breach → audit\n`rate_limit_breach` + 429 to the caller (which is internal but still logged).\n\n### 7.6 Acceptance criteria\n- A submission containing `import socket` returns `SandboxRejected` before any subprocess spawn.\n- A submission that AST-passes but tries `socket.socket()` at runtime exits with EPERM and\n emits `sandbox_violation` audit event.\n- Memory bomb (`x = \"a\" * 10**9`) is killed at 256 MB; `exit_code=137`, reward 0.0.\n- Walltime > 10s → `exit_code=124`, reward 0.0.\n- The sandbox process can read `/work/main.py` only; `ls /etc` from inside returns no host paths.\n- Soak test: 10,000 adversarial submissions cause zero host-side file writes outside\n `/tmp/runner/`.\n\n---\n\n## 8. Encryption at rest & in transit\n\n### 8.1 In transit\n- TLS 1.3 only at the public edge (terminated at ALB or Caddy reverse proxy).\n- `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload`.\n- All internal service-to-service hops over mTLS (cert-manager + step-ca, or Istio if K8s).\n- SSE streams (`/api/runs/{id}/events`) inherit TLS from HTTP; no plaintext fallback.\n\n### 8.2 At rest\n| Data | Mechanism | Key |\n|---|---|---|\n| S3 artifacts | SSE-KMS, per-tenant-scoped key alias `alias/juptr-tenant-{tenant_id}` | AWS KMS CMK |\n| Postgres | `rds.force_ssl=1` + RDS storage encryption | AWS-managed |\n| Redis | TLS in-transit + at-rest encryption enabled | ElastiCache-managed |\n| Local `runs/` (dev) | `sqlcipher` for any sqlite index; manifest.json + skill.md unencrypted in dev | n/a |\n| Local `runs/` (prod self-host) | `dm-crypt`/LUKS on the volume mount | OS keystore |\n| Uploads | Streamed through `cryptography.fernet` with tenant key from Secrets Manager before disk | per-tenant DEK |\n\n### 8.3 Code skeleton — encrypted file wrapper\n\n```python\n# server/security/crypto.py\nfrom cryptography.fernet import Fernet\nclass EncryptedFile:\n def __init__(self, path: Path, tenant_id: str):\n self._f = Fernet(_dek_for(tenant_id))\n self._path = path\n def write_bytes(self, data: bytes):\n self._path.write_bytes(self._f.encrypt(data))\n def read_bytes(self) -> bytes:\n return self._f.decrypt(self._path.read_bytes())\n```\n\n### 8.4 Acceptance criteria\n- `curl http://...` (no TLS) returns a 301 redirect to `https://`.\n- A captured S3 object opened raw on disk is unintelligible without KMS access.\n- `tcpdump` between the FastAPI worker and Postgres shows only TLS handshakes.\n- Local dev with `JUPTR_ENCRYPT_LOCAL=1` writes ciphertext to `runs/{tenant_id}/{run_id}/manifest.json.enc`.\n\n---\n\n## 9. CORS, CSRF, CSP, security headers\n\n### 9.1 Header set (applied by `server/security/headers.py` middleware)\n\n```http\nStrict-Transport-Security: max-age=31536000; includeSubDomains; preload\nContent-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; object-src 'none'\nX-Content-Type-Options: nosniff\nX-Frame-Options: DENY\nReferrer-Policy: strict-origin-when-cross-origin\nPermissions-Policy: geolocation=(), camera=(), microphone=()\nCross-Origin-Opener-Policy: same-origin\nCross-Origin-Resource-Policy: same-origin\n```\n\n### 9.2 CORS\n\n- Allowed origins = single env value `FRONTEND_ORIGIN` (no wildcard, ever).\n- Allowed methods = `GET, POST, OPTIONS`.\n- `Access-Control-Allow-Credentials: true` (needed for refresh cookie).\n- Preflight cached 5 min (`Access-Control-Max-Age: 300`).\n\n### 9.3 CSRF\n\nDouble-submit cookie pattern with HMAC binding to session:\n\n- On any state-changing request (`POST`, `PUT`, `DELETE`, `PATCH`) the backend requires:\n - `Cookie: __Host-csrf=; Path=/; Secure; HttpOnly=false; SameSite=Strict`\n - `Header: X-CSRF-Token: ` (matches cookie, HMAC-validated)\n- Token = `b64(hmac_sha256(session_id, csrf_secret) || ts || random32)`.\n- Rotated on login and every 24h.\n- Frontend reads cookie via JS and copies to header on each mutation.\n\n### 9.4 Acceptance criteria\n- `securityheaders.com` grade A on the production hostname.\n- A POST without `X-CSRF-Token` returns **403** `{error:\"csrf_missing\"}`.\n- A POST from origin `evil.com` is rejected at CORS preflight.\n- Embedding the SPA in an `