Files
kite/.planning/phases/03-document-migration-multi-user-isolation/03-SECURITY.md
T

98 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: "03"
slug: document-migration-multi-user-isolation
status: verified
threats_open: 0
asvs_level: 2
created: 2026-06-01
---
# Phase 03 — Security
> Per-phase security contract: threat register, accepted risks, and audit trail.
---
## Trust Boundaries
| Boundary | Description | Data Crossing |
|----------|-------------|---------------|
| migration runtime → MinIO | DDL transaction holds DB rows; MinIO deletes happen outside any DB transaction | Object keys (UUIDs) |
| test fixtures → backend code | Fixtures fabricate JWTs that hit every guarded endpoint — must not leak across tests | Short-lived JWT tokens |
| browser → MinIO (presigned PUT) | Time-limited presigned URL; MinIO authenticates via HMAC; no Authorization header from browser | File bytes |
| browser → FastAPI /confirm | Authenticated user provides only document_id; FastAPI reads size_bytes from MinIO stat | Document ID |
| FastAPI /confirm → quotas table | Concurrent /confirm calls race against Quota row; atomic UPDATE WHERE prevents overflow | Byte counts |
| Celery beat → DB+MinIO | Runs as docuvault_app role; deletes only its own pending rows and MinIO objects | Document metadata, object keys |
| browser → /api/documents/* | Bearer JWT; ownership assertion gates every resource read/write | Document content, metadata |
| browser → /api/topics/* | Bearer JWT; namespace filter prevents cross-user topic enumeration | Topic labels |
| admin token → /api/documents/* | Admin role explicitly rejected 403 — cannot access document content | (blocked) |
| Celery task → users table | AI config resolved via doc.user_id → users row; no user-controlled provider input | AI provider/model config |
| admin → /api/admin/users/{id}/ai-config | Only admin write path to user.ai_provider / user.ai_model | AI provider selection |
---
## Threat Register
| Threat ID | Category | Component | Disposition | Mitigation | Status |
|-----------|----------|-----------|-------------|------------|--------|
| T-03-01 | Tampering | `migrations/versions/0003_multi_user_isolation.py` | mitigate | `null_user_objects` collected via SELECT before DELETE; each `remove_object()` wrapped in `try/except Exception: pass` (lines 5688) | closed |
| T-03-02 | Denial of Service | Alembic migration when MinIO unreachable | accept | See Accepted Risks Log | closed |
| T-03-03 | Information Disclosure | `tests/conftest.py` — xfail fixture JWTs | mitigate | `create_access_token` uses standard auth service with test secret; `async_client` clears `dependency_overrides` on teardown (line 155); no token values logged | closed |
| T-03-04 | Spoofing | `api/documents.py` — upload-url endpoint | mitigate | `object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}"` computed server-side (lines 112113); filename stored in DB only; extension from `Path(body.filename).suffix.lower()` | closed |
| T-03-05 | Tampering | `api/documents.py` — confirm endpoint | mitigate | `size = await get_storage_backend().stat_object(doc.object_key)` from MinIO authoritative source (line 327); no size param in confirm body | closed |
| T-03-06 | Denial of Service | `api/documents.py` — concurrent /confirm at quota boundary | mitigate | Atomic SQL: `UPDATE quotas SET used_bytes = used_bytes + :delta WHERE … AND (used_bytes + :delta) <= limit_bytes RETURNING`; `fetchone() None` → HTTP 413 (lines 341351) | closed |
| T-03-07 | Information Disclosure | Presigned URL leakage in logs | accept | See Accepted Risks Log | closed |
| T-03-08 | Repudiation | `tasks/document_tasks.py` — abandoned upload orphans | mitigate | `cleanup_abandoned_uploads` Celery beat task present (lines 132177); `celery_app.py` beat_schedule runs every 30 min (lines 4346) | closed |
| T-03-09 | Information Disclosure | `docker-compose.yml` — MinIO CORS | mitigate | `MINIO_API_CORS_ALLOW_ORIGIN: ${FRONTEND_URL:-http://localhost:5173}` — explicit non-wildcard origin (line 26) | closed |
| T-03-10 | Tampering | `storage/minio_backend.py` — Docker hostname in presigned URL | mitigate | Dual MinIO client: `self._client` for internal ops (stat/get/delete), `self._public_client` for `generate_presigned_put_url` (lines 5460, 154, 169) | closed |
| T-03-11 | Information Disclosure | `api/documents.py` — cross-user doc access | mitigate | `if doc is None or doc.user_id != current_user.id: raise HTTPException(404)` at lines 322323, 545546, 579580, 633634, 702703, 767 — returns 404 not 403 | closed |
| T-03-12 | Elevation of Privilege | `api/documents.py` — admin reading doc content | mitigate | `get_regular_user` raises 403 for admin role (deps/auth.py:95109); `Depends(get_regular_user)` on all document handlers at lines 99, 143, 302, 416, 530, 557, 613, 688, 742 | closed |
| T-03-13 | Information Disclosure | `api/topics.py` — cross-user topic enumeration | mitigate | All queries filter: `or_(Topic.user_id == current_user.id, Topic.user_id.is_(None))`; `create_topic` scoped by `user_id=current_user.id` | closed |
| T-03-14 | Elevation of Privilege | `api/topics.py`, `api/admin.py` — regular user creating system topic | mitigate | `POST /api/admin/topics` uses `Depends(get_current_admin)` and creates `user_id=None`; regular `POST /api/topics` forces `user_id=current_user.id` | closed |
| T-03-15 | Tampering | `api/documents.py` — object_key forged with another user's UUID prefix | mitigate | `object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}"` — prefix always from `current_user.id`; no user-supplied prefix accepted | closed |
| T-03-16 | Spoofing | `api/documents.py` — anonymous traffic | mitigate | `HTTPBearer()` with `auto_error=True` raises 403 on missing header (deps/auth.py:35); `get_current_user` raises 401 on invalid/expired token (lines 5255) | closed |
| T-03-17 | Elevation of Privilege | `/api/settings` endpoint | mitigate | `backend/api/settings.py` absent; `main.py` contains no `settings_router` reference — endpoint fully removed | closed |
| T-03-18 | Information Disclosure | `services/storage.py` — settings.json flat file | mitigate | No `load_settings`, `save_settings` function bodies present; settings.json no longer read or written; API keys in env only | closed |
| T-03-19 | Tampering | `tasks/document_tasks.py` — Celery task ai_provider injection | mitigate | Task signature is `document_id: str` only; `user.ai_provider` resolved inside `_run()` from DB lookup (lines 6264) | closed |
| T-03-20 | Information Disclosure | `system_prompt` env var in container logs | accept | See Accepted Risks Log | closed |
| T-03-21 | Repudiation | `frontend/src/views/SettingsView.vue` — old API calls | mitigate | `getSettings`/`patchSettings`/`testProvider`/`getDefaultPrompt` absent from `api/client.js`; `SettingsView.vue` is static display only | closed |
| T-03-22 | Information Disclosure | `stores/documents.js` — XHR PUT Authorization header | mitigate | Only `Content-Type` header set via `setRequestHeader`; no `Authorization` header in `uploadToMinIO` helper (line 2425, comment cites T-03-22) | closed |
| T-03-23 | Spoofing | `components/upload/UploadProgress.vue` — client-side quota from file.size | mitigate | All three values (`rejected_bytes`, `used_bytes`, `limit_bytes`) read from `item.quotaError` populated by 413 response body; no `file.size` used (lines 27, 30) | closed |
| T-03-24 | Denial of Service | Concurrent uploads exhaust browser memory | accept | See Accepted Risks Log | closed |
| T-03-25 | Tampering | `stores/documents.js` — upload state race condition | mitigate | `const rowKey = \`${file.name}__${Date.now()}\`` composite key prevents collisions on duplicate filename uploads (line 70) | closed |
| T-03-26 | Repudiation | `stores/auth.js` — quota refetch silent failure | mitigate | `fetchQuota()` wrapped in `try/catch`; `QuotaBar.vue` uses `v-if="!loadFailed"` to hide on error (auth.js:144149) | closed |
| T-03-SC | Tampering | Package managers (pip/npm) — all 5 plans | mitigate | No new pip or npm dependencies added across all 5 plans; all packages were already pinned in Phase 1/2 requirements.txt and package-lock.json | closed |
*Status: open · closed*
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
---
## Accepted Risks Log
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|---------|------------|-----------|-------------|------|
| AR-03-01 | T-03-02 | Alembic migration runs only after docker-compose health checks confirm MinIO availability. If MinIO is unreachable during migration, the migration exits without data loss (DB-side changes roll back; MinIO deletes are skipped). The deploy can be retried on next startup. Documented in upgrade() docstring. | orchestrator | 2026-06-01 |
| AR-03-02 | T-03-07 | Presigned PUT URLs have a 15-minute TTL and are single-use per object key. If leaked, the worst-case outcome is an attacker completing an already-authorized upload within the window. Full URL is never logged — only document_id is logged. Risk is low for v1. | orchestrator | 2026-06-01 |
| AR-03-03 | T-03-20 | SYSTEM_PROMPT is a static AI instruction string containing no PII, credentials, or user data. It is safe to appear in container logs. Not a sensitive env var. | orchestrator | 2026-06-01 |
| AR-03-04 | T-03-24 | XHR-based uploads stream bytes natively without buffering to JavaScript memory. Browser memory exhaustion from concurrent uploads is a user-driven concurrency issue acceptable for v1 scope. No concurrent upload limit enforced in the frontend. | orchestrator | 2026-06-01 |
---
## Security Audit Trail
| Audit Date | Threats Total | Closed | Open | Run By |
|------------|---------------|--------|------|--------|
| 2026-06-01 | 27 (26 functional + T-03-SC) | 27 | 0 | gsd-security-auditor (sonnet) |
---
## Sign-Off
- [x] All threats have a disposition (mitigate / accept / transfer)
- [x] Accepted risks documented in Accepted Risks Log
- [x] `threats_open: 0` confirmed
- [x] `status: verified` set in frontmatter
**Approval:** verified 2026-06-01