6e5e5c08bf
- Add can_delete column to document_shares (migration 0005) - Inject x-user-is-admin header from backend proxy to doc-service - Add get_user_is_admin() dep in doc-service - Delete endpoint now allows: owner, admin, or group member with can_delete=true - Watch documents (user_id='watch') deletable by admins only - DocumentOut gains viewer_can_delete (computed per-request) - Share UI: 'Allow group members to delete' checkbox + trash badge on shares - RowActionsMenu dropdown portaled to document.body — fixes overflow-hidden clipping - Delete mutation onError handler — no more silent failures Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
351 lines
15 KiB
Markdown
351 lines
15 KiB
Markdown
# 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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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 to /config volume; theme files in /config/themes/
|
||
│ ├── 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/*
|
||
│ └── 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 |
|
||
| `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` |
|
||
|
||
---
|
||
|
||
## 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 |
|
||
|
||
### 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.
|
||
|
||
### 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 (A–Z)
|
||
- ≥ 1 lowercase (a–z)
|
||
- ≥ 1 digit (0–9)
|
||
- ≥ 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`:
|
||
|
||
```python
|
||
sanitize_str(value, max_len=255)
|
||
# → strips whitespace; rejects null bytes (\x00); rejects control chars
|
||
# (0x01–0x1F, 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_superuser` → `is_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:
|
||
```python
|
||
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` |
|