docs(04): create phase 4 plan (9 plans, 7 waves)

Folders, Sharing, Quotas & Document UX — plans verified (0 blockers,
2 non-blocking warnings). Covers FOLD-01..05, SHARE-01..05, SEC-08/09,
ADMIN-06, DOC-01/02.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-05-25 18:20:16 +02:00
parent 752cf987aa
commit 747303246a
14 changed files with 4832 additions and 11 deletions
@@ -0,0 +1,219 @@
---
phase: 04-folders-sharing-quotas-document-ux
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- backend/tests/test_folders.py
- backend/tests/test_shares.py
- backend/tests/test_audit.py
- backend/tests/test_documents.py
- backend/tests/test_security.py
- backend/tests/test_migration.py
autonomous: true
requirements:
- FOLD-01
- FOLD-02
- FOLD-03
- FOLD-04
- FOLD-05
- SHARE-01
- SHARE-02
- SHARE-03
- SHARE-04
- SHARE-05
- SEC-08
- SEC-09
- ADMIN-06
- DOC-01
- DOC-02
must_haves:
truths:
- "Wave 0 test stubs exist for every requirement in Phase 4"
- "All new test functions are xfail(strict=False) so CI stays green before implementation"
- "Shared fixtures from Phase 3 conftest are reused without modification"
artifacts:
- path: "backend/tests/test_folders.py"
provides: "FOLD-01..05 test stubs (create, rename, delete empty, cascade-delete, move, breadcrumb, sort, FTS)"
- path: "backend/tests/test_shares.py"
provides: "SHARE-01..05 stubs + IDOR negative tests"
- path: "backend/tests/test_audit.py"
provides: "ADMIN-06 stubs: viewer, no-doc-content, regular-user-403"
- path: "backend/tests/test_documents.py"
provides: "DOC-02 proxy stubs appended: 200, 206, admin-403, no-presigned-url"
- path: "backend/tests/test_security.py"
provides: "SEC-08 credentials_enc exclusion + SEC-09 delete-user-cleans-files stubs"
key_links:
- from: "backend/tests/test_folders.py"
to: "backend/tests/conftest.py"
via: "auth_user / admin_user / async_client fixtures"
pattern: "from tests.conftest import|fixture"
- from: "backend/tests/test_shares.py"
to: "backend/tests/conftest.py"
via: "auth_user fixture"
pattern: "auth_user|async_client"
---
<objective>
Create Wave 0 test scaffolds for Phase 4. Every Phase 4 requirement gets at least one
xfail test stub. All stubs use `strict=False` so unexpected passes do not break CI.
No implementation code is written in this plan.
Purpose: Establish the Nyquist test gate before any backend code is written.
Output: Five test files (three new, two extended) with named xfail stubs for all 22 planned tests.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/04-folders-sharing-quotas-document-ux/04-CONTEXT.md
@.planning/phases/04-folders-sharing-quotas-document-ux/04-VALIDATION.md
@backend/tests/conftest.py
@backend/tests/test_documents.py
@backend/tests/test_security.py
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create test_folders.py and test_shares.py stubs</name>
<files>backend/tests/test_folders.py, backend/tests/test_shares.py</files>
<read_first>
backend/tests/conftest.py — read the entire file to understand available fixtures (auth_user, admin_user, async_client, db_session) and how they are declared; extract fixture names and signatures before writing any test
backend/tests/test_documents.py — read lines 1-50 for established import and fixture-injection patterns
</read_first>
<behavior>
- test_create_folder: POST /api/folders creates folder, returns 201
- test_create_folder_duplicate_name: POST /api/folders with same name under same parent returns 409
- test_rename_folder: PATCH /api/folders/{id} changes name, returns 200
- test_rename_folder_wrong_owner: PATCH /api/folders/{id} by non-owner returns 404
- test_delete_empty_folder: DELETE /api/folders/{id} on empty folder returns 204
- test_delete_folder_cascade: DELETE /api/folders/{id} on non-empty folder deletes all docs + decrements quota
- test_delete_folder_wrong_owner: DELETE /api/folders/{id} by non-owner returns 404
- test_move_document: PATCH /api/documents/{id}/folder moves doc to target folder, returns 200
- test_move_wrong_owner_404: PATCH /api/documents/{id}/folder where doc or target folder belongs to other user returns 404
- test_breadcrumb_path: GET /api/folders/{id} returns breadcrumb array of {id, name} from root to current
- test_document_sort: GET /api/documents?sort=name|date|size returns correctly ordered results
- test_fts_search: GET /api/documents?q=term returns matching docs only; marked with pytest.mark.skipif for non-PostgreSQL
- test_fts_search_scoped_to_owner: GET /api/documents?q=term does not return other user's matching docs
- test_share_success: POST /api/shares grants share, recipient can see doc via GET /api/shares/received
- test_share_handle_not_found: POST /api/shares with unknown handle returns 404
- test_shared_with_me: GET /api/shares/received lists docs shared with current user
- test_share_no_quota_impact: share does not increment recipient's quota used_bytes
- test_revoke_share: DELETE /api/shares/{id} removes share; GET /api/shares/received no longer lists the doc
- test_share_revoke_wrong_owner_404: DELETE /api/shares/{id} by non-owner returns 404
- test_share_duplicate: POST /api/shares same doc+recipient twice returns 409
</behavior>
<action>
Create backend/tests/test_folders.py. Header imports: `import pytest` and `pytest.mark.xfail(strict=False)`.
Import `pytest_asyncio` and any fixtures from conftest using injection (no direct import — pytest injects by name).
Every test function body is `pytest.xfail("not implemented yet")` as the first line — no other code.
Name stubs exactly as listed in the behavior block (test_create_folder, test_create_folder_duplicate_name, etc.).
Mark test_fts_search and test_fts_search_scoped_to_owner with both `@pytest.mark.xfail(strict=False)` and a `pytest.mark.skipif` checking for the INTEGRATION env var — pattern: `pytest.mark.skipif(not os.environ.get("INTEGRATION"), reason="requires PostgreSQL")`.
Create backend/tests/test_shares.py with the same xfail stub pattern.
Stubs: test_share_success, test_share_handle_not_found, test_shared_with_me, test_share_no_quota_impact, test_revoke_share, test_share_revoke_wrong_owner_404, test_share_duplicate.
Do NOT write any assertion code in stub bodies. One line only: `pytest.xfail("not implemented yet")`.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_folders.py tests/test_shares.py -v --no-header 2>&1 | tail -30</automated>
</verify>
<acceptance_criteria>
- backend/tests/test_folders.py exists with all 13 stub functions listed in the behavior block
- backend/tests/test_shares.py exists with all 7 stub functions listed in the behavior block
- pytest reports all stubs as xfail (x) or xpass — zero failures (F) or errors (E)
- test_fts_search and test_fts_search_scoped_to_owner have both @pytest.mark.xfail and @pytest.mark.skipif decorators
- No import errors: `python -c "import tests.test_folders; import tests.test_shares"` exits 0
</acceptance_criteria>
<done>Both files exist; pytest collects them with zero errors; all tests show as xfail.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Extend test_documents.py, test_audit.py, test_security.py, test_migration.py with Phase 4 stubs</name>
<files>backend/tests/test_documents.py, backend/tests/test_audit.py, backend/tests/test_security.py, backend/tests/test_migration.py</files>
<read_first>
backend/tests/test_documents.py — read the entire file; identify the last test function and existing imports to find the correct append point; note the fixture names in use
backend/tests/test_security.py — read the entire file; note what already exists to avoid duplicate function names; if test_delete_user_cleans_files already exists skip it
backend/tests/test_audit.py — this file does NOT exist yet; create it fresh
</read_first>
<behavior>
Additions to test_documents.py:
- test_content_stream_200: GET /api/documents/{id}/content returns 200 with correct Content-Type and Content-Disposition: inline
- test_content_stream_206_range: GET /api/documents/{id}/content with Range header returns 206 and Content-Range header
- test_content_stream_admin_403: GET /api/documents/{id}/content with admin JWT returns 403
- test_content_stream_no_presigned_url: GET /api/documents/{id}/content response body does not contain any presigned URL token (no "X-Amz-Signature" or similar in body)
New test_audit.py:
- test_audit_log_viewer: GET /api/admin/audit-log returns paginated entries
- test_audit_log_no_doc_content: audit log entries contain no "filename", "extracted_text" keys in metadata_
- test_audit_log_regular_user_403: GET /api/admin/audit-log with regular user token returns 403
- test_audit_log_export_csv: GET /api/admin/audit-log/export?format=csv returns CSV content-type
Additions to test_security.py:
- test_credentials_enc_not_in_response: no API response for current user includes credentials_enc field
- test_delete_user_cleans_files: admin DELETE /api/admin/users/{id} triggers MinIO object deletion before DB removal
</behavior>
<action>
For test_documents.py: read the existing file, then APPEND the four new stub functions at the end. Each is `@pytest.mark.xfail(strict=False)` decorated, body is `pytest.xfail("not implemented yet")`.
Create backend/tests/test_audit.py from scratch with the four stubs plus necessary imports (pytest, pytest_asyncio pattern from conftest — no direct imports, fixture injection only).
For test_security.py: read the existing file first. Append the two new stubs ONLY if they do not already exist (check for test_credentials_enc_not_in_response and test_delete_user_cleans_files). If test_security.py does not exist, create it with both stubs.
All new stubs: `@pytest.mark.xfail(strict=False)`, body `pytest.xfail("not implemented yet")`.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_documents.py tests/test_audit.py tests/test_security.py -v --no-header 2>&1 | tail -30</automated>
</verify>
<acceptance_criteria>
- backend/tests/test_audit.py exists with 4 stub functions
- backend/tests/test_documents.py contains test_content_stream_200, test_content_stream_206_range, test_content_stream_admin_403, test_content_stream_no_presigned_url (verified by grep)
- backend/tests/test_security.py contains test_credentials_enc_not_in_response and test_delete_user_cleans_files
- `cd backend && python -m pytest tests/ -v --no-header 2>&1 | grep -E "FAILED|ERROR"` produces zero lines
- Total xfail count increases by at least 10 compared to pre-plan baseline (all new stubs collected)
</acceptance_criteria>
<done>All three files contain Phase 4 stubs; full test suite runs with zero failures or errors.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Test code → conftest fixtures | Test stubs must not import production secrets or live services |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-00-01 | Tampering | test stub files | mitigate | xfail(strict=False) ensures stubs cannot falsely pass and mask missing implementation |
| T-04-00-02 | Information Disclosure | test fixture reuse | accept | Phase 3 conftest fixtures already use ephemeral DB + mock MinIO; no real credentials in tests |
| T-04-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed in this plan |
</threat_model>
<verification>
Run full suite: `cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest -v --no-header 2>&1 | tail -20`
Expected: zero FAILED, zero ERROR. All new stubs appear as xfail (x) in summary.
</verification>
<success_criteria>
- Five test files collectively contain all 23 Phase 4 test stubs
- `pytest -v` exits 0 (green)
- No existing passing tests regress
- All stubs are properly named (exact names matching 04-VALIDATION.md Per-Task Verification Map)
</success_criteria>
<output>
Create `.planning/phases/04-folders-sharing-quotas-document-ux/04-01-SUMMARY.md` when done.
</output>
@@ -0,0 +1,216 @@
---
phase: 04-folders-sharing-quotas-document-ux
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- backend/migrations/versions/0004_phase4_pdf_open_mode_tsvector.py
- backend/storage/minio_backend.py
autonomous: true
requirements:
- FOLD-05
- DOC-02
- ADMIN-06
must_haves:
truths:
- "Alembic migration 0004 upgrades cleanly; downgrade reverses all DDL changes"
- "users.pdf_open_mode column exists with server_default 'in_app'"
- "GIN expression index ix_documents_fts on documents.extracted_text exists in PostgreSQL"
- "MinIOBackend.put_object_raw() is callable for audit-logs bucket writes"
- "audit-logs MinIO bucket is created when MINIO_ENDPOINT is set"
artifacts:
- path: "backend/migrations/versions/0004_phase4_pdf_open_mode_tsvector.py"
provides: "Alembic migration: pdf_open_mode column, GIN FTS index, audit-logs bucket creation"
- path: "backend/storage/minio_backend.py"
provides: "put_object_raw(bucket, key, data, length, content_type) method added"
key_links:
- from: "backend/migrations/versions/0004_phase4_pdf_open_mode_tsvector.py"
to: "backend/db/models.py"
via: "Alembic op.execute GIN index must not collide with ORM model table_args"
pattern: "ix_documents_fts"
- from: "backend/tasks/audit_tasks.py"
to: "backend/storage/minio_backend.py"
via: "put_object_raw called by daily export task"
pattern: "put_object_raw"
---
<objective>
Create Alembic migration 0004 and add MinIOBackend.put_object_raw(). This plan has no
dependency on the test scaffolds plan (04-01) and can run in parallel.
Purpose: Establish the database schema additions (pdf_open_mode, FTS index) and storage
method (put_object_raw for audit CSV upload) that later plans depend on.
Output: Migration file 0004 + extended MinIOBackend.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/04-folders-sharing-quotas-document-ux/04-CONTEXT.md
@.planning/phases/04-folders-sharing-quotas-document-ux/04-PATTERNS.md
@backend/migrations/versions/0003_multi_user_isolation.py
@backend/storage/minio_backend.py
@backend/db/models.py
</context>
<interfaces>
<!-- Key interfaces the executor needs. Extracted from codebase. -->
<!-- From backend/storage/minio_backend.py — existing put_object signature for reference: -->
<!--
async def put_object(
self,
file_bytes: bytes,
content_type: str,
user_id: str,
document_id: str,
ext: str,
) -> str: # returns object_key
...
await asyncio.to_thread(
self._client.put_object,
self._bucket, # hardcoded documents bucket
object_key, # {user_id}/{document_id}/{uuid4()}{ext}
io.BytesIO(file_bytes),
length=len(file_bytes),
content_type=content_type,
)
return object_key
-->
<!-- New put_object_raw signature (per D-17, PATTERNS.md):
async def put_object_raw(
self,
bucket: str, # caller supplies bucket name (e.g., "audit-logs")
key: str, # caller supplies complete key (e.g., "audit-logs/2026-05-25.csv")
data: io.BytesIO,
length: int,
content_type: str,
) -> None
-->
<!-- From backend/migrations/versions/0003_multi_user_isolation.py — batch_alter_table pattern:
with op.batch_alter_table("users") as batch_op:
batch_op.add_column(sa.Column("col_name", sa.String(), nullable=False, server_default="value"))
-->
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Alembic migration 0004 — pdf_open_mode column + GIN FTS index + audit-logs bucket</name>
<files>backend/migrations/versions/0004_phase4_pdf_open_mode_tsvector.py</files>
<read_first>
backend/migrations/versions/0003_multi_user_isolation.py — read the entire file; extract the batch_alter_table pattern for adding columns, the MinIO bucket creation pattern gated on MINIO_ENDPOINT env var, the module docstring format, and the downgrade() function pattern
backend/db/models.py — read lines 44-70 (User model) to confirm pdf_open_mode does NOT already exist before adding it
</read_first>
<action>
Create backend/migrations/versions/0004_phase4_pdf_open_mode_tsvector.py.
Module docstring (follow 0003 format): list three changes in order — (1) users.pdf_open_mode column, (2) GIN expression index on documents.extracted_text, (3) audit-logs MinIO bucket creation.
Set: revision = "0004", down_revision = "0003", branch_labels = None, depends_on = None.
upgrade() function — three steps in this exact order:
Step 1: Add users.pdf_open_mode column using batch_alter_table (per D-10, for SQLite compat):
`with op.batch_alter_table("users") as batch_op: batch_op.add_column(sa.Column("pdf_open_mode", sa.String(), nullable=False, server_default="in_app"))`
Step 2: Create GIN expression index MANUALLY — do NOT use Column Computed() or Index() — use op.execute with raw SQL (per D-11, Pitfall 2, PATTERNS.md). The exact SQL is:
`CREATE INDEX ix_documents_fts ON documents USING GIN (to_tsvector('english', coalesce(extracted_text, '')))`
Add a comment above this line: `# managed manually — do not autogenerate (Alembic issue #1390)`.
Step 3: Create the audit-logs MinIO bucket gated on MINIO_ENDPOINT env var (per Pitfall 8, PATTERNS.md). Follow the exact same guard pattern from migration 0003: `if os.environ.get("MINIO_ENDPOINT"):` then instantiate Minio client using env vars MINIO_ENDPOINT, MINIO_ACCESS_KEY, MINIO_SECRET_KEY with secure=False. Check `client.bucket_exists("audit-logs")` and call `client.make_bucket("audit-logs")` only if it does not exist.
downgrade() function: (1) `op.execute("DROP INDEX IF EXISTS ix_documents_fts")`, (2) `with op.batch_alter_table("users") as batch_op: batch_op.drop_column("pdf_open_mode")`. Add a comment: `# MinIO bucket NOT reversed — bucket may contain audit data`.
Imports needed at module top: `from __future__ import annotations`, `import os`, `import sqlalchemy as sa`, `from alembic import op`.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from migrations.versions import 0004_phase4_pdf_open_mode_tsvector" 2>&1 || python -c "import importlib.util; spec = importlib.util.spec_from_file_location('m', 'migrations/versions/0004_phase4_pdf_open_mode_tsvector.py'); m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m); print('OK')"</automated>
</verify>
<acceptance_criteria>
- File exists at backend/migrations/versions/0004_phase4_pdf_open_mode_tsvector.py
- `revision = "0004"` and `down_revision = "0003"` present in file
- File contains `batch_alter_table("users")` with pdf_open_mode column, server_default="in_app"
- File contains `CREATE INDEX ix_documents_fts ON documents USING GIN` (grep confirms)
- File contains `# managed manually — do not autogenerate` comment
- File contains `if os.environ.get("MINIO_ENDPOINT")` MinIO gate for audit-logs bucket
- File contains `audit-logs` bucket name (grep confirms)
- File contains `DROP INDEX IF EXISTS ix_documents_fts` in downgrade()
- `python -c "import py_compile; py_compile.compile('migrations/versions/0004_phase4_pdf_open_mode_tsvector.py')"` exits 0 (no syntax errors)
</acceptance_criteria>
<done>Migration file is syntactically valid and contains all three upgrade steps + downgrade reversal.</done>
</task>
<task type="auto">
<name>Task 2: Add put_object_raw() to MinIOBackend</name>
<files>backend/storage/minio_backend.py</files>
<read_first>
backend/storage/minio_backend.py — read the entire file; identify the existing put_object() method signature and asyncio.to_thread() call pattern; identify the class definition and existing imports including io import
backend/storage/base.py — read to see if StorageBackend ABC needs a corresponding abstract method added (it does NOT — put_object_raw is concrete only on MinIOBackend, not on the ABC, because only MinIOBackend has an audit-logs bucket)
</read_first>
<action>
Add the put_object_raw() async method to the MinIOBackend class in backend/storage/minio_backend.py.
Place it immediately after the existing put_object() method.
Method signature: `async def put_object_raw(self, bucket: str, key: str, data: io.BytesIO, length: int, content_type: str) -> None:`
Docstring: "Upload bytes to an arbitrary bucket+key (used for audit-logs CSV export). Unlike put_object(), does NOT apply the document key schema — the caller supplies the complete key. The main documents bucket is NOT used."
Implementation: call `await asyncio.to_thread(self._client.put_object, bucket, key, data, length=length, content_type=content_type)`. This mirrors the exact pattern of the existing put_object() method (which uses self._client.put_object via asyncio.to_thread).
Do NOT add io import if it already exists. Do NOT add this method to StorageBackend ABC (base.py) — this is MinIOBackend-only.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from storage.minio_backend import MinIOBackend; import inspect; src = inspect.getsource(MinIOBackend.put_object_raw); print('OK')"</automated>
</verify>
<acceptance_criteria>
- MinIOBackend.put_object_raw exists as an async method (grep: `async def put_object_raw`)
- Method accepts: bucket: str, key: str, data: io.BytesIO, length: int, content_type: str
- Method body calls `asyncio.to_thread(self._client.put_object, bucket, key, data, length=length, content_type=content_type)`
- `python -c "from storage.minio_backend import MinIOBackend"` exits 0 (no import errors)
- StorageBackend ABC (base.py) is NOT modified (grep: `put_object_raw` absent from base.py)
</acceptance_criteria>
<done>MinIOBackend.put_object_raw() is importable and callable; base.py unchanged.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Migration → MinIO | Bucket creation uses env var credentials; no credentials in code |
| put_object_raw → MinIO SDK | Caller supplies bucket name; method does not validate bucket against allowlist |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-02-01 | Tampering | migration 0004 GIN index | mitigate | Index created via raw SQL (not autogenerate) to prevent Alembic repeat-generation bug; comment documents the decision |
| T-04-02-02 | Information Disclosure | audit-logs MinIO bucket | mitigate | Bucket creation gated on MINIO_ENDPOINT env var (no hardcoded credentials); bucket policy is private-by-default (MinIO default) |
| T-04-02-03 | Tampering | put_object_raw caller-supplied key | accept | put_object_raw is called only from trusted application code (Celery task); key is constructed from application logic, not user input |
| T-04-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed in this plan |
</threat_model>
<verification>
1. Verify migration syntax: `python -c "import py_compile; py_compile.compile('backend/migrations/versions/0004_phase4_pdf_open_mode_tsvector.py')"`
2. Verify MinIOBackend import: `cd backend && python -c "from storage.minio_backend import MinIOBackend; print(hasattr(MinIOBackend, 'put_object_raw'))"`
3. Grep checks: `grep -n "ix_documents_fts\|audit-logs\|pdf_open_mode\|put_object_raw" backend/migrations/versions/0004_phase4_pdf_open_mode_tsvector.py backend/storage/minio_backend.py`
</verification>
<success_criteria>
- Migration 0004 file is syntactically valid Python with revision="0004" and down_revision="0003"
- Migration contains all three upgrade steps: pdf_open_mode column, GIN FTS index, audit-logs bucket creation
- MinIOBackend.put_object_raw() is an async method that delegates to asyncio.to_thread
- No existing tests regress: `cd backend && pytest -v --no-header 2>&1 | grep -E "FAILED|ERROR"` returns nothing
</success_criteria>
<output>
Create `.planning/phases/04-folders-sharing-quotas-document-ux/04-02-SUMMARY.md` when done.
</output>
@@ -0,0 +1,289 @@
---
phase: 04-folders-sharing-quotas-document-ux
plan: 03
type: execute
wave: 2
depends_on:
- "04-01"
- "04-02"
files_modified:
- backend/services/audit.py
- backend/api/folders.py
- backend/api/documents.py
- backend/main.py
autonomous: true
requirements:
- FOLD-01
- FOLD-02
- FOLD-03
- FOLD-04
- FOLD-05
must_haves:
truths:
- "User can create, rename, delete, and list folders via REST API"
- "Deleting a non-empty folder cascade-deletes all documents (MinIO + DB) and decrements quota atomically"
- "GET /api/folders/{id} returns a breadcrumb array from root to the requested folder"
- "Document list supports sort by name, date, size via ?sort= query param"
- "Full-text search via ?q= uses plainto_tsquery on documents.extracted_text GIN index"
- "write_audit_log() helper is available for all Phase 4 handlers"
- "Duplicate folder name under same parent returns 409"
artifacts:
- path: "backend/services/audit.py"
provides: "write_audit_log() async helper — flush-not-commit, never-raises"
exports: ["write_audit_log"]
- path: "backend/api/folders.py"
provides: "FOLD-01..05 endpoints: POST /api/folders, GET /api/folders, GET /api/folders/{id}, PATCH /api/folders/{id}, DELETE /api/folders/{id}, PATCH /api/documents/{id}/folder"
- path: "backend/main.py"
provides: "folders router registered; all Phase 4 routers wired"
key_links:
- from: "backend/api/folders.py"
to: "backend/services/audit.py"
via: "write_audit_log() call after successful folder operations"
pattern: "write_audit_log"
- from: "backend/api/folders.py"
to: "backend/db/models.py"
via: "Folder, Document, Quota ORM models"
pattern: "from db.models import Folder"
- from: "backend/api/documents.py"
to: "backend/api/folders.py"
via: "PATCH /api/documents/{id}/folder uses same ownership assertion as documents.py"
pattern: "get_regular_user"
---
<objective>
Implement the audit service helper and all folder backend endpoints (FOLD-01..05).
This plan introduces write_audit_log() which subsequent plans (shares, proxy, audit viewer) depend on.
Purpose: Deliver the complete folder CRUD and document organization API.
Output: backend/services/audit.py + backend/api/folders.py + main.py router registration.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/04-folders-sharing-quotas-document-ux/04-CONTEXT.md
@.planning/phases/04-folders-sharing-quotas-document-ux/04-PATTERNS.md
@.planning/phases/04-folders-sharing-quotas-document-ux/04-RESEARCH.md
@backend/api/documents.py
@backend/db/models.py
@backend/deps/auth.py
@backend/deps/db.py
</context>
<interfaces>
<!-- Key interfaces the executor must replicate exactly. Extracted from codebase. -->
<!-- From backend/deps/auth.py:
async def get_regular_user(...) -> User: # raises 403 for admin role
async def get_current_admin(...) -> User: # raises 403 for non-admin
-->
<!-- From backend/db/models.py Folder model (lines ~120-145 approximately):
class Folder(Base):
__tablename__ = "folders"
id: Mapped[uuid.UUID] # primary key, default uuid4
user_id: Mapped[uuid.UUID] # FK to users.id
parent_id: Mapped[Optional[uuid.UUID]] # FK to folders.id self-reference, nullable
name: Mapped[str] # String, not null
created_at: Mapped[datetime]
# UniqueConstraint("user_id", "parent_id", "name") — triggers 409 on duplicate
-->
<!-- Ownership assertion pattern (from backend/api/documents.py):
doc = await session.get(Document, uid)
if doc is None or doc.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Document not found")
-->
<!-- Atomic quota decrement (CASE WHEN for SQLite compat, established in STATE.md):
await session.execute(
text(
"UPDATE quotas SET used_bytes = "
"CASE WHEN used_bytes > :delta THEN used_bytes - :delta ELSE 0 END "
"WHERE user_id = :uid"
),
{"delta": total_bytes, "uid": str(current_user.id)},
)
-->
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create backend/services/audit.py — write_audit_log() helper</name>
<files>backend/services/audit.py</files>
<read_first>
backend/services/storage.py — read the entire file; extract the import pattern for async service modules and how AsyncSession is used; note the module-level logger pattern
backend/db/models.py — search for the AuditLog class definition; read it fully to confirm attribute names: event_type, user_id, actor_id, resource_id, ip_address, metadata_ (ORM attribute; DB column name is "metadata" per CLAUDE.md note)
</read_first>
<behavior>
- write_audit_log() called with all fields: session, event_type (str), user_id (Optional[uuid.UUID]), actor_id (Optional[uuid.UUID]), resource_id (Optional[uuid.UUID]), ip_address (Optional[str]), metadata_ (Optional[dict])
- Function creates AuditLog ORM instance, adds to session, calls session.flush() (NOT commit)
- Function NEVER raises — exception caught, logged as warning, then swallowed
- AuditLog.metadata_ ORM attribute (not "metadata") used per CLAUDE.md note about reserved SQLAlchemy attribute name
</behavior>
<action>
Create backend/services/audit.py.
Imports: `from __future__ import annotations`, `import logging`, `import uuid`, `from typing import Optional`, `from sqlalchemy.ext.asyncio import AsyncSession`, `from db.models import AuditLog`.
Module-level: `logger = logging.getLogger(__name__)`.
Implement async def write_audit_log with signature: `(session: AsyncSession, event_type: str, user_id: Optional[uuid.UUID], actor_id: Optional[uuid.UUID], resource_id: Optional[uuid.UUID], ip_address: Optional[str], metadata_: Optional[dict] = None) -> None`.
Body: try block that creates `AuditLog(event_type=event_type, user_id=user_id, actor_id=actor_id, resource_id=resource_id, ip_address=ip_address, metadata_=metadata_)`, calls `session.add(entry)`, then `await session.flush()`. Except block: `except Exception as exc: logger.warning("audit log write failed: %s", exc)`. No re-raise.
Critical: use `session.flush()` not `session.commit()` — this is a hard architectural requirement (D-14).
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from services.audit import write_audit_log; import inspect; sig = inspect.signature(write_audit_log); print(list(sig.parameters.keys()))"</automated>
</verify>
<acceptance_criteria>
- backend/services/audit.py exists and is importable
- write_audit_log is an async function (grep: `async def write_audit_log`)
- Function uses `await session.flush()` not `await session.commit()` (grep: `session.flush` present; `session.commit` absent from this file)
- Function has a bare `except Exception` that logs and does NOT re-raise (grep: `logger.warning` inside except block)
- `python -c "from services.audit import write_audit_log"` exits 0
</acceptance_criteria>
<done>write_audit_log() is importable; uses flush-not-commit; never raises.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Create backend/api/folders.py — all FOLD-01..05 endpoints</name>
<files>backend/api/folders.py, backend/main.py</files>
<read_first>
backend/api/documents.py — read the entire file; extract the import block, UUID parse pattern (try/except ValueError → 404), ownership assertion pattern (resource is None or resource.user_id != current_user.id → 404), atomic quota UPDATE text(), and the list_documents handler signature for the sort/search extension
backend/main.py — read the entire file; identify the include_router section to know where to add the folders router
backend/db/models.py — read the Folder class and Document class fully to confirm all column names (parent_id, folder_id FK on Document)
</read_first>
<behavior>
POST /api/folders:
- Body: {name: str, parent_id: Optional[str] = null}
- Auth: get_regular_user (403 for admin per CLAUDE.md)
- If parent_id provided: assert parent folder exists and belongs to current_user (→ 404 if not)
- Create Folder(user_id=current_user.id, name=name, parent_id=parsed_parent_uuid or None)
- Catch IntegrityError → 409 "A folder with that name already exists here" (Pitfall 6)
- Call write_audit_log after commit: event_type="folder.created", resource_id=folder.id, metadata_={"name": folder.name}
- Return 201 with folder JSON
GET /api/folders (list top-level): returns folders where user_id=current_user.id and parent_id IS NULL
GET /api/folders/{id}: returns folder JSON + breadcrumb array
- Breadcrumb: iterative Python walk up parent chain (not WITH RECURSIVE — avoids SQLite incompatibility in unit tests)
- Walk: start from folder, load parent via session.get(Folder, folder.parent_id), repeat until parent_id is None
- Breadcrumb array: [{id: str, name: str}, ...] from root to current folder (root first)
- Response: {id, name, parent_id, user_id, created_at, breadcrumb: [...]}
PATCH /api/folders/{id}: rename folder
- Body: {name: str}
- Assert folder.user_id == current_user.id → 404 if not
- Catch IntegrityError → 409 on duplicate name
- Call write_audit_log: event_type="folder.renamed"
DELETE /api/folders/{id}: delete folder
- Assert folder.user_id == current_user.id → 404 if not
- Collect all documents in this folder only (direct children — parent is not responsible for sub-folder docs via cascade; D-03 says cascade-delete but the UI only deletes the selected folder tree, not sub-folders of sub-folders; use WITH RECURSIVE via sqlalchemy text() for PostgreSQL; in test environment without PostgreSQL the recursive CTE will be skipped via a try/except fallback to direct children only)
- Use WITH RECURSIVE CTE (via text()) to find all folder IDs in subtree (Pitfall 1) — wrap in try/except OperationalError for SQLite test compat, fallback collects only direct children
- Sum size_bytes of all collected documents
- Atomic quota decrement with CASE WHEN pattern
- Delete MinIO objects best-effort (try/except per each object, per PATTERNS.md Pattern 2)
- Delete all documents via ORM, then delete folder via session.delete(folder)
- Call write_audit_log: event_type="folder.deleted", metadata_={"doc_count": len(docs), "name": folder.name}
- Return 204
PATCH /api/documents/{id}/folder (move document):
- Body: {folder_id: Optional[str]} — null = move to root (no folder)
- Assert doc.user_id == current_user.id → 404 if not
- If folder_id provided: assert target folder.user_id == current_user.id → 404 if not (Pitfall from CONTEXT.md)
- Update doc.folder_id and commit
- Return 200 with updated doc JSON
Extension to GET /api/documents list endpoint (in documents.py, not folders.py):
- Add query params: sort (str, default "date"), order (str, default "desc"), folder_id (Optional[str]), q (Optional[str])
- sort=name → order_by(Document.filename), sort=size → order_by(Document.size_bytes), sort=date → order_by(Document.created_at)
- order=asc → .asc(), order=desc → .desc()
- folder_id provided → add .where(Document.folder_id == parsed_uuid)
- q provided and len >= 2: add FTS where clause using func.to_tsvector("english", func.coalesce(Document.extracted_text, "")).op("@@")(func.plainto_tsquery("english", q)) — import sqlalchemy.func
- Add is_shared subquery: select(Share.document_id).where(Share.owner_id == current_user.id).scalar_subquery(); add Document.id.in_(shared_subq).label("is_shared") to select
</behavior>
<action>
Create backend/api/folders.py with APIRouter(prefix="/api/folders", tags=["folders"]).
Imports: `from __future__ import annotations`, `import uuid`, `from typing import Optional`, `from fastapi import APIRouter, Depends, HTTPException, Query, Request, status`, `from pydantic import BaseModel`, `from sqlalchemy import select, text, delete, func`, `from sqlalchemy.exc import IntegrityError, OperationalError`, `from sqlalchemy.ext.asyncio import AsyncSession`, `from db.models import Document, Folder, Quota, Share`, `from deps.auth import get_regular_user`, `from deps.db import get_db`, `from storage import get_storage_backend`, `from services.audit import write_audit_log`.
Pydantic models: FolderCreate(name: str, parent_id: Optional[str] = None), FolderRename(name: str), DocumentMove(folder_id: Optional[str] = None).
Implement all five endpoints as specified in the behavior block. For the WITH RECURSIVE CTE, use:
`await session.execute(text("WITH RECURSIVE subtree AS (SELECT id FROM folders WHERE id = :root_id AND user_id = :uid UNION ALL SELECT f.id FROM folders f JOIN subtree s ON f.parent_id = s.id WHERE f.user_id = :uid) SELECT id FROM subtree"), {"root_id": str(folder.id), "uid": str(current_user.id)})`
Wrap in try/except OperationalError and fallback to `select(Document).where(Document.folder_id == folder.id, Document.user_id == current_user.id)`.
For the ip_address extraction pattern (per Pitfall 5): `ip_address = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)`.
After creating backend/api/folders.py, modify backend/main.py: add `from api.folders import router as folders_router` and `app.include_router(folders_router)` in the routes section alongside the other routers.
Also extend backend/api/documents.py: add sort, order, folder_id, q query params to the list_documents handler. Add import for func and Share. Add the is_shared subquery to the select. Add FTS where clause when q is present. Do NOT break existing tests — these are additive changes.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_folders.py -x -v --no-header 2>&1 | tail -30</automated>
</verify>
<acceptance_criteria>
- backend/api/folders.py exists with all five endpoint functions (grep: `async def create_folder`, `async def list_folders`, `async def get_folder`, `async def rename_folder`, `async def delete_folder`)
- backend/api/folders.py contains PATCH /api/documents/{id}/folder endpoint (grep: `move_document` or equivalent)
- backend/main.py includes folders router (grep: `folders_router`)
- `python -c "from api.folders import router"` exits 0
- All folder ownership assertions use 404 not 403 (grep: `status_code=404` in folders.py; no `status_code=403`)
- IntegrityError caught and returns 409 (grep: `IntegrityError` and `409` in folders.py)
- write_audit_log called after folder create, rename, delete (grep: `write_audit_log` appears at least 3 times)
- test_create_folder and test_rename_folder turn green (xpass) or remain xfail — no FAILED
- `cd backend && python -m pytest tests/ -x --no-header 2>&1 | grep -E "^FAILED"` returns nothing
</acceptance_criteria>
<done>Folders API endpoints are implemented; document list has sort/search; all existing tests still pass.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Client → POST /api/folders | Untrusted name/parent_id input; Pydantic validates types; IntegrityError → 409 |
| Client → DELETE /api/folders/{id} | Untrusted folder_id path parameter; UUID parse → 404; ownership assertion → 404 |
| Client → GET /api/documents?q= | Untrusted search query; plainto_tsquery is injection-safe (parameterized via SQLAlchemy func) |
| Client → PATCH /api/documents/{id}/folder | Untrusted doc_id + folder_id; both validated via ownership assertion |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-03-01 | Elevation of Privilege | POST/PATCH/DELETE /api/folders | mitigate | get_regular_user dep: admin role returns 403; regular user passes through |
| T-04-03-02 | Information Disclosure | GET /api/documents?q= (FTS scope) | mitigate | FTS query MUST include Document.user_id == current_user.id filter; plainto_tsquery is parameterized (no injection) |
| T-04-03-03 | Tampering | DELETE /api/folders/{id} cascade-delete | mitigate | Ownership assertion on folder before cascade; atomic quota decrement (CASE WHEN); MinIO deletion is best-effort (does not abort primary operation on failure) |
| T-04-03-04 | Information Disclosure | folder IDOR via path parameter | mitigate | All folder endpoints assert folder.user_id == current_user.id → 404 (not 403 — prevents attacker enumeration of folder IDs) |
| T-04-03-05 | Information Disclosure | PATCH /api/documents/{id}/folder cross-user target folder | mitigate | Both document ownership AND target folder ownership asserted → 404 on mismatch |
| T-04-03-06 | Tampering | folder name UniqueConstraint violation surfaced as 500 | mitigate | IntegrityError caught → 409 Conflict with clear error message (Pitfall 6) |
| T-04-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed in this plan |
</threat_model>
<verification>
1. Endpoint smoke: `cd backend && python -m pytest tests/test_folders.py -v --no-header 2>&1 | tail -30`
2. Full suite: `cd backend && python -m pytest -v --no-header 2>&1 | grep -E "FAILED|ERROR"` — expect empty
3. Import check: `cd backend && python -c "from api.folders import router; from services.audit import write_audit_log; print('OK')"`
4. Security grep: `grep -n "get_regular_user\|status_code=404\|IntegrityError\|write_audit_log\|plainto_tsquery" backend/api/folders.py`
</verification>
<success_criteria>
- write_audit_log() is importable from services.audit; uses session.flush(); never raises
- All five folder endpoints exist in api/folders.py; router registered in main.py
- Document list endpoint supports sort, folder_id, q params; is_shared field returned
- test_folders.py tests turn from xfail to xpass (green) or remain xfail — zero FAILED
- Full pytest suite is green
</success_criteria>
<output>
Create `.planning/phases/04-folders-sharing-quotas-document-ux/04-03-SUMMARY.md` when done.
</output>
@@ -0,0 +1,229 @@
---
phase: 04-folders-sharing-quotas-document-ux
plan: 04
type: execute
wave: 3
depends_on:
- "04-01"
- "04-02"
- "04-03"
files_modified:
- backend/api/shares.py
- backend/main.py
autonomous: true
requirements:
- SHARE-01
- SHARE-02
- SHARE-03
- SHARE-04
- SHARE-05
must_haves:
truths:
- "User can share a document with another user by their exact handle"
- "GET /api/shares/received returns documents shared with the current user (virtual folder)"
- "Recipient's quota is never modified by a share operation"
- "Share revocation is immediate: DELETE /api/shares/{id} with owner assertion"
- "Sharing the same document with the same user twice returns 409"
- "Wrong-owner revocation attempt returns 404 (IDOR prevention)"
artifacts:
- path: "backend/api/shares.py"
provides: "POST /api/shares, GET /api/shares, GET /api/shares/received, DELETE /api/shares/{id}"
exports: ["router"]
key_links:
- from: "backend/api/shares.py"
to: "backend/services/audit.py"
via: "write_audit_log called after share grant and revoke"
pattern: "write_audit_log"
- from: "backend/api/shares.py"
to: "backend/db/models.py"
via: "Share ORM model; User.handle for recipient lookup"
pattern: "Share.owner_id|Share.recipient_id|User.handle"
---
<objective>
Implement the document sharing API: grant share by handle, list owned shares per document,
list "shared with me" virtual folder, and revoke share with IDOR protection.
Purpose: Deliver SHARE-01 through SHARE-05 and the security invariant test for share IDOR.
Output: backend/api/shares.py + main.py registration.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/04-folders-sharing-quotas-document-ux/04-CONTEXT.md
@.planning/phases/04-folders-sharing-quotas-document-ux/04-PATTERNS.md
@backend/api/documents.py
@backend/api/admin.py
@backend/db/models.py
@backend/deps/auth.py
@backend/services/audit.py
</context>
<interfaces>
<!-- Key interfaces the executor needs. Extracted from codebase. -->
<!-- From backend/db/models.py — Share model (read the actual file to confirm column names):
class Share(Base):
__tablename__ = "shares"
id: Mapped[uuid.UUID] # primary key
document_id: Mapped[uuid.UUID] # FK to documents.id
owner_id: Mapped[uuid.UUID] # FK to users.id (document owner who grants share)
recipient_id: Mapped[uuid.UUID] # FK to users.id (recipient)
permission: Mapped[str] # "view" (only value for Phase 4, D-07)
created_at: Mapped[datetime]
# UniqueConstraint on (document_id, recipient_id) — triggers IntegrityError on duplicate share
-->
<!-- From backend/db/models.py — User.handle:
handle: Mapped[str] = mapped_column(String, unique=True, nullable=False)
-->
<!-- Handle lookup pattern (from PATTERNS.md):
result = await session.execute(select(User).where(User.handle == body.recipient_handle))
recipient = result.scalar_one_or_none()
if recipient is None:
raise HTTPException(404, "User not found")
-->
<!-- Share IDOR assertion (from RESEARCH.md Pitfall 4 — CRITICAL):
share = await session.get(Share, share_id)
if share is None or share.owner_id != current_user.id:
raise HTTPException(404, "Share not found")
-->
<!-- "Shared with me" query (from PATTERNS.md):
stmt = (
select(Document)
.join(Share, Share.document_id == Document.id)
.where(Share.recipient_id == current_user.id)
.order_by(Document.created_at.desc())
)
-->
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create backend/api/shares.py — full sharing API</name>
<files>backend/api/shares.py, backend/main.py</files>
<read_first>
backend/db/models.py — read the Share class definition fully; confirm column names (document_id, owner_id, recipient_id, permission); check for UniqueConstraint on (document_id, recipient_id); confirm User.handle column name
backend/api/documents.py — read the UUID parse pattern (try: uid = uuid.UUID(doc_id) except ValueError: raise HTTPException(404)) and the ownership assertion pattern to replicate exactly
backend/api/admin.py — read lines 140-170 for the handle/user lookup pattern used in admin endpoints
backend/services/audit.py — verify the write_audit_log signature (already created in plan 04-03)
backend/main.py — read the include_router section; note current router list to add shares_router
</read_first>
<behavior>
POST /api/shares:
- Body: {document_id: str, recipient_handle: str}
- Auth: get_regular_user
- Parse document_id as UUID → 404 on invalid
- Assert document.user_id == current_user.id → 404 (per D-16 ownership rule)
- Look up User by recipient_handle (exact match) → 404 "User not found" if absent (D-04)
- Prevent self-share: if recipient.id == current_user.id → 400 "Cannot share with yourself"
- Create Share(document_id=uid, owner_id=current_user.id, recipient_id=recipient.id, permission="view")
- Catch IntegrityError → 409 "Document already shared with this user"
- Call write_audit_log: event_type="share.granted", resource_id=uid, metadata_={"recipient_id": str(recipient.id)}
- Return 201 with share JSON {id, document_id, owner_id, recipient_id, permission, created_at}
GET /api/shares?document_id={id} (list shares owned by current user for a document):
- Auth: get_regular_user
- Assert document.user_id == current_user.id → 404
- Return list of shares for this document: [{id, recipient_id, recipient_handle, permission, created_at}]
- Join Share with User on Share.recipient_id = User.id to get recipient handle
GET /api/shares/received (virtual "Shared with me" folder — D-06):
- Auth: get_regular_user
- Return documents shared WITH current_user (Share.recipient_id == current_user.id)
- Response: [{doc metadata fields}, owner_handle] — no quota impact, no share_count on this view
- Does NOT return extracted_text (Pitfall 7 — shared docs list shows metadata only)
- Returns: {items: [{id, filename, content_type, size_bytes, created_at, owner_handle}]}
DELETE /api/shares/{share_id}:
- Auth: get_regular_user
- Parse share_id as UUID → 404 on invalid
- CRITICAL: assert share.owner_id == current_user.id → 404 "Share not found" if mismatch (Pitfall 4, IDOR)
- Delete share; commit
- Call write_audit_log: event_type="share.revoked", resource_id=share.document_id, metadata_={"recipient_id": str(share.recipient_id)}
- Return 204
</behavior>
<action>
Create backend/api/shares.py. APIRouter(prefix="/api/shares", tags=["shares"]).
Imports: `from __future__ import annotations`, `import uuid`, `from typing import Optional`, `from fastapi import APIRouter, Depends, HTTPException, Query, Request`, `from pydantic import BaseModel`, `from sqlalchemy import select`, `from sqlalchemy.exc import IntegrityError`, `from sqlalchemy.ext.asyncio import AsyncSession`, `from db.models import Document, Share, User`, `from deps.auth import get_regular_user`, `from deps.db import get_db`, `from services.audit import write_audit_log`.
Pydantic request model: ShareCreate(document_id: str, recipient_handle: str).
Implement all four endpoints in order: POST /api/shares, GET /api/shares, GET /api/shares/received, DELETE /api/shares/{share_id}.
The GET /api/shares/received endpoint MUST be defined BEFORE DELETE /api/shares/{share_id} in the router — otherwise FastAPI will route GET /api/shares/received as DELETE with share_id="received" (path parameter conflict).
CRITICAL security requirement: DELETE /api/shares/{share_id} MUST check `share.owner_id != current_user.id` → 404 (not merely that the share exists). This is the IDOR test that the security agent will verify.
ip_address extraction: `request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)`.
After creating shares.py, modify backend/main.py: add `from api.shares import router as shares_router` and `app.include_router(shares_router)`.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_shares.py -x -v --no-header 2>&1 | tail -30</automated>
</verify>
<acceptance_criteria>
- backend/api/shares.py exists with all four endpoint functions
- GET /api/shares/received is defined BEFORE DELETE /api/shares/{share_id} in the file (grep line numbers confirm ordering)
- DELETE /api/shares/{share_id} checks `share.owner_id != current_user.id` → 404 (grep: `share.owner_id != current_user.id` or `share.owner_id == current_user.id` inverted with raise)
- IntegrityError → 409 for duplicate share (grep: `IntegrityError` and `409` in shares.py)
- write_audit_log called for share.granted and share.revoked (grep: `write_audit_log` appears at least twice)
- GET /api/shares/received response does NOT include extracted_text field (grep: `extracted_text` absent from the received endpoint's return dict)
- `python -c "from api.shares import router"` exits 0
- test_share_revoke_wrong_owner_404 turns green (xpass) or remains xfail — not FAILED
- `cd backend && python -m pytest tests/ -x --no-header 2>&1 | grep -E "^FAILED"` returns nothing
</acceptance_criteria>
<done>Shares API is implemented; IDOR protection on revoke confirmed; full test suite passes.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Client → POST /api/shares | Untrusted document_id and recipient_handle; document ownership asserted; handle is exact-match lookup only |
| Client → DELETE /api/shares/{id} | Untrusted share_id; ownership asserted via share.owner_id == current_user.id |
| Client → GET /api/shares/received | Returns only metadata, never extracted_text; scoped to current_user as recipient |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-04-01 | Elevation of Privilege | POST /api/shares | mitigate | get_regular_user dep: admin cannot share documents |
| T-04-04-02 | Information Disclosure | Share IDOR — DELETE /api/shares/{id} | mitigate | Ownership assertion: share.owner_id == current_user.id → 404 if mismatch (not 403 — prevents ID enumeration); test_share_revoke_wrong_owner_404 validates this |
| T-04-04-03 | Information Disclosure | GET /api/shares/received leaking extracted_text | mitigate | Received endpoint returns metadata only: id, filename, content_type, size_bytes, created_at, owner_handle — extracted_text is explicitly excluded |
| T-04-04-04 | Information Disclosure | Recipient quota modified by share | mitigate | Share creation MUST NOT touch quotas table; no quota UPDATE in shares.py |
| T-04-04-05 | Denial of Service | Duplicate share flooding | mitigate | UniqueConstraint(document_id, recipient_id) → IntegrityError → 409; no unbounded inserts |
| T-04-04-06 | Information Disclosure | Share reveals document existence to non-recipients | mitigate | Ownership assertion on POST /api/shares: only document owner can grant; ID enumeration blocked by 404 |
| T-04-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed in this plan |
</threat_model>
<verification>
1. IDOR test: `cd backend && python -m pytest tests/test_shares.py::test_share_revoke_wrong_owner_404 -v`
2. Full suite: `cd backend && python -m pytest tests/ -v --no-header 2>&1 | grep -E "FAILED|ERROR"`
3. Route order grep: `grep -n "def list_shared_with_me\|def delete_share\|received\|share_id" backend/api/shares.py`
4. extracted_text absence: `grep -n "extracted_text" backend/api/shares.py` — expect empty (no results)
</verification>
<success_criteria>
- All four share endpoints implement the behavior contract from CONTEXT.md D-04..D-07
- IDOR protection: DELETE /api/shares/{id} with wrong owner returns 404 (not 200/204)
- test_shares.py tests turn green or remain xfail — zero FAILED
- Full pytest suite green
</success_criteria>
<output>
Create `.planning/phases/04-folders-sharing-quotas-document-ux/04-04-SUMMARY.md` when done.
</output>
@@ -0,0 +1,240 @@
---
phase: 04-folders-sharing-quotas-document-ux
plan: 05
type: execute
wave: 4
depends_on:
- "04-03"
- "04-04"
files_modified:
- backend/api/documents.py
- backend/api/auth.py
autonomous: true
requirements:
- DOC-01
- DOC-02
- FOLD-04
must_haves:
truths:
- "GET /api/documents/{id}/content streams document bytes from MinIO through FastAPI"
- "Range header requests return 206 with correct Content-Range header"
- "Admin cannot access the streaming proxy — get_regular_user dep returns 403"
- "No presigned URL is generated or exposed in the proxy response"
- "Share recipients can access document content via the proxy"
- "PATCH /api/auth/me/preferences stores and returns pdf_open_mode preference"
artifacts:
- path: "backend/api/documents.py"
provides: "GET /api/documents/{id}/content streaming proxy with Range header support"
- path: "backend/api/auth.py"
provides: "PATCH /api/auth/me/preferences endpoint for pdf_open_mode"
key_links:
- from: "backend/api/documents.py"
to: "backend/storage/minio_backend.py"
via: "get_storage_backend().get_object(doc.object_key) — bytes fetched directly, not via presigned URL"
pattern: "get_object"
- from: "backend/api/documents.py"
to: "backend/db/models.py"
via: "Share model for _can_access_document() recipient check"
pattern: "Share.recipient_id"
---
<objective>
Add the PDF streaming proxy to the documents API (DOC-02) and the pdf_open_mode preferences
endpoint to the auth API (D-10, DOC-01). These can be implemented together since they both
modify existing API modules and neither depends on the other.
Purpose: Complete document content access — both the proxy stream and the user preference for
how to open PDFs.
Output: Streaming proxy endpoint in documents.py + preferences PATCH in auth.py.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/04-folders-sharing-quotas-document-ux/04-CONTEXT.md
@.planning/phases/04-folders-sharing-quotas-document-ux/04-PATTERNS.md
@.planning/phases/04-folders-sharing-quotas-document-ux/04-RESEARCH.md
@backend/api/documents.py
@backend/api/auth.py
@backend/storage/minio_backend.py
@backend/db/models.py
</context>
<interfaces>
<!-- Key interfaces the executor needs. Extracted from codebase. -->
<!-- From backend/storage/minio_backend.py — get_object signature (read the actual file to confirm):
async def get_object(self, object_key: str) -> bytes:
# wraps self._client.get_object via asyncio.to_thread; returns raw bytes
-->
<!-- Range header parse pattern (from RESEARCH.md Pattern 3):
def _parse_range(range_header: str, file_size: int) -> tuple[int, int]:
try:
h = range_header.replace("bytes=", "").split("-")
start = int(h[0]) if h[0] != "" else 0
end = int(h[1]) if h[1] != "" else file_size - 1
except (ValueError, IndexError):
raise HTTPException(status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE)
if start > end or start < 0 or end >= file_size:
raise HTTPException(status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE)
return start, end
-->
<!-- _can_access_document pattern (from RESEARCH.md Pattern 4):
async def _can_access_document(session, doc, current_user_id):
if doc.user_id == current_user_id: return True
result = await session.execute(select(Share).where(Share.document_id == doc.id, Share.recipient_id == current_user_id))
return result.scalar_one_or_none() is not None
-->
<!-- User.pdf_open_mode column (added in migration 0004):
pdf_open_mode: Mapped[str] # server_default = "in_app"; allowed: "in_app" | "new_tab"
-->
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add GET /api/documents/{id}/content streaming proxy to documents.py</name>
<files>backend/api/documents.py</files>
<read_first>
backend/api/documents.py — read the entire file; identify current imports (Request, StreamingResponse if already present; func, Select, etc.); find the endpoint list to determine where to insert the new endpoint; confirm existing UUID parse pattern and get_regular_user usage
backend/storage/minio_backend.py — read to confirm the exact name and signature of get_object(); confirm it returns bytes (not a stream object)
backend/db/models.py — search for the Share class definition; confirm Share.document_id and Share.recipient_id column names
</read_first>
<behavior>
GET /api/documents/{id}/content:
- MUST use get_regular_user dep (admin role → 403 — Pitfall 3, CRITICAL)
- Parse doc_id as UUID → 404 "Document not found" on ValueError
- Load document via session.get(Document, uid) → 404 if None
- Access check: if doc.user_id == current_user.id → proceed; else query Share where Share.document_id == doc.id AND Share.recipient_id == current_user.id; if no share → 404 "Document not found"
- Fetch bytes: `file_bytes = await get_storage_backend().get_object(doc.object_key)` — this calls MinIO directly, NO presigned URL
- Build base headers: content-type = doc.content_type, content-disposition = `inline; filename="{doc.filename}"`, accept-ranges = "bytes", content-length = str(file_size)
- Range header handling: if request.headers.get("range") is truthy, call _parse_range() → on valid range: add content-range header, set content-length to chunk length, return StreamingResponse(iter([chunk]), status_code=206, headers=headers)
- No range: return StreamingResponse(iter([file_bytes]), status_code=200, headers=headers)
- _parse_range() is a module-level helper function, not an endpoint (defined above the endpoint)
DOC-01 note: GET /api/documents/{id} already returns extracted_text — verify this is still included in the existing response; add it to the response if absent (read the actual endpoint to check before modifying).
</behavior>
<action>
Modify backend/api/documents.py.
Add to imports if not already present: `from fastapi import Request` (alongside existing FastAPI imports), `from fastapi.responses import StreamingResponse`, `from fastapi import status`. Confirm `from db.models import Share` is in imports (added in plan 04-03 for the is_shared subquery — if absent, add it now).
Define _parse_range() helper function at module level (above the stream_document_content endpoint). Implement exactly as specified in the behavior block and RESEARCH.md Pattern 3: parse "bytes=X-Y" format, handle open-ended ranges (end = file_size - 1 when Y is empty), raise HTTPException(status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE) on invalid range.
Add the stream_document_content endpoint after the existing document endpoints. Implement the access check using an inline query for Share (do NOT call a separate helper function — the access check is in the handler body for clarity and to avoid test mocking complexity).
CRITICAL: endpoint decorator uses `get_regular_user` not `get_current_user`. This is verified by the security agent and by test_content_stream_admin_403.
CRITICAL: NEVER call presigned_get_url() or any presigned URL method inside this endpoint. Bytes must flow directly from MinIO through FastAPI.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_documents.py -x -v --no-header 2>&1 | tail -30</automated>
</verify>
<acceptance_criteria>
- `GET /api/documents/{id}/content` endpoint exists in documents.py (grep: `async def stream_document_content` or similar)
- Endpoint uses `get_regular_user` dep, NOT `get_current_user` (grep: `Depends(get_regular_user)` on the content endpoint; no `get_current_user` on content endpoint)
- _parse_range() helper exists at module level (grep: `def _parse_range`)
- StreamingResponse imported and used (grep: `StreamingResponse` in documents.py)
- No presigned URL call in the handler (grep: `presigned` absent from stream_document_content function body)
- Share access check present (grep: `Share.recipient_id` or `recipient_id` in documents.py)
- test_content_stream_admin_403 turns green (xpass) or remains xfail — not FAILED
- test_content_stream_no_presigned_url turns green or remains xfail — not FAILED
- `cd backend && python -m pytest tests/ -x --no-header 2>&1 | grep -E "^FAILED"` returns nothing
</acceptance_criteria>
<done>Streaming proxy delivers bytes via StreamingResponse; admin blocked; Range headers supported; no presigned URL exposure.</done>
</task>
<task type="auto">
<name>Task 2: Add PATCH /api/auth/me/preferences endpoint for pdf_open_mode</name>
<files>backend/api/auth.py</files>
<read_first>
backend/api/auth.py — read the entire file; identify the /api/auth/me GET endpoint (existing); find the router prefix and how it is declared; confirm get_current_user (not get_regular_user) is used for /me endpoints (admin can read/update their own preferences); find the existing import block to add pydantic BaseModel if needed
backend/db/models.py — confirm users.pdf_open_mode column name and that it exists (added by migration 0004)
</read_first>
<behavior>
PATCH /api/auth/me/preferences:
- Auth: get_current_user (both regular users and admins can set their own PDF preference — D-10)
- Body: {pdf_open_mode: str} — validated to be one of ["in_app", "new_tab"]
- If pdf_open_mode not in allowed values → 422 (Pydantic validation handles this via Literal type or custom validator)
- Update current_user.pdf_open_mode = body.pdf_open_mode
- session.add(current_user); await session.commit()
- Return 200: {pdf_open_mode: current_user.pdf_open_mode}
GET /api/auth/me/preferences (optional but recommended for frontend to load initial value):
- Auth: get_current_user
- Return 200: {pdf_open_mode: current_user.pdf_open_mode}
- If pdf_open_mode column is missing from User model (migration not yet run), return {"pdf_open_mode": "in_app"} as default
</behavior>
<action>
Modify backend/api/auth.py.
Add a Pydantic request model: PreferencesUpdate with field `pdf_open_mode: Literal["in_app", "new_tab"]`. Import `Literal` from typing.
Add two new endpoints at the end of the router function list:
1. GET /api/auth/me/preferences — returns {pdf_open_mode: str}. Uses `get_current_user` dep. Reads current_user.pdf_open_mode. If AttributeError (column not yet in ORM map, e.g., test env without migration), return {"pdf_open_mode": "in_app"}.
2. PATCH /api/auth/me/preferences — accepts PreferencesUpdate body. Updates current_user.pdf_open_mode. Commits. Returns {pdf_open_mode: updated value}.
Both endpoints are placed after the existing /api/auth/me endpoint. No new router prefix needed — they use the same auth router.
Do NOT break any existing auth tests. Do NOT modify any existing endpoint.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from api.auth import router; print([r.path for r in router.routes])" 2>&1</automated>
</verify>
<acceptance_criteria>
- `/api/auth/me/preferences` appears in auth router routes (grep: `me/preferences` in auth.py)
- PATCH endpoint validates pdf_open_mode via Literal["in_app", "new_tab"] (grep: `Literal` and `pdf_open_mode` in auth.py)
- `python -c "from api.auth import router"` exits 0
- Existing auth tests still pass: `cd backend && python -m pytest tests/test_auth_api.py -v --no-header 2>&1 | grep -E "^FAILED"` returns nothing
- Full suite: `cd backend && python -m pytest -v --no-header 2>&1 | grep -E "^FAILED"` returns nothing
</acceptance_criteria>
<done>PATCH /api/auth/me/preferences stores pdf_open_mode; GET returns current value; existing auth tests unaffected.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Client → GET /api/documents/{id}/content | Untrusted doc_id; access checked via ownership or active share; bytes flow from MinIO through FastAPI only |
| Client → Range header | Untrusted byte ranges; _parse_range() validates start/end bounds against file_size; invalid range → 416 |
| Client → PATCH /api/auth/me/preferences | Untrusted pdf_open_mode value; Pydantic Literal validates allowlist |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-05-01 | Broken Access Control | GET /api/documents/{id}/content — admin access | mitigate | MUST use get_regular_user dep: admin role → 403; test_content_stream_admin_403 validates; security agent checks |
| T-04-05-02 | Information Disclosure | Presigned URL exposure in proxy response | mitigate | presigned_get_url() NEVER called in stream_document_content; bytes fetched via get_object() directly; test_content_stream_no_presigned_url validates |
| T-04-05-03 | Information Disclosure | Range header bypass — out-of-bounds access | mitigate | _parse_range() validates: start <= end, start >= 0, end < file_size; → 416 on any violation |
| T-04-05-04 | Information Disclosure | Non-recipient accessing shared document via proxy | mitigate | Access check: doc.user_id == current_user.id OR active Share.recipient_id == current_user.id; neither → 404 |
| T-04-05-05 | Tampering | pdf_open_mode mass assignment | mitigate | Pydantic Literal["in_app", "new_tab"] enforces strict allowlist; no other user fields touched by preferences endpoint |
| T-04-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed in this plan |
</threat_model>
<verification>
1. Security test: `cd backend && python -m pytest tests/test_documents.py::test_content_stream_admin_403 tests/test_documents.py::test_content_stream_no_presigned_url -v`
2. Full suite: `cd backend && python -m pytest tests/ -v --no-header 2>&1 | grep -E "FAILED|ERROR"`
3. Admin dep grep: `grep -A5 "content" backend/api/documents.py | grep "get_regular_user"` — must match
4. Presigned URL grep: `grep -n "presigned" backend/api/documents.py` — confirms absence from stream handler
</verification>
<success_criteria>
- GET /api/documents/{id}/content proxies bytes from MinIO; admin gets 403; Range → 206; no presigned URL
- PATCH /api/auth/me/preferences stores valid pdf_open_mode; GET returns current value
- test_content_stream_* tests turn green or remain xfail; zero FAILED
- Full pytest suite green
</success_criteria>
<output>
Create `.planning/phases/04-folders-sharing-quotas-document-ux/04-05-SUMMARY.md` when done.
</output>
@@ -0,0 +1,274 @@
---
phase: 04-folders-sharing-quotas-document-ux
plan: 06
type: execute
wave: 4
depends_on:
- "04-03"
- "04-04"
files_modified:
- backend/api/audit.py
- backend/tasks/audit_tasks.py
- backend/celery_app.py
- backend/main.py
autonomous: true
requirements:
- ADMIN-06
must_haves:
truths:
- "Admin can retrieve paginated audit log entries filtered by date range, user, and action type"
- "Audit log entries never contain document content, filenames, or extracted text"
- "Regular user requesting audit log returns 403"
- "CSV export returns streaming response with correct Content-Disposition: attachment header"
- "Celery beat daily export task runs at midnight UTC and uploads CSV to audit-logs bucket"
- "MinIOBackend.put_object_raw() is used for the daily export (not the documents key scheme)"
artifacts:
- path: "backend/api/audit.py"
provides: "GET /api/admin/audit-log (paginated, filtered), GET /api/admin/audit-log/export (CSV stream)"
- path: "backend/tasks/audit_tasks.py"
provides: "audit_log_daily_export Celery task — queries DB, writes CSV, uploads to MinIO audit-logs bucket"
- path: "backend/celery_app.py"
provides: "beat_schedule extended with audit-log-daily-export at midnight UTC"
key_links:
- from: "backend/api/audit.py"
to: "backend/deps/auth.py"
via: "get_current_admin dep on all audit log endpoints"
pattern: "get_current_admin"
- from: "backend/tasks/audit_tasks.py"
to: "backend/storage/minio_backend.py"
via: "put_object_raw(bucket='audit-logs', key='audit-logs/YYYY-MM-DD.csv', ...)"
pattern: "put_object_raw"
---
<objective>
Implement the admin audit log viewer API (ADMIN-06) and the daily Celery beat export task (D-17).
This plan runs in parallel with plan 04-05 (both depend on Wave 2 completion, neither depends on each other).
Purpose: Give admins a paginated, filterable audit log and ensure log data is exported daily to MinIO.
Output: backend/api/audit.py + backend/tasks/audit_tasks.py + celery_app.py + main.py updates.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/04-folders-sharing-quotas-document-ux/04-CONTEXT.md
@.planning/phases/04-folders-sharing-quotas-document-ux/04-PATTERNS.md
@.planning/phases/04-folders-sharing-quotas-document-ux/04-RESEARCH.md
@backend/api/admin.py
@backend/tasks/document_tasks.py
@backend/celery_app.py
@backend/db/models.py
</context>
<interfaces>
<!-- Key interfaces the executor needs. Extracted from codebase. -->
<!-- From backend/db/models.py — AuditLog model (read the actual file to confirm):
class AuditLog(Base):
__tablename__ = "audit_log"
id: Mapped[int] # primary key, auto-increment
event_type: Mapped[str]
user_id: Mapped[Optional[uuid.UUID]] # FK to users.id
actor_id: Mapped[Optional[uuid.UUID]] # FK to users.id
resource_id: Mapped[Optional[uuid.UUID]]
ip_address: Mapped[Optional[str]] # INET type in PostgreSQL
metadata_: mapped_column(JSONB, name="metadata") # ORM attr = metadata_; DB col = metadata
created_at: Mapped[datetime]
-->
<!-- _audit_to_dict() whitelist (from PATTERNS.md — MUST include ONLY these fields):
{
"id": entry.id,
"event_type": entry.event_type,
"user_id": str(entry.user_id) if entry.user_id else None,
"actor_id": str(entry.actor_id) if entry.actor_id else None,
"resource_id": str(entry.resource_id) if entry.resource_id else None,
"ip_address": str(entry.ip_address) if entry.ip_address else None,
"metadata_": entry.metadata_,
"created_at": entry.created_at.isoformat(),
}
# FORBIDDEN keys: filename, extracted_text, content (ADMIN-06, D-15)
-->
<!-- From backend/tasks/document_tasks.py — Celery task pattern (read actual file for exact imports):
@celery_app.task(name="tasks.document_tasks.extract_and_classify")
def extract_and_classify(document_id: str) -> dict:
return asyncio.run(_run(document_id))
async def _run(document_id: str) -> dict:
from db.session import AsyncSessionLocal # deferred import avoids circular
...
async with AsyncSessionLocal() as session:
...
-->
<!-- From backend/celery_app.py — existing beat_schedule (read actual file for exact structure):
celery_app.conf.beat_schedule = {
"cleanup-abandoned-uploads": {
"task": "tasks.document_tasks.cleanup_abandoned_uploads",
"schedule": _timedelta(minutes=30),
},
}
-->
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create backend/api/audit.py — admin audit log viewer + CSV export</name>
<files>backend/api/audit.py, backend/main.py</files>
<read_first>
backend/api/admin.py — read the entire file; extract the get_current_admin dep usage pattern, the paginated list query pattern (lines ~140-160), the _user_to_dict() whitelist helper pattern, and how the router is prefixed
backend/db/models.py — read the AuditLog class fully; confirm the ORM attribute name is metadata_ (not metadata); confirm all column names
</read_first>
<behavior>
GET /api/admin/audit-log:
- Auth: get_current_admin (regular user → 403)
- Query params: start (Optional[datetime] = None), end (Optional[datetime] = None), user_id (Optional[uuid.UUID] = None), event_type (Optional[str] = None), page (int = 1, ge=1), per_page (int = 50, ge=1, le=500)
- Build SQLAlchemy query: select(AuditLog).order_by(AuditLog.created_at.desc())
- Apply filters: if start → .where(AuditLog.created_at >= start); if end → .where(AuditLog.created_at <= end); if user_id → .where(AuditLog.user_id == user_id); if event_type → .where(AuditLog.event_type == event_type)
- Apply pagination: .limit(per_page).offset((page - 1) * per_page)
- Also run a COUNT query with same filters (no limit/offset) for total
- Return {items: [_audit_to_dict(e) for e in entries], total: count, page: page, per_page: per_page}
GET /api/admin/audit-log/export:
- Auth: get_current_admin
- Query params: same filters as viewer (start, end, user_id, event_type), format: str = "csv"
- Query all matching rows (no pagination)
- For format="csv": use csv.DictWriter with io.StringIO; write all rows via _audit_to_dict(); return StreamingResponse(iter([output.getvalue()]), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=audit-export.csv"})
- CRITICAL: _audit_to_dict() must NEVER include filename, extracted_text, or document content keys
_audit_to_dict() helper: pure whitelist dict — id, event_type, user_id, actor_id, resource_id, ip_address, metadata_, created_at — no other keys possible.
Register router in main.py.
</behavior>
<action>
Create backend/api/audit.py.
Imports: `from __future__ import annotations`, `import csv`, `import io`, `import uuid`, `from datetime import datetime`, `from typing import Optional`, `from fastapi import APIRouter, Depends, Query`, `from fastapi.responses import StreamingResponse`, `from pydantic import BaseModel`, `from sqlalchemy import select, func`, `from sqlalchemy.ext.asyncio import AsyncSession`, `from db.models import AuditLog`, `from deps.auth import get_current_admin`, `from deps.db import get_db`.
Router: `router = APIRouter(prefix="/api/admin", tags=["audit"])`.
Define _audit_to_dict() as a module-level function. The whitelist is: id, event_type, user_id (str or None), actor_id (str or None), resource_id (str or None), ip_address (str or None), metadata_ (the JSONB dict value), created_at (isoformat). The function MUST NOT include filename, extracted_text, or any document content fields. Add a docstring: "Safe audit log serializer — never includes filename, extracted_text, or document content (ADMIN-06, D-15)."
Define _build_query() helper (or inline) that accepts filters and returns a SQLAlchemy Select statement with filters applied. Reuse for both list and export endpoints.
Implement GET /api/admin/audit-log and GET /api/admin/audit-log/export as specified.
After creating audit.py, modify backend/main.py: add `from api.audit import router as audit_router` and `app.include_router(audit_router)`.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_audit.py -x -v --no-header 2>&1 | tail -30</automated>
</verify>
<acceptance_criteria>
- backend/api/audit.py exists with GET /api/admin/audit-log and GET /api/admin/audit-log/export endpoints
- Both endpoints use get_current_admin dep (grep: `Depends(get_current_admin)` appears twice in audit.py)
- _audit_to_dict() whitelist does NOT contain filename, extracted_text, password_hash, or credentials_enc (grep: these strings absent from the dict literal in _audit_to_dict)
- CSV export returns StreamingResponse with Content-Disposition: attachment (grep: `attachment` in audit.py)
- `python -c "from api.audit import router"` exits 0
- test_audit_log_regular_user_403 turns green or remains xfail — not FAILED
- test_audit_log_no_doc_content turns green or remains xfail — not FAILED
- `cd backend && python -m pytest tests/ -x --no-header 2>&1 | grep -E "^FAILED"` returns nothing
</acceptance_criteria>
<done>Audit log viewer and CSV export implemented; admin-only access confirmed; no doc content in serializer.</done>
</task>
<task type="auto">
<name>Task 2: Create backend/tasks/audit_tasks.py + extend celery_app.py beat schedule</name>
<files>backend/tasks/audit_tasks.py, backend/celery_app.py</files>
<read_first>
backend/tasks/document_tasks.py — read the entire file; extract the Celery task decorator pattern, the asyncio.run(_run()) bridge pattern, the AsyncSessionLocal usage with deferred imports inside the async function body, and the error handling pattern
backend/celery_app.py — read the entire file; extract the beat_schedule dict structure, how _timedelta is imported, and where to add the crontab import and new beat entry; find the task_routes dict
</read_first>
<behavior>
audit_log_daily_export Celery task:
- Sync Celery entry point: `@celery_app.task(name="tasks.audit_tasks.audit_log_daily_export") def audit_log_daily_export() -> dict: return asyncio.run(_run_daily_export())`
- _run_daily_export() async function:
- Compute yesterday: `yesterday = date.today() - timedelta(days=1)` using `from datetime import date, datetime, timedelta, timezone`
- start = datetime(yesterday.year, yesterday.month, yesterday.day, tzinfo=timezone.utc)
- end = start + timedelta(days=1)
- Open AsyncSessionLocal() session; query AuditLog where created_at >= start AND created_at < end; order by created_at
- Build CSV using csv.DictWriter + io.StringIO; use the same field list as _audit_to_dict: id, event_type, user_id, actor_id, resource_id, ip_address, metadata_, created_at
- csv_bytes = output.getvalue().encode("utf-8")
- key = f"audit-logs/{yesterday.isoformat()}.csv"
- Call `await get_storage_backend().put_object_raw(bucket="audit-logs", key=key, data=io.BytesIO(csv_bytes), length=len(csv_bytes), content_type="text/csv")`
- Return {"exported": len(rows), "key": key, "date": yesterday.isoformat()}
- Error handling: wrap the entire _run_daily_export in try/except; on exception: log error and return {"exported": 0, "error": str(e)}
celery_app.py beat_schedule extension:
- Import `from celery.schedules import crontab as _crontab` (alias with underscore like _timedelta)
- Add to beat_schedule dict: "audit-log-daily-export" → {"task": "tasks.audit_tasks.audit_log_daily_export", "schedule": _crontab(hour=0, minute=0)}
- Add to task_routes: "tasks.audit_tasks.*": {"queue": "documents"} (reuse documents worker queue per PATTERNS.md)
</behavior>
<action>
Create backend/tasks/audit_tasks.py. Module docstring: describe the daily audit export task and its MinIO target.
Top-level imports: `import asyncio`, `from celery_app import celery_app`. All other imports go inside the async function body to avoid circular imports (per established pattern from document_tasks.py).
Inside _run_daily_export() body (deferred imports): `from datetime import date, datetime, timedelta, timezone`, `import csv`, `import io`, `from db.session import AsyncSessionLocal`, `from db.models import AuditLog`, `from sqlalchemy import select`, `from storage import get_storage_backend`.
Implement the task and async function exactly as specified in the behavior block.
Modify backend/celery_app.py: add `from celery.schedules import crontab as _crontab` to imports. Add the new beat entry to `celery_app.conf.beat_schedule`. Add the task route to `celery_app.conf.task_routes`.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from tasks.audit_tasks import audit_log_daily_export; print('OK')"</automated>
</verify>
<acceptance_criteria>
- backend/tasks/audit_tasks.py exists and is importable without error
- `python -c "from tasks.audit_tasks import audit_log_daily_export"` exits 0
- audit_log_daily_export is registered with name "tasks.audit_tasks.audit_log_daily_export" (grep: `name="tasks.audit_tasks.audit_log_daily_export"`)
- _run_daily_export calls put_object_raw with bucket="audit-logs" (grep: `put_object_raw` and `audit-logs` in audit_tasks.py)
- celery_app.py beat_schedule contains "audit-log-daily-export" entry with `_crontab(hour=0, minute=0)` (grep: `audit-log-daily-export` and `crontab` in celery_app.py)
- celery_app.py task_routes contains `tasks.audit_tasks.*` (grep confirms)
- All deferred imports are inside the async function body, not at module top level (grep: import lines inside `async def _run_daily_export`)
- `cd backend && python -m pytest tests/ -x --no-header 2>&1 | grep -E "^FAILED"` returns nothing
</acceptance_criteria>
<done>Celery export task importable; beat schedule updated; put_object_raw used for audit-logs bucket writes.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Admin → GET /api/admin/audit-log | Admin-authenticated; regular users cannot access |
| Celery worker → MinIO audit-logs bucket | Service-to-service; uses env-var credentials; bucket is private |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-06-01 | Broken Access Control | GET /api/admin/audit-log access by regular user | mitigate | get_current_admin dep: regular user role → 403; test_audit_log_regular_user_403 validates |
| T-04-06-02 | Sensitive Data Exposure | Audit log returning document content | mitigate | _audit_to_dict() whitelist explicitly excludes filename, extracted_text; no other fields can be added without modifying the dict literal (safe-by-default) |
| T-04-06-03 | Information Disclosure | CSV export containing sensitive data | mitigate | CSV export uses the same _audit_to_dict() whitelist as the JSON viewer; both share the same helper function |
| T-04-06-04 | Tampering | audit-logs MinIO bucket publicly accessible | mitigate | Bucket created without public policy (MinIO default is private); confirmed in migration 0004 |
| T-04-06-05 | Denial of Service | Unbounded CSV export | accept | Export scoped by same date/user/event_type filters as viewer; max rows bounded by time window (1 day per task run); admin-only endpoint |
| T-04-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed in this plan |
</threat_model>
<verification>
1. Audit log tests: `cd backend && python -m pytest tests/test_audit.py -v --no-header`
2. Admin-only check: `grep -n "get_current_admin" backend/api/audit.py` — must appear on both endpoints
3. No doc content in serializer: `grep -n "filename\|extracted_text\|password_hash\|credentials_enc" backend/api/audit.py` — must return nothing from _audit_to_dict function body
4. Celery task import: `cd backend && python -c "from tasks.audit_tasks import audit_log_daily_export; print('OK')"`
5. Full suite: `cd backend && python -m pytest tests/ -v --no-header 2>&1 | grep -E "FAILED|ERROR"`
</verification>
<success_criteria>
- Audit log viewer returns paginated, filtered entries with no document content in any field
- CSV export streams valid CSV with Content-Disposition: attachment
- Daily Celery task exports to audit-logs MinIO bucket using put_object_raw
- Beat schedule updated; task route registered
- test_audit.py tests green or xfail; zero FAILED in full suite
</success_criteria>
<output>
Create `.planning/phases/04-folders-sharing-quotas-document-ux/04-06-SUMMARY.md` when done.
</output>
@@ -0,0 +1,271 @@
---
phase: 04-folders-sharing-quotas-document-ux
plan: 07
type: execute
wave: 5
depends_on:
- "04-05"
- "04-06"
files_modified:
- backend/api/auth.py
- backend/api/admin.py
- backend/api/documents.py
autonomous: true
requirements:
- SEC-08
- SEC-09
- ADMIN-06
- DOC-01
- DOC-02
- SHARE-01
- SHARE-02
- SHARE-03
- SHARE-04
- SHARE-05
must_haves:
truths:
- "Auth events (login, logout, password change, TOTP, sign-out-all) are written to the audit log"
- "Admin events (user create, deactivate, quota change, AI provider assign) are written to the audit log"
- "credentials_enc field is absent from every serialized response across the entire API"
- "Admin delete-user triggers delete_user_files() — MinIO objects deleted before DB records removed"
- "Document upload and delete events are written to the audit log"
artifacts:
- path: "backend/api/auth.py"
provides: "write_audit_log() calls backfilled into login, logout, password change, TOTP, sign-out-all handlers"
- path: "backend/api/admin.py"
provides: "write_audit_log() calls in user create/deactivate/quota/AI-config handlers; delete-user cleanup; CloudConnectionOut Pydantic model for SEC-08"
- path: "backend/api/documents.py"
provides: "write_audit_log() calls in upload confirm and document delete handlers"
key_links:
- from: "backend/api/auth.py"
to: "backend/services/audit.py"
via: "write_audit_log called after successful auth events"
pattern: "write_audit_log"
- from: "backend/api/admin.py"
to: "backend/db/models.py"
via: "delete_user_files() iterates Document records and calls storage.delete_object"
pattern: "delete_user_files\|delete_object"
---
<objective>
Back-fill audit log writes into auth and admin handlers (D-13), implement SEC-08
(credentials_enc exclusion) and SEC-09 (delete-user file cleanup). These are security
hardening tasks — they modify existing handlers without changing their primary behavior.
Purpose: Complete the audit trail (ADMIN-06) and close the two security requirements
(SEC-08, SEC-09) that are in scope for Phase 4.
Output: Audit writes in auth.py, admin.py, documents.py + CloudConnectionOut Pydantic model
+ delete_user_files() cleanup in admin.py.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/04-folders-sharing-quotas-document-ux/04-CONTEXT.md
@.planning/phases/04-folders-sharing-quotas-document-ux/04-PATTERNS.md
@backend/api/auth.py
@backend/api/admin.py
@backend/api/documents.py
@backend/services/audit.py
@backend/db/models.py
</context>
<interfaces>
<!-- Key interfaces the executor needs. Extracted from codebase. -->
<!-- write_audit_log signature (from backend/services/audit.py, created in plan 04-03):
async def write_audit_log(
session: AsyncSession,
event_type: str,
user_id: Optional[uuid.UUID],
actor_id: Optional[uuid.UUID],
resource_id: Optional[uuid.UUID],
ip_address: Optional[str],
metadata_: Optional[dict] = None,
) -> None
-->
<!-- From backend/db/models.py — CloudConnection model (read actual file to confirm columns):
class CloudConnection(Base):
__tablename__ = "cloud_connections"
id: ...
user_id: ...
provider: Mapped[str]
display_name: Mapped[str]
credentials_enc: Mapped[bytes] # THIS MUST NEVER APPEAR IN API RESPONSES (SEC-08)
status: Mapped[str]
connected_at: Mapped[datetime]
-->
<!-- SEC-08 response model (per D-18, RESEARCH.md Pattern 9):
class CloudConnectionOut(BaseModel):
id: str
provider: str
display_name: str
status: str
connected_at: datetime
# credentials_enc is deliberately absent — SEC-08
model_config = {"from_attributes": True}
-->
<!-- IP extraction pattern (established in folders.py and shares.py):
ip_address = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
-->
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Back-fill audit log writes into auth.py and documents.py handlers</name>
<files>backend/api/auth.py, backend/api/documents.py</files>
<read_first>
backend/api/auth.py — read the entire file; identify all state-changing endpoints: login (POST /api/auth/login), logout (POST /api/auth/logout), password change (POST /api/auth/change-password), TOTP enroll (POST /api/auth/totp/enroll), TOTP verify/enable, backup code use, sign-out-all (POST /api/auth/sign-out-all); note which already inject Request and which need it added
backend/api/documents.py — read the entire file; identify the confirm endpoint (POST /api/documents/{id}/confirm or similar) and the delete endpoint (DELETE /api/documents/{id}); note existing imports
backend/services/audit.py — confirm write_audit_log is importable
</read_first>
<behavior>
Auth events to write (D-13 — all 4 categories required):
1. login success → event_type="auth.login", user_id=user.id, actor_id=user.id, resource_id=None, metadata_={"totp_used": bool}
2. login failure → event_type="auth.login_failed", user_id=None, actor_id=None, resource_id=None, metadata_={"email_hash": hmac of email for correlation without PII exposure — or simply omit metadata_}
NOTE: Do NOT log the email or password in metadata_. Log only the IP and event type.
3. logout → event_type="auth.logout", user_id=current_user.id
4. password change → event_type="auth.password_changed", user_id=current_user.id
5. TOTP enrolled → event_type="auth.totp_enrolled", user_id=current_user.id
6. TOTP revoked (if endpoint exists) → event_type="auth.totp_revoked"
7. backup code used → event_type="auth.backup_code_used" (on login with backup code)
8. sign-out-all → event_type="auth.sign_out_all", user_id=current_user.id
Document events (D-13):
9. document uploaded (on confirm) → event_type="document.uploaded", user_id=current_user.id, resource_id=doc.id, metadata_={"size_bytes": doc.size_bytes, "storage_backend": "minio"} — MUST NOT include filename or extracted_text
10. document deleted → event_type="document.deleted", user_id=current_user.id, resource_id=doc.id, metadata_={"size_bytes": doc.size_bytes}
write_audit_log is called AFTER the successful operation. If the operation fails (exception raised before commit), write_audit_log is not called. Call it just before the return statement.
</behavior>
<action>
Modify backend/api/auth.py:
- Add import: `from services.audit import write_audit_log`
- For handlers that do not already inject `request: Request`, add it as a parameter
- After each successful auth operation (commit or token issuance), call write_audit_log with appropriate event_type
- IP extraction: `ip_address = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)`
- CRITICAL: login_failed event must NOT log email or password in metadata_ — log only IP
Modify backend/api/documents.py:
- Add import: `from services.audit import write_audit_log` if not already present
- In the confirm/finalize endpoint (where document upload completes): call write_audit_log with event_type="document.uploaded"; metadata_ contains size_bytes and storage_backend only — NOT filename, NOT extracted_text
- In the delete endpoint: call write_audit_log with event_type="document.deleted"; metadata_ contains size_bytes only
Do NOT change any existing endpoint return values, status codes, or response schemas.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_auth_api.py tests/test_documents.py -x -v --no-header 2>&1 | tail -30</automated>
</verify>
<acceptance_criteria>
- `from services.audit import write_audit_log` appears in both auth.py and documents.py
- write_audit_log called in at least 6 distinct places across auth.py (grep: `write_audit_log` count >= 6 in auth.py)
- write_audit_log called in at least 2 distinct places in documents.py (confirm + delete)
- auth.py audit calls for document.uploaded and document.deleted do NOT include filename or extracted_text in metadata_ (grep these strings absent from write_audit_log call sites in documents.py)
- Existing auth tests still pass: `pytest tests/test_auth_api.py -v --no-header 2>&1 | grep -E "^FAILED"` returns nothing
- Full suite: `pytest tests/ -x --no-header 2>&1 | grep -E "^FAILED"` returns nothing
</acceptance_criteria>
<done>Audit writes backfilled into auth and documents handlers; no sensitive data in metadata_; existing tests pass.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: SEC-08 / SEC-09 hardening + admin event audit writes in admin.py</name>
<files>backend/api/admin.py</files>
<read_first>
backend/api/admin.py — read the entire file; identify all state-changing admin endpoints: create user, deactivate user, activate user, reset password (admin-triggered), change quota, assign AI provider; find the delete-user endpoint if it exists (SEC-09 requires file cleanup before DB deletion); identify the _user_to_dict() whitelist pattern; find imports
backend/db/models.py — read the CloudConnection model class fully to confirm credentials_enc column name; read the Document model to identify columns used for cleanup
backend/services/audit.py — confirm write_audit_log signature
</read_first>
<behavior>
Admin event audit writes (D-13 category 4):
- user created by admin → event_type="admin.user_created", user_id=new_user.id, actor_id=admin.id
- user deactivated → event_type="admin.user_deactivated", user_id=target_user.id, actor_id=admin.id
- user activated → event_type="admin.user_activated", user_id=target_user.id, actor_id=admin.id
- quota changed → event_type="admin.quota_changed", user_id=target_user.id, actor_id=admin.id, metadata_={"old_bytes": old_limit, "new_bytes": new_limit}
- AI provider assigned → event_type="admin.ai_provider_assigned", user_id=target_user.id, actor_id=admin.id, metadata_={"provider": new_provider, "model": new_model}
SEC-08 — credentials_enc exclusion (D-18):
- Define CloudConnectionOut Pydantic model: id (str), provider (str), display_name (str), status (str), connected_at (datetime) — credentials_enc is ABSENT; model_config = {"from_attributes": True}
- If any admin endpoint currently returns CloudConnection ORM objects as JSON: add response_model=CloudConnectionOut or use explicit dict serialization that excludes credentials_enc
- Phase 4 note: no cloud connection admin endpoints exist yet (Phase 5). Define the model now so Phase 5 cannot accidentally expose credentials_enc. Place it in admin.py schemas section.
SEC-09 — delete-user file cleanup (D-19):
- If DELETE /api/admin/users/{id} endpoint exists: before deleting DB records, collect all documents for the user via query; call get_storage_backend().delete_object(doc.object_key) for each (best-effort, try/except); decrement quota for deleted files (or simply rely on CASCADE DELETE of quota row since user is deleted); then delete the user DB record
- If DELETE endpoint does not exist: create it with the cleanup logic
- Endpoint: DELETE /api/admin/users/{user_id} — auth: get_current_admin; assert user is not admin (cannot delete admin accounts); cleanup user files; delete user record; write audit log event_type="admin.user_deleted"
</behavior>
<action>
Modify backend/api/admin.py:
1. Add imports: `from services.audit import write_audit_log`, `from datetime import datetime` (if not present), `from storage import get_storage_backend`, `from sqlalchemy import select` (if not present), `from db.models import Document` (if not already imported).
2. Define CloudConnectionOut Pydantic model near the top of the file (after existing request models): fields id, provider, display_name, status, connected_at; add docstring "SEC-08: credentials_enc deliberately excluded".
3. Add write_audit_log calls after each successful admin state change as specified in the behavior block. ip_address from request.headers or request.client.
4. Implement or extend DELETE /api/admin/users/{user_id}: read all user documents, delete MinIO objects best-effort, then call `await session.delete(user)` and `await session.commit()`. Write audit log event_type="admin.user_deleted".
5. Do NOT change _user_to_dict() whitelist — it already excludes credentials_enc and password_hash (per Phase 3 implementation). Only add the new CloudConnectionOut model.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_admin_api.py tests/test_security.py -x -v --no-header 2>&1 | tail -30</automated>
</verify>
<acceptance_criteria>
- CloudConnectionOut Pydantic model defined in admin.py with no credentials_enc field (grep: `CloudConnectionOut` in admin.py; grep: `credentials_enc` absent from CloudConnectionOut class body)
- write_audit_log called in at least 4 admin state-changing endpoints (grep: `write_audit_log` count >= 4 in admin.py)
- DELETE /api/admin/users/{id} endpoint exists and calls get_storage_backend().delete_object (grep: `delete_object` in admin.py delete-user handler)
- test_credentials_enc_not_in_response turns green or remains xfail — not FAILED
- test_delete_user_cleans_files turns green or remains xfail — not FAILED
- `cd backend && python -m pytest tests/ -x --no-header 2>&1 | grep -E "^FAILED"` returns nothing
- `cd backend && python -m pytest tests/test_admin_api.py -v --no-header 2>&1 | grep -E "^FAILED"` returns nothing
</acceptance_criteria>
<done>Audit writes backfilled; CloudConnectionOut model defined without credentials_enc; delete-user cleanup implemented.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Audit write ← auth handler | write_audit_log must not receive PII (email, password) in metadata_ |
| Admin → DELETE /api/admin/users | Must not delete admin accounts; must clean MinIO before DB |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-07-01 | Sensitive Data Exposure | auth.login_failed logs email or password | mitigate | login_failed event metadata_ MUST be empty or contain only non-PII correlation data; email never logged |
| T-04-07-02 | Sensitive Data Exposure | document.uploaded audit event contains filename or extracted_text | mitigate | metadata_ for document.uploaded contains only size_bytes and storage_backend; filename and extracted_text explicitly excluded |
| T-04-07-03 | Sensitive Data Exposure | credentials_enc in API response (SEC-08) | mitigate | CloudConnectionOut Pydantic model excludes credentials_enc; safe-by-default (whitelist not blacklist) |
| T-04-07-04 | Tampering | Admin deletes their own account | mitigate | DELETE /api/admin/users/{id} asserts target user role != admin before proceeding |
| T-04-07-05 | Information Disclosure | Orphaned MinIO objects after user deletion (SEC-09) | mitigate | delete_user_files logic collects all Document.object_key values and calls delete_object before DB delete |
| T-04-07-06 | Repudiation | Auth events not logged | mitigate | All 8 auth event types logged per D-13; audit log is append-only (no DELETE on audit_log table) |
| T-04-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed in this plan |
</threat_model>
<verification>
1. SEC-08 test: `cd backend && python -m pytest tests/test_security.py::test_credentials_enc_not_in_response -v`
2. SEC-09 test: `cd backend && python -m pytest tests/test_admin_api.py::test_delete_user_cleans_files -v`
3. PII check on audit writes: `grep -A5 "login_failed\|auth.login_failed" backend/api/auth.py | grep "email\|password"` — expect no match
4. Full suite: `cd backend && python -m pytest tests/ -v --no-header 2>&1 | grep -E "FAILED|ERROR"`
</verification>
<success_criteria>
- Audit log has entries for all D-13 event categories (auth, document, folder/share, admin)
- credentials_enc never appears in any serialized response — CloudConnectionOut model enforces this
- delete-user endpoint cleans MinIO objects before removing DB records
- Full pytest suite green — all existing tests pass
</success_criteria>
<output>
Create `.planning/phases/04-folders-sharing-quotas-document-ux/04-07-SUMMARY.md` when done.
</output>
@@ -0,0 +1,243 @@
---
phase: 04-folders-sharing-quotas-document-ux
plan: 08
type: execute
wave: 6
depends_on:
- "04-07"
files_modified:
- frontend/src/api/client.js
- frontend/src/stores/folders.js
- frontend/src/stores/documents.js
- frontend/src/router/index.js
autonomous: true
requirements:
- FOLD-01
- FOLD-02
- FOLD-03
- FOLD-04
- FOLD-05
- SHARE-01
- SHARE-02
- SHARE-03
- SHARE-04
- SHARE-05
- DOC-01
- DOC-02
must_haves:
truths:
- "All backend API endpoints have matching client.js wrapper functions"
- "useFoldersStore exposes state and actions for folder CRUD and navigation"
- "useDocumentsStore extended with search, sort, folderId, shareDocument, revokeShare, listShares"
- "Vue Router has routes for /folders/:folderId and /shared"
- "Access token injected via existing request() helper — no new auth logic added"
artifacts:
- path: "frontend/src/api/client.js"
provides: "listFolders, createFolder, renameFolder, deleteFolder, moveDocument, createShare, deleteShare, listShares, getSharedWithMe, getMyPreferences, updateMyPreferences, getDocumentContentUrl"
- path: "frontend/src/stores/folders.js"
provides: "useFoldersStore: folders, currentFolderId, breadcrumb, loading, error + folder CRUD actions"
- path: "frontend/src/stores/documents.js"
provides: "extended with folderId, searchQuery, sort, order state + share actions"
- path: "frontend/src/router/index.js"
provides: "/folders/:folderId route + /shared route"
key_links:
- from: "frontend/src/stores/folders.js"
to: "frontend/src/api/client.js"
via: "all folder CRUD actions call api.* functions"
pattern: "import.*api"
- from: "frontend/src/router/index.js"
to: "frontend/src/views/FolderView.vue"
via: "route /folders/:folderId component lazy-loaded"
pattern: "FolderView"
---
<objective>
Wire the frontend data layer: API client functions, Pinia stores, and router routes for all
Phase 4 backend endpoints. This plan creates no UI components — those are in plan 04-09.
Stores and API client are the contracts the UI components depend on.
Purpose: Establish frontend data contracts before writing view and component code.
Output: client.js additions + folders store + extended documents store + new routes.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/04-folders-sharing-quotas-document-ux/04-CONTEXT.md
@.planning/phases/04-folders-sharing-quotas-document-ux/04-PATTERNS.md
@.planning/phases/04-folders-sharing-quotas-document-ux/04-UI-SPEC.md
@frontend/src/api/client.js
@frontend/src/stores/documents.js
@frontend/src/stores/topics.js
@frontend/src/router/index.js
</context>
<interfaces>
From frontend/src/api/client.js: read actual file to confirm request() signature.
Pattern: all exported functions call the internal request() helper which injects
the access token from the auth store and handles 401 refresh.
From frontend/src/stores/topics.js: full defineStore pattern to replicate.
State refs: typed refs, loading, error. Actions: async try/catch/finally pattern.
From frontend/src/router/index.js: read to find the route array and lazy-import
pattern for view components (e.g., () => import('../views/SomeView.vue')).
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Add Phase 4 API functions to client.js and create folders store</name>
<files>frontend/src/api/client.js, frontend/src/stores/folders.js</files>
<read_first>
frontend/src/api/client.js — read the ENTIRE file; identify the request() helper signature; find the last exported function; note if any listFolders or share functions already exist
frontend/src/stores/topics.js — read ENTIRE file for the exact defineStore + ref + async action pattern
frontend/src/stores/documents.js — read lines 1-30 to confirm existing imports
</read_first>
<action>
Modify frontend/src/api/client.js: APPEND these named export functions after existing functions. Follow the existing named-export style exactly.
Folder API: listFolders(parentId = null) → GET /api/folders with optional ?parent_id= query param; createFolder(name, parentId = null) → POST /api/folders with JSON body {name, parent_id: parentId || null}; getFolder(folderId) → GET /api/folders/{folderId}; renameFolder(folderId, name) → PATCH /api/folders/{folderId} with JSON {name}; deleteFolder(folderId) → DELETE /api/folders/{folderId}; moveDocument(docId, folderId) → PATCH /api/documents/{docId}/folder with JSON {folder_id: folderId || null}.
Share API: createShare(docId, recipientHandle) → POST /api/shares with JSON {document_id: docId, recipient_handle: recipientHandle}; listShares(docId) → GET /api/shares?document_id={docId}; deleteShare(shareId) → DELETE /api/shares/{shareId}; getSharedWithMe() → GET /api/shares/received.
Preference API: getMyPreferences() → GET /api/auth/me/preferences; updateMyPreferences(payload) → PATCH /api/auth/me/preferences with JSON payload.
PDF proxy URL helper: getDocumentContentUrl(docId) → returns the string `/api/documents/${docId}/content` — pure string, no fetch. Used for iframe src or window.open.
For listDocuments (the existing function): read its current signature. If it does not already accept folderId, q, sort, order params (added in plan 04-03), update it to pass these as query params via URLSearchParams, including params only when non-null and non-empty.
Create frontend/src/stores/folders.js as a NEW file. Follow the exact useTopicsStore pattern.
Imports: `import { defineStore } from 'pinia'`, `import { ref } from 'vue'`, `import * as api from '../api/client.js'`.
State refs: folders (ref([])), currentFolderId (ref(null)), breadcrumb (ref([])), loading (ref(false)), error (ref(null)).
Actions:
- fetchFolders(parentId = null): calls api.listFolders(parentId); sets folders.value
- createFolder(name, parentId = null): calls api.createFolder(name, parentId); pushes result to folders.value
- renameFolder(folderId, name): calls api.renameFolder; updates matching entry in folders.value
- deleteFolder(folderId): calls api.deleteFolder; removes from folders.value
- navigateTo(folderId): sets currentFolderId.value = folderId; if folderId is not null, calls api.getFolder(folderId) and sets breadcrumb.value = response.breadcrumb; if folderId is null, sets breadcrumb.value = []
All actions follow the loading/error/finally pattern from topics store.
Export: `export const useFoldersStore = defineStore('folders', () => { ... return { folders, currentFolderId, breadcrumb, loading, error, fetchFolders, createFolder, renameFolder, deleteFolder, navigateTo } })`.
</action>
<verify>
Run: grep -n "listFolders\|createShare\|getMyPreferences\|getDocumentContentUrl\|getSharedWithMe" /Users/nik/Documents/Progamming/document_scanner/frontend/src/api/client.js
Expected: all five function names appear in the grep output.
Also: grep -n "useFoldersStore\|navigateTo\|currentFolderId" /Users/nik/Documents/Progamming/document_scanner/frontend/src/stores/folders.js
Expected: all three identifiers appear.
</verify>
<acceptance_criteria>
- frontend/src/api/client.js contains: listFolders, createFolder, getFolder, renameFolder, deleteFolder, moveDocument, createShare, listShares, deleteShare, getSharedWithMe, getMyPreferences, updateMyPreferences, getDocumentContentUrl (13 new functions — grep confirms each name)
- frontend/src/stores/folders.js exists and exports useFoldersStore
- useFoldersStore returns: folders, currentFolderId, breadcrumb, loading, error, fetchFolders, createFolder, renameFolder, deleteFolder, navigateTo
- listDocuments in client.js accepts folderId, q, sort, order params
- No existing API functions are modified (only appended to)
</acceptance_criteria>
<done>client.js has all Phase 4 API wrappers; folders store is created and exported.</done>
</task>
<task type="auto">
<name>Task 2: Extend documents store with folder/search/share actions + add Vue Router routes</name>
<files>frontend/src/stores/documents.js, frontend/src/router/index.js</files>
<read_first>
frontend/src/stores/documents.js — read the ENTIRE file; identify fetchDocuments signature; find existing actions (upload, remove, etc.); find the return statement at the end to know what to add to it
frontend/src/router/index.js — read the ENTIRE file; identify the routes array; find the lazy import pattern; note auth guard usage (requiresAuth meta, beforeEach hook) to replicate for new routes
</read_first>
<action>
Modify frontend/src/stores/documents.js:
Add new refs (insert after existing state refs, before existing actions):
- currentFolderId: ref(null)
- searchQuery: ref('')
- sortField: ref('date') — 'name' | 'date' | 'size'
- sortOrder: ref('desc') — 'asc' | 'desc'
Add debounced search watcher (RESEARCH.md Pattern 10 — no lodash):
After existing watch declarations (or before return), add:
let _searchTimer = null
watch(searchQuery, (newVal) => {
clearTimeout(_searchTimer)
if (newVal.length < 2) { fetchDocuments({ folderId: currentFolderId.value }); return }
_searchTimer = setTimeout(() => { fetchDocuments({ q: newVal, folderId: currentFolderId.value, sort: sortField.value, order: sortOrder.value }) }, 300)
})
Update fetchDocuments() to accept and pass {folderId, q, sort, order} params to api.listDocuments().
Add new actions (append before return statement):
- shareDocument(docId, recipientHandle): try { return await api.createShare(docId, recipientHandle) } catch (e) { throw e }
- revokeShare(shareId): try { await api.deleteShare(shareId) } catch (e) { throw e }
- listShares(docId): try { return await api.listShares(docId) } catch (e) { throw e }
Add new state/actions to the return statement: currentFolderId, searchQuery, sortField, sortOrder, shareDocument, revokeShare, listShares.
Modify frontend/src/router/index.js:
Add two new route objects to the routes array. Follow the exact lazy-import pattern from existing routes. Apply the same requiresAuth guard as existing protected routes.
Route 1: { path: '/folders/:folderId', name: 'folder', component: () => import('../views/FolderView.vue'), meta: { requiresAuth: true } }
Route 2: { path: '/shared', name: 'shared', component: () => import('../views/SharedView.vue'), meta: { requiresAuth: true } }
Note: FolderView.vue and SharedView.vue do not exist yet (created in plan 04-09). The router import is lazy-loaded so missing files do not cause import errors at router init — only at navigation time.
</action>
<verify>
Run: grep -n "currentFolderId\|searchQuery\|sortField\|shareDocument\|revokeShare" /Users/nik/Documents/Progamming/document_scanner/frontend/src/stores/documents.js
Expected: all five identifiers appear.
Also: grep -n "FolderView\|SharedView\|/folders\|/shared" /Users/nik/Documents/Progamming/document_scanner/frontend/src/router/index.js
Expected: both view names and both paths appear.
</verify>
<acceptance_criteria>
- documents.js contains: currentFolderId ref, searchQuery ref, sortField ref, sortOrder ref
- documents.js contains debounced watch on searchQuery with 300ms timeout and 2-char minimum
- documents.js return statement includes: currentFolderId, searchQuery, sortField, sortOrder, shareDocument, revokeShare, listShares
- router/index.js contains /folders/:folderId route and /shared route
- Both new routes use lazy component import and requiresAuth meta (grep confirms)
- No existing routes modified (only new routes appended)
</acceptance_criteria>
<done>Documents store extended with folder/search/share state; router has two new protected routes.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Frontend store → backend API | All requests go through request() helper which injects JWT; no auth logic duplicated in new stores |
| Vue Router → view components | New routes use same requiresAuth guard as existing protected routes |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-08-01 | Broken Access Control | /folders/:folderId route without auth guard | mitigate | requiresAuth: true meta applied to both new routes; same router beforeEach guard as existing protected routes |
| T-04-08-02 | Information Disclosure | Access token stored in localStorage via new store | accept | All token handling is in existing auth store + request() helper; folders store and documents store extension do not touch auth state |
| T-04-08-03 | Tampering | Debounced search fires before 2-char minimum | mitigate | searchQuery watch checks newVal.length < 2 before firing API call; short inputs restore full list without API call |
| T-04-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed in this plan |
</threat_model>
<verification>
1. API functions: grep -c "export function" /Users/nik/Documents/Progamming/document_scanner/frontend/src/api/client.js
2. Folders store: grep -n "useFoldersStore\|navigateTo\|breadcrumb" /Users/nik/Documents/Progamming/document_scanner/frontend/src/stores/folders.js
3. Documents store: grep -n "currentFolderId\|shareDocument\|searchQuery" /Users/nik/Documents/Progamming/document_scanner/frontend/src/stores/documents.js
4. Router: grep -n "FolderView\|SharedView" /Users/nik/Documents/Progamming/document_scanner/frontend/src/router/index.js
</verification>
<success_criteria>
- All 13 new API functions exist in client.js (verified by grep)
- useFoldersStore is created and exports folder CRUD actions + navigation state
- documents store return includes all new state/action exports
- Vue Router has /folders/:folderId and /shared routes with requiresAuth guard
</success_criteria>
<output>
Create `.planning/phases/04-folders-sharing-quotas-document-ux/04-08-SUMMARY.md` when done.
</output>
@@ -0,0 +1,349 @@
---
phase: 04-folders-sharing-quotas-document-ux
plan: 09
type: execute
wave: 7
depends_on:
- "04-08"
files_modified:
- frontend/src/views/FolderView.vue
- frontend/src/views/SharedView.vue
- frontend/src/views/SettingsView.vue
- frontend/src/views/DocumentView.vue
- frontend/src/views/HomeView.vue
- frontend/src/views/AdminView.vue
- frontend/src/components/folders/FolderRow.vue
- frontend/src/components/folders/FolderBreadcrumb.vue
- frontend/src/components/folders/FolderDeleteModal.vue
- frontend/src/components/sharing/ShareModal.vue
- frontend/src/components/documents/DocumentPreviewModal.vue
- frontend/src/components/documents/SearchBar.vue
- frontend/src/components/documents/SortControls.vue
- frontend/src/components/admin/AuditLogTab.vue
- frontend/src/components/layout/AppSidebar.vue
- frontend/src/components/documents/DocumentCard.vue
- frontend/src/api/client.js
autonomous: false
requirements:
- FOLD-01
- FOLD-02
- FOLD-03
- FOLD-04
- FOLD-05
- SHARE-01
- SHARE-02
- SHARE-03
- SHARE-04
- SHARE-05
- ADMIN-06
- DOC-01
- DOC-02
must_haves:
truths:
- "Folders section visible in AppSidebar above topics; top-level folders clickable to navigate"
- "Shared with me entry in AppSidebar above folders; shows count badge when non-empty"
- "Document list shows SearchBar and SortControls above; sort persists until changed"
- "DocumentCard shows share button on hover; shared indicator pill when doc.share_count > 0"
- "Share modal: handle input, Share button, current recipient list with Revoke button"
- "FolderView renders sub-folders as FolderRow components + breadcrumb + document list"
- "FolderDeleteModal shows document count and requires explicit confirmation before delete"
- "PDF documents open in DocumentPreviewModal (in_app mode) or new tab (new_tab mode)"
- "SettingsView Document Preferences card shows pdf_open_mode radio; auto-saves on change"
- "Admin AuditLog tab is accessible from AdminView; filters and CSV export work"
artifacts:
- path: "frontend/src/views/FolderView.vue"
provides: "Folder contents: sub-folder rows + breadcrumb + document list + new subfolder inline input"
- path: "frontend/src/views/SharedView.vue"
provides: "Shared with me virtual folder view"
- path: "frontend/src/components/folders/FolderRow.vue"
provides: "Folder row with three-dot menu (rename inline, delete folder) per UI-SPEC"
- path: "frontend/src/components/folders/FolderBreadcrumb.vue"
provides: "Breadcrumb nav with truncation at depth > 4; each segment clickable"
- path: "frontend/src/components/folders/FolderDeleteModal.vue"
provides: "Destructive confirmation modal with document count"
- path: "frontend/src/components/sharing/ShareModal.vue"
provides: "Share by handle + current recipients list + revoke"
- path: "frontend/src/components/documents/DocumentPreviewModal.vue"
provides: "In-app PDF preview via iframe"
- path: "frontend/src/components/documents/SearchBar.vue"
provides: "Debounced search input; clears on Escape"
- path: "frontend/src/components/documents/SortControls.vue"
provides: "Name / Date / Size sort toggle buttons"
- path: "frontend/src/components/admin/AuditLogTab.vue"
provides: "Paginated audit log table with date/user/action filters + CSV export button"
key_links:
- from: "frontend/src/views/FolderView.vue"
to: "frontend/src/stores/folders.js"
via: "useFoldersStore for folder data and navigation"
pattern: "useFoldersStore"
- from: "frontend/src/components/sharing/ShareModal.vue"
to: "frontend/src/stores/documents.js"
via: "shareDocument, revokeShare, listShares actions"
pattern: "useDocumentsStore"
- from: "frontend/src/components/layout/AppSidebar.vue"
to: "frontend/src/stores/folders.js"
via: "top-level folder list + currentFolderId for active state"
pattern: "useFoldersStore"
---
<objective>
Build all Phase 4 Vue components and wire them into the existing views. This is the final
frontend plan — all stores, API client, and routes are established by plan 04-08.
Purpose: Deliver the complete document management UX visible to users and admins.
Output: 10 new components + 6 modified views following 04-UI-SPEC.md exactly.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/04-folders-sharing-quotas-document-ux/04-UI-SPEC.md
@.planning/phases/04-folders-sharing-quotas-document-ux/04-CONTEXT.md
@.planning/phases/04-folders-sharing-quotas-document-ux/04-PATTERNS.md
@frontend/src/components/layout/AppSidebar.vue
@frontend/src/components/documents/DocumentCard.vue
@frontend/src/views/HomeView.vue
@frontend/src/views/AdminView.vue
@frontend/src/views/SettingsView.vue
@frontend/src/views/DocumentView.vue
@frontend/src/components/admin/AdminUsersTab.vue
</context>
<interfaces>
From 04-UI-SPEC.md — exact Tailwind classes for each new component are specified.
Executor must read 04-UI-SPEC.md fully before writing any component template.
Key design tokens from 04-UI-SPEC.md:
- Modal overlay: `fixed inset-0 bg-black/40 flex items-center justify-center z-50`
- Modal panel: `bg-white rounded-2xl shadow-xl p-6 max-w-md w-full mx-4`
- Primary button: `bg-indigo-600 hover:bg-indigo-700 text-white text-sm px-4 py-2 rounded-lg`
- Destructive button: `bg-red-600 hover:bg-red-700 text-white text-sm font-semibold px-4 py-2 rounded-lg min-h-[44px]`
- FolderRow: `flex items-center gap-3 px-4 py-3 bg-white border border-gray-200 rounded-xl hover:border-indigo-300 hover:shadow-sm transition-all cursor-pointer`
- Share badge (SHARE-05): `bg-indigo-50 text-indigo-600 text-xs font-medium px-2 py-1 rounded-full`
- Shared with me icon: `bg-purple-50 text-purple-500` (only purple UI element)
- Section label: `text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1`
- Accessibility: all icon-only buttons have aria-label; modals have role="dialog" aria-modal="true"
Copywriting (from 04-UI-SPEC.md):
- Share modal title: "Share document"
- Handle input placeholder: "Enter username handle"
- Share submit: "Share document"
- Empty share state: "Not shared with anyone yet."
- User not found: "User not found. Check the handle and try again."
- Non-empty folder delete body: "This folder contains {N} document{s}. Deleting it will permanently delete all documents inside. This cannot be undone."
- Folder delete confirm: "Delete folder and documents"
- Folder delete dismiss: "Keep folder"
- Search placeholder: "Search documents..."
- PDF pref section heading: "Document Preferences"
- PDF open modes: "Open documents in-app" / "Open documents in new tab"
- Audit log tab label: "Audit Log"
- Sort labels: "Name" / "Date" / "Size"
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Create new components (FolderRow, FolderBreadcrumb, FolderDeleteModal, ShareModal, DocumentPreviewModal, SearchBar, SortControls, AuditLogTab)</name>
<files>
frontend/src/components/folders/FolderRow.vue,
frontend/src/components/folders/FolderBreadcrumb.vue,
frontend/src/components/folders/FolderDeleteModal.vue,
frontend/src/components/sharing/ShareModal.vue,
frontend/src/components/documents/DocumentPreviewModal.vue,
frontend/src/components/documents/SearchBar.vue,
frontend/src/components/documents/SortControls.vue,
frontend/src/components/admin/AuditLogTab.vue
</files>
<read_first>
frontend/src/components/admin/AdminUsersTab.vue — read the ENTIRE file; extract the form pattern (reactive form state, loading/error refs, onMounted fetch, table with filters, action buttons, inline confirm pattern); use this as the template for ShareModal and AuditLogTab
frontend/src/components/ui/ConfirmBlock.vue — read fully for the confirm/cancel button pattern
frontend/src/components/documents/DocumentCard.vue — read for the card layout pattern and SVG icon style
frontend/src/views/AdminView.vue — read to understand how tabs are currently implemented; extract the tab switch pattern
</read_first>
<action>
Create each component file in the exact directory specified. All use Vue 3 Options API with Composition API (script setup) — follow the same pattern as existing components (read the existing files to confirm which style is used and replicate it exactly).
FolderRow.vue (per UI-SPEC FolderRow section):
- Props: folder {id, name, doc_count}, onNavigate (function), onRename (function), onDelete (function)
- Template: flex container with folder icon (bg-gray-100), folder name + doc count, three-dot menu button
- Three-dot menu: dropdown with "Rename" and "Delete folder" items; closes on outside click (use @click.outside or a boolean ref + document listener)
- Rename mode: toggles to inline input pre-filled with current name; Enter=save, Escape=cancel, empty=rejected with inline error
- No modal for rename — inline text input replaces the name display
FolderBreadcrumb.vue (per UI-SPEC FolderBreadcrumb section):
- Props: segments Array of {id, name}
- Emits: navigate(folderId) — folderId is null for root
- Computed: visibleSegments — if segments.length > 4, return [segments[0], {id: null, name: '...'}, ...segments.slice(-2)]; otherwise return segments as-is
- Template: nav with aria-label="Folder navigation", ol list structure (accessibility), separator chevron SVG between segments
- All segments except the last are clickable (emit navigate); last segment is non-clickable (current folder)
- Wrap with nav aria-label="Folder navigation" and ol per UI-SPEC accessibility contract
FolderDeleteModal.vue (per UI-SPEC FolderDeleteModal section):
- Props: folder {id, name, doc_count}, onConfirm (function), onCancel (function)
- Template: fixed overlay, warning icon (red exclamation), heading "Delete folder?", body text with doc_count interpolated, two buttons: "Keep folder" (neutral) and "Delete folder and documents" (red, min-h-[44px])
- Emits: confirm, cancel
- Accessibility: role="dialog" aria-modal="true" aria-labelledby="modal-title-id"
ShareModal.vue (per UI-SPEC ShareModal section):
- Props: doc {id, filename}
- Emits: close
- Script state: handle ref(''), submitting ref(false), error ref(null), shares ref([])
- onMounted: load current shares via useDocumentsStore().listShares(props.doc.id); set shares.value
- submitShare(): calls store.shareDocument(props.doc.id, handle.value); clears handle on success; updates shares list; shows error for 404/409
- revokeShare(shareId): calls store.revokeShare(shareId); removes from shares array optimistically; on error re-adds
- Template: overlay, panel, title "Share document", handle input + Share document button, separator, recipient list OR empty state, close X button
- Error messages per UI-SPEC copywriting (User not found, already shared)
- Accessibility: role="dialog" aria-modal="true"; close button aria-label="Close"
DocumentPreviewModal.vue (per UI-SPEC DocumentPreviewModal section):
- Props: doc {id, filename}
- Emits: close
- Script: proxyUrl computed = `/api/documents/${props.doc.id}/content`
- Template: fixed overlay (bg-black/60 z-50 flex flex-col), header bar with doc name + close button, flex-1 iframe filling the remaining height
- Close on overlay click (check event.target === overlay element) and Escape key (keydown listener on mounted/unmounted)
- Accessibility: role="dialog" aria-modal="true" aria-label="Document preview"
SearchBar.vue (per UI-SPEC SearchBar section):
- Props: modelValue (String), placeholder defaults to "Search documents..."
- Emits: update:modelValue
- Template: input type="search", role="search" on wrapper div, aria-label="Search documents"
- Clears on Escape key (@keydown.escape)
- Width: w-56
SortControls.vue (per UI-SPEC SortControls section):
- Props: sort ('name'|'date'|'size'), order ('asc'|'desc')
- Emits: change({sort, order})
- Template: three text buttons; active sort has `text-indigo-600 font-semibold` class; direction indicator appended (arrow up/down unicode or ↑/↓ text)
- Clicking the already-active sort toggles order; clicking a different sort switches to it with desc order
- aria-pressed="true" on active button; aria-label includes direction (e.g., "Sort by name, ascending")
AuditLogTab.vue (per UI-SPEC AuditLogTab section):
- Script state: entries ref([]), total ref(0), page ref(1), loading ref(false), filters reactive({start:'', end:'', user_id:'', event_type:''})
- onMounted: fetchLog()
- fetchLog(): calls api.adminListAuditLog({...filters, page: page.value}) from api/client.js (add this function — GET /api/admin/audit-log with query params)
- exportCsv(): sets window.location.href to `/api/admin/audit-log/export?format=csv&${new URLSearchParams(activeFilters)}` — triggers browser download
- Template: filter bar (date inputs, user dropdown placeholder, event_type dropdown, Apply button, Export CSV button), paginated table (Timestamp | User | Action Type | IP Address), Previous/Next pagination, empty state
- Action type pill badge colors per UI-SPEC: auth=bg-blue-50 text-blue-600, document=bg-gray-100 text-gray-600, folder/share=bg-purple-50 text-purple-600, admin=bg-amber-50 text-amber-700
- Table headers with scope="col"; timestamp cell uses font-mono text-xs
Also add adminListAuditLog function to frontend/src/api/client.js (append): GET /api/admin/audit-log with query params start, end, user_id, event_type, page, per_page.
</action>
<verify>
Run: find /Users/nik/Documents/Progamming/document_scanner/frontend/src/components/folders /Users/nik/Documents/Progamming/document_scanner/frontend/src/components/sharing -name "*.vue" 2>/dev/null
Expected: FolderRow.vue, FolderBreadcrumb.vue, FolderDeleteModal.vue appear; ShareModal.vue appears.
Also: find /Users/nik/Documents/Progamming/document_scanner/frontend/src/components/documents /Users/nik/Documents/Progamming/document_scanner/frontend/src/components/admin -name "*.vue" | sort
Expected: DocumentPreviewModal.vue, SearchBar.vue, SortControls.vue, AuditLogTab.vue appear.
</verify>
<acceptance_criteria>
- All 8 component files exist in their respective directories (verify with find)
- FolderBreadcrumb.vue has nav aria-label="Folder navigation" and ol list structure (grep: `aria-label.*Folder navigation` and `ol` in template)
- ShareModal.vue has role="dialog" and aria-modal="true" (grep: `role="dialog"` and `aria-modal="true"`)
- FolderDeleteModal.vue contains "Keep folder" and "Delete folder and documents" button text (grep)
- DocumentPreviewModal.vue contains iframe with :src bound to proxyUrl (grep: `iframe` and `proxyUrl`)
- SearchBar.vue has @keydown.escape handler (grep: `keydown.escape` or `keydown.esc`)
- SortControls.vue has aria-pressed (grep: `aria-pressed`)
- AuditLogTab.vue has window.location.href for CSV export (grep: `window.location.href`)
- frontend/src/api/client.js contains adminListAuditLog function (grep)
</acceptance_criteria>
<done>All new components created following UI-SPEC; accessibility contracts met; CSV export uses window.location.href.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
All frontend views and components for Phase 4:
- AppSidebar.vue extended with "Shared with me" entry (purple icon, count badge) and Folders section with "New folder" CTA; folder links navigate to /folders/:id
- DocumentCard.vue extended with share button (hover, opacity-0 group-hover:opacity-100) and shared indicator pill
- HomeView.vue wired with SearchBar + SortControls above document list; folder-aware fetchDocuments
- FolderView.vue: breadcrumb + sub-folder FolderRow list + document list + inline new-folder input
- SharedView.vue: filtered document list from GET /api/shares/received; empty state
- DocumentView.vue: PDF click triggers DocumentPreviewModal (in_app) or window.open (new_tab) based on pdf_open_mode preference
- SettingsView.vue: Document Preferences card with radio buttons auto-saved via PATCH /api/auth/me/preferences
- AdminView.vue: AuditLog tab added alongside existing tabs
This checkpoint also triggers human verification of all visual and interactive Phase 4 features before the phase is marked complete.
</what-built>
<how-to-verify>
Before this checkpoint is reached, the executor MUST complete the following modifications:
AppSidebar.vue: read the entire file; add "Shared with me" entry (above folders section, inline inbox icon, purple bg-purple-50 text-purple-500 icon container, count badge when sharedCount > 0); add Folders section below (section label "FOLDERS", "New folder" button, top-level folder list from useFoldersStore); import useFoldersStore; add sharedCount computed using getSharedWithMe() or a dedicated ref.
DocumentCard.vue: read the entire file; add `group` class to outer container; add share button (opacity-0 group-hover:opacity-100 transition-opacity, aria-label="Share document", min-h-[44px] min-w-[44px]); add ShareModal component import and v-if=showShareModal; add shared indicator pill below metadata line if doc.share_count > 0.
HomeView.vue: read the entire file; add SearchBar component above document list with v-model=docsStore.searchQuery; add SortControls with sort=docsStore.sortField, order=docsStore.sortOrder, @change handler; add useFoldersStore; update onMounted to also call foldersStore.fetchFolders(null).
FolderView.vue: create new file; uses useFoldersStore and useDocumentsStore; on route param folderId change, calls foldersStore.navigateTo(folderId) and docsStore.fetchDocuments({folderId}); renders FolderBreadcrumb + FolderRow list + document list + inline new-folder input.
SharedView.vue: create new file; on mounted calls api.getSharedWithMe(); renders document list using same DocumentCard layout but with owner handle shown; empty state per UI-SPEC copywriting.
DocumentView.vue: read the entire file; find where PDF documents would be clicked or previewed; add logic — if current_user.pdf_open_mode === 'in_app', show DocumentPreviewModal; if 'new_tab', call window.open(getDocumentContentUrl(doc.id), '_blank'); load preference via api.getMyPreferences() on mount.
SettingsView.vue: read the entire file; add Document Preferences section after existing sections; radio group with v-model=pdfOpenMode; watch pdfOpenMode to auto-call api.updateMyPreferences.
AdminView.vue: read the entire file; add "Audit Log" tab button alongside existing tabs; add AuditLogTab component conditional display.
AFTER completing all view modifications, start docker compose up and navigate to the application:
1. Navigate to the home page — verify Folders section and "Shared with me" appear in sidebar
2. Create a folder via sidebar "New folder" — verify it appears in the folder list
3. Navigate into the folder — verify FolderView loads with breadcrumb showing the folder name
4. Upload a document (use existing upload flow) — verify it appears in the current folder
5. Click the share button on a DocumentCard — verify ShareModal opens with handle input and empty state
6. In ShareModal, type a non-existent handle — verify "User not found" error appears
7. Delete a non-empty folder — verify FolderDeleteModal shows the document count and correct copy
8. Navigate to Settings — verify Document Preferences card with radio buttons appears
9. Navigate to Admin — verify Audit Log tab appears; click Apply filters — verify table loads
10. Click Export CSV in audit log — verify file downloads
11. Open a PDF document (set preference to in_app first) — verify DocumentPreviewModal with iframe appears
</how-to-verify>
<resume-signal>Type "approved" after verifying all 11 checkpoints pass. Describe any issues for the executor to fix before approving.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → /api/documents/{id}/content | All document content access goes through proxy; iframe src is the proxy URL, not a presigned URL |
| Share modal → shares API | Recipient handle is user-supplied text; backend validates existence; no autocomplete/search that could enumerate users |
| Admin audit log export | window.location.href triggers a download authenticated by the existing httpOnly cookie; no token in URL |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-09-01 | Information Disclosure | iframe src reveals presigned URL in browser | mitigate | DocumentPreviewModal iframe src = `/api/documents/${docId}/content` (the proxy URL, never presigned); Content-Disposition: inline drives rendering |
| T-04-09-02 | Information Disclosure | Share modal autocomplete reveals user handles | mitigate | D-04: exact handle input only; no autocomplete API; 404 response reveals only that the handle does not exist |
| T-04-09-03 | Broken Access Control | Audit log tab visible to regular users in AdminView | mitigate | AuditLogTab only visible inside AdminView which is already guarded by admin-only route; backend enforces get_current_admin |
| T-04-09-04 | Information Disclosure | window.location.href for CSV export embeds sensitive params in URL | accept | Params are only filter values (dates, event types, user IDs) — not auth tokens; the request is authenticated via httpOnly cookie already set |
| T-04-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed in this plan |
</threat_model>
<verification>
1. Component existence: find frontend/src/components/folders frontend/src/components/sharing -name "*.vue"
2. Security: grep -rn "presigned" frontend/src/components/documents/DocumentPreviewModal.vue — expect zero matches
3. Accessibility: grep -n "role=\"dialog\"\|aria-modal" frontend/src/components/sharing/ShareModal.vue frontend/src/components/folders/FolderDeleteModal.vue
4. Share IDOR prevention: grep -n "aria-label" frontend/src/components/documents/DocumentCard.vue — share button must have aria-label
5. Human checkpoint: all 11 scenarios verified by the developer in a running instance
</verification>
<success_criteria>
- All 10 new component files exist
- AppSidebar has "Shared with me" (purple icon) and Folders section
- DocumentCard has share button (hover-reveal) and shared indicator pill
- DocumentPreviewModal uses proxy URL (/api/documents/{id}/content), not presigned URL
- AuditLogTab is accessible only inside AdminView (protected route)
- Developer approves all 11 manual verification checkpoints
</success_criteria>
<output>
Create `.planning/phases/04-folders-sharing-quotas-document-ux/04-09-SUMMARY.md` when done.
</output>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
---
phase: 4
slug: folders-sharing-quotas-document-ux
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-05-25
---
# Phase 4 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | pytest + pytest-asyncio (already configured) |
| **Config file** | `backend/pytest.ini` or `backend/pyproject.toml` |
| **Quick run command** | `pytest backend/tests/test_folders.py backend/tests/test_shares.py backend/tests/test_audit.py backend/tests/test_documents.py -x` |
| **Full suite command** | `cd backend && pytest -v` |
| **Estimated runtime** | ~60 seconds |
---
## Sampling Rate
- **After every task commit:** Run `pytest backend/tests/test_folders.py backend/tests/test_shares.py backend/tests/test_audit.py backend/tests/test_documents.py -x`
- **After every plan wave:** Run `cd backend && pytest -v`
- **Before `/gsd:verify-work`:** Full suite must be green
- **Max feedback latency:** 60 seconds
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 4-01-01 | 01 | 1 | FOLD-01..05, SHARE-01..05, DOC-02, ADMIN-06, SEC-08, SEC-09 | T-4-00 / — | Wave 0 test stubs — all xfail(strict=False) | unit | `pytest backend/tests/ -x` | ❌ W0 | ⬜ pending |
| 4-02-01 | 02 | 2 | — | — | Alembic 0004 migration adds pdf_open_mode + GIN index; audit-logs bucket created | integration | `pytest backend/tests/test_migration.py -x -m integration` | ❌ W0 | ⬜ pending |
| 4-03-01 | 03 | 2 | FOLD-01 | T-4-01 | Create folder returns 201; duplicate name returns 409 | integration | `pytest backend/tests/test_folders.py::test_create_folder -x` | ❌ W0 | ⬜ pending |
| 4-03-02 | 03 | 2 | FOLD-01 | T-4-01 | Rename folder returns 200; wrong owner returns 404 | integration | `pytest backend/tests/test_folders.py::test_rename_folder -x` | ❌ W0 | ⬜ pending |
| 4-03-03 | 03 | 2 | FOLD-01 | T-4-01 | Delete empty folder returns 204 | integration | `pytest backend/tests/test_folders.py::test_delete_empty_folder -x` | ❌ W0 | ⬜ pending |
| 4-03-04 | 03 | 2 | FOLD-01, FOLD-02 | T-4-01 | Delete non-empty folder cascade-deletes all docs; quota decrements | integration | `pytest backend/tests/test_folders.py::test_delete_folder_cascade -x` | ❌ W0 | ⬜ pending |
| 4-03-05 | 03 | 2 | FOLD-02 | T-4-04 | Move document — ownership assertion on both doc and target folder (404) | integration | `pytest backend/tests/test_folders.py::test_move_wrong_owner_404 -x` | ❌ W0 | ⬜ pending |
| 4-03-06 | 03 | 2 | FOLD-03 | — | Breadcrumb path returned from folder endpoint | unit | `pytest backend/tests/test_folders.py::test_breadcrumb_path -x` | ❌ W0 | ⬜ pending |
| 4-03-07 | 03 | 2 | FOLD-04 | — | Document list sort by name/date/size returns correctly ordered results | integration | `pytest backend/tests/test_folders.py::test_document_sort -x` | ❌ W0 | ⬜ pending |
| 4-03-08 | 03 | 2 | FOLD-05 | T-4-05 | tsvector search returns matching docs; does not return other users' docs | integration (PostgreSQL) | `pytest backend/tests/test_folders.py::test_fts_search -x -m integration` | ❌ W0 | ⬜ pending |
| 4-04-01 | 04 | 3 | SHARE-01 | T-4-02 | Share by handle — success; handle not found returns 404 | integration | `pytest backend/tests/test_shares.py::test_share_success -x` | ❌ W0 | ⬜ pending |
| 4-04-02 | 04 | 3 | SHARE-02 | T-4-02 | Shared doc appears in recipient virtual folder; zero quota charged | integration | `pytest backend/tests/test_shares.py::test_shared_with_me -x` | ❌ W0 | ⬜ pending |
| 4-04-03 | 04 | 3 | SHARE-04 | T-4-02 | Revoke share — immediate; recipient can no longer access | integration | `pytest backend/tests/test_shares.py::test_revoke_share -x` | ❌ W0 | ⬜ pending |
| 4-04-04 | 04 | 3 | SHARE-01..04 | T-4-02 | Share IDOR — wrong owner cannot revoke (404) | security (negative) | `pytest backend/tests/test_shares.py::test_share_revoke_wrong_owner_404 -x` | ❌ W0 | ⬜ pending |
| 4-05-01 | 05 | 3 | DOC-02 | T-4-03 | PDF proxy streams bytes; no presigned URL in response; Content-Disposition: inline | integration | `pytest backend/tests/test_documents.py::test_content_stream_200 -x` | ❌ W0 | ⬜ pending |
| 4-05-02 | 05 | 3 | DOC-02 | T-4-03 | Range header → 206 with Content-Range header | integration | `pytest backend/tests/test_documents.py::test_content_stream_206_range -x` | ❌ W0 | ⬜ pending |
| 4-05-03 | 05 | 3 | DOC-02 | T-4-03 | Admin blocked from proxy (403) | security (negative) | `pytest backend/tests/test_documents.py::test_content_stream_admin_403 -x` | ❌ W0 | ⬜ pending |
| 4-05-04 | 05 | 3 | DOC-02 | T-4-03 | No presigned URL generated or returned in proxy response | security (negative) | `pytest backend/tests/test_documents.py::test_content_stream_no_presigned_url -x` | ❌ W0 | ⬜ pending |
| 4-06-01 | 06 | 4 | ADMIN-06 | T-4-06 | Audit log viewer returns paginated entries; filters work | integration | `pytest backend/tests/test_audit.py::test_audit_log_viewer -x` | ❌ W0 | ⬜ pending |
| 4-06-02 | 06 | 4 | ADMIN-06 | T-4-06 | Audit log entries contain no document content, filename, or extracted_text | security (negative) | `pytest backend/tests/test_audit.py::test_audit_log_no_doc_content -x` | ❌ W0 | ⬜ pending |
| 4-06-03 | 06 | 4 | ADMIN-06 | T-4-06 | Regular user cannot access audit log (403) | security (negative) | `pytest backend/tests/test_audit.py::test_audit_log_regular_user_403 -x` | ❌ W0 | ⬜ pending |
| 4-07-01 | 07 | 4 | SEC-08 | T-4-07 | credentials_enc absent from all API responses | security (negative) | `pytest backend/tests/test_security.py::test_credentials_enc_not_in_response -x` | ❌ W0 | ⬜ pending |
| 4-07-02 | 07 | 4 | SEC-09 | T-4-08 | Admin delete user triggers delete_user_files() before DB removal | integration | `pytest backend/tests/test_admin_api.py::test_delete_user_cleans_files -x` | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `backend/tests/test_folders.py` — stubs for FOLD-01..05
- [ ] `backend/tests/test_shares.py` — stubs for SHARE-01..05 + IDOR security tests
- [ ] `backend/tests/test_audit.py` — stubs for ADMIN-06 + no-doc-content security tests
- [ ] `backend/tests/test_documents.py` — add proxy test stubs (test_content_stream_*) to existing file
- [ ] `backend/tests/test_security.py` — add SEC-08, SEC-09 test stubs (or in test_admin_api.py)
- [ ] Shared fixtures: `auth_user`, `admin_user`, `mock_minio` already established in Phase 3 conftest
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Breadcrumb ellipsis truncation at depth > 4 | FOLD-03 | Visual rendering; depth truncation requires human verification | Create nested folder chain > 4 levels deep; verify breadcrumb shows first + "..." + last 2 segments |
| PDF in-app modal rendering (iframe) | DOC-02, D-10 | Browser rendering; cannot be asserted in pytest | Set preference to `in_app`; click document; verify PDF opens in modal iframe |
| PDF new-tab opening | D-10 | Browser window.open behavior | Set preference to `new_tab`; click document; verify PDF opens in new tab |
| Share modal UX — handle input, share list, revoke | SHARE-01..04, D-05 | Vue component interaction; visual layout | Open share modal; enter handle; verify share appears in list; click Revoke; verify removal |
| Admin audit log CSV download | ADMIN-06, D-16 | File download via StreamingResponse | As admin; click CSV export; verify file downloads with correct columns; verify no doc content |
| Daily Celery beat audit export to MinIO | D-17 | Celery beat scheduling not testable without live Redis + MinIO + time passage | Trigger task manually via Celery CLI; verify CSV uploaded to `audit-logs` MinIO bucket |
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 60s
- [ ] `nyquist_compliant: true` set in frontmatter
**Approval:** pending