Files
Business-Management/backend/CLAUDE.md
T
curo1305 cfec3bb906 feat: Phase 4+5 — admin storage UI, backend proxy, CLAUDE.md enforcement
- backend/app/routers/storage_config.py: 5 admin-only endpoints proxying
  storage-service config + migration API (GET/PATCH/POST/DELETE)
- backend/app/main.py: register storage_config router
- frontend/src/api/client.ts: StorageStatus, MigrationStatus,
  StorageBackendConfig interfaces + 5 API functions
- frontend/src/pages/StorageAdminPage.tsx: full admin UI — backend health
  dot, driver selector (local/S3/WebDAV), conditional credential fields,
  Test & Migrate button, live 2s-poll migration progress bar, Cancel
- frontend/src/App.tsx: /admin/storage route (AdminRoute guard)
- CLAUDE.md: storage enforcement rule, updated Docker tables (6 services,
  3 volumes), §20 in merge checklist
- backend/CLAUDE.md, frontend/CLAUDE.md, doc-service/CLAUDE.md,
  ai-service/CLAUDE.md: updated to reflect storage-service integration
- tests/ALL_TESTS.md + tests/storage-service_tests.md: §20 (20 tests)
- backend/STATUS.md, frontend/STATUS.md: updated with new endpoints/routes
- changelog/2026-04-20_storage-service.md: full change log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:13:05 +02:00

16 KiB
Raw Blame History

backend — Claude context

FastAPI async gateway, port 8000 (internal). Handles auth, user/group management, settings, and proxies document/category requests to doc-service:8001. See root CLAUDE.md for architecture, Docker, and project-wide workflows.


Commands

All commands run inside Docker — never on the host.

Migrations

docker compose exec backend alembic revision --autogenerate -m "describe change"
docker compose exec backend alembic upgrade head
docker compose exec backend alembic downgrade -1

Lint

docker compose exec backend ruff check . && ruff format .

File & Folder Tree

backend/
├── app/
│   ├── main.py                        ← App factory, router registration, lifespan (health loop)
│   ├── database.py                    ← AsyncEngine, AsyncSessionLocal, Base
│   ├── deps.py                        ← get_current_user, get_current_admin, get_service_admin(id), check_plugin_access (also get_user_groups in doc-service)
│   ├── core/
│   │   ├── config.py                  ← All settings via pydantic-settings (reads .env)
│   │   ├── security.py                ← JWT sign/verify (RS256), bcrypt hash/verify
│   │   ├── sanitize.py                ← Input sanitization helpers (see Security Standards)
│   │   ├── app_config.py              ← Per-service config load/save via storage-service; theme files in config/themes/{id}.json
│   │   └── config_storage.py          ← Thin async HTTP helpers: read_json/write_json/delete_key/list_keys → storage-service config bucket
│   ├── models/
│   │   ├── __init__.py                ← Imports all models (required for Alembic autogenerate)
│   │   ├── user.py                    ← User model
│   │   ├── profile.py                 ← Profile model
│   │   └── group.py                   ← Group, GroupMembership models
│   ├── schemas/
│   │   ├── user.py                    ← UserCreate/Out, Token, DashboardPrefsOut/Update
│   │   ├── profile.py                 ← ProfileRead, ProfileUpdate
│   │   └── group.py                   ← GroupCreate/Update/Out/DetailOut, GroupMemberOut
│   ├── routers/
│   │   ├── auth.py                    ← POST /register, POST /login
│   │   ├── users.py                   ← GET /me, GET+PATCH /me/preferences, PATCH /me/color-mode, GET /me/groups
│   │   ├── profile.py                 ← GET+PUT /me (profile)
│   │   ├── admin.py                   ← User admin CRUD (admin-only)
│   │   ├── groups.py                  ← Group CRUD + member management (admin-only)
│   │   ├── settings.py                ← AI, doc limits, system prompts, appearance, themes (admin-only)
│   │   ├── services.py                ← GET /services (health status)
│   │   ├── plugins.py                 ← Generic plugin proxy (GET/PATCH /api/plugins/*)
│   │   ├── categories_proxy.py        ← Transparent proxy → doc-service /categories/*
│   │   ├── documents_proxy.py         ← Transparent proxy → doc-service /documents/*
│   │   └── storage_config.py          ← Admin proxy → storage-service config + migration endpoints
│   └── services/
│       ├── service_health.py          ← Background 30s health-check loop; caches /plugin/manifest per service
│       └── group_bootstrap.py         ← Ensures {service-id}-admin group exists for every registered service at startup
├── alembic/
│   ├── env.py                         ← Async migration runner
│   └── versions/                      ← Migration chain (see Database Models)
├── scripts/seed.py                    ← Seed test user
├── Dockerfile                         ← python:3.12-slim, non-root user 1001
└── STATUS.md

Database Models

users

Column Type Constraints Notes
id String PK, UUID auto-generated
email String UNIQUE, indexed, NOT NULL lowercased before storing
hashed_password String NOT NULL bcrypt 13 rounds
full_name String nullable sanitized max 128 chars
is_active Boolean default=True soft-delete flag
is_superuser Boolean default=False admin role; never exposed as-is (serialised as is_admin)
dashboard_app_ids JSON NOT NULL, default=[] list of pinned service IDs
color_mode String nullable, default=NULL user's preferred mode: "light" / "dark" / "system" / NULL (use admin default)

Relationship: profile (one-to-one, cascade all+delete-orphan)

profiles

Column Type Constraints Notes
id String PK, UUID auto-generated
user_id String FK→users.id UNIQUE, cascade delete one-to-one
phone String(20) nullable validated format
date_of_birth Date nullable 1900+ and not future
position String(128) nullable job title
address String(255) nullable
updated_at DateTime(tz) server_default=now(), onupdate=now()

groups

Column Type Constraints
id String PK, UUID
name String(128) UNIQUE indexed, NOT NULL
description String(512) nullable
created_at DateTime(tz) server_default=now()

group_memberships

Column Type Constraints
id String PK, UUID
group_id String FK→groups.id, indexed, CASCADE
user_id String FK→users.id, indexed, CASCADE
is_group_admin Boolean NOT NULL, default=false
joined_at DateTime(tz) server_default=now()

Unique constraint: (group_id, user_id)

Migration chain (must be applied in order)

Rev ID Slug
38efeff7c45a create_users_table
676084df61d1 add_profiles_table
a3f9c2d14e87 add_groups_and_group_memberships
c7e8f9a0b1d2 add_dashboard_app_ids_to_users
dd6ad2f2c211 add_color_mode_to_users
e1f2a3b4c5d6 add_group_member_is_admin

API Endpoints

Auth (/api/auth) — public

Method Path Auth Description
POST /api/auth/register Create account; returns UserOut; enforces password policy
POST /api/auth/login OAuth2 password flow; returns {access_token, token_type}

Users (/api/users) — authenticated

Method Path Auth Description
GET /api/users/me user Current user info → UserOut
GET /api/users/me/preferences user Dashboard pinned app IDs → {app_ids}
PATCH /api/users/me/preferences user Save pinned app IDs (max 50, slug-safe)
PATCH /api/users/me/color-mode user Save colour mode preference ("light"/"dark"/"system")
GET /api/users/me/groups user Groups current user belongs to → list[UserGroupOut]

Profile (/api/profile) — authenticated

Method Path Auth Description
GET /api/profile/me user Fetch profile; auto-creates if missing
PUT /api/profile/me user Update profile fields

Admin — Users (/api/admin) — admin-only

Method Path Description
GET /api/admin/users List all users → list[UserAdminOut]
POST /api/admin/users Create user (with optional is_admin)
DELETE /api/admin/users/{user_id} Delete user (204)
PATCH /api/admin/users/{user_id}/active Toggle active status

Admin — Groups (/api/admin/groups) — admin-only

Method Path Description
GET /api/admin/groups List groups with member count
POST /api/admin/groups Create group
GET /api/admin/groups/{id} Group detail + members
PATCH /api/admin/groups/{id} Update name / description
DELETE /api/admin/groups/{id} Delete (cascades memberships)
POST /api/admin/groups/{id}/members/{user_id} Add member
DELETE /api/admin/groups/{id}/members/{user_id} Remove member
PATCH /api/admin/groups/{id}/members/{user_id}/admin Set/unset group admin role (body: { is_group_admin: bool })

Settings (/api/settings) — admin-only

Method Path Description
GET /api/settings/ai AI config (keys masked)
PATCH /api/settings/ai Update AI provider / credentials
POST /api/settings/ai/test Test AI connection
GET /api/settings/documents/limits PDF upload limits
PATCH /api/settings/documents/limits Update max PDF size
GET /api/settings/system-prompts All editable system prompts
PATCH /api/settings/system-prompts/{service_id} Update system prompt
GET /api/settings/appearance Active theme + default mode (auth)
PATCH /api/settings/appearance Update active theme + default mode (admin)
GET /api/settings/themes List all themes — built-in + custom (auth)
POST /api/settings/themes Create custom theme (admin)
PATCH /api/settings/themes/{id} Update custom theme label/colours (admin)
DELETE /api/settings/themes/{id} Delete custom theme (admin, 204)

Services (/api/services) — authenticated

Method Path Description
GET /api/services Health status of all registered services → list[ServiceStatus]

Plugins (/api/plugins) — authenticated, auth-per-plugin

Method Path Description
GET /api/plugins List plugins accessible to current user
GET /api/plugins/{id}/manifest Plugin manifest with settings JSON Schema (auth-gated)
GET /api/plugins/{id}/settings Proxy to feature /plugin/settings (auth-gated)
PATCH /api/plugins/{id}/settings Proxy to feature /plugin/settings (auth-gated)

Auth: is_superuser OR member of group listed in manifest required_groups. Returns 404 (not 403) to hide existence.

Admin — Storage (/api/admin) — admin-only

Method Path Description
GET /api/admin/storage-config Current backend driver + health → proxied from storage-service /health
PATCH /api/admin/storage-config Reconfigure backend without data migration (same-backend credential update)
POST /api/admin/storage-config/migrate Start async migration to a new backend (copy → verify → switch → cleanup)
GET /api/admin/storage-config/migrate/status Poll migration progress: {state, total, done, failed, errors[]}
DELETE /api/admin/storage-config/migrate Cancel a running migration; old backend remains active

Documents and Categories — proxied

/api/documents/* and /api/documents/categories/* are transparently proxied to doc-service:8001. The backend injects x-user-id, x-user-groups, and x-user-is-admin headers. See features/doc-service/CLAUDE.md for the internal endpoint list.


Security Standards

These standards are non-negotiable. Every change must comply.

JWT

  • Algorithm: RS256 (4096-bit RSA key pair, generated by scripts/generate_jwt_keys.py)
  • Keys: PEM-encoded in backend/.env as JWT_PRIVATE_KEY / JWT_PUBLIC_KEY (gitignored)
  • Expiry: 8 hours (EXPIRE_MINUTES=480) — never set longer; no refresh tokens
  • Claims: {sub: user_id, exp, iat} — user_id is a UUID string
  • Validation: decode_access_token() in core/security.py; called by get_current_user
  • Never: set algorithm to "none", disable verify_exp, or hardcode secrets in code

Password hashing

  • Algorithm: bcrypt, 13 rounds (bcrypt.gensalt(rounds=13))
  • Timing: ~300 ms per hash (intentional brute-force resistance)
  • Never use MD5, SHA1, or plain SHA256 for password storage

Password policy (enforced in UserCreate schema)

All of the following must pass:

  • ≥ 8 characters
  • ≥ 1 uppercase (AZ)
  • ≥ 1 lowercase (az)
  • ≥ 1 digit (09)
  • ≥ 1 special character: !@#$%^&*()\-_=+[]{}|;:'"<>?/\~`
  • No common words (password, secret, login, admin, test, qwerty, welcome, …)

Input sanitization

Every user-supplied string stored in the database must pass through core/sanitize.py:

sanitize_str(value, max_len=255)
# → strips whitespace; rejects null bytes (\x00); rejects control chars
#   (0x010x1F, 0x7F except \t \n \r); enforces max_len; returns None for ""

normalize_email(value)       # lowercase + strip
validate_phone(value)        # sanitize_str(max=20) + regex ^\+?[\d\s\-()\[\]]{7,20}$
validate_date_of_birth(v)    # must be ≥ 1900, not future

Apply via Pydantic @field_validator on all request schemas.

SQL injection prevention

  • Use SQLAlchemy ORM (bound parameters) — never raw SQL strings.
  • If text() is needed, use bindparam() for all user-supplied values.
  • Never use f-strings, .format(), or %-formatting for SQL.

Admin route security

  • Use get_current_admin dependency (checks is_superuser).
  • Return 404 (not 403) for unauthorized access — hides both endpoint existence and permission model.

Naming & Code Conventions

Database

  • Tables: lowercase, plural, snake_case (users, group_memberships, document_category_assignments)
  • Columns: lowercase, snake_case
  • ORM models: PascalCase, singular (User, Group, GroupMembership, Document)
  • Primary keys: id (String UUID, auto-generated)
  • Timestamps: created_at / updated_at / joined_at / processed_at — always timezone-aware

Pydantic schemas

Suffix Purpose
Create POST request body (user-supplied input)
Update PATCH request body (partial update)
Out API response (safe subset of model)
AdminOut Extended response for admin endpoints
Read GET response (same as Out, used for profiles)

Always set model_config = {"from_attributes": True} on response schemas. Use validation_alias when the ORM field name differs from the JSON key (e.g., is_superuseris_admin).

HTTP status codes

Code Use
200 Successful GET / PATCH / PUT
201 Successful POST that creates a resource
202 Accepted (async processing started, e.g., document upload)
204 Successful DELETE or action with no response body
400 Bad request (duplicates, invalid data beyond Pydantic)
401 Missing / invalid JWT
404 Not found and admin routes when not admin
413 Payload too large (file exceeds limit)
415 Unsupported media type (not a PDF)
422 Pydantic validation failure (FastAPI default)
502 Downstream service unreachable
503 Service unavailable (queue stopped, AI error)
504 Gateway timeout

Backend code style

  • Async/await for all I/O (DB, HTTP, file).
  • raise HTTPException(status_code=..., detail="...") for all errors.
  • Response models always declared in route decorator: @router.get("/path", response_model=XOut).
  • Background tasks via BackgroundTasks param; tasks open their own AsyncSessionLocal session.
  • Commit + refresh pattern after mutations:
    await db.commit()
    await db.refresh(obj)
    

Default Values & Limits

Parameter Value Location
JWT expiry 480 min (8 h) core/security.py
Bcrypt rounds 13 core/security.py
User color_mode default NULL (falls back to admin default_mode, then system) models/user.py
Max dashboard pinned apps 50 schemas/user.py
App ID max length 64 chars schemas/user.py
App ID allowed chars [a-zA-Z0-9_\-] schemas/user.py
full_name max length 128 chars schemas/user.py
Group name max length 128 chars schemas/group.py
Group description max 512 chars schemas/group.py
Phone max length 20 chars sanitize.py
Position max length 128 chars schemas/profile.py
Address max length 255 chars schemas/profile.py