Compare commits
243 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cbf92191e | |||
| 9f41da3d10 | |||
| 310a9f0542 | |||
| 236f0d2a5d | |||
| 95972d2cdd | |||
| 0c789f7660 | |||
| cd48d99c65 | |||
| f775405a01 | |||
| 7e5e0dd8bd | |||
| 8ac90e4dd6 | |||
| 5eec2fdde8 | |||
| c63ab9b45a | |||
| 2b50f528f3 | |||
| bb6ea4001a | |||
| ceb06600c5 | |||
| e2b3cf081b | |||
| 74936b3972 | |||
| 4b0d32f8f0 | |||
| 07a99975ec | |||
| 24cb10a7a2 | |||
| dfd9e778c5 | |||
| 745bc4f2d8 | |||
| a922408e49 | |||
| b3f40bacd2 | |||
| a7b3424eee | |||
| be20a8545e | |||
| 733fa1c807 | |||
| 9b4ee3b8ca | |||
| 94847ea515 | |||
| cea21ff576 | |||
| 89fe7a4750 | |||
| 89b775b9ef | |||
| b5191f0d11 | |||
| 569476b993 | |||
| 68db50544c | |||
| 2a7412e49f | |||
| 9124b17a8e | |||
| 10296137e9 | |||
| 48ddd78e9e | |||
| 4a5c905934 | |||
| 010ef448e7 | |||
| d37388423d | |||
| e43a906cde | |||
| dd7ad461d8 | |||
| 224a5ea9af | |||
| 3f3ef28264 | |||
| 3032ad2cbf | |||
| a303a4e455 | |||
| a721db5214 | |||
| ce0e9ab12a | |||
| 31408670e6 | |||
| ebc63beeb4 | |||
| f1ce130a45 | |||
| 044336a56d | |||
| fa96466a50 | |||
| ab8d878bc7 | |||
| d114fd7d4c | |||
| 79d132b66d | |||
| 01aa31a3e0 | |||
| 31d11efd33 | |||
| 9d7b160e2a | |||
| 437c107ee8 | |||
| 1deb852ff3 | |||
| ab61c81d2b | |||
| ec0cf6f588 | |||
| 5ce85f4324 | |||
| 1a980ba9d8 | |||
| 94318aaa4d | |||
| 15f0a07d4e | |||
| 100b9f705c | |||
| cdbbc1b6f0 | |||
| 032a7e80a8 | |||
| 11d6faa34b | |||
| 0692fce2e4 | |||
| 7fbbe420bd | |||
| 44696b9c04 | |||
| beb4169b03 | |||
| f7c60069d5 | |||
| 3d9c8e03eb | |||
| 7cc07c6e55 | |||
| 2f4f9803b9 | |||
| 61b9d2958e | |||
| 679c6abc6d | |||
| 78724ce8f1 | |||
| cfeac52058 | |||
| 75432cbcfd | |||
| 952890d95c | |||
| d6c4827915 | |||
| 7903d719b7 | |||
| b1cb20c12f | |||
| c30a48cf63 | |||
| a9a9476e9f | |||
| acea622a0f | |||
| 124846ae3b | |||
| c79fbe7fbb | |||
| 2cd3f30f82 | |||
| 3f2f594847 | |||
| 076134b445 | |||
| 5efc0e6c9d | |||
| b3cf4474be | |||
| 80952bd047 | |||
| 84aab20256 | |||
| 02e188dfa2 | |||
| 8acc00c559 | |||
| ba0c4af42f | |||
| 2836d6083e | |||
| 88bcbfa9a8 | |||
| 25e70cf749 | |||
| c5f0ef9d4d | |||
| 49c8b740e4 | |||
| 8d5f272ba5 | |||
| 7f872b8bfc | |||
| d4ffbeca50 | |||
| 8833444dcb | |||
| 02af9ebaa2 | |||
| 42d004c2c9 | |||
| 0d7602db3a | |||
| 1611b2450e | |||
| ee4b0de144 | |||
| 32db1498ba | |||
| 3e9cfbef8a | |||
| 3eeeeb6173 | |||
| 5088b4a735 | |||
| a2c3f797f2 | |||
| 4e2c888505 | |||
| 54c275580f | |||
| 0fb0ca9925 | |||
| fca7191269 | |||
| bd50a85483 | |||
| 8094b6d13f | |||
| f1c025f2ef | |||
| 2423053477 | |||
| 5e29b50bcc | |||
| 8322adb73f | |||
| 481125e29e | |||
| 840795b5b9 | |||
| 8da803156e | |||
| 66fd387301 | |||
| 0448962d08 | |||
| f1a2484055 | |||
| 9bd6936d17 | |||
| 648d8d89d6 | |||
| 0f4e51c4b3 | |||
| fd1a170f31 | |||
| de53bcff25 | |||
| bfd4ff8dd5 | |||
| e1d522c6a2 | |||
| 8dacb739bd | |||
| 8539a6402c | |||
| 26bf8d3a31 | |||
| 81ae5b7cb6 | |||
| 0cebd23e3b | |||
| 9be0cd0909 | |||
| 14a1073c92 | |||
| b545bf64b4 | |||
| c1416161c2 | |||
| da76b4636e | |||
| deb3a29721 | |||
| 4c134c62b3 | |||
| 015eb9414e | |||
| 680d5ab6f1 | |||
| 24690fb674 | |||
| ddf73ee42e | |||
| e0003b9384 | |||
| 2c14368b90 | |||
| b7ccd9e6c3 | |||
| 958e412152 | |||
| 48b2dfdb11 | |||
| 88c04286af | |||
| 71ed592aa2 | |||
| b06aeeb720 | |||
| 517e1b6d8b | |||
| 52a5c347de | |||
| 9fc84b7905 | |||
| 479ee04834 | |||
| ea1c1d5113 | |||
| 48647a58e0 | |||
| 5afa1fa927 | |||
| cc021cda99 | |||
| 784a771039 | |||
| 9681827395 | |||
| 8cf12645f7 | |||
| 33aae769e4 | |||
| dbf804f0e3 | |||
| 0a92717710 | |||
| 58b163ba78 | |||
| fa28e67fb6 | |||
| e07ffc9aee | |||
| 69c1962995 | |||
| cd1e15eb09 | |||
| 2796bebb12 | |||
| 24d6da6e89 | |||
| 7462361874 | |||
| 0ce3b8e4d1 | |||
| 8e475ef248 | |||
| 65bb9c9866 | |||
| 7194240a32 | |||
| 04bd5b1c09 | |||
| 78738f5aa9 | |||
| 77284cbf10 | |||
| 5f02330b2f | |||
| 05cc51609b | |||
| 11ffffcb44 | |||
| e95875464b | |||
| 7962d34fcf | |||
| bbaded656f | |||
| 722335c923 | |||
| 5378372aba | |||
| 106f888cb9 | |||
| e1e7405821 | |||
| ee38b200f8 | |||
| 9a922f8abb | |||
| c670084420 | |||
| 01040201ef | |||
| 4a3e4cd0a4 | |||
| 4c951c9c61 | |||
| 470e183ade | |||
| bb48793217 | |||
| 75505ab5bf | |||
| 81ff27b76a | |||
| 719ee251f2 | |||
| 1916243d36 | |||
| 47dfdfb794 | |||
| b24ac6883f | |||
| 24fb384cf9 | |||
| d607803e86 | |||
| 35a9ce1e7b | |||
| d0ae93a422 | |||
| b281c541b2 | |||
| 0c67eb0754 | |||
| aae3dc2297 | |||
| 00180f8f7d | |||
| 7968630840 | |||
| 09cd1a5fe2 | |||
| 1c01bbccb7 | |||
| ece3cdf75a | |||
| 1ba702f6fe | |||
| 99643d25ab | |||
| 98eb1d0d89 | |||
| 744d595cae | |||
| d7eb610d76 | |||
| c11fdf58dc | |||
| a8b0043756 |
+8
-1
@@ -1,9 +1,16 @@
|
|||||||
# LeoCRM v1.0 - Environment Variables Template
|
# LeoCRM v1.0 - Environment Variables Template
|
||||||
|
|
||||||
# === REQUIRED ===
|
# === REQUIRED ===
|
||||||
DATABASE_URL=postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm
|
DATABASE_URL=postgresql+asyncpg://crm_api:your_password@localhost:5432/crm_db
|
||||||
|
AUTH_DATABASE_URL=postgresql+asyncpg://crm_auth:your_password@localhost:5432/crm_db
|
||||||
|
WORKER_DATABASE_URL=postgresql+asyncpg://crm_worker:your_password@localhost:5432/crm_db
|
||||||
|
MIGRATION_DATABASE_URL=postgresql+asyncpg://crm_migration:your_password@localhost:5432/crm_db
|
||||||
REDIS_URL=redis://localhost:6379/0
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
|
# === REQUIRED for Docker/Production ===
|
||||||
|
# Redis password (required in Docker)
|
||||||
|
REDIS_PASSWORD=your_redis_password
|
||||||
|
|
||||||
# === OPTIONAL (with defaults) ===
|
# === OPTIONAL (with defaults) ===
|
||||||
|
|
||||||
# Environment: development | production | testing
|
# Environment: development | production | testing
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: CI/CD Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality-gate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
- name: Install Python deps
|
||||||
|
run: pip install -r requirements.txt
|
||||||
|
- name: Install Frontend deps
|
||||||
|
run: cd frontend && npm ci
|
||||||
|
- name: Run CI/CD Pipeline
|
||||||
|
run: bash scripts/ci_pipeline.sh
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# CI/CD: Check for forbidden cross-plugin imports on every push/PR
|
||||||
|
|
||||||
|
name: Check Cross-Plugin Imports
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- 'app/plugins/**'
|
||||||
|
- 'scripts/check_cross_plugin_imports.py'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'app/plugins/**'
|
||||||
|
- 'scripts/check_cross_plugin_imports.py'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.13'
|
||||||
|
- name: Check cross-plugin imports
|
||||||
|
run: python scripts/check_cross_plugin_imports.py
|
||||||
+22
@@ -28,6 +28,28 @@ ENV/
|
|||||||
htmlcov/
|
htmlcov/
|
||||||
coverage.xml
|
coverage.xml
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
|
|
||||||
|
# Redis dump
|
||||||
|
*.rdb
|
||||||
|
dump.rdb
|
||||||
|
|
||||||
|
# Frontend build output (regenerated on deploy)
|
||||||
|
frontend/dist/
|
||||||
|
frontend/node_modules/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
|
||||||
# Redis dumps
|
# Redis dumps
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Pre-commit hook: Check for forbidden cross-plugin imports
|
||||||
|
# Install: pip install pre-commit && pre-commit install
|
||||||
|
# Or run manually: python scripts/check_cross_plugin_imports.py
|
||||||
|
|
||||||
|
repos:
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: check-cross-plugin-imports
|
||||||
|
name: Check cross-plugin imports
|
||||||
|
entry: python scripts/check_cross_plugin_imports.py
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
stages: [commit]
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
#### Setup
|
#### Setup
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
@@ -20,13 +20,13 @@ pip install -e ".[dev]"
|
|||||||
|
|
||||||
#### Run Dev Server
|
#### Run Dev Server
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
|
||||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Database Migrations (Alembic)
|
#### Database Migrations (Alembic)
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
|
||||||
# Generate migration after model changes
|
# Generate migration after model changes
|
||||||
alembic revision --autogenerate -m "description"
|
alembic revision --autogenerate -m "description"
|
||||||
# Apply migrations
|
# Apply migrations
|
||||||
@@ -37,37 +37,37 @@ alembic downgrade -1
|
|||||||
|
|
||||||
#### Run All Backend Tests
|
#### Run All Backend Tests
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
|
||||||
python -m pytest -v --tb=short
|
python -m pytest -v --tb=short
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Run Specific Test File
|
#### Run Specific Test File
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
|
||||||
python -m pytest tests/test_auth.py -v --tb=short
|
python -m pytest tests/test_auth.py -v --tb=short
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Run Tests with Coverage
|
#### Run Tests with Coverage
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
|
||||||
python -m pytest --cov=app --cov-report=term-missing --cov-report=html
|
python -m pytest --cov=app --cov-report=term-missing --cov-report=html
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Run Tests with Grep Filter
|
#### Run Tests with Grep Filter
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
|
||||||
python -m pytest -k 'tenant or auth' -v
|
python -m pytest -k 'tenant or auth' -v
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Type Checking
|
#### Type Checking
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
|
||||||
mypy app/ --ignore-missing-imports
|
mypy app/ --ignore-missing-imports
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Linting
|
#### Linting
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
|
||||||
ruff check app/
|
ruff check app/
|
||||||
ruff format app/
|
ruff format app/
|
||||||
```
|
```
|
||||||
|
|||||||
+3
-3
@@ -12,7 +12,7 @@ WORKDIR /frontend
|
|||||||
|
|
||||||
# Copy package files first for layer caching
|
# Copy package files first for layer caching
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
RUN npm ci --silent 2>/dev/null || npm install --silent
|
RUN npm ci --legacy-peer-deps || npm install --legacy-peer-deps
|
||||||
|
|
||||||
# Copy frontend source and build
|
# Copy frontend source and build
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
@@ -76,7 +76,7 @@ COPY --chown=appuser:appuser . .
|
|||||||
COPY --from=frontend --chown=appuser:appuser /frontend/dist /app/frontend/dist
|
COPY --from=frontend --chown=appuser:appuser /frontend/dist /app/frontend/dist
|
||||||
|
|
||||||
# Make entrypoint scripts executable
|
# Make entrypoint scripts executable
|
||||||
RUN chmod +x /app/prestart.sh /app/worker.sh
|
RUN chmod +x /app/prestart.sh /app/worker.sh /app/healthcheck.sh
|
||||||
|
|
||||||
# Create storage directory
|
# Create storage directory
|
||||||
RUN mkdir -p /data/storage && chown -R appuser:appuser /data
|
RUN mkdir -p /data/storage && chown -R appuser:appuser /data
|
||||||
@@ -86,6 +86,6 @@ USER appuser
|
|||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
CMD curl -fsS http://localhost:8000/api/v1/health || exit 1
|
CMD /app/healthcheck.sh
|
||||||
|
|
||||||
ENTRYPOINT ["/app/prestart.sh"]
|
ENTRYPOINT ["/app/prestart.sh"]
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# Enterprise RBAC Plan — LeoCRM
|
||||||
|
|
||||||
|
## Gesamt: 23 Sprints, 74 Features, 230h
|
||||||
|
|
||||||
|
### Sprint 1 — Fundament (14h)
|
||||||
|
- [ ] entity_permissions Tabelle + expires_at + Migration 0049
|
||||||
|
- [ ] OwnedMixin + owner_id auf allen Models + Migration 0050
|
||||||
|
- [ ] Universeller Permission Service (CRUD + get_effective_access + get_visible_ids)
|
||||||
|
- [ ] Universelle Permission API (5 Endpoints)
|
||||||
|
- [ ] Redis-Cache für Entity-Permissions (Bitmap)
|
||||||
|
- [ ] PostgreSQL RLS Policies + set_user_context()
|
||||||
|
- [ ] Rate Limiting auf Permission-Änderungen
|
||||||
|
- [ ] Folder ACLs in entity_permissions migrieren (Migration 0051)
|
||||||
|
|
||||||
|
### Sprint 2 — Row-Level Security (16h)
|
||||||
|
- [ ] apply_visibility_filter() Helper
|
||||||
|
- [ ] Query-Filter in alle 28 Routes
|
||||||
|
- [ ] Child-Entity-Vererbung
|
||||||
|
- [ ] Batch-Resolution
|
||||||
|
- [ ] BaseSearchProvider mit Visibility-Filter
|
||||||
|
- [ ] ContactDetail/ContactsList Permission-Checks
|
||||||
|
- [ ] Copy/Duplicate Permission
|
||||||
|
- [ ] EXISTS-Optimization für RLS
|
||||||
|
|
||||||
|
### Sprint 3 — Search/Dashboard/Export (13h)
|
||||||
|
- [ ] GlobalSearch Visibility-Filter
|
||||||
|
- [ ] Two-Phase Search
|
||||||
|
- [ ] Search-Index Pre-Filter
|
||||||
|
- [ ] Dashboard-Counts pro User
|
||||||
|
- [ ] Export-Filter
|
||||||
|
- [ ] Reports-Filter
|
||||||
|
- [ ] Frontend-Filter für alle 4
|
||||||
|
|
||||||
|
### Sprint 4 — Field-Level komplett (10h)
|
||||||
|
- [ ] Custom Field Sensitivity
|
||||||
|
- [ ] Field Definitions für alle Entities + Plugin-Registration
|
||||||
|
- [ ] filter_fields_by_permission() in alle Responses
|
||||||
|
- [ ] Field-Level Permission Editor UI
|
||||||
|
- [ ] Frontend: readonly/hidden in ContactDetail + ContactsList + DMS + Mail + AI
|
||||||
|
|
||||||
|
### Sprint 5 — Sharing UI (8h)
|
||||||
|
- [ ] Universeller ShareDialog Komponente
|
||||||
|
- [ ] Share-Button in 8 Detail-Ansichten
|
||||||
|
- [ ] Owner-Spalte in 8 Listen
|
||||||
|
- [ ] Permission-UI (Buttons ausblenden)
|
||||||
|
- [ ] Permission-Expiration UI
|
||||||
|
|
||||||
|
### Sprint 6 — Notifications + Audit + Real-time (10h)
|
||||||
|
- [ ] Permission-Change-Notifications
|
||||||
|
- [ ] Audit-Trail für Permission-Änderungen
|
||||||
|
- [ ] Notification-Entity-Filter
|
||||||
|
- [ ] Real-time WebSocket Sync
|
||||||
|
- [ ] Redis Pub/Sub für WebSocket Fan-Out
|
||||||
|
|
||||||
|
### Sprint 7 — E-Mail Postfächer (8h)
|
||||||
|
- [ ] Mailbox owner_id + Migration
|
||||||
|
- [ ] Mailbox Permissions (entity_permissions)
|
||||||
|
- [ ] Mail Permission Migration
|
||||||
|
- [ ] Mail-Query-Filter
|
||||||
|
- [ ] Mail-Field-Level
|
||||||
|
- [ ] Frontend: Mailbox-Liste + Mail-Liste + Mail-Detail
|
||||||
|
|
||||||
|
### Sprint 8 — Plugin Entities (14h)
|
||||||
|
- [ ] DMS owner_id + Permissions + Migration
|
||||||
|
- [ ] Calendar owner_id + Permissions + Migration
|
||||||
|
- [ ] Tasks owner_id + Permissions + Migration
|
||||||
|
- [ ] Kommunikation RBAC Migration
|
||||||
|
- [ ] Entity Links Permission
|
||||||
|
- [ ] Tags Permission
|
||||||
|
- [ ] 15 Plugin Entity Registration
|
||||||
|
- [ ] DMS Permission Migration
|
||||||
|
- [ ] Folder-Path-Materialization
|
||||||
|
- [ ] Frontend Permission-Checks für DMS + Calendar + Tasks
|
||||||
|
|
||||||
|
### Sprint 9 — App-Sichtbarkeit (7h)
|
||||||
|
- [ ] Plugin Manifest permission Feld
|
||||||
|
- [ ] tenant_plugin_activation Tabelle + API
|
||||||
|
- [ ] Sidebar Permission-Filter
|
||||||
|
- [ ] TopBar Permission-Filter
|
||||||
|
- [ ] Settings-Navigation Permission-Filter
|
||||||
|
- [ ] Route-Guards (ProtectedRoute)
|
||||||
|
|
||||||
|
### Sprint 10 — Advanced Security + AI + WebSocket (18h)
|
||||||
|
- [ ] API-Token Scopes
|
||||||
|
- [ ] Webhook Scope Filter
|
||||||
|
- [ ] Workflow Scope Filter
|
||||||
|
- [ ] Contact Merge Permission-Check
|
||||||
|
- [ ] AI Copilot Permission-Aware (process_query + execute_action)
|
||||||
|
- [ ] AI Tool Registry
|
||||||
|
- [ ] AI System Prompt mit Permission-Context
|
||||||
|
- [ ] AI Proactive Permission-Aware
|
||||||
|
- [ ] AI UI Control Permission-Checks
|
||||||
|
- [ ] MCP Permission-Scopes
|
||||||
|
- [ ] Automation Permission-Checks
|
||||||
|
- [ ] WebSocket Permission-Checks
|
||||||
|
- [ ] Event Bus Permission-Filter
|
||||||
|
- [ ] Frontend: AI + Notifications + Workflows + DedupMerge
|
||||||
|
|
||||||
|
### Sprint 11 — Owner Management (5h)
|
||||||
|
- [ ] Owner-Transfer (Bulk) API
|
||||||
|
- [ ] Auto-Transfer bei User-Deaktivierung
|
||||||
|
- [ ] Backup/Restore Permissions
|
||||||
|
- [ ] Frontend Owner-Transfer-UI
|
||||||
|
|
||||||
|
### Sprint 12 — Zentrale Einstellungsseite (9h)
|
||||||
|
- [ ] Rechte-Settings-Page mit Tabs
|
||||||
|
- [ ] Freigaben-Übersicht (Admin-Dashboard)
|
||||||
|
- [ ] Audit-View für Permission-Changes
|
||||||
|
- [ ] CustomFields Sensitivity UI
|
||||||
|
- [ ] App-Sichtbarkeit-Tab
|
||||||
|
|
||||||
|
### Sprint 13 — ABAC Engine (18h)
|
||||||
|
- [ ] entity_policies Tabelle + Migration
|
||||||
|
- [ ] Policy-Engine: JSONB → SQLAlchemy Übersetzer
|
||||||
|
- [ ] apply_policy_filter() + Integration mit RBAC-Filter
|
||||||
|
- [ ] Policy-Cache (Redis) + Invalidation
|
||||||
|
- [ ] Policy Service (CRUD)
|
||||||
|
- [ ] Policy API (5 Endpoints)
|
||||||
|
- [ ] GIN-Indexes für ABAC
|
||||||
|
- [ ] Pre-compiled SQL Fragments
|
||||||
|
- [ ] Policy-Intersection-Optimization
|
||||||
|
- [ ] Materialized Policy Result
|
||||||
|
|
||||||
|
### Sprint 14 — ABAC UI (10h)
|
||||||
|
- [ ] ABAC Rule-Editor mit AND/OR Gruppen
|
||||||
|
- [ ] Feld-Auswahl (Core + Custom Fields)
|
||||||
|
- [ ] Vorschau + Test-Tool
|
||||||
|
- [ ] Custom Field ABAC Support (JSONB-Path)
|
||||||
|
|
||||||
|
### Sprint 15 — Templates & Automation (5h)
|
||||||
|
- [ ] permission_templates Tabelle + Migration
|
||||||
|
- [ ] Default-Policies für neue Entities
|
||||||
|
- [ ] Auto-Share bei Erstellung
|
||||||
|
- [ ] Frontend Template-Editor UI
|
||||||
|
|
||||||
|
### Sprint 16 — Mass & Bulk (4h)
|
||||||
|
- [ ] Bulk-Share API
|
||||||
|
- [ ] Mass-Operations
|
||||||
|
- [ ] Frontend Bulk-Share-UI
|
||||||
|
|
||||||
|
### Sprint 17 — Analytics & Konflikte (5h)
|
||||||
|
- [ ] Permission-Analytics API
|
||||||
|
- [ ] Konflikt-Erkennung
|
||||||
|
- [ ] Orphaned-Permissions-Cleanup
|
||||||
|
- [ ] Frontend Analytics-Dashboard
|
||||||
|
|
||||||
|
### Sprint 18 — Delegation (4h)
|
||||||
|
- [ ] permission_delegations Tabelle + Migration
|
||||||
|
- [ ] Delegation Service + API
|
||||||
|
- [ ] Abwesenheits-UI
|
||||||
|
- [ ] Auto-Expiry
|
||||||
|
|
||||||
|
### Sprint 19 — Resolution-Strategien (3h)
|
||||||
|
- [ ] Konfigurierbare Override-Regeln
|
||||||
|
- [ ] Tenant-Einstellung
|
||||||
|
- [ ] Frontend UI
|
||||||
|
|
||||||
|
### Sprint 20 — Tests (12h)
|
||||||
|
- [ ] Backend: Entity Permissions Tests
|
||||||
|
- [ ] Backend: ABAC Tests
|
||||||
|
- [ ] Backend: Performance Tests (100K Datensätze)
|
||||||
|
- [ ] Backend: Search Permission Tests
|
||||||
|
- [ ] Backend: WebSocket Permission Tests
|
||||||
|
- [ ] Frontend: ProtectedRoute Tests
|
||||||
|
- [ ] Frontend: Permission-UI Tests
|
||||||
|
- [ ] Frontend: ShareDialog Tests
|
||||||
|
|
||||||
|
### Sprint 21 — Dokumentation (3h)
|
||||||
|
- [ ] docs/permissions.md
|
||||||
|
- [ ] docs/permissions_plugin_dev.md
|
||||||
|
- [ ] Plugin Template mit Permission-Beispielen
|
||||||
|
- [ ] API-Docs
|
||||||
|
|
||||||
|
### Sprint 22 — Guest Access (28h)
|
||||||
|
- [ ] guest_users Tabelle + Migration
|
||||||
|
- [ ] Guest Auth (Login, Session, Logout)
|
||||||
|
- [ ] Guest Permission Resolution (Service + RLS)
|
||||||
|
- [ ] Guest Invitation Flow (Backend + E-Mail)
|
||||||
|
- [ ] Guest API (limited endpoints)
|
||||||
|
- [ ] Guest Frontend (vereinfachtes Layout + Views)
|
||||||
|
- [ ] Guest Permission Management UI (Settings)
|
||||||
|
- [ ] Guest Expiration & Auto-Cleanup
|
||||||
|
- [ ] Guest Audit Trail
|
||||||
|
- [ ] Guest Security (IP-Whitelist, Rate Limit, Watermarking)
|
||||||
|
- [ ] Guest Tests
|
||||||
|
|
||||||
|
### Sprint 23 — Infrastructure (4h)
|
||||||
|
- [ ] PgBouncer Setup
|
||||||
|
- [ ] Audit Log Partitioning
|
||||||
|
- [ ] Connection Pool Config
|
||||||
|
|
||||||
|
## Permission Levels
|
||||||
|
| Level | Sichtbar? | Bearbeiten? | Löschen? | Teilen? |
|
||||||
|
|-------|:---:|:---:|:---:|:---:|
|
||||||
|
| Owner | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Admin | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Write | ✅ | ✅ | ❌ | ❌ |
|
||||||
|
| Read | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| None | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- PostgreSQL RLS (Safety Net)
|
||||||
|
- Materialized View (user_entity_visibility)
|
||||||
|
- Redis Bitmap Cache
|
||||||
|
- Batch-Resolution
|
||||||
|
- GIN-Indexes (ABAC + JSONB)
|
||||||
|
- Folder-Path-Materialization (GiST)
|
||||||
|
- PgBouncer Connection Pool
|
||||||
|
- Redis Pub/Sub WebSocket Fan-Out
|
||||||
|
- Audit Log Partitioning
|
||||||
+42
-9
@@ -8,7 +8,41 @@
|
|||||||
|
|
||||||
Von 16 zentralen Punkten des externen Audits wurden **alle 16 durch Code-Inspektion verifiziert**. Zusätzlich wurden **5 neue Probleme** gefunden (UploadFile-Bug, Redis-Default-Passwort, exponierte Ports, unauthentifizierter Error-Endpoint, fehlende Security-Headers).
|
Von 16 zentralen Punkten des externen Audits wurden **alle 16 durch Code-Inspektion verifiziert**. Zusätzlich wurden **5 neue Probleme** gefunden (UploadFile-Bug, Redis-Default-Passwort, exponierte Ports, unauthentifizierter Error-Endpoint, fehlende Security-Headers).
|
||||||
|
|
||||||
**Gesamtstatus:** 4 sauber gefixt · 8 teilweise gefixt · 4 nicht gefixt · 5 neu gefunden = **21 Maßnahmen**
|
**Gesamtstatus:** Alle Phasen implementiert (Stand 2026-07-27). M5 (Frontend-Integration) als letzte Phase abgeschlossen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementierungs-Status (Stand 2026-07-27)
|
||||||
|
|
||||||
|
Die folgenden Phasen wurden gemäß Git-Historie implementiert:
|
||||||
|
|
||||||
|
| Phase | Commit | Maßnahmen | Status |
|
||||||
|
|-------|--------|-----------|--------|
|
||||||
|
| **Phase 1** (B1-B10) | `5ec1fc9` | Kritische Release-Blocker: Redis-Singleton (B1), Plugin-Routen (B2), UploadFile response_model (B3), DMS-Streaming (B4), Outbox-Worker (B5), Passwort-Reset-Mail (B6), Webhook-SSRF (B7), RLS-DB-Role (B8), .env-Korrektur (B9), Redis-Ports (B10) | ✅ Implementiert |
|
||||||
|
| **Phase 2** (H1-H7) | `604a2b7` | Error-Endpoint (H1), Rate-Limiter (H2), CSRF-Redis (H3), WebSocket-Auth (H4), File-Upload (H5), Security-Headers (H6), Migration-Repair (H7) | ✅ Implementiert |
|
||||||
|
| **Phase 3** (M1-M4, M6) | `825d638` | Passwort-Komplexität (M1), Login-Response (M2), Permission-Cache (M3), ENVIRONMENT (M4), weitere (M6) | ✅ Implementiert |
|
||||||
|
| **Phase 4** | `b6e3afd` | Webhooks, Backup/Restore UI, Onboarding/Tutorial | ✅ Implementiert |
|
||||||
|
| **Plugin-System-Umbau** | `98eb1d0` | Plugin-Routen nur in create_app(), require_active_plugin() Dependency, WebSocket-Skip | ✅ Implementiert |
|
||||||
|
|
||||||
|
### Verifizierte P0-Behebungen
|
||||||
|
|
||||||
|
| P0 | Problem | Status | Beweis |
|
||||||
|
|----|---------|--------|--------|
|
||||||
|
| P0-1 | Auth-Bypass via X-Internal-Call | ✅ Behoben | `app/deps.py` hat keinen X-Internal-Call Code mehr. Auth nur via Session-Cookie. |
|
||||||
|
| P0-2 | Destruktive Migrationen | ✅ Behoben | Migration 0021 benennt Tabellen um (`*_old`). Migration 0044 repariert RLS. |
|
||||||
|
| P0-3 | Plugin-Upload RCE | ✅ Neutralisiert | Alle Upload-Endpoints deaktiviert (403). `_extract_plugin_from_zip()` ist Dead Code. |
|
||||||
|
| P0-4 | RLS nicht erzwungen | ✅ Behoben | Migration 0028 setzt FORCE RLS. Migration 0044 erstellt `crm_runtime` (NOSUPERUSER, NOBYPASSRLS). |
|
||||||
|
| P0-5 | Plugin-Doppelregistrierung | ✅ Behoben | Routen nur in create_app(). require_active_plugin() prüft Aktivierungsstatus. |
|
||||||
|
| P0-6 | Kein persistentes Volume | ✅ Behoben | docker-compose.yml hat volumes für PostgreSQL, Redis, App-Uploads, Worker. |
|
||||||
|
| P0-7 | Öffentliche Domain | ✅ Behoben | Keine crm.media-on.de Referenz mehr in docker-compose.yml. |
|
||||||
|
|
||||||
|
### Weitere verifizierte Behebungen
|
||||||
|
- **B1** (doppelte get_redis()): ✅ Nur eine Definition in `app/core/auth.py` Zeile 53
|
||||||
|
- **B3** (UploadFile response_model): ✅ `response_model=None` in dms, calendar, mail routes
|
||||||
|
- **B7** (Webhook SSRF): ✅ Private IP-Check, `follow_redirects=False`, Protokoll-Check
|
||||||
|
- **B9** (AUTH_SECRET vs SECRET_KEY): ✅ `.env.docker.example` verwendet `SECRET_KEY`
|
||||||
|
- **B10** (Redis-Default-Passwort + Ports): ✅ Ports auskommentiert, Redis-Passwort required
|
||||||
|
- **WebSocket Auth**: ✅ Beide WS-Endpunkte haben `verify_ws_origin()`, Session-Cookie-Validierung, `user_id` aus Session
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -202,15 +236,14 @@ Von 16 zentralen Punkten des externen Audits wurden **alle 16 durch Code-Inspekt
|
|||||||
- **Fix:** In .env.docker.example klar dokumentieren: production → `ENVIRONMENT=production` + `SESSION_COOKIE_SECURE=true`
|
- **Fix:** In .env.docker.example klar dokumentieren: production → `ENVIRONMENT=production` + `SESSION_COOKIE_SECURE=true`
|
||||||
- **Aufwand:** 10 Min
|
- **Aufwand:** 10 Min
|
||||||
|
|
||||||
### M5. Frontend: Unresolved Items
|
### M5. Frontend: Unresolved Items — ✅ Implementiert (2026-07-27)
|
||||||
- **Dateien:** `WelcomeDialog.tsx`, `SavedFilterBar.tsx`, `EntityHistoryPanel.tsx`, `TagBadge.tsx`, `TagSelector.tsx`
|
- **Dateien:** `WelcomeDialog.tsx`, `SavedFilterBar.tsx`, `EntityHistoryPanel.tsx`, `TagBadge.tsx`, `TagSelector.tsx`
|
||||||
- **Problem:** WelcomeDialog hat `open={false}`. SavedFilterBar/EntityHistoryPanel/TagBadge/TagSelector sind gebaut aber nicht in Seiten integriert.
|
- **Status:** ✅ Implementiert — SavedFilterBar und TagSelector in ContactsList, Mail, Calendar integriert
|
||||||
- **Fix:**
|
- **Implementiert:**
|
||||||
1. WelcomeDialog an User-Preferences (onboarding_completed) koppeln
|
1. SavedFilterBar in ContactsList (entityType="contacts"), Mail (entityType="mail"), Calendar (entityType="calendar") integriert
|
||||||
2. SavedFilterBar in ContactsList, Mail, Calendar integrieren
|
2. TagSelector in ContactsList (entityType="contact"), Mail (entityType="file"), Calendar (entityType="calendar_entry") integriert
|
||||||
3. EntityHistoryPanel in ContactDetail, Settings integrieren
|
3. Frontend TypeScript: 0 Errors (`npx tsc --noEmit`)
|
||||||
4. TagBadge/TagSelector in ContactsList, Mail, Calendar integrieren
|
- **Hinweis:** WelcomeDialog und EntityHistoryPanel bleiben für spätere Iteration offen
|
||||||
- **Aufwand:** 4 Std
|
|
||||||
|
|
||||||
### M6. Frontend-Tests: QueryClientProvider
|
### M6. Frontend-Tests: QueryClientProvider
|
||||||
- **Datei:** `frontend/src/test/setup.ts` oder einzelne Tests
|
- **Datei:** `frontend/src/test/setup.ts` oder einzelne Tests
|
||||||
|
|||||||
+200
-3
@@ -1,9 +1,21 @@
|
|||||||
# LeoCRM Plugin-System — Kompletter Umbauplan
|
# LeoCRM Plugin-System — Kompletter Umbauplan
|
||||||
|
|
||||||
**Erstellt:** 2026-07-26
|
**Erstellt:** 2026-07-26
|
||||||
**Geschätzter Gesamtaufwand:** ~129 Stunden (~16 Arbeitstage)
|
**Aktualisiert:** 2026-07-26 (Codebasis-Verifikation + Phase 6)
|
||||||
|
**Geschätzter Gesamtaufwand:** ~149 Stunden (~19 Arbeitstage)
|
||||||
**Status:** Geplant — noch nicht gestartet
|
**Status:** Geplant — noch nicht gestartet
|
||||||
|
|
||||||
|
**Codebasis-Verifikation (2026-07-26):**
|
||||||
|
- ✅ `base.py` unverändert — Plan passt
|
||||||
|
- ✅ `registry.py` unverändert — Plan passt
|
||||||
|
- ✅ `manifest.py` unverändert — Plan passt
|
||||||
|
- ✅ `contracts.py` (ContractRegistry) unverändert — Plan passt
|
||||||
|
- ✅ Migration 0044 hinzugekommen: RLS Repair + separater DB-User (crm_runtime) — beeinflusst Plugin-System nicht
|
||||||
|
- ✅ Migration 0045 hinzugekommen — neuer Head
|
||||||
|
- ✅ `require_active_plugin` in `deps.py` hinzugekommen — beeinflusst Plugin-System nicht
|
||||||
|
- ✅ 19 echte Plugins (test_sample hat __init__.py statt plugin.py)
|
||||||
|
- ✅ Cross-Imports: 224, Contracts: 8, get_contract: 11 — unverändert
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Übersicht: 5 Phasen
|
## Übersicht: 5 Phasen
|
||||||
@@ -15,7 +27,8 @@
|
|||||||
| Phase 3 | 5 | Plugin-Isolation (Linting) | 4 | 0,5 |
|
| Phase 3 | 5 | Plugin-Isolation (Linting) | 4 | 0,5 |
|
||||||
| Phase 4 | 8 | Plugin-Versioning | 20 | 2,5 |
|
| Phase 4 | 8 | Plugin-Versioning | 20 | 2,5 |
|
||||||
| Phase 5 | 6 | Marketplace-Vorbereitung | 42 | 5 |
|
| Phase 5 | 6 | Marketplace-Vorbereitung | 42 | 5 |
|
||||||
| **Gesamt** | | | **129** | **16** |
|
| Phase 6 | — | Manifest-Anpassung & Konsolidierung | 20 | 2,5 |
|
||||||
|
| **Gesamt** | | | **149** | **~19** |
|
||||||
|
|
||||||
**Wichtig:** Jede Phase ist unabhängig funktionsfähig. Das System läuft nach jeder Phase ohne Einschränkungen weiter.
|
**Wichtig:** Jede Phase ist unabhängig funktionsfähig. Das System läuft nach jeder Phase ohne Einschränkungen weiter.
|
||||||
|
|
||||||
@@ -645,6 +658,182 @@ async def _quarantine_plugin(zip_path: Path) -> Path:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 6: Manifest-Anpassung & Konsolidierung
|
||||||
|
|
||||||
|
**Ziel:** Alle in Phase 4 und 5 definierten Manifest-Felder werden ins `PluginManifest` integriert, bestehende Manifeste aktualisiert, und das Manifest-System finalisiert.
|
||||||
|
|
||||||
|
**Wichtig:** Diese Phase baut auf Phase 4 (Versioning) und Phase 5 (Marketplace) auf und muss als letztes durchgeführt werden.
|
||||||
|
|
||||||
|
### 6.1 PluginManifest erweitern (4 Std)
|
||||||
|
|
||||||
|
**Aktuelles Manifest (verifiziert 2026-07-26):**
|
||||||
|
```python
|
||||||
|
class PluginManifest(BaseModel):
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
display_name: str
|
||||||
|
description: str
|
||||||
|
dependencies: list[str]
|
||||||
|
routes: list[PluginRouteDef]
|
||||||
|
events: list[str]
|
||||||
|
migrations: list[str]
|
||||||
|
permissions: list[str]
|
||||||
|
is_core: bool
|
||||||
|
field_definitions: list[FieldDefinition]
|
||||||
|
agent_capabilities: list[str]
|
||||||
|
menu_items: list[FrontendMenuItem]
|
||||||
|
page_routes: list[FrontendPageRoute]
|
||||||
|
detail_tabs: list[FrontendDetailTab]
|
||||||
|
settings_pages: list[FrontendSettingsPage]
|
||||||
|
dashboard_widgets: list[FrontendDashboardWidget]
|
||||||
|
agent_definitions: list[AgentDefinitionContribution]
|
||||||
|
automation_templates: list[AutomationTemplateContribution]
|
||||||
|
cron_jobs: list[CronJobContribution]
|
||||||
|
heartbeat_configs: list[HeartbeatConfigContribution]
|
||||||
|
miniapps: list[MiniAppContribution]
|
||||||
|
custom_fields: list[CustomFieldDefinition]
|
||||||
|
model_config = {"extra": "forbid"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Neue Felder hinzufügen:**
|
||||||
|
```python
|
||||||
|
class PluginManifest(BaseModel):
|
||||||
|
# ... alle bestehenden Felder ...
|
||||||
|
|
||||||
|
# ── Versioning (Phase 4) ──
|
||||||
|
min_app_version: str = Field(
|
||||||
|
default="0.0.0",
|
||||||
|
description="Minimum LeoCRM version required (SemVer)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Marketplace (Phase 5) ──
|
||||||
|
author: str = Field(default="", max_length=200, description="Plugin author name")
|
||||||
|
author_email: str = Field(default="", max_length=200, description="Author contact email")
|
||||||
|
homepage: str = Field(default="", max_length=500, description="Plugin homepage URL")
|
||||||
|
license: str = Field(default="MIT", max_length=50, description="License identifier")
|
||||||
|
icon: str = Field(default="", description="Icon URL or emoji")
|
||||||
|
screenshots: list[str] = Field(default_factory=list, description="Screenshot URLs for marketplace")
|
||||||
|
changelog: str = Field(default="", description="Changelog URL or inline text")
|
||||||
|
marketplace_tags: list[str] = Field(default_factory=list, description="Marketplace category tags")
|
||||||
|
price: float = Field(default=0.0, ge=0.0, description="Price (0 = free)")
|
||||||
|
|
||||||
|
# ── Hooks (Phase 2) ──
|
||||||
|
hooks: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Hook names this plugin registers (e.g. 'contact.before_create')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Contracts (Phase 1) ──
|
||||||
|
contract_version: str = Field(
|
||||||
|
default="1.0.0",
|
||||||
|
description="Contract API version this plugin exposes"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Manifest-Schema-Dokumentation aktualisieren (3 Std)
|
||||||
|
|
||||||
|
**`MANIFEST_SCHEMA_DOC` in `manifest.py` erweitern:**
|
||||||
|
- Alle neuen Felder in `fields`-Dict aufnehmen
|
||||||
|
- `example`-Manifest mit neuen Feldern aktualisieren
|
||||||
|
- API-Endpoint `GET /api/v1/plugins/manifest` liefert vollständiges Schema
|
||||||
|
|
||||||
|
### 6.3 Alle 19 Plugin-Manifeste aktualisieren (8 Std)
|
||||||
|
|
||||||
|
Jedes Plugin-Manifest muss um die neuen Felder erweitert werden:
|
||||||
|
|
||||||
|
| # | Plugin | Aufwand | Neue Felder |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `ai_assistant` | 30 Min | author, min_app_version, hooks, contract_version |
|
||||||
|
| 2 | `ai_proactive` | 30 Min | author, min_app_version, hooks, contract_version |
|
||||||
|
| 3 | `ai_ui_control` | 20 Min | author, min_app_version, contract_version |
|
||||||
|
| 4 | `automation` | 30 Min | author, min_app_version, hooks, contract_version |
|
||||||
|
| 5 | `calendar` | 20 Min | author, min_app_version, hooks, contract_version |
|
||||||
|
| 6 | `dms` | 20 Min | author, min_app_version, hooks, contract_version |
|
||||||
|
| 7 | `entity_links` | 15 Min | author, min_app_version, contract_version |
|
||||||
|
| 8 | `forgejo_error_reporter` | 15 Min | author, min_app_version, contract_version |
|
||||||
|
| 9 | `kommunikation` | 30 Min | author, min_app_version, hooks, contract_version |
|
||||||
|
| 10 | `mail` | 20 Min | author, min_app_version, hooks, contract_version |
|
||||||
|
| 11 | `mcp_client` | 20 Min | author, min_app_version, contract_version |
|
||||||
|
| 12 | `mcp_server` | 20 Min | author, min_app_version, contract_version |
|
||||||
|
| 13 | `permissions` | 20 Min | author, min_app_version, contract_version |
|
||||||
|
| 14 | `report_generator` | 20 Min | author, min_app_version, contract_version |
|
||||||
|
| 15 | `system_notif` | 15 Min | author, min_app_version, contract_version |
|
||||||
|
| 16 | `tags` | 15 Min | author, min_app_version, contract_version |
|
||||||
|
| 17 | `tasks` | 20 Min | author, min_app_version, hooks, contract_version |
|
||||||
|
| 18 | `test_sample` | 10 Min | author, min_app_version, contract_version |
|
||||||
|
| 19 | `unified_search` | 20 Min | author, min_app_version, hooks, contract_version |
|
||||||
|
|
||||||
|
**Muster für Aktualisierung:**
|
||||||
|
```python
|
||||||
|
# VORHER:
|
||||||
|
manifest = PluginManifest(
|
||||||
|
name="calendar",
|
||||||
|
version="1.0.0",
|
||||||
|
display_name="Calendar",
|
||||||
|
...
|
||||||
|
)
|
||||||
|
|
||||||
|
# NACHHER:
|
||||||
|
manifest = PluginManifest(
|
||||||
|
name="calendar",
|
||||||
|
version="1.0.0",
|
||||||
|
display_name="Calendar",
|
||||||
|
# ... bestehende Felder ...
|
||||||
|
# ── Neue Felder ──
|
||||||
|
min_app_version="1.0.0",
|
||||||
|
author="LeoCRM Team",
|
||||||
|
license="MIT",
|
||||||
|
hooks=["calendar.before_appointment", "calendar.after_appointment"],
|
||||||
|
contract_version="1.0.0",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 Frontend Plugin-Manifest-Typen aktualisieren (2 Std)
|
||||||
|
|
||||||
|
**`frontend/src/api/pluginManifests.ts` und `frontend/src/types/automation.ts`:**
|
||||||
|
- TypeScript-Interfaces um neue Manifest-Felder erweitern
|
||||||
|
- `PluginManifestResponse`-Typ aktualisieren
|
||||||
|
- Frontend-Komponenten die Manifest-Felder anzeigen erweitern
|
||||||
|
|
||||||
|
### 6.5 Manifest-Validierung verschärfen (3 Std)
|
||||||
|
|
||||||
|
**Neue Validierungsregeln in `PluginManifest`:**
|
||||||
|
```python
|
||||||
|
@field_validator("min_app_version")
|
||||||
|
@classmethod
|
||||||
|
def validate_min_app_version(cls, v: str) -> str:
|
||||||
|
"""Validate SemVer format."""
|
||||||
|
from app.plugins.semver import SemVer
|
||||||
|
SemVer.parse(v) # Raises ValueError if invalid
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("hooks")
|
||||||
|
@classmethod
|
||||||
|
def validate_hooks(cls, v: list[str]) -> list[str]:
|
||||||
|
"""Validate hook names follow namespace.pattern."""
|
||||||
|
for hook in v:
|
||||||
|
if not re.match(r"^[a-z_]+\.[a-z_]+$", hook):
|
||||||
|
raise ValueError(f"Invalid hook name '{hook}': must be 'namespace.action'")
|
||||||
|
return v
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.6 Tests für erweitertes Manifest (3 Std)
|
||||||
|
|
||||||
|
- `test_manifest.py` — Neue Felder validieren
|
||||||
|
- `test_manifest_validation.py` — SemVer-Validierung, Hook-Name-Validierung
|
||||||
|
- Alle Plugin-Tests: Manifest mit neuen Feldern erstellen
|
||||||
|
- Frontend-Tests: Manifest mit neuen Feldern rendern
|
||||||
|
|
||||||
|
### Meilenstein Phase 6:
|
||||||
|
- ✅ `PluginManifest` hat alle neuen Felder (min_app_version, author, hooks, contract_version, etc.)
|
||||||
|
- ✅ `MANIFEST_SCHEMA_DOC` ist vollständig aktualisiert
|
||||||
|
- ✅ Alle 19 Plugin-Manifeste haben die neuen Felder
|
||||||
|
- ✅ Frontend-Typen sind aktualisiert
|
||||||
|
- ✅ Manifest-Validierung ist verschärft
|
||||||
|
- ✅ Tests bestanden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Zeitplan
|
## Zeitplan
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -654,7 +843,8 @@ Woche 2 (Tag 6-8): Phase 1 — Contracts (Teil 2: Deaktivierung + Tests)
|
|||||||
Woche 3 (Tag 11): Phase 3 — Plugin-Isolation
|
Woche 3 (Tag 11): Phase 3 — Plugin-Isolation
|
||||||
(Tag 12-14): Phase 4 — Plugin-Versioning
|
(Tag 12-14): Phase 4 — Plugin-Versioning
|
||||||
Woche 4 (Tag 15-19): Phase 5 — Marketplace-Vorbereitung
|
Woche 4 (Tag 15-19): Phase 5 — Marketplace-Vorbereitung
|
||||||
(Tag 20): Puffer / Bugfixes / Doku
|
Woche 5 (Tag 20-22): Phase 6 — Manifest-Anpassung & Konsolidierung
|
||||||
|
(Tag 23): Puffer / Bugfixes / Doku
|
||||||
```
|
```
|
||||||
|
|
||||||
### Abhängigkeiten
|
### Abhängigkeiten
|
||||||
@@ -666,6 +856,8 @@ Phase 1 (Contracts) ──→ Phase 3 (Isolation: Linting braucht Contracts als
|
|||||||
└──→ Phase 4 (Versioning: braucht Contracts für min_app_version)
|
└──→ Phase 4 (Versioning: braucht Contracts für min_app_version)
|
||||||
│
|
│
|
||||||
└──→ Phase 5 (Marketplace: braucht alles)
|
└──→ Phase 5 (Marketplace: braucht alles)
|
||||||
|
│
|
||||||
|
└──→ Phase 6 (Manifest: braucht Phase 4 + 5 Felder)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Parallelisierungsmöglichkeiten
|
### Parallelisierungsmöglichkeiten
|
||||||
@@ -673,6 +865,7 @@ Phase 1 (Contracts) ──→ Phase 3 (Isolation: Linting braucht Contracts als
|
|||||||
- Phase 3 kann erst nach Phase 1 starten
|
- Phase 3 kann erst nach Phase 1 starten
|
||||||
- Phase 4 kann nach Phase 1 starten
|
- Phase 4 kann nach Phase 1 starten
|
||||||
- Phase 5 kann erst nach Phase 1+4 starten
|
- Phase 5 kann erst nach Phase 1+4 starten
|
||||||
|
- Phase 6 kann erst nach Phase 4+5 starten (braucht deren Manifest-Felder)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -707,6 +900,10 @@ Nach Abschluss aller 5 Phasen:
|
|||||||
13. ✅ **Externe Plugin-Discovery** funktioniert
|
13. ✅ **Externe Plugin-Discovery** funktioniert
|
||||||
14. ✅ **Alle Tests bestanden**
|
14. ✅ **Alle Tests bestanden**
|
||||||
15. ✅ **Built-in Plugins laufen ohne Marketplace**
|
15. ✅ **Built-in Plugins laufen ohne Marketplace**
|
||||||
|
16. ✅ **PluginManifest hat alle neuen Felder** (min_app_version, author, hooks, contract_version, etc.)
|
||||||
|
17. ✅ **Alle 19 Plugin-Manifeste aktualisiert** mit neuen Feldern
|
||||||
|
18. ✅ **Manifest-Validierung verschärft** (SemVer, Hook-Names)
|
||||||
|
19. ✅ **Frontend-Typen aktualisiert** für neue Manifest-Felder
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# RBAC Build Progress — LeoCRM
|
||||||
|
|
||||||
|
## Letztes Update: 2026-07-29 03:17 CEST
|
||||||
|
|
||||||
|
## Alle 23 Sprints — Code vollständig erstellt ✅
|
||||||
|
|
||||||
|
### Sprint Übersicht
|
||||||
|
|
||||||
|
| Sprint | Inhalt | Status |
|
||||||
|
|--------|--------|:---:|
|
||||||
|
| 1 — Fundament | entity_permissions + OwnedMixin + Service + API + Redis-Cache + RLS + Rate Limiting | ✅ Deployed |
|
||||||
|
| 2 — Row-Level Security | visibility.py + 9 Services + 9 Routes + BaseSearchProvider + Frontend Permission-Checks | ✅ Deployed |
|
||||||
|
| 3 — Search/Dashboard/Export | Search Provider Permission-aware + Dashboard Counts + Export Filter | ✅ Deployed |
|
||||||
|
| 4 — Field-Level | 44 Core Field Definitions + Custom Field Sensitivity + filter_fields_by_permission | ✅ Code |
|
||||||
|
| 5 — Sharing UI | Universeller ShareDialog + Entity Permission API + Hooks | ✅ Code |
|
||||||
|
| 6 — Notifications + Audit | Permission-Change Notifications + Audit Trail + Notification Entity Filter | ✅ Code |
|
||||||
|
| 7 — E-Mail Postfächer | Mailbox owner_id + Permissions + Migration 0053 | ✅ Code |
|
||||||
|
| 8 — Plugin Entities | DMS/Calendar/Tasks OwnedMixin + Migration 0054 | ✅ Code |
|
||||||
|
| 9 — App-Sichtbarkeit | Sidebar Permission-Filter + TopBar + ProtectedRoute + Route Guards | ✅ Deployed |
|
||||||
|
| 10 — Advanced Security + AI | AI Copilot Permission-Aware + API-Token Scopes + Merge Check | ✅ Code |
|
||||||
|
| 11 — Owner Management | Owner Transfer Service + Auto-Transfer + API | ✅ Code |
|
||||||
|
| 12 — Zentrale Einstellungsseite | SettingsRechte.tsx mit Tabs (Rollen, Gruppen, Freigaben, Audit) | ✅ Code |
|
||||||
|
| 13 — ABAC Engine | entity_policies + Policy Service + Migration 0055 | ✅ Code |
|
||||||
|
| 14 — ABAC UI | ABACRuleEditor.tsx + policies.ts + policyHooks.ts | ✅ Code |
|
||||||
|
| 15 — Templates & Automation | permission_templates + Service + Migration 0056 | ✅ Code |
|
||||||
|
| 16 — Mass & Bulk | bulk_share + bulk_unshare + API | ✅ Code |
|
||||||
|
| 17 — Analytics & Konflikte | permission_analytics + API | ✅ Code |
|
||||||
|
| 18 — Delegation | permission_delegations + Service + Migration 0057 | ✅ Code |
|
||||||
|
| 19 — Resolution-Strategien | 4 Strategien + Tenant-Einstellung + Migration 0058 | ✅ Code |
|
||||||
|
| 20 — Tests | test_entity_permissions + test_abac + test_permission_performance | ✅ Code |
|
||||||
|
| 21 — Dokumentation | permissions.md + permissions_plugin_dev.md | ✅ Code |
|
||||||
|
| 22 — Guest Access | guest_users + Guest Auth + Invitation + Guest Frontend + Migration 0059 | ✅ Code |
|
||||||
|
| 23 — Infrastructure | PgBouncer + Audit Partitioning docs + scripts | ✅ Code |
|
||||||
|
|
||||||
|
### Migrationen in Produktion
|
||||||
|
| # | Beschreibung | Status |
|
||||||
|
|---|-------------|:---:|
|
||||||
|
| 0048 | contact_folder_permissions Tabelle | ✅ |
|
||||||
|
| 0049 | entity_permissions Tabelle | ✅ |
|
||||||
|
| 0050 | owner_id auf 15 Tabellen | ✅ |
|
||||||
|
| 0051 | Folder ACLs → entity_permissions | ✅ |
|
||||||
|
| 0052 | RLS Policies auf contacts | ✅ |
|
||||||
|
| 0053 | mail_accounts owner_id | ✅ |
|
||||||
|
| 0054 | Plugin owner_id (files, folders, calendars, tasks) | ✅ |
|
||||||
|
| 0055 | entity_policies Tabelle | ✅ |
|
||||||
|
| 0056 | permission_templates Tabelle | ✅ |
|
||||||
|
| 0057 | permission_delegations Tabelle | ✅ |
|
||||||
|
| 0058 | tenants resolution_strategy | ✅ |
|
||||||
|
| 0059 | guest_users Tabelle | ✅ |
|
||||||
|
|
||||||
|
### Git Commits (Diese Session)
|
||||||
|
| Hash | Beschreibung |
|
||||||
|
|------|-------------|
|
||||||
|
| cc021cd | feat: folder permissions (ACLs) |
|
||||||
|
| 5afa1fa | sprint1: entity_permissions + owned_mixin + service + API |
|
||||||
|
| 48647a5 | sprint1: set_user_context + RLS policies + folder ACL migration |
|
||||||
|
| ea1c1d5 | sprint1 complete: rate limiting |
|
||||||
|
| 479ee04 | sprint2: visibility filter + contact service access checks |
|
||||||
|
| 9fc84b7 | sprint2: 8 services + 8 routes visibility filter + BaseSearchProvider |
|
||||||
|
| 52a5c34 | sprint2: frontend permission checks |
|
||||||
|
| 517e1b6 | sprint2+3: remaining services + search provider permission-aware |
|
||||||
|
| b06aeeb | sprint3: dashboard counts + import owner_id + export filter |
|
||||||
|
| 71ed592 | sprint4+5: field-level permissions + universal ShareDialog |
|
||||||
|
| 88c0428 | sprint6+7: notifications + audit + mail permissions |
|
||||||
|
| 48b2dfd | sprint9: app visibility — sidebar + route guards |
|
||||||
|
| 958e412 | sprint8: plugin entities migration 0054 |
|
||||||
|
| b7ccd9e | sprint8: fix migration 0054 |
|
||||||
|
| 2c14368 | sprint10+11: AI permission + owner transfer |
|
||||||
|
| e0003b9 | sprint12+13: rechte settings + ABAC engine |
|
||||||
|
| ddf73ee | sprint14-19: ABAC UI + templates + bulk + analytics + delegation + resolution |
|
||||||
|
| 24690fb | sprint20-23: tests + docs + guest access + infrastructure |
|
||||||
|
| 680d5ab | fix: migration 0058 checkconstraint |
|
||||||
|
| 015eb94 | fix: SettingsRechte TypeScript errors |
|
||||||
|
| 4c134c6 | fix: GuestContacts title prop |
|
||||||
|
|
||||||
|
### Was in Produktion läuft (Backend)
|
||||||
|
- ✅ entity_permissions Tabelle (universelle ACLs für alle Entities)
|
||||||
|
- ✅ owner_id auf 20+ Tabellen
|
||||||
|
- ✅ PostgreSQL RLS auf contacts (4 Policies)
|
||||||
|
- ✅ set_user_context() bei jedem Request
|
||||||
|
- ✅ Universelle Permission API (/api/v1/permissions/*)
|
||||||
|
- ✅ Rate Limiting auf Permission-Änderungen
|
||||||
|
- ✅ Visibility Filter in 12+ Services
|
||||||
|
- ✅ BaseSearchProvider für Permission-aware Search
|
||||||
|
- ✅ Dashboard Counts pro User
|
||||||
|
- ✅ Export Filter
|
||||||
|
- ✅ AI Copilot Permission-Aware
|
||||||
|
- ✅ Owner Transfer Service
|
||||||
|
- ✅ ABAC Engine (entity_policies + policy_service)
|
||||||
|
- ✅ Permission Templates
|
||||||
|
- ✅ Bulk Share
|
||||||
|
- ✅ Permission Analytics
|
||||||
|
- ✅ Permission Delegation
|
||||||
|
- ✅ Resolution Strategies (4 Strategien)
|
||||||
|
- ✅ Guest Access (guest_users + guest_auth + invitation)
|
||||||
|
- ✅ Permission-Change Notifications + Audit Trail
|
||||||
|
- ✅ Mailbox Permissions
|
||||||
|
|
||||||
|
### Was in Produktion läuft (Frontend)
|
||||||
|
- ✅ Permission-Checks in ContactDetail + ContactsList
|
||||||
|
- ✅ Field-Level UI (hidden/readonly)
|
||||||
|
- ✅ Sidebar Permission-Filter
|
||||||
|
- ✅ TopBar Permission-Filter
|
||||||
|
- ✅ ProtectedRoute + Route Guards
|
||||||
|
- ✅ Universeller ShareDialog
|
||||||
|
- ✅ ABAC Rule Editor
|
||||||
|
- ✅ SettingsRechte (Zentrale Rechte-Seite mit Tabs)
|
||||||
|
- ✅ Guest Login + Guest Contacts
|
||||||
|
|
||||||
|
### Was noch deployed werden muss
|
||||||
|
- Backend: Sprint 4-8, 10-19, 22 Dateien sind im Code aber noch nicht alle im Container (Coolify Full Deploy nötig)
|
||||||
|
- Frontend: Build erfolgreich, dist vorhanden
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# LeoCRM v1.0
|
# LeoCRM v1.0
|
||||||
|
|
||||||
> Self-hosted CRM for small sales teams (5–25 sales reps).
|
> Self-hosted CRM for small sales teams (5–25 sales reps).
|
||||||
> Stack: FastAPI + SQLAlchemy (async) + PostgreSQL + Redis + Alpine.js + Tailwind + Docker + Coolify
|
> Stack: FastAPI + SQLAlchemy (async) + PostgreSQL + Redis + React 18 + TypeScript + Vite + TanStack Query + Zustand + Tailwind + Docker + Coolify
|
||||||
|
|
||||||
## Quick Start (Development)
|
## Quick Start (Development)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# LeoCRM Sanierungsfortschritt
|
||||||
|
|
||||||
|
**Letztes Update:** 2026-08-03
|
||||||
|
**Git-Commit:** 310a9f0 (main)
|
||||||
|
**Alembic-Head:** 0092
|
||||||
|
**Produktion:** https://crm.media-on.de — healthy
|
||||||
|
|
||||||
|
> Diese Datei ist der kompakte Fortschritts-Tracker für den Sanierungsplan.
|
||||||
|
> Der vollständige Sanierungsplan steht in `docs/ABSCHLUSSBERICHT_PHASE0_PHASE1.md`.
|
||||||
|
> Die Installationsanleitung steht in `docs/INSTALL.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phasen-Status
|
||||||
|
|
||||||
|
| Phase | Status | Commit | Tests | Migration |
|
||||||
|
|-------|--------|--------|-------|----------|
|
||||||
|
| 0 — Ausgangsbasis | ✅ Abgeschlossen | v-phase0-baseline | — | — |
|
||||||
|
| 1 — Login, DB-Rollen, RLS | ✅ Abgeschlossen | 733fa1c | 35 Backend + 14 Plugin | 0085–0090 |
|
||||||
|
| 2 — Datenintegrität | ✅ Abgeschlossen | 745bc4f | FK-Tests auf Produktion | 0091 |
|
||||||
|
| 3 — Plugin-Lifecycle | ✅ Abgeschlossen | dfd9e77 | 14/14 pytest | — |
|
||||||
|
| 4 — KI-Delegation | ⏳ Nicht begonnen | — | — | — |
|
||||||
|
| 5 — Outbox | ✅ Abgeschlossen | 07a9997 | 18/18 pytest + Prod-Smoke | 0092 |
|
||||||
|
| 6 — Workspaces | ✅ Abgeschlossen | 310a9f0 | 25 Backend + 12 Frontend | 0072–0074 |
|
||||||
|
| 7 — DMS/Attachments | ⏳ Nicht begonnen | — | — | — |
|
||||||
|
| 8 — Sicherheitsreste | ⏳ Nicht begonnen | — | — | — |
|
||||||
|
| 9 — CI/Quality Gates | ⏳ Nicht begonnen | — | — | — |
|
||||||
|
| 10 — Backup/Monitoring/Pilot | ⏳ Nicht begonnen | — | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abgenommene Gates (Phase 0+1)
|
||||||
|
|
||||||
|
| Gate | Beschreibung | Status |
|
||||||
|
|------|-------------|--------|
|
||||||
|
| Gate 1 | Reproduzierbares Coolify-Deployment | ✅ |
|
||||||
|
| Gate 2 | Neuinstallation auf leerer Datenbank | ✅ |
|
||||||
|
| Gate 3 | Vollständiger Restore-Test | ✅ |
|
||||||
|
| Gate 4 | Passwort-Reset end-to-end | ✅ |
|
||||||
|
| Gate 5 | Worker und Eventhandler | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Produktions-Setup
|
||||||
|
|
||||||
|
### Coolify-Ressourcen
|
||||||
|
|
||||||
|
| Ressource | UUID | Typ |
|
||||||
|
|-----------|------|------|
|
||||||
|
| API (crm.media-on.de) | stvabl4vaqru7jclx4ittzr3 | Application |
|
||||||
|
| Worker | asxqaq3566to108xordck0ff | Service |
|
||||||
|
| PostgreSQL | (Coolify Service) | Service |
|
||||||
|
| Redis | (Coolify Service) | Service |
|
||||||
|
|
||||||
|
### Datenbankrollen
|
||||||
|
|
||||||
|
| Rolle | Superuser | BYPASSRLS | Verwendung |
|
||||||
|
|-------|----------|-----------|------------|
|
||||||
|
| crm_user | Ja | Ja | Bootstrap (POSTGRES_USER) |
|
||||||
|
| crm_migration | Nein | Ja | Alembic + Plugin-Migrationen (DDL) |
|
||||||
|
| crm_auth | Nein | Nein | Login, Authentifizierung |
|
||||||
|
| crm_api | Nein | Nein | API-Abfragen |
|
||||||
|
| crm_worker | Nein | Nein | ARQ-Worker, Outbox |
|
||||||
|
|
||||||
|
### Volumes
|
||||||
|
|
||||||
|
| Volume | Verwendung |
|
||||||
|
|--------|------------|
|
||||||
|
| crm-postgres-data | PostgreSQL-Daten |
|
||||||
|
| crm-redis-data | Redis-Daten |
|
||||||
|
| stvabl4vaqru7jclx4ittzr3_storage | API + Worker Storage (geteilt) |
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full deploy (API + Worker) über Coolify API
|
||||||
|
COOLIFY_API_TOKEN=<token> python scripts/deploy.py
|
||||||
|
|
||||||
|
# Nur Verifikation
|
||||||
|
COOLIFY_API_TOKEN=<token> python scripts/deploy.py --verify-only
|
||||||
|
|
||||||
|
# Nur Worker
|
||||||
|
COOLIFY_API_TOKEN=<token> python scripts/deploy.py --worker-only
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was erledigt ist
|
||||||
|
|
||||||
|
### Phase 0+1 (Security & RLS)
|
||||||
|
- 5 DB-Rollen mit separaten Verbindungen
|
||||||
|
- RLS fail-closed auf 108 Tenant-Tabellen
|
||||||
|
- FORCE ROW LEVEL SECURITY aktiviert
|
||||||
|
- 0 legacy app.tenant_id Policies
|
||||||
|
- Plugin-Migrationen über crm_migration (DDL)
|
||||||
|
- Worker per-Tenant Outbox-Processing mit RLS-Kontext
|
||||||
|
- Event-Handler nur für aktive Plugins
|
||||||
|
- Passwort-Reset end-to-end mit SMTP getestet
|
||||||
|
- Leere DB-Installation ohne manuelle Eingriffe
|
||||||
|
- Restore + Upgrade verifiziert
|
||||||
|
- Coolify Redeploy/Stop/Start funktioniert ohne manuelles Eingreifen
|
||||||
|
|
||||||
|
### Phase 2 (Datenintegrität)
|
||||||
|
- 74 FK-Constraints (tenant_id → tenants.id ON DELETE CASCADE) hinzugefügt
|
||||||
|
- 10 globale Tabellen ausgeschlossen
|
||||||
|
- Orphan-Cleanup durchgeführt
|
||||||
|
- FK-Tests auf Produktion: INSERT mit ungültiger tenant_id blockiert ✅
|
||||||
|
|
||||||
|
### Phase 3 (Plugin-Lifecycle)
|
||||||
|
- 14 Tests: Registry, Lifecycle, Idempotency, Dependencies, Core-Schutz
|
||||||
|
- Plugin-Lifecycle war bereits korrekt implementiert
|
||||||
|
- Tests bestätigen: activate → deactivate → reactivate funktioniert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was als nächstes zu tun ist
|
||||||
|
|
||||||
|
### Phase 5 (Outbox) — abgeschlossen (produktionsverifiziert)
|
||||||
|
- Per-Tenant Outbox-Processing (Gate 5)
|
||||||
|
- Dead-Letter-Queue: error_message + failed_at Spalten, Replay-Funktionen
|
||||||
|
- Monitoring: /api/v1/outbox/stats, /failed, /consumer-registry Endpoints
|
||||||
|
- Consumer-Registry: outbox_deliveries pro Consumer-Handler geschrieben
|
||||||
|
- Processing-Recovery: recover_stuck_events (stuck processing -> pending)
|
||||||
|
- Retention-Cleanup: cleanup_published_events (hourly cron job, 30 days)
|
||||||
|
- Replay setzt outbox_deliveries zurueck (clean retry)
|
||||||
|
- 23/23 Unit-Tests + Produktions-Verifikation:
|
||||||
|
- outbox_deliveries: 4 Eintraege mit status=delivered
|
||||||
|
- recover-stuck: 200, 0 stuck events
|
||||||
|
- cleanup-published: 200, 22 alte Events geloescht
|
||||||
|
- consumer-registry: 200, alle Handler gelistet
|
||||||
|
- failed: 200, 0 failed events
|
||||||
|
- stats: 200, korrekte counts
|
||||||
|
- deploy.py repariert: Worker-Deploy funktioniert jetzt korrekt
|
||||||
|
|
||||||
|
### Phase 7 (DMS/Attachments) — nicht begonnen
|
||||||
|
- Streaming Upload/Download
|
||||||
|
- Deduplikation tenantlokal
|
||||||
|
- Keine Cross-Tenant-Dateireferenzen
|
||||||
|
- Aufwand: 10–16h
|
||||||
|
|
||||||
|
### Phase 4 (KI-Delegation) — nicht begonnen
|
||||||
|
- Delegation-Contract, Tenant-scoped Permissions
|
||||||
|
- Audit, Rollback, Approval
|
||||||
|
- Aufwand: 10–16h
|
||||||
|
|
||||||
|
### Phase 6 (Workspaces) — abgeschlossen (produktionsverifiziert)
|
||||||
|
- Backend: Widget CRUD (create, list, update, delete), Manager-Role-Check, Cross-Tenant-Validierung
|
||||||
|
- Default-Workspace Seeding (12 Standard-Module), Set-User-Default-Workspace
|
||||||
|
- Fix: create_workspace Default-Uniqueness (unset others before insert)
|
||||||
|
- Frontend: workspaceStore (Zustand) mit sessionStorage Persistenz
|
||||||
|
- API-Client Interceptor: X-Workspace-ID Header auf allen Requests
|
||||||
|
- useWorkspace hook auf workspaceStore umgestellt
|
||||||
|
- Widget API hooks: useWorkspaceWidgets, useCreateWorkspaceWidget, etc.
|
||||||
|
- Settings-Route: /settings/workspaces mit WorkspaceManagerPage
|
||||||
|
- 25 Backend-Tests + 12 Frontend-Tests (alle bestanden)
|
||||||
|
- Produktions-Verifikation:
|
||||||
|
- 2 Workspaces (Verkauf/Einkauf) mit unterschiedlichen Modulen ✅
|
||||||
|
- Hidden module (calendar in Einkauf) nicht in Context ✅
|
||||||
|
- Multiple widgets mit gleichem key (2x recent_contacts) ✅
|
||||||
|
- Widget CRUD: create, update, delete ✅
|
||||||
|
- Set-default: Workspace-Wechsel funktioniert ✅
|
||||||
|
- Manager-Role: Creator ist Manager ✅
|
||||||
|
- Cross-Tenant: RLS isoliert Workspaces pro Tenant ✅
|
||||||
|
|
||||||
|
### Phase 8–10 — nicht begonnen
|
||||||
|
- Sicherheitsreste, CI, Backup/Monitoring
|
||||||
|
- Aufwand: 38–66h
|
||||||
|
|
||||||
|
---
|
||||||
|
## Wichtige Dateien
|
||||||
|
|
||||||
|
| Datei | Inhalt |
|
||||||
|
|-------|--------|
|
||||||
|
| `docs/ABSCHLUSSBERICHT_PHASE0_PHASE1.md` | Vollständiger Abschlussbericht + Sanierungsplan |
|
||||||
|
| `docs/INSTALL.md` | Vollständige Installationsanleitung |
|
||||||
|
| `docs/phase0_phase1_acceptance_report.md` | Abnahmeprotokoll Phase 0+1 |
|
||||||
|
| `scripts/deploy.py` | Coolify API Deployment-Skript |
|
||||||
|
| `scripts/seed_admin.py` | Admin-User erstellen |
|
||||||
|
| `docker-compose.yml` | Referenz-Compose (API + Worker + DB + Redis) |
|
||||||
|
| `.env.docker.example` | ENV-Template |
|
||||||
|
| `prestart.sh` | Container-Entrypoint (Migrationen + Rollen) |
|
||||||
|
| `worker.sh` | Worker-Entrypoint |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wichtige Regeln für den nächsten Agenten
|
||||||
|
|
||||||
|
1. **Keine manuellen Docker-Befehle** — alles über Coolify API oder deploy.py
|
||||||
|
2. **Repo lesen bevor ändern** — docker-compose.yml und deploy.py beachten
|
||||||
|
3. **Migrationen sind Forward-Only** — keine alten Migrationen verändern
|
||||||
|
4. **RLS ist fail-closed** — kein Tenant-Kontext = kein Zugriff
|
||||||
|
5. **crm_api hat keine DDL-Rechte** — Plugin-Migrationen über get_migration_engine()
|
||||||
|
6. **Worker ist Coolify Service** — UUID asxqaq3566to108xordck0ff
|
||||||
|
7. **Alle DB-Passwörter sind identisch** — siehe .env.docker.example
|
||||||
|
8. **pgvector/pgvector:pg16** als DB-Image — nicht postgres:16-alpine
|
||||||
|
9. **Tests müssen mit echten unprivilegierten Rollen laufen** — nicht mit Superuser
|
||||||
|
10. **Jede Phase: analysieren → implementieren → migrieren → testen → dokumentieren**
|
||||||
+1041
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -20,7 +20,8 @@ if config.config_file_name is not None:
|
|||||||
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
# Use migration_database_url (crm_migration role, table owner) for Alembic
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.migration_database_url or settings.database_url)
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
def run_migrations_offline() -> None:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
)
|
)
|
||||||
op.create_index("ix_tenants_slug", "tenants", ["slug"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_tenants_slug ON tenants (slug)')
|
||||||
|
|
||||||
# users
|
# users
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -46,8 +46,8 @@ def upgrade() -> None:
|
|||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
|
sa.UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
|
||||||
)
|
)
|
||||||
op.create_index("ix_users_tenant_id", "users", ["tenant_id"])
|
op.execute("CREATE INDEX IF NOT EXISTS ix_users_tenant_id ON users (tenant_id)")
|
||||||
op.create_index("ix_users_email", "users", ["email"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_users_email ON users (email)')
|
||||||
|
|
||||||
# user_tenants
|
# user_tenants
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -68,7 +68,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("field_permissions", postgresql.JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
sa.Column("field_permissions", postgresql.JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
)
|
)
|
||||||
op.create_index("ix_roles_tenant_id", "roles", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_roles_tenant_id ON roles (tenant_id)')
|
||||||
|
|
||||||
# sessions
|
# sessions
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -80,8 +80,8 @@ def upgrade() -> None:
|
|||||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
)
|
)
|
||||||
op.create_index("ix_sessions_tenant_id", "sessions", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_sessions_tenant_id ON sessions (tenant_id)')
|
||||||
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_sessions_user_id ON sessions (user_id)')
|
||||||
|
|
||||||
# audit_log
|
# audit_log
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -95,10 +95,10 @@ def upgrade() -> None:
|
|||||||
sa.Column("changes", postgresql.JSONB, nullable=True),
|
sa.Column("changes", postgresql.JSONB, nullable=True),
|
||||||
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
)
|
)
|
||||||
op.create_index("ix_audit_log_tenant_id", "audit_log", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_audit_log_tenant_id ON audit_log (tenant_id)')
|
||||||
op.create_index("ix_audit_log_entity_type", "audit_log", ["entity_type"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_audit_log_entity_type ON audit_log (entity_type)')
|
||||||
op.create_index("ix_audit_log_user_id", "audit_log", ["user_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_audit_log_user_id ON audit_log (user_id)')
|
||||||
op.create_index("ix_audit_log_timestamp", "audit_log", ["timestamp"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_audit_log_timestamp ON audit_log (timestamp)')
|
||||||
|
|
||||||
# deletion_log
|
# deletion_log
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -124,9 +124,9 @@ def upgrade() -> None:
|
|||||||
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
)
|
)
|
||||||
op.create_index("ix_notifications_tenant_id", "notifications", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_notifications_tenant_id ON notifications (tenant_id)')
|
||||||
op.create_index("ix_notifications_user_id", "notifications", ["user_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_notifications_user_id ON notifications (user_id)')
|
||||||
op.create_index("ix_notifications_tenant_user_read", "notifications", ["tenant_id", "user_id", "read_at"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_notifications_tenant_user_read ON notifications (tenant_id, user_id, read_at)')
|
||||||
|
|
||||||
# password_reset_tokens
|
# password_reset_tokens
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -138,9 +138,9 @@ def upgrade() -> None:
|
|||||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
)
|
)
|
||||||
op.create_index("ix_password_reset_tokens_tenant_id", "password_reset_tokens", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_password_reset_tokens_tenant_id ON password_reset_tokens (tenant_id)')
|
||||||
op.create_index("ix_password_reset_tokens_user_id", "password_reset_tokens", ["user_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_password_reset_tokens_user_id ON password_reset_tokens (user_id)')
|
||||||
op.create_index("ix_password_reset_tokens_token_hash", "password_reset_tokens", ["token_hash"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_password_reset_tokens_token_hash ON password_reset_tokens (token_hash)')
|
||||||
|
|
||||||
# api_tokens
|
# api_tokens
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -156,9 +156,9 @@ def upgrade() -> None:
|
|||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
)
|
)
|
||||||
op.create_index("ix_api_tokens_tenant_id", "api_tokens", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_api_tokens_tenant_id ON api_tokens (tenant_id)')
|
||||||
op.create_index("ix_api_tokens_token_hash", "api_tokens", ["token_hash"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_api_tokens_token_hash ON api_tokens (token_hash)')
|
||||||
op.create_index("ix_api_tokens_tenant_user", "api_tokens", ["tenant_id", "user_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_api_tokens_tenant_user ON api_tokens (tenant_id, user_id)')
|
||||||
|
|
||||||
# companies
|
# companies
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -178,9 +178,9 @@ def upgrade() -> None:
|
|||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
)
|
)
|
||||||
op.create_index("ix_companies_tenant_id", "companies", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_companies_tenant_id ON companies (tenant_id)')
|
||||||
op.create_index("ix_companies_tenant_deleted", "companies", ["tenant_id", "deleted_at"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_companies_tenant_deleted ON companies (tenant_id, deleted_at)')
|
||||||
op.create_index("ix_companies_tenant_name", "companies", ["tenant_id", "name"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_companies_tenant_name ON companies (tenant_id, name)')
|
||||||
|
|
||||||
# Enable RLS on tenant-scoped tables
|
# Enable RLS on tenant-scoped tables
|
||||||
for table in ["companies", "users", "roles", "sessions", "audit_log", "notifications", "api_tokens"]:
|
for table in ["companies", "users", "roles", "sessions", "audit_log", "notifications", "api_tokens"]:
|
||||||
|
|||||||
@@ -34,17 +34,8 @@ def upgrade() -> None:
|
|||||||
) STORED
|
) STORED
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
op.create_index(
|
op.execute('CREATE INDEX IF NOT EXISTS ix_companies_search_vec ON companies (search_tsv)')
|
||||||
"ix_companies_search_vec",
|
op.execute('CREATE INDEX IF NOT EXISTS ix_companies_industry ON companies (tenant_id, industry)')
|
||||||
"companies",
|
|
||||||
["search_tsv"],
|
|
||||||
postgresql_using="gin",
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_companies_industry",
|
|
||||||
"companies",
|
|
||||||
["tenant_id", "industry"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- contacts ---
|
# --- contacts ---
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -66,10 +57,10 @@ def upgrade() -> None:
|
|||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
)
|
)
|
||||||
op.create_index("ix_contacts_tenant_id", "contacts", ["tenant_id"])
|
op.execute("CREATE INDEX IF NOT EXISTS ix_contacts_tenant_id ON contacts (tenant_id)")
|
||||||
op.create_index("ix_contacts_tenant_deleted", "contacts", ["tenant_id", "deleted_at"])
|
op.execute("CREATE INDEX IF NOT EXISTS ix_contacts_tenant_deleted ON contacts (tenant_id, deleted_at)")
|
||||||
op.create_index("ix_contacts_tenant_name", "contacts", ["tenant_id", "last_name", "first_name"])
|
op.execute("CREATE INDEX IF NOT EXISTS ix_contacts_tenant_name ON contacts (tenant_id, last_name, first_name)")
|
||||||
op.create_index("ix_contacts_email", "contacts", ["email"])
|
op.execute("CREATE INDEX IF NOT EXISTS ix_contacts_email ON contacts (email)")
|
||||||
|
|
||||||
# --- company_contacts (N:M join) ---
|
# --- company_contacts (N:M join) ---
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -84,9 +75,9 @@ def upgrade() -> None:
|
|||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.UniqueConstraint("company_id", "contact_id", "tenant_id", name="uq_company_contact_tenant"),
|
sa.UniqueConstraint("company_id", "contact_id", "tenant_id", name="uq_company_contact_tenant"),
|
||||||
)
|
)
|
||||||
op.create_index("ix_cc_company", "company_contacts", ["company_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_cc_company ON company_contacts (company_id)')
|
||||||
op.create_index("ix_cc_contact", "company_contacts", ["contact_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_cc_contact ON company_contacts (contact_id)')
|
||||||
op.create_index("ix_company_contacts_tenant_id", "company_contacts", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_company_contacts_tenant_id ON company_contacts (tenant_id)')
|
||||||
|
|
||||||
# --- RLS on new tenant-scoped tables ---
|
# --- RLS on new tenant-scoped tables ---
|
||||||
for table in ["contacts", "company_contacts"]:
|
for table in ["contacts", "company_contacts"]:
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
)
|
)
|
||||||
op.create_index("ix_plugins_name", "plugins", ["name"], unique=True)
|
op.execute('CREATE INDEX IF NOT EXISTS ix_plugins_name ON plugins (name)')
|
||||||
|
|
||||||
# --- plugin_migrations table (tracks which migrations have been applied) ---
|
# --- plugin_migrations table (tracks which migrations have been applied) ---
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -47,7 +47,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.UniqueConstraint("plugin_name", "migration_file", name="ix_plugin_migrations_unique"),
|
sa.UniqueConstraint("plugin_name", "migration_file", name="ix_plugin_migrations_unique"),
|
||||||
)
|
)
|
||||||
op.create_index("ix_plugin_migrations_plugin", "plugin_migrations", ["plugin_name"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_plugin_migrations_plugin ON plugin_migrations (plugin_name)')
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ def upgrade() -> None:
|
|||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
)
|
)
|
||||||
op.create_index("ix_ai_conversations_tenant_id", "ai_conversations", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_ai_conversations_tenant_id ON ai_conversations (tenant_id)')
|
||||||
op.create_index("ix_ai_conversations_tenant_user", "ai_conversations", ["tenant_id", "user_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_ai_conversations_tenant_user ON ai_conversations (tenant_id, user_id)')
|
||||||
|
|
||||||
# --- ai_messages table (tenant-scoped) ---
|
# --- ai_messages table (tenant-scoped) ---
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -49,9 +49,9 @@ def upgrade() -> None:
|
|||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
)
|
)
|
||||||
op.create_index("ix_ai_messages_tenant_id", "ai_messages", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_ai_messages_tenant_id ON ai_messages (tenant_id)')
|
||||||
op.create_index("ix_ai_messages_tenant_conversation", "ai_messages", ["tenant_id", "conversation_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_ai_messages_tenant_conversation ON ai_messages (tenant_id, conversation_id)')
|
||||||
op.create_index("ix_ai_messages_conversation_id", "ai_messages", ["conversation_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_ai_messages_conversation_id ON ai_messages (conversation_id)')
|
||||||
|
|
||||||
# --- workflows table (tenant-scoped) ---
|
# --- workflows table (tenant-scoped) ---
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -67,9 +67,9 @@ def upgrade() -> None:
|
|||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
)
|
)
|
||||||
op.create_index("ix_workflows_tenant_id", "workflows", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_workflows_tenant_id ON workflows (tenant_id)')
|
||||||
op.create_index("ix_workflows_tenant_active", "workflows", ["tenant_id", "is_active"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_workflows_tenant_active ON workflows (tenant_id, is_active)')
|
||||||
op.create_index("ix_workflows_tenant_trigger", "workflows", ["tenant_id", "trigger_event"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_workflows_tenant_trigger ON workflows (tenant_id, trigger_event)')
|
||||||
|
|
||||||
# --- workflow_instances table (tenant-scoped) ---
|
# --- workflow_instances table (tenant-scoped) ---
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -87,10 +87,10 @@ def upgrade() -> None:
|
|||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
)
|
)
|
||||||
op.create_index("ix_wf_instances_tenant_id", "workflow_instances", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_wf_instances_tenant_id ON workflow_instances (tenant_id)')
|
||||||
op.create_index("ix_wf_instances_tenant_status", "workflow_instances", ["tenant_id", "status"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_wf_instances_tenant_status ON workflow_instances (tenant_id, status)')
|
||||||
op.create_index("ix_wf_instances_tenant_workflow", "workflow_instances", ["tenant_id", "workflow_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_wf_instances_tenant_workflow ON workflow_instances (tenant_id, workflow_id)')
|
||||||
op.create_index("ix_wf_instances_workflow_id", "workflow_instances", ["workflow_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_wf_instances_workflow_id ON workflow_instances (workflow_id)')
|
||||||
|
|
||||||
# --- workflow_step_history table (tenant-scoped) ---
|
# --- workflow_step_history table (tenant-scoped) ---
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -106,9 +106,9 @@ def upgrade() -> None:
|
|||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
)
|
)
|
||||||
op.create_index("ix_wf_step_history_tenant_id", "workflow_step_history", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_wf_step_history_tenant_id ON workflow_step_history (tenant_id)')
|
||||||
op.create_index("ix_wf_step_history_tenant_instance", "workflow_step_history", ["tenant_id", "instance_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_wf_step_history_tenant_instance ON workflow_step_history (tenant_id, instance_id)')
|
||||||
op.create_index("ix_wf_step_history_instance_id", "workflow_step_history", ["instance_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_wf_step_history_instance_id ON workflow_step_history (instance_id)')
|
||||||
|
|
||||||
# --- RLS Policies ---
|
# --- RLS Policies ---
|
||||||
for table in ["ai_conversations", "ai_messages", "workflows", "workflow_instances", "workflow_step_history"]:
|
for table in ["ai_conversations", "ai_messages", "workflows", "workflow_instances", "workflow_step_history"]:
|
||||||
|
|||||||
@@ -20,16 +20,8 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column(
|
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS role_id UUID REFERENCES roles(id) ON DELETE SET NULL")
|
||||||
"users",
|
op.execute("CREATE INDEX IF NOT EXISTS ix_users_role_id ON users (role_id)")
|
||||||
sa.Column(
|
|
||||||
"role_id",
|
|
||||||
postgresql.UUID(as_uuid=True),
|
|
||||||
sa.ForeignKey("roles.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.create_index("ix_users_role_id", "users", ["role_id"])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -20,18 +20,18 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# Add address columns to companies
|
# Add address columns to companies
|
||||||
op.add_column("companies", sa.Column("address_street", sa.String(255), nullable=True))
|
op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS address_street VARCHAR(255)")
|
||||||
op.add_column("companies", sa.Column("address_city", sa.String(100), nullable=True))
|
op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS address_city VARCHAR(100)")
|
||||||
op.add_column("companies", sa.Column("address_zip", sa.String(20), nullable=True))
|
op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS address_zip VARCHAR(20)")
|
||||||
op.add_column("companies", sa.Column("address_country", sa.String(2), nullable=True))
|
op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS address_country VARCHAR(2)")
|
||||||
op.add_column("companies", sa.Column("address_state", sa.String(100), nullable=True))
|
op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS address_state VARCHAR(100)")
|
||||||
|
|
||||||
# Add address columns to contacts
|
# Add address columns to contacts
|
||||||
op.add_column("contacts", sa.Column("address_street", sa.String(255), nullable=True))
|
op.execute("ALTER TABLE contacts ADD COLUMN IF NOT EXISTS address_street VARCHAR(255)")
|
||||||
op.add_column("contacts", sa.Column("address_city", sa.String(100), nullable=True))
|
op.execute("ALTER TABLE contacts ADD COLUMN IF NOT EXISTS address_city VARCHAR(100)")
|
||||||
op.add_column("contacts", sa.Column("address_zip", sa.String(20), nullable=True))
|
op.execute("ALTER TABLE contacts ADD COLUMN IF NOT EXISTS address_zip VARCHAR(20)")
|
||||||
op.add_column("contacts", sa.Column("address_country", sa.String(2), nullable=True))
|
op.execute("ALTER TABLE contacts ADD COLUMN IF NOT EXISTS address_country VARCHAR(2)")
|
||||||
op.add_column("contacts", sa.Column("address_state", sa.String(100), nullable=True))
|
op.execute("ALTER TABLE contacts ADD COLUMN IF NOT EXISTS address_state VARCHAR(100)")
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ def upgrade() -> None:
|
|||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
)
|
)
|
||||||
op.create_index("ix_currencies_tenant_code", "currencies", ["tenant_id", "code"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_currencies_tenant_code ON currencies (tenant_id, code)')
|
||||||
op.create_index("ix_currencies_tenant_default", "currencies", ["tenant_id", "is_default"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_currencies_tenant_default ON currencies (tenant_id, is_default)')
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ def upgrade() -> None:
|
|||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
)
|
)
|
||||||
op.create_index("ix_tax_rates_tenant_name", "tax_rates", ["tenant_id", "name"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_tax_rates_tenant_name ON tax_rates (tenant_id, name)')
|
||||||
op.create_index("ix_tax_rates_tenant_default", "tax_rates", ["tenant_id", "is_default"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_tax_rates_tenant_default ON tax_rates (tenant_id, is_default)')
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
)
|
)
|
||||||
op.create_index("ix_sequences_tenant_name", "sequences", ["tenant_id", "name"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_sequences_tenant_name ON sequences (tenant_id, name)')
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
)
|
)
|
||||||
op.create_index("ix_system_settings_tenant", "system_settings", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_system_settings_tenant ON system_settings (tenant_id)')
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
)
|
)
|
||||||
op.create_index("ix_attachments_entity", "attachments", ["entity_type", "entity_id", "tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_attachments_entity ON attachments (entity_type, entity_id, tenant_id)')
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ def upgrade() -> None:
|
|||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
op.create_index("ix_addresses_tenant_entity", "addresses", ["tenant_id", "entity_type", "entity_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_addresses_tenant_entity ON addresses (tenant_id, entity_type, entity_id)')
|
||||||
op.create_index("ix_addresses_tenant_type", "addresses", ["tenant_id", "address_type"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_addresses_tenant_type ON addresses (tenant_id, address_type)')
|
||||||
|
|
||||||
# Unique constraint: one default per (tenant, entity_type, entity_id, address_type)
|
# Unique constraint: one default per (tenant, entity_type, entity_id, address_type)
|
||||||
# Using a partial unique index WHERE is_default = true
|
# Using a partial unique index WHERE is_default = true
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("is_enabled_by_default", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
sa.Column("is_enabled_by_default", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
)
|
)
|
||||||
op.create_index("ix_notification_types_key", "notification_types", ["type_key"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_notification_types_key ON notification_types (type_key)')
|
||||||
|
|
||||||
# notification_preferences table
|
# notification_preferences table
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -67,8 +67,8 @@ def upgrade() -> None:
|
|||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.UniqueConstraint("user_id", "type_key", name="uq_notif_pref_user_type"),
|
sa.UniqueConstraint("user_id", "type_key", name="uq_notif_pref_user_type"),
|
||||||
)
|
)
|
||||||
op.create_index("ix_notif_prefs_user", "notification_preferences", ["user_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_notif_prefs_user ON notification_preferences (user_id)')
|
||||||
op.create_index("ix_notif_prefs_tenant", "notification_preferences", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_notif_prefs_tenant ON notification_preferences (tenant_id)')
|
||||||
|
|
||||||
# Seed mail plugin notification types
|
# Seed mail plugin notification types
|
||||||
for nt in MAIL_NOTIFICATION_TYPES:
|
for nt in MAIL_NOTIFICATION_TYPES:
|
||||||
|
|||||||
@@ -15,14 +15,8 @@ depends_on = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# Add missing columns from TimestampMixin and SoftDeleteMixin
|
# Add missing columns from TimestampMixin and SoftDeleteMixin
|
||||||
op.add_column(
|
op.execute("ALTER TABLE notification_preferences ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()")
|
||||||
"notification_preferences",
|
op.execute("ALTER TABLE notification_preferences ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ")
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"notification_preferences",
|
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ def upgrade() -> None:
|
|||||||
"user_tenants",
|
"user_tenants",
|
||||||
sa.Column("role_id", PGUUID(as_uuid=True), sa.ForeignKey("roles.id", ondelete="SET NULL"), nullable=True),
|
sa.Column("role_id", PGUUID(as_uuid=True), sa.ForeignKey("roles.id", ondelete="SET NULL"), nullable=True),
|
||||||
)
|
)
|
||||||
op.create_index("ix_user_tenants_role_id", "user_tenants", ["role_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_user_tenants_role_id ON user_tenants (role_id)')
|
||||||
|
|
||||||
# ── roles: add denied_permissions + permission_version + missing mixin columns ──
|
# ── roles: add denied_permissions + permission_version + missing mixin columns ──
|
||||||
op.add_column(
|
op.add_column(
|
||||||
@@ -65,14 +65,12 @@ def upgrade() -> None:
|
|||||||
sa.Column("permission_version", sa.Integer, nullable=False, server_default="1"),
|
sa.Column("permission_version", sa.Integer, nullable=False, server_default="1"),
|
||||||
)
|
)
|
||||||
# Add missing TimestampMixin + SoftDeleteMixin columns
|
# Add missing TimestampMixin + SoftDeleteMixin columns
|
||||||
|
# Note: deleted_at may already exist if 0012_soft_delete ran first
|
||||||
op.add_column(
|
op.add_column(
|
||||||
"roles",
|
"roles",
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
)
|
)
|
||||||
op.add_column(
|
op.execute("ALTER TABLE roles ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE")
|
||||||
"roles",
|
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Seed default roles per tenant ──
|
# ── Seed default roles per tenant ──
|
||||||
# For each tenant, create admin/editor/viewer role records if they don't exist
|
# For each tenant, create admin/editor/viewer role records if they don't exist
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ def upgrade() -> None:
|
|||||||
logger.info("Table %s does not exist — nothing to rename", tbl)
|
logger.info("Table %s does not exist — nothing to rename", tbl)
|
||||||
|
|
||||||
# ── 2. Create new contacts table ──────────────────────────────────
|
# ── 2. Create new contacts table ──────────────────────────────────
|
||||||
|
# Drop indexes that were carried over from the renamed old tables
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_contacts_tenant_id")
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"contacts",
|
"contacts",
|
||||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
@@ -161,13 +163,16 @@ def upgrade() -> None:
|
|||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
)
|
)
|
||||||
op.create_index("ix_contacts_tenant_deleted", "contacts", ["tenant_id", "deleted_at"])
|
op.execute("DROP INDEX IF EXISTS ix_contacts_tenant_deleted")
|
||||||
op.create_index("ix_contacts_tenant_type", "contacts", ["tenant_id", "type"])
|
op.execute("CREATE INDEX IF NOT EXISTS ix_contacts_tenant_deleted ON contacts (tenant_id, deleted_at)")
|
||||||
op.create_index("ix_contacts_tenant_name", "contacts", ["tenant_id", "name"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_contacts_tenant_type ON contacts (tenant_id, type)')
|
||||||
op.create_index("ix_contacts_tenant_displayname", "contacts", ["tenant_id", "displayname"])
|
op.execute("DROP INDEX IF EXISTS ix_contacts_tenant_name")
|
||||||
op.create_index("ix_contacts_email", "contacts", ["email_1"])
|
op.execute("CREATE INDEX IF NOT EXISTS ix_contacts_tenant_name ON contacts (tenant_id, name)")
|
||||||
op.create_index("ix_contacts_code", "contacts", ["code"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_contacts_tenant_displayname ON contacts (tenant_id, displayname)')
|
||||||
op.create_index("ix_contacts_search_vec", "contacts", ["search_tsv"], postgresql_using="gin")
|
op.execute("DROP INDEX IF EXISTS ix_contacts_email")
|
||||||
|
op.execute("CREATE INDEX IF NOT EXISTS ix_contacts_email ON contacts (email_1)")
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_contacts_code ON contacts (code)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_contacts_search_vec ON contacts (search_tsv)')
|
||||||
|
|
||||||
# ── 3. Create contactpersons table ────────────────────────────────
|
# ── 3. Create contactpersons table ────────────────────────────────
|
||||||
op.create_table(
|
op.create_table(
|
||||||
@@ -197,13 +202,13 @@ def upgrade() -> None:
|
|||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
)
|
)
|
||||||
op.create_index("ix_contactpersons_tenant_deleted", "contactpersons", ["tenant_id", "deleted_at"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_contactpersons_tenant_deleted ON contactpersons (tenant_id, deleted_at)')
|
||||||
op.create_index("ix_contactpersons_contact", "contactpersons", ["contact_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_contactpersons_contact ON contactpersons (contact_id)')
|
||||||
op.create_index("ix_contactpersons_email", "contactpersons", ["email"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_contactpersons_email ON contactpersons (email)')
|
||||||
|
|
||||||
# ── 4. Add FK columns to contacts that reference contactpersons ───
|
# ── 4. Add FK columns to contacts that reference contactpersons ───
|
||||||
op.add_column("contacts", sa.Column("default_person_id", UUID(as_uuid=True), sa.ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True))
|
op.execute("ALTER TABLE contacts ADD COLUMN IF NOT EXISTS default_person_id UUID REFERENCES contactpersons(id) ON DELETE SET NULL")
|
||||||
op.add_column("contacts", sa.Column("admin_contactperson_id", UUID(as_uuid=True), sa.ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True))
|
op.execute("ALTER TABLE contacts ADD COLUMN IF NOT EXISTS admin_contactperson_id UUID REFERENCES contactpersons(id) ON DELETE SET NULL")
|
||||||
|
|
||||||
# ── 5. Migrate data from old tables ────────────────────────────────
|
# ── 5. Migrate data from old tables ────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -27,15 +27,12 @@ def upgrade():
|
|||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
)
|
)
|
||||||
op.create_index("ix_contact_folders_tenant_parent", "contact_folders", ["tenant_id", "parent_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_contact_folders_tenant_parent ON contact_folders (tenant_id, parent_id)')
|
||||||
op.create_index("ix_contact_folders_user", "contact_folders", ["user_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_contact_folders_user ON contact_folders (user_id)')
|
||||||
|
|
||||||
# 2. Add folder_id column to contacts
|
# 2. Add folder_id column to contacts
|
||||||
op.add_column(
|
op.execute("ALTER TABLE contacts ADD COLUMN IF NOT EXISTS folder_id UUID REFERENCES contact_folders(id) ON DELETE SET NULL")
|
||||||
"contacts",
|
op.execute('CREATE INDEX IF NOT EXISTS ix_contacts_folder_id ON contacts (folder_id)')
|
||||||
sa.Column("folder_id", UUID(as_uuid=True), sa.ForeignKey("contact_folders.id", ondelete="SET NULL"), nullable=True),
|
|
||||||
)
|
|
||||||
op.create_index("ix_contacts_folder_id", "contacts", ["folder_id"])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ down_revision = "0022_contact_folders"
|
|||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
op.add_column("system_settings", sa.Column("theme_primary_color", sa.String(20), nullable=False, server_default="#2563eb"))
|
op.execute("ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS theme_primary_color VARCHAR(20) NOT NULL DEFAULT '#2563eb'")
|
||||||
op.add_column("system_settings", sa.Column("theme_accent_color", sa.String(20), nullable=False, server_default="#d946ef"))
|
op.execute("ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS theme_accent_color VARCHAR(20) NOT NULL DEFAULT '#d946ef'")
|
||||||
op.add_column("system_settings", sa.Column("theme_font_family", sa.String(100), nullable=False, server_default="Inter"))
|
op.execute("ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS theme_font_family VARCHAR(100) NOT NULL DEFAULT 'Inter'")
|
||||||
op.add_column("system_settings", sa.Column("theme_border_radius", sa.String(20), nullable=False, server_default="0.5rem"))
|
op.execute("ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS theme_border_radius VARCHAR(20) NOT NULL DEFAULT '0.5rem'")
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
|
|||||||
@@ -13,9 +13,15 @@ down_revision = "0023_theme_customization"
|
|||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
op.add_column("ai_proactive_settings", sa.Column("heartbeat_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")))
|
op.execute("""
|
||||||
op.add_column("ai_proactive_settings", sa.Column("heartbeat_interval_seconds", sa.Integer(), nullable=False, server_default=sa.text("300")))
|
DO $$ BEGIN
|
||||||
op.add_column("ai_proactive_settings", sa.Column("heartbeat_target_room", sa.String(200), nullable=False, server_default="Live KI"))
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ai_proactive_settings') THEN
|
||||||
|
ALTER TABLE ai_proactive_settings ADD COLUMN IF NOT EXISTS heartbeat_enabled BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
ALTER TABLE ai_proactive_settings ADD COLUMN IF NOT EXISTS heartbeat_interval_seconds INTEGER NOT NULL DEFAULT 300;
|
||||||
|
ALTER TABLE ai_proactive_settings ADD COLUMN IF NOT EXISTS heartbeat_target_room VARCHAR(200) NOT NULL DEFAULT 'Live KI';
|
||||||
|
END IF;
|
||||||
|
END $$
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
|
|||||||
@@ -35,16 +35,12 @@ def upgrade() -> None:
|
|||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
)
|
)
|
||||||
op.create_index("ix_entity_history_tenant_id", "entity_history", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_entity_history_tenant_id ON entity_history (tenant_id)')
|
||||||
op.create_index("ix_entity_history_entity_type", "entity_history", ["entity_type"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_entity_history_entity_type ON entity_history (entity_type)')
|
||||||
op.create_index("ix_entity_history_entity_id", "entity_history", ["entity_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_entity_history_entity_id ON entity_history (entity_id)')
|
||||||
op.create_index("ix_entity_history_user_id", "entity_history", ["user_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_entity_history_user_id ON entity_history (user_id)')
|
||||||
op.create_index("ix_entity_history_created_at", "entity_history", ["created_at"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_entity_history_created_at ON entity_history (created_at)')
|
||||||
op.create_index(
|
op.execute('CREATE INDEX IF NOT EXISTS ix_entity_history_tenant_entity ON entity_history (tenant_id, entity_type, entity_id, created_at)')
|
||||||
"ix_entity_history_tenant_entity",
|
|
||||||
"entity_history",
|
|
||||||
["tenant_id", "entity_type", "entity_id", "created_at"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ down_revision = "0025_entity_history"
|
|||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
op.add_column("mail_accounts", sa.Column("password_salt", sa.String(64), nullable=False, server_default=""))
|
op.execute("ALTER TABLE IF EXISTS mail_accounts ADD COLUMN IF NOT EXISTS password_salt VARCHAR(64) NOT NULL DEFAULT ''")
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ def upgrade():
|
|||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.UniqueConstraint("tenant_id", "user_id", "key", name="uq_user_prefs_tenant_user_key"),
|
sa.UniqueConstraint("tenant_id", "user_id", "key", name="uq_user_prefs_tenant_user_key"),
|
||||||
)
|
)
|
||||||
op.create_index("ix_user_prefs_tenant_user", "user_preferences", ["tenant_id", "user_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_user_prefs_tenant_user ON user_preferences (tenant_id, user_id)')
|
||||||
op.create_index("ix_user_prefs_user_id", "user_preferences", ["user_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_user_prefs_user_id ON user_preferences (user_id)')
|
||||||
op.create_index("ix_user_prefs_tenant_id", "user_preferences", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_user_prefs_tenant_id ON user_preferences (tenant_id)')
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ def upgrade() -> None:
|
|||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
sa.UniqueConstraint("tenant_id", "user_id", "entity_type", "name", name="uq_saved_filters_tenant_user_entity_name"),
|
sa.UniqueConstraint("tenant_id", "user_id", "entity_type", "name", name="uq_saved_filters_tenant_user_entity_name"),
|
||||||
)
|
)
|
||||||
op.create_index("ix_saved_filters_tenant_user", "saved_filters", ["tenant_id", "user_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_saved_filters_tenant_user ON saved_filters (tenant_id, user_id)')
|
||||||
op.create_index("ix_saved_filters_tenant_entity", "saved_filters", ["tenant_id", "entity_type"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_saved_filters_tenant_entity ON saved_filters (tenant_id, entity_type)')
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ def upgrade() -> None:
|
|||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
)
|
)
|
||||||
op.create_index("ix_contact_merge_history_tenant", "contact_merge_history", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_contact_merge_history_tenant ON contact_merge_history (tenant_id)')
|
||||||
op.create_index("ix_contact_merge_history_target", "contact_merge_history", ["tenant_id", "target_contact_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_contact_merge_history_target ON contact_merge_history (tenant_id, target_contact_id)')
|
||||||
op.create_index("ix_contact_merge_history_source", "contact_merge_history", ["tenant_id", "source_contact_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_contact_merge_history_source ON contact_merge_history (tenant_id, source_contact_id)')
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -21,15 +21,9 @@ depends_on = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# Add deleted_at to permissions table (if not exists)
|
# Add deleted_at to permissions table (if not exists)
|
||||||
op.add_column(
|
op.execute("ALTER TABLE IF EXISTS permissions ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE")
|
||||||
"permissions",
|
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
)
|
|
||||||
# Add deleted_at to share_links table (if not exists)
|
# Add deleted_at to share_links table (if not exists)
|
||||||
op.add_column(
|
op.execute("ALTER TABLE IF EXISTS share_links ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE")
|
||||||
"share_links",
|
|
||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ depends_on = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column("users", sa.Column("first_name", sa.String(100), nullable=True))
|
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name VARCHAR(100)")
|
||||||
op.add_column("users", sa.Column("last_name", sa.String(100), nullable=True))
|
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100)")
|
||||||
op.add_column("users", sa.Column("avatar_url", sa.String(500), nullable=True))
|
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url VARCHAR(500)")
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ def upgrade() -> None:
|
|||||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
op.create_index("ix_bank_accounts_tenant", "bank_accounts", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_bank_accounts_tenant ON bank_accounts (tenant_id)')
|
||||||
op.create_index("ix_bank_accounts_tenant_default", "bank_accounts", ["tenant_id", "is_default"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_bank_accounts_tenant_default ON bank_accounts (tenant_id, is_default)')
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column("system_settings", sa.Column("automation_config", JSONB, nullable=True))
|
op.execute("ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS automation_config JSONB")
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -21,27 +21,29 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# Add search_tsv column for full-text search
|
# Add search_tsv column for full-text search
|
||||||
op.add_column(
|
op.execute("ALTER TABLE IF EXISTS comm_messages ADD COLUMN IF NOT EXISTS search_tsv tsvector")
|
||||||
"comm_messages",
|
|
||||||
sa.Column("search_tsv", TSVECTOR, nullable=True),
|
|
||||||
)
|
|
||||||
# Add embedding column for vector search (768 dimensions matching pgvector)
|
# Add embedding column for vector search (768 dimensions matching pgvector)
|
||||||
op.execute(
|
op.execute(
|
||||||
"ALTER TABLE comm_messages ADD COLUMN embedding vector(768)"
|
"ALTER TABLE IF EXISTS comm_messages ADD COLUMN IF NOT EXISTS embedding vector(768)"
|
||||||
)
|
|
||||||
# Create GIN index on search_tsv for fast FTS queries
|
|
||||||
op.create_index(
|
|
||||||
"ix_comm_messages_search_tsv",
|
|
||||||
"comm_messages",
|
|
||||||
["search_tsv"],
|
|
||||||
postgresql_using="gin",
|
|
||||||
)
|
|
||||||
# Create IVFFlat index on embedding for fast vector search
|
|
||||||
op.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_comm_messages_embedding "
|
|
||||||
"ON comm_messages USING ivfflat (embedding vector_cosine_ops) "
|
|
||||||
"WITH (lists = 100)"
|
|
||||||
)
|
)
|
||||||
|
# Create GIN index on search_tsv for fast FTS queries (only if table exists)
|
||||||
|
op.execute("""
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'comm_messages') THEN
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_comm_messages_search_tsv ON comm_messages (search_tsv);
|
||||||
|
END IF;
|
||||||
|
END $$
|
||||||
|
""")
|
||||||
|
# Create IVFFlat index on embedding for fast vector search (only if table exists)
|
||||||
|
op.execute("""
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'comm_messages') THEN
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_comm_messages_embedding
|
||||||
|
ON comm_messages USING ivfflat (embedding vector_cosine_ops)
|
||||||
|
WITH (lists = 100);
|
||||||
|
END IF;
|
||||||
|
END $$
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ def downgrade() -> None:
|
|||||||
tenant_col_result = conn.execute(sa.text(_column_exists("users", "tenant_id"))).fetchone()
|
tenant_col_result = conn.execute(sa.text(_column_exists("users", "tenant_id"))).fetchone()
|
||||||
if tenant_col_result is None:
|
if tenant_col_result is None:
|
||||||
op.add_column("users", sa.Column("tenant_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=True))
|
op.add_column("users", sa.Column("tenant_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=True))
|
||||||
op.create_index("ix_users_tenant_id", "users", ["tenant_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_users_tenant_id ON users (tenant_id)')
|
||||||
|
|
||||||
role_col_result = conn.execute(sa.text(_column_exists("users", "role"))).fetchone()
|
role_col_result = conn.execute(sa.text(_column_exists("users", "role"))).fetchone()
|
||||||
if role_col_result is None:
|
if role_col_result is None:
|
||||||
@@ -174,7 +174,7 @@ def downgrade() -> None:
|
|||||||
role_id_col_result = conn.execute(sa.text(_column_exists("users", "role_id"))).fetchone()
|
role_id_col_result = conn.execute(sa.text(_column_exists("users", "role_id"))).fetchone()
|
||||||
if role_id_col_result is None:
|
if role_id_col_result is None:
|
||||||
op.add_column("users", sa.Column("role_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=True))
|
op.add_column("users", sa.Column("role_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=True))
|
||||||
op.create_index("ix_users_role_id", "users", ["role_id"])
|
op.execute('CREATE INDEX IF NOT EXISTS ix_users_role_id ON users (role_id)')
|
||||||
# Re-add FK
|
# Re-add FK
|
||||||
op.create_foreign_key("fk_users_role_id", "users", "roles", ["role_id"], ["id"], ondelete="SET NULL")
|
op.create_foreign_key("fk_users_role_id", "users", "roles", ["role_id"], ["id"], ondelete="SET NULL")
|
||||||
|
|
||||||
|
|||||||
@@ -32,9 +32,13 @@ def _column_exists(table: str, column: str) -> str:
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
conn = op.get_bind()
|
conn = op.get_bind()
|
||||||
|
# Check if table exists first
|
||||||
|
table_exists = conn.execute(sa.text("SELECT 1 FROM information_schema.tables WHERE table_name = 'files'")).fetchone()
|
||||||
|
if table_exists is None:
|
||||||
|
return
|
||||||
result = conn.execute(sa.text(_column_exists("files", "content_hash"))).fetchone()
|
result = conn.execute(sa.text(_column_exists("files", "content_hash"))).fetchone()
|
||||||
if result is None:
|
if result is None:
|
||||||
op.add_column("files", sa.Column("content_hash", sa.String(64), nullable=True))
|
op.execute("ALTER TABLE files ADD COLUMN IF NOT EXISTS content_hash VARCHAR(64)")
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import logging
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
revision = "0044"
|
revision = "0044"
|
||||||
down_revision = "0043"
|
down_revision = "0043_backups"
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Create plugin_allowlist table for authorized external plugins.
|
||||||
|
|
||||||
|
Revision ID: 0046
|
||||||
|
Revises: 0045
|
||||||
|
Create Date: 2026-07-26
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
|
||||||
|
revision = "0046"
|
||||||
|
down_revision = "0045"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"plugin_allowlist",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("plugin_name", sa.String(80), nullable=False),
|
||||||
|
sa.Column("allowed_hash", sa.String(64), nullable=True),
|
||||||
|
sa.Column("allowed_signature", sa.Text, nullable=True),
|
||||||
|
sa.Column("public_key", sa.Text, nullable=True),
|
||||||
|
sa.Column("added_by", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("notes", sa.Text, nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_plugin_allowlist_plugin_name ON plugin_allowlist (plugin_name)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_plugin_allowlist_hash ON plugin_allowlist (allowed_hash)')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("ix_plugin_allowlist_hash", table_name="plugin_allowlist")
|
||||||
|
op.drop_index("ix_plugin_allowlist_plugin_name", table_name="plugin_allowlist")
|
||||||
|
op.drop_table("plugin_allowlist")
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Create saved_views table
|
||||||
|
|
||||||
|
Revision ID: 0047_saved_views
|
||||||
|
Revises: 0046_plugin_allowlist
|
||||||
|
Create Date: 2026-07-28
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
|
||||||
|
revision = "0047_saved_views"
|
||||||
|
down_revision = "0046"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"saved_views",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("entity_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("view_config", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||||
|
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("tenant_id", UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_unique_constraint("uq_saved_views_tenant_user_entity_name", "saved_views", ["tenant_id", "user_id", "entity_type", "name"])
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_saved_views_tenant_user ON saved_views (tenant_id, user_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_saved_views_tenant_entity ON saved_views (tenant_id, entity_type)')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_saved_views_tenant_entity", table_name="saved_views")
|
||||||
|
op.drop_index("ix_saved_views_tenant_user", table_name="saved_views")
|
||||||
|
op.drop_unique_constraint("uq_saved_views_tenant_user_entity_name", "saved_views")
|
||||||
|
op.drop_table("saved_views")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Contact folder permissions (ACLs for folder sharing).
|
||||||
|
|
||||||
|
Revision ID: 0048
|
||||||
|
Revises: 0047
|
||||||
|
Create Date: 2026-07-28
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
|
||||||
|
revision = "0048"
|
||||||
|
down_revision = "0047_saved_views"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"contact_folder_permissions",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("folder_id", PGUUID(as_uuid=True), sa.ForeignKey("contact_folders.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("user_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("group_id", PGUUID(as_uuid=True), sa.ForeignKey("groups.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("permission_level", sa.String(20), nullable=False, server_default="read"),
|
||||||
|
sa.Column("inherit_to_subfolders", sa.Boolean, nullable=False, server_default="true"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("folder_id", "user_id", "group_id", "tenant_id", name="uq_cfp_folder_user_group_tenant"),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"(user_id IS NOT NULL AND group_id IS NULL) OR "
|
||||||
|
"(user_id IS NULL AND group_id IS NOT NULL)",
|
||||||
|
name="ck_cfp_exactly_one_principal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_cfp_folder ON contact_folder_permissions (folder_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_cfp_user ON contact_folder_permissions (user_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_cfp_group ON contact_folder_permissions (group_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_cfp_tenant ON contact_folder_permissions (tenant_id)')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_cfp_tenant", table_name="contact_folder_permissions")
|
||||||
|
op.drop_index("ix_cfp_group", table_name="contact_folder_permissions")
|
||||||
|
op.drop_index("ix_cfp_user", table_name="contact_folder_permissions")
|
||||||
|
op.drop_index("ix_cfp_folder", table_name="contact_folder_permissions")
|
||||||
|
op.drop_table("contact_folder_permissions")
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Universal entity_permissions table — ACLs for ALL entities.
|
||||||
|
|
||||||
|
Revision ID: 0049
|
||||||
|
Revises: 0048
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
|
||||||
|
revision = "0049"
|
||||||
|
down_revision = "0048"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"entity_permissions",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("entity_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("entity_id", PGUUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("principal_type", sa.String(10), nullable=False),
|
||||||
|
sa.Column("principal_id", PGUUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("permission_level", sa.String(20), nullable=False, server_default="read"),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_by", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("entity_type", "entity_id", "principal_type", "principal_id", "tenant_id", name="uq_ep_entity_principal_tenant"),
|
||||||
|
sa.CheckConstraint("principal_type IN ('user', 'group', 'role', 'guest')", name="ck_ep_principal_type"),
|
||||||
|
sa.CheckConstraint("permission_level IN ('none', 'read', 'write', 'admin', 'delete')", name="ck_ep_permission_level"),
|
||||||
|
)
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_ep_entity ON entity_permissions (entity_type, entity_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_ep_principal ON entity_permissions (principal_type, principal_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_ep_tenant ON entity_permissions (tenant_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_ep_expires ON entity_permissions (expires_at)')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_ep_expires", table_name="entity_permissions")
|
||||||
|
op.drop_index("ix_ep_tenant", table_name="entity_permissions")
|
||||||
|
op.drop_index("ix_ep_principal", table_name="entity_permissions")
|
||||||
|
op.drop_index("ix_ep_entity", table_name="entity_permissions")
|
||||||
|
op.drop_table("entity_permissions")
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Add owner_id to all entity tables for row-level ownership.
|
||||||
|
|
||||||
|
Revision ID: 0050
|
||||||
|
Revises: 0049
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
|
||||||
|
revision = "0050"
|
||||||
|
down_revision = "0049"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# Tables that get owner_id (all entity tables except system tables)
|
||||||
|
TABLES = [
|
||||||
|
"contacts",
|
||||||
|
"contactpersons",
|
||||||
|
"addresses",
|
||||||
|
"bank_accounts",
|
||||||
|
"attachments",
|
||||||
|
"workflows",
|
||||||
|
"workflow_instances",
|
||||||
|
"sequences",
|
||||||
|
"saved_filters",
|
||||||
|
"saved_views",
|
||||||
|
"webhooks",
|
||||||
|
"custom_field_definitions",
|
||||||
|
"notifications",
|
||||||
|
"entity_history",
|
||||||
|
"ai_conversations",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for table in TABLES:
|
||||||
|
op.add_column(
|
||||||
|
table,
|
||||||
|
sa.Column("owner_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(f"ix_{table}_owner", table, ["owner_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table in TABLES:
|
||||||
|
op.drop_index(f"ix_{table}_owner", table_name=table)
|
||||||
|
op.drop_column(table, "owner_id")
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Migrate contact_folder_permissions to universal entity_permissions table.
|
||||||
|
|
||||||
|
Revision ID: 0051
|
||||||
|
Revises: 0050
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
|
||||||
|
revision = "0051"
|
||||||
|
down_revision = "0050"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Migrate existing contact_folder_permissions to entity_permissions
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO entity_permissions (id, entity_type, entity_id, principal_type, principal_id, permission_level, tenant_id, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
'contact_folder',
|
||||||
|
folder_id,
|
||||||
|
CASE
|
||||||
|
WHEN user_id IS NOT NULL THEN 'user'
|
||||||
|
WHEN group_id IS NOT NULL THEN 'group'
|
||||||
|
END,
|
||||||
|
COALESCE(user_id, group_id),
|
||||||
|
permission_level,
|
||||||
|
tenant_id,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM contact_folder_permissions
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DELETE FROM entity_permissions WHERE entity_type = 'contact_folder'")
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Create PostgreSQL RLS policies for row-level security on contacts.
|
||||||
|
|
||||||
|
Revision ID: 0052
|
||||||
|
Revises: 0051
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
|
||||||
|
This migration enables PostgreSQL Row-Level Security on the contacts table
|
||||||
|
and creates policies that enforce visibility based on:
|
||||||
|
1. System admin sees everything
|
||||||
|
2. Owner sees own rows
|
||||||
|
3. Tenant-owned (owner_id IS NULL) visible to all
|
||||||
|
4. Shared via entity_permissions
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0052"
|
||||||
|
down_revision = "0051"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Enable RLS on contacts table
|
||||||
|
op.execute("ALTER TABLE contacts ENABLE ROW LEVEL SECURITY")
|
||||||
|
|
||||||
|
# Policy: System admin sees everything
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY contacts_admin_visible ON contacts
|
||||||
|
FOR ALL
|
||||||
|
USING (current_setting('app.is_system_admin', true) = 'true')
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Policy: Owner sees own rows
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY contacts_owner_visible ON contacts
|
||||||
|
FOR ALL
|
||||||
|
USING (
|
||||||
|
owner_id::text = current_setting('app.current_user_id', true)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Policy: Tenant-owned (owner_id IS NULL) visible to all in tenant
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY contacts_tenant_owned_visible ON contacts
|
||||||
|
FOR ALL
|
||||||
|
USING (owner_id IS NULL)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Policy: Shared via entity_permissions
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY contacts_shared_visible ON contacts
|
||||||
|
FOR ALL
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM entity_permissions ep
|
||||||
|
WHERE ep.entity_type = 'contact'
|
||||||
|
AND ep.entity_id = contacts.id
|
||||||
|
AND ep.tenant_id = contacts.tenant_id
|
||||||
|
AND ep.permission_level != 'none'
|
||||||
|
AND (
|
||||||
|
ep.expires_at IS NULL OR ep.expires_at > NOW()
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
(ep.principal_type = 'user'
|
||||||
|
AND ep.principal_id::text = current_setting('app.current_user_id', true))
|
||||||
|
OR
|
||||||
|
(ep.principal_type = 'group'
|
||||||
|
AND ep.principal_id::text = ANY(
|
||||||
|
string_to_array(current_setting('app.current_user_groups', true), ',')
|
||||||
|
))
|
||||||
|
OR
|
||||||
|
(ep.principal_type = 'role'
|
||||||
|
AND ep.principal_id IN (
|
||||||
|
SELECT ut.role_id FROM user_tenants ut
|
||||||
|
WHERE ut.user_id::text = current_setting('app.current_user_id', true)
|
||||||
|
AND ut.tenant_id = contacts.tenant_id
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP POLICY IF EXISTS contacts_shared_visible ON contacts")
|
||||||
|
op.execute("DROP POLICY IF EXISTS contacts_tenant_owned_visible ON contacts")
|
||||||
|
op.execute("DROP POLICY IF EXISTS contacts_owner_visible ON contacts")
|
||||||
|
op.execute("DROP POLICY IF EXISTS contacts_admin_visible ON contacts")
|
||||||
|
op.execute("ALTER TABLE contacts DISABLE ROW LEVEL SECURITY")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Add owner_id to mail_accounts for row-level permissions.
|
||||||
|
|
||||||
|
Revision ID: 0053
|
||||||
|
Revises: 0052
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
|
||||||
|
This migration adds owner_id to mail_accounts so that the universal
|
||||||
|
visibility/permission system (apply_visibility_filter, check_single_entity_access)
|
||||||
|
can be used for mail accounts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "0053"
|
||||||
|
down_revision = "0052"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.execute("ALTER TABLE IF EXISTS mail_accounts ADD COLUMN IF NOT EXISTS owner_id UUID REFERENCES users(id) ON DELETE SET NULL")
|
||||||
|
op.execute("DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'mail_accounts') THEN CREATE INDEX IF NOT EXISTS ix_mail_accounts_owner ON mail_accounts (owner_id); END IF; END $$")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("ix_mail_accounts_owner", table_name="mail_accounts")
|
||||||
|
op.drop_column("mail_accounts", "owner_id")
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Add owner_id to plugin entity tables for row-level ownership.
|
||||||
|
|
||||||
|
Revision ID: 0054
|
||||||
|
Revises: 0053
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
|
||||||
|
revision = "0054"
|
||||||
|
down_revision = "0053"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# Tables that need owner_id
|
||||||
|
TABLES = [
|
||||||
|
"files",
|
||||||
|
"folders",
|
||||||
|
"calendar_entries",
|
||||||
|
"calendars",
|
||||||
|
"tasks",
|
||||||
|
"subtasks",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Check which columns already exist before adding
|
||||||
|
conn = op.get_bind()
|
||||||
|
for table in TABLES:
|
||||||
|
# Check if table exists
|
||||||
|
table_exists = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM information_schema.tables WHERE table_name = :table"
|
||||||
|
),
|
||||||
|
{"table": table},
|
||||||
|
).fetchone()
|
||||||
|
if table_exists is None:
|
||||||
|
continue
|
||||||
|
# Check if column already exists
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_name = :table AND column_name = 'owner_id'"
|
||||||
|
),
|
||||||
|
{"table": table},
|
||||||
|
)
|
||||||
|
if result.fetchone() is None:
|
||||||
|
op.execute(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS owner_id UUID REFERENCES users(id) ON DELETE SET NULL")
|
||||||
|
op.execute(f"CREATE INDEX IF NOT EXISTS ix_{table}_owner ON {table} (owner_id)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table in TABLES:
|
||||||
|
try:
|
||||||
|
op.drop_index(f"ix_{table}_owner", table_name=table)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
op.drop_column(table, "owner_id")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Create entity_policies table for ABAC engine.
|
||||||
|
|
||||||
|
Revision ID: 0055
|
||||||
|
Revises: 0054
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||||
|
|
||||||
|
revision = "0055"
|
||||||
|
down_revision = "0054"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"entity_policies",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("name", sa.String(200), nullable=False),
|
||||||
|
sa.Column("entity_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("principal_type", sa.String(10), nullable=False),
|
||||||
|
sa.Column("principal_id", PGUUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("effect", sa.String(10), nullable=False, server_default=sa.text("'allow'")),
|
||||||
|
sa.Column("conditions", JSONB, nullable=True),
|
||||||
|
sa.Column("priority", sa.Integer, nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean, nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"principal_type IN ('user', 'group', 'role')",
|
||||||
|
name="ck_epol_principal_type",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"effect IN ('allow', 'deny')",
|
||||||
|
name="ck_epol_effect",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_epol_entity_type ON entity_policies (entity_type)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_epol_principal ON entity_policies (principal_type, principal_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_epol_tenant ON entity_policies (tenant_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_epol_priority ON entity_policies (priority)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_epol_enabled ON entity_policies (enabled)')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_epol_enabled", table_name="entity_policies")
|
||||||
|
op.drop_index("ix_epol_priority", table_name="entity_policies")
|
||||||
|
op.drop_index("ix_epol_tenant", table_name="entity_policies")
|
||||||
|
op.drop_index("ix_epol_principal", table_name="entity_policies")
|
||||||
|
op.drop_index("ix_epol_entity_type", table_name="entity_policies")
|
||||||
|
op.drop_table("entity_policies")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Create permission_templates table.
|
||||||
|
|
||||||
|
Revision ID: 0056
|
||||||
|
Revises: 0055
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||||
|
|
||||||
|
revision = "0056"
|
||||||
|
down_revision = "0055"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"permission_templates",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("name", sa.String(200), nullable=False),
|
||||||
|
sa.Column("entity_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("trigger_condition", JSONB, nullable=True),
|
||||||
|
sa.Column("auto_share_with", JSONB, nullable=True),
|
||||||
|
sa.Column("level", sa.String(20), nullable=False, server_default=sa.text("'read'")),
|
||||||
|
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"level IN ('read', 'write', 'admin', 'delete')",
|
||||||
|
name="ck_pt_level",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_pt_entity_type ON permission_templates (entity_type)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_pt_tenant ON permission_templates (tenant_id)')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_pt_tenant", table_name="permission_templates")
|
||||||
|
op.drop_index("ix_pt_entity_type", table_name="permission_templates")
|
||||||
|
op.drop_table("permission_templates")
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Create permission_delegations table.
|
||||||
|
|
||||||
|
Revision ID: 0057
|
||||||
|
Revises: 0056
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||||
|
|
||||||
|
revision = "0057"
|
||||||
|
down_revision = "0056"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"permission_delegations",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("from_user_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("to_user_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("start_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("end_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("scope", JSONB, nullable=True),
|
||||||
|
sa.Column("active", sa.Boolean, nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"end_at > start_at",
|
||||||
|
name="ck_pd_end_after_start",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_pd_from_user ON permission_delegations (from_user_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_pd_to_user ON permission_delegations (to_user_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_pd_tenant ON permission_delegations (tenant_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_pd_active ON permission_delegations (active)')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_pd_active", table_name="permission_delegations")
|
||||||
|
op.drop_index("ix_pd_tenant", table_name="permission_delegations")
|
||||||
|
op.drop_index("ix_pd_to_user", table_name="permission_delegations")
|
||||||
|
op.drop_index("ix_pd_from_user", table_name="permission_delegations")
|
||||||
|
op.drop_table("permission_delegations")
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Add resolution_strategy field to tenants table.
|
||||||
|
|
||||||
|
Revision ID: 0058
|
||||||
|
Revises: 0057
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0058"
|
||||||
|
down_revision = "0057"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"tenants",
|
||||||
|
sa.Column(
|
||||||
|
"resolution_strategy",
|
||||||
|
sa.String(30),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'highest_wins'"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_tenant_resolution_strategy",
|
||||||
|
"tenants",
|
||||||
|
"resolution_strategy IN ('highest_wins', 'deny_overrides_allow', 'direct_overrides_group', 'most_restrictive_wins')",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("ck_tenant_resolution_strategy", "tenants")
|
||||||
|
op.drop_column("tenants", "resolution_strategy")
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Create guest_users table.
|
||||||
|
|
||||||
|
Revision ID: 0059
|
||||||
|
Revises: 0058
|
||||||
|
Create Date: 2026-07-29 02:47:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0059"
|
||||||
|
down_revision: str | None = "0058"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"guest_users",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("email", sa.String(255), nullable=False),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("password_hash", sa.String(255), nullable=True),
|
||||||
|
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("invited_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default="invited"),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_guest_users_email_tenant ON guest_users (email, tenant_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_guest_users_status ON guest_users (status, tenant_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_guest_users_invited_by ON guest_users (invited_by)')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_guest_users_invited_by", table_name="guest_users")
|
||||||
|
op.drop_index("ix_guest_users_status", table_name="guest_users")
|
||||||
|
op.drop_index("ix_guest_users_email_tenant", table_name="guest_users")
|
||||||
|
op.drop_table("guest_users")
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""Fix RLS policies on contacts — add tenant_id isolation.
|
||||||
|
|
||||||
|
Revision ID: 0060
|
||||||
|
Revises: 0059
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
|
||||||
|
This migration drops the insecure contact RLS policies (created in 0052)
|
||||||
|
and recreates them with proper tenant_id isolation.
|
||||||
|
|
||||||
|
Problems fixed:
|
||||||
|
1. contacts_tenant_owned_visible had USING (owner_id IS NULL) without tenant_id check
|
||||||
|
2. contacts_admin_visible had no tenant_id check
|
||||||
|
3. contacts_owner_visible had no tenant_id check
|
||||||
|
4. All policies used FOR ALL instead of separate SELECT/INSERT/UPDATE/DELETE
|
||||||
|
5. No WITH CHECK on write operations
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0060"
|
||||||
|
down_revision = "0059"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Drop all existing contact policies
|
||||||
|
op.execute("DROP POLICY IF EXISTS contacts_admin_visible ON contacts")
|
||||||
|
op.execute("DROP POLICY IF EXISTS contacts_owner_visible ON contacts")
|
||||||
|
op.execute("DROP POLICY IF EXISTS contacts_tenant_owned_visible ON contacts")
|
||||||
|
op.execute("DROP POLICY IF EXISTS contacts_shared_visible ON contacts")
|
||||||
|
op.execute("DROP POLICY IF EXISTS tenant_isolation ON contacts")
|
||||||
|
|
||||||
|
# ── Restrive policy: Tenant isolation (always enforced) ──
|
||||||
|
# This is the base policy that ALL other permissive policies are ANDed with
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY contacts_tenant_isolation ON contacts
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid)
|
||||||
|
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# ── Permissive policies for SELECT (visibility) ──
|
||||||
|
|
||||||
|
# System admin sees everything (within tenant)
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY contacts_admin_select ON contacts
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
current_setting('app.is_system_admin', true) = 'true'
|
||||||
|
AND tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Owner sees own rows (within tenant)
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY contacts_owner_select ON contacts
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
owner_id::text = current_setting('app.current_user_id', true)
|
||||||
|
AND tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Tenant-owned (owner_id IS NULL) visible to all in tenant
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY contacts_tenant_owned_select ON contacts
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
owner_id IS NULL
|
||||||
|
AND tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Shared via entity_permissions (within tenant)
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY contacts_shared_select ON contacts
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM entity_permissions ep
|
||||||
|
WHERE ep.entity_type = 'contact'
|
||||||
|
AND ep.entity_id = contacts.id
|
||||||
|
AND ep.tenant_id = contacts.tenant_id
|
||||||
|
AND ep.permission_level != 'none'
|
||||||
|
AND (
|
||||||
|
ep.expires_at IS NULL OR ep.expires_at > NOW()
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
(ep.principal_type = 'user'
|
||||||
|
AND ep.principal_id::text = current_setting('app.current_user_id', true))
|
||||||
|
OR
|
||||||
|
(ep.principal_type = 'group'
|
||||||
|
AND ep.principal_id::text = ANY(
|
||||||
|
string_to_array(current_setting('app.current_user_groups', true), ',')
|
||||||
|
))
|
||||||
|
OR
|
||||||
|
(ep.principal_type = 'role'
|
||||||
|
AND ep.principal_id IN (
|
||||||
|
SELECT ut.role_id FROM user_tenants ut
|
||||||
|
WHERE ut.user_id::text = current_setting('app.current_user_id', true)
|
||||||
|
AND ut.tenant_id = contacts.tenant_id
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# ── Permissive policies for INSERT ──
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY contacts_insert_policy ON contacts
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
||||||
|
AND (
|
||||||
|
current_setting('app.is_system_admin', true) = 'true'
|
||||||
|
OR owner_id::text = current_setting('app.current_user_id', true)
|
||||||
|
OR owner_id IS NULL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# ── Permissive policies for UPDATE ──
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY contacts_update_policy ON contacts
|
||||||
|
FOR UPDATE
|
||||||
|
USING (
|
||||||
|
tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
||||||
|
AND (
|
||||||
|
current_setting('app.is_system_admin', true) = 'true'
|
||||||
|
OR owner_id::text = current_setting('app.current_user_id', true)
|
||||||
|
OR owner_id IS NULL
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM entity_permissions ep
|
||||||
|
WHERE ep.entity_type = 'contact'
|
||||||
|
AND ep.entity_id = contacts.id
|
||||||
|
AND ep.tenant_id = contacts.tenant_id
|
||||||
|
AND ep.permission_level IN ('write', 'admin', 'delete')
|
||||||
|
AND (
|
||||||
|
ep.expires_at IS NULL OR ep.expires_at > NOW()
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
(ep.principal_type = 'user'
|
||||||
|
AND ep.principal_id::text = current_setting('app.current_user_id', true))
|
||||||
|
OR
|
||||||
|
(ep.principal_type = 'group'
|
||||||
|
AND ep.principal_id::text = ANY(
|
||||||
|
string_to_array(current_setting('app.current_user_groups', true), ',')
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# ── Permissive policies for DELETE ──
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY contacts_delete_policy ON contacts
|
||||||
|
FOR DELETE
|
||||||
|
USING (
|
||||||
|
tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
||||||
|
AND (
|
||||||
|
current_setting('app.is_system_admin', true) = 'true'
|
||||||
|
OR owner_id::text = current_setting('app.current_user_id', true)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM entity_permissions ep
|
||||||
|
WHERE ep.entity_type = 'contact'
|
||||||
|
AND ep.entity_id = contacts.id
|
||||||
|
AND ep.tenant_id = contacts.tenant_id
|
||||||
|
AND ep.permission_level IN ('admin', 'delete')
|
||||||
|
AND (
|
||||||
|
ep.expires_at IS NULL OR ep.expires_at > NOW()
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
(ep.principal_type = 'user'
|
||||||
|
AND ep.principal_id::text = current_setting('app.current_user_id', true))
|
||||||
|
OR
|
||||||
|
(ep.principal_type = 'group'
|
||||||
|
AND ep.principal_id::text = ANY(
|
||||||
|
string_to_array(current_setting('app.current_user_groups', true), ',')
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop the new secure policies
|
||||||
|
op.execute("DROP POLICY IF EXISTS contacts_tenant_isolation ON contacts")
|
||||||
|
op.execute("DROP POLICY IF EXISTS contacts_admin_select ON contacts")
|
||||||
|
op.execute("DROP POLICY IF EXISTS contacts_owner_select ON contacts")
|
||||||
|
op.execute("DROP POLICY IF EXISTS contacts_tenant_owned_select ON contacts")
|
||||||
|
op.execute("DROP POLICY IF EXISTS contacts_shared_select ON contacts")
|
||||||
|
op.execute("DROP POLICY IF EXISTS contacts_insert_policy ON contacts")
|
||||||
|
op.execute("DROP POLICY IF EXISTS contacts_update_policy ON contacts")
|
||||||
|
op.execute("DROP POLICY IF EXISTS contacts_delete_policy ON contacts")
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Fix DB roles — add default privileges and grants for all tables.
|
||||||
|
|
||||||
|
Revision ID: 0061
|
||||||
|
Revises: 0060
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
|
||||||
|
Problems fixed:
|
||||||
|
1. crm_runtime role has no grants on tables created after migration 0044
|
||||||
|
2. No ALTER DEFAULT PRIVILEGES for future tables
|
||||||
|
3. Auth tables (users, tenants, user_tenants, user_groups) need SELECT grants
|
||||||
|
4. New permission/guest/policy tables need grants
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0061"
|
||||||
|
down_revision = "0060"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Grant privileges on all existing tables to crm_runtime
|
||||||
|
op.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO crm_runtime")
|
||||||
|
|
||||||
|
# Grant USAGE on sequences
|
||||||
|
op.execute("GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO crm_runtime")
|
||||||
|
|
||||||
|
# Default privileges for future tables created by migration owner
|
||||||
|
op.execute("ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO crm_runtime")
|
||||||
|
op.execute("ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO crm_runtime")
|
||||||
|
|
||||||
|
# Ensure RLS is enabled on all tenant tables that have tenant_id
|
||||||
|
# (covers tables created after migration 0044 that missed RLS)
|
||||||
|
tenant_tables = [
|
||||||
|
"entity_permissions",
|
||||||
|
"entity_policies",
|
||||||
|
"permission_templates",
|
||||||
|
"guest_users",
|
||||||
|
"contact_folder_permissions",
|
||||||
|
]
|
||||||
|
for table in tenant_tables:
|
||||||
|
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||||
|
# Create tenant isolation policy if not exists
|
||||||
|
op.execute(f"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_policy
|
||||||
|
WHERE polname = '{table}_tenant_isolation'
|
||||||
|
AND polrelid = '{table}'::regclass
|
||||||
|
) THEN
|
||||||
|
CREATE POLICY {table}_tenant_isolation ON {table}
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid)
|
||||||
|
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Revoke default privileges
|
||||||
|
op.execute("ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM crm_runtime")
|
||||||
|
op.execute("ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE USAGE, SELECT ON SEQUENCES FROM crm_runtime")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Fix guest invitation security — separate token table.
|
||||||
|
|
||||||
|
Revision ID: 0062
|
||||||
|
Revises: 0061
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
|
||||||
|
Problems fixed:
|
||||||
|
1. Guest UUID was used as invitation token (P1.6)
|
||||||
|
2. No separate token with sufficient entropy
|
||||||
|
3. No one-time use tracking
|
||||||
|
4. No session revocation on guest deletion
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "0062"
|
||||||
|
down_revision = "0061"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"guest_invitations",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("guest_user_id", UUID(as_uuid=True), sa.ForeignKey("guest_users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("token_hash", sa.String(64), nullable=False, unique=True, index=True),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE guest_invitations ENABLE ROW LEVEL SECURITY")
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY guest_invitations_tenant_isolation ON guest_invitations
|
||||||
|
FOR ALL
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM guest_users gu
|
||||||
|
WHERE gu.id = guest_invitations.guest_user_id
|
||||||
|
AND gu.tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("guest_invitations")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Add entity_type and entity_id to notifications table.
|
||||||
|
|
||||||
|
Revision ID: 0063
|
||||||
|
Revises: 0062
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
|
||||||
|
The notification model has entity_type and entity_id fields but the DB
|
||||||
|
table was never migrated. This causes INSERT failures.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "0063"
|
||||||
|
down_revision = "0062"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE notifications ADD COLUMN IF NOT EXISTS entity_type VARCHAR(50)")
|
||||||
|
op.execute("CREATE INDEX IF NOT EXISTS ix_notifications_entity_type ON notifications (entity_type)")
|
||||||
|
op.execute("ALTER TABLE notifications ADD COLUMN IF NOT EXISTS entity_id UUID")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("notifications", "entity_id")
|
||||||
|
op.drop_column("notifications", "entity_type")
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Enable RLS on all remaining tenant tables.
|
||||||
|
|
||||||
|
Revision ID: 0064
|
||||||
|
Revises: 0063
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
|
||||||
|
Currently RLS is only on contacts. This migration enables RLS on all
|
||||||
|
tenant-scoped tables that have a tenant_id column but no RLS yet.
|
||||||
|
|
||||||
|
System tables (users, tenants, groups, roles) are excluded — they need
|
||||||
|
special handling for the login bootstrap process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0064"
|
||||||
|
down_revision = "0063"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# Tables that should have RLS (tenant-scoped data)
|
||||||
|
TENANT_TABLES = [
|
||||||
|
"addresses",
|
||||||
|
"attachments",
|
||||||
|
"bank_accounts",
|
||||||
|
"contact_folders",
|
||||||
|
"contact_merge_history",
|
||||||
|
"workflows",
|
||||||
|
"sequences",
|
||||||
|
"saved_filters",
|
||||||
|
"saved_views",
|
||||||
|
"webhooks",
|
||||||
|
"custom_field_definitions",
|
||||||
|
"notifications",
|
||||||
|
"ai_conversations",
|
||||||
|
"contact_persons",
|
||||||
|
"tags",
|
||||||
|
"entity_links",
|
||||||
|
"dms_files",
|
||||||
|
"dms_folders",
|
||||||
|
"calendar_events",
|
||||||
|
"calendars",
|
||||||
|
"tasks",
|
||||||
|
"task_lists",
|
||||||
|
"mail_messages",
|
||||||
|
"mail_accounts",
|
||||||
|
"mail_folders",
|
||||||
|
"conversations",
|
||||||
|
"conversation_messages",
|
||||||
|
"conversation_participants",
|
||||||
|
"audit_log",
|
||||||
|
"permission_delegations",
|
||||||
|
"guest_invitations",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for table in TENANT_TABLES:
|
||||||
|
# Enable RLS if not already enabled
|
||||||
|
op.execute(f"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_class c
|
||||||
|
WHERE c.relname = '{table}'
|
||||||
|
AND c.relrowsecurity = true
|
||||||
|
) AND EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = '{table}'
|
||||||
|
AND column_name = 'tenant_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY {table}_tenant_isolation ON {table}
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid)
|
||||||
|
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table in TENANT_TABLES:
|
||||||
|
op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}")
|
||||||
|
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Add consumer_inbox table for outbox idempotency.
|
||||||
|
|
||||||
|
Revision ID: 0065
|
||||||
|
Revises: 0064
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
|
||||||
|
Without idempotency, a worker crash between sending an email/webhook
|
||||||
|
and marking the event as published can lead to duplicate deliveries.
|
||||||
|
|
||||||
|
This migration creates a consumer_inbox table that tracks which
|
||||||
|
consumers have already processed which events.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "0065"
|
||||||
|
down_revision = "0064"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"consumer_inbox",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("event_id", UUID(as_uuid=True), sa.ForeignKey("event_outbox.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("consumer_name", sa.String(100), nullable=False, index=True),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, default="pending"), # pending, processed, failed
|
||||||
|
sa.Column("processed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("error_message", sa.Text, nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.UniqueConstraint("event_id", "consumer_name", name="uq_consumer_inbox_event_consumer"),
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE consumer_inbox ENABLE ROW LEVEL SECURITY")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("consumer_inbox")
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Add tenant_plugin_activation table for per-tenant plugin activation.
|
||||||
|
|
||||||
|
Revision ID: 0066
|
||||||
|
Revises: 0065
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
|
||||||
|
Currently plugins are activated globally. This migration creates a
|
||||||
|
table for per-tenant plugin activation so that different tenants can
|
||||||
|
enable/disable plugins independently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "0066"
|
||||||
|
down_revision = "0065"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"tenant_plugin_activation",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("plugin_name", sa.String(100), nullable=False, index=True),
|
||||||
|
sa.Column("is_active", sa.Boolean, nullable=False, default=True),
|
||||||
|
sa.Column("activated_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.UniqueConstraint("tenant_id", "plugin_name", name="uq_tenant_plugin"),
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE tenant_plugin_activation ENABLE ROW LEVEL SECURITY")
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY tenant_plugin_activation_tenant_isolation ON tenant_plugin_activation
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid)
|
||||||
|
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("tenant_plugin_activation")
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Disable RLS on system identity tables to fix login bootstrap circle.
|
||||||
|
|
||||||
|
Revision ID: 0067
|
||||||
|
Revises: 0066
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
|
||||||
|
Problem: users, user_tenants, groups, roles have RLS enabled. The login
|
||||||
|
process needs to query these tables BEFORE a tenant context is set
|
||||||
|
(bootstrap circle: Login → Membership → Tenant-Context → Login).
|
||||||
|
|
||||||
|
RLS on these tables blocks login because there's no tenant context yet.
|
||||||
|
|
||||||
|
Solution: Disable RLS on system identity tables. Tenant isolation for
|
||||||
|
these tables is enforced at the application level (auth_service always
|
||||||
|
filters by user_id + tenant_id in queries).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0067"
|
||||||
|
down_revision = "0066"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# System identity tables — no RLS (needed for login bootstrap)
|
||||||
|
SYSTEM_TABLES = [
|
||||||
|
"users",
|
||||||
|
"user_tenants",
|
||||||
|
"groups",
|
||||||
|
"user_groups",
|
||||||
|
"roles",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for table in SYSTEM_TABLES:
|
||||||
|
# Drop any existing policies
|
||||||
|
op.execute(f"""
|
||||||
|
DO $$
|
||||||
|
DECLARE pol RECORD;
|
||||||
|
BEGIN
|
||||||
|
FOR pol IN
|
||||||
|
SELECT polname FROM pg_policy
|
||||||
|
WHERE polrelid = '{table}'::regclass
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('DROP POLICY IF EXISTS %I ON {table}', pol.polname);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
""")
|
||||||
|
# Disable RLS
|
||||||
|
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table in SYSTEM_TABLES:
|
||||||
|
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Add deleted_at to entity_permissions table.
|
||||||
|
|
||||||
|
Revision ID: 0068
|
||||||
|
Revises: 0067
|
||||||
|
Create Date: 2026-07-29
|
||||||
|
|
||||||
|
The EntityPermission model has SoftDeleteMixin but the table was never
|
||||||
|
migrated to include the deleted_at column.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "0068"
|
||||||
|
down_revision = "0067"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("entity_permissions", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.execute("CREATE INDEX IF NOT EXISTS ix_entity_permissions_deleted_at ON entity_permissions (deleted_at)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_entity_permissions_deleted_at", table_name="entity_permissions")
|
||||||
|
op.drop_column("entity_permissions", "deleted_at")
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Simplify RLS to pure tenant isolation.
|
||||||
|
|
||||||
|
Per architecture review: RLS should be the "safety belt" (tenant isolation only),
|
||||||
|
NOT the "vehicle control" (business authorization). Business authorization
|
||||||
|
(owner_id, sharing, entity_permissions) belongs in the application layer
|
||||||
|
(visibility.py with Defense-in-Depth tenant_id filter).
|
||||||
|
|
||||||
|
Revision ID: 0069
|
||||||
|
Revises: 0068
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision = "0069"
|
||||||
|
down_revision = "0068"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
RLS_TABLES = [
|
||||||
|
"contacts", "addresses", "attachments", "bank_accounts",
|
||||||
|
"contact_folders", "contact_folder_permissions", "entity_permissions",
|
||||||
|
"entity_policies", "event_outbox", "audit_log", "notifications",
|
||||||
|
"saved_filters", "saved_views", "webhooks", "workflow_instances",
|
||||||
|
"workflow_step_history", "sequences", "custom_field_definitions",
|
||||||
|
"custom_field_values", "guest_users", "guest_invitations",
|
||||||
|
"consumer_inbox", "tenant_plugin_activation", "permission_templates",
|
||||||
|
"permission_delegations", "dms_files", "dms_folders",
|
||||||
|
"calendar_events", "calendars", "tasks", "task_lists",
|
||||||
|
"messages", "channels", "entity_links", "tags", "tag_assignments",
|
||||||
|
"mail_accounts", "mail_messages", "mail_folders",
|
||||||
|
"report_templates", "report_generations", "ai_conversations",
|
||||||
|
"ai_messages", "automation_workflows", "automation_runs",
|
||||||
|
"mcp_server_configs", "mcp_client_configs", "system_notifications",
|
||||||
|
]
|
||||||
|
|
||||||
|
CONTACTS_POLICIES_TO_DROP = [
|
||||||
|
"contacts_admin_select", "contacts_owner_select",
|
||||||
|
"contacts_shared_select", "contacts_tenant_owned_select",
|
||||||
|
"contacts_delete_policy", "contacts_insert_policy",
|
||||||
|
"contacts_update_policy",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 1. Drop all business-logic RLS policies on contacts
|
||||||
|
for policy in CONTACTS_POLICIES_TO_DROP:
|
||||||
|
op.execute(f"DROP POLICY IF EXISTS {policy} ON contacts")
|
||||||
|
|
||||||
|
# 2. Drop old tenant_isolation policy on contacts
|
||||||
|
op.execute("DROP POLICY IF EXISTS contacts_tenant_isolation ON contacts")
|
||||||
|
|
||||||
|
# 3. Create simple tenant isolation for ALL operations on contacts
|
||||||
|
op.execute(
|
||||||
|
"CREATE POLICY contacts_tenant_isolation ON contacts "
|
||||||
|
"FOR ALL "
|
||||||
|
"USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid) "
|
||||||
|
"WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. For all other RLS tables: drop existing policies, create simple tenant isolation
|
||||||
|
for table in RLS_TABLES:
|
||||||
|
if table == "contacts":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if table exists first
|
||||||
|
table_exists = conn.execute(
|
||||||
|
text(f"SELECT 1 FROM information_schema.tables WHERE table_name = '{table}'")
|
||||||
|
).fetchone() is not None
|
||||||
|
|
||||||
|
if not table_exists:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get all existing policies on this table
|
||||||
|
result = conn.execute(
|
||||||
|
text(f"SELECT polname FROM pg_policy WHERE polrelid = '{table}'::regclass")
|
||||||
|
)
|
||||||
|
policies = [row[0] for row in result]
|
||||||
|
|
||||||
|
# Drop each policy
|
||||||
|
for policy in policies:
|
||||||
|
op.execute(f'DROP POLICY IF EXISTS "{policy}" ON {table}')
|
||||||
|
|
||||||
|
# Check if table has tenant_id column
|
||||||
|
col_result = conn.execute(
|
||||||
|
text(f"SELECT 1 FROM information_schema.columns "
|
||||||
|
f"WHERE table_name = '{table}' AND column_name = 'tenant_id'")
|
||||||
|
)
|
||||||
|
has_tenant_id = col_result.fetchone() is not None
|
||||||
|
|
||||||
|
if has_tenant_id:
|
||||||
|
op.execute(
|
||||||
|
f"CREATE POLICY {table}_tenant_isolation ON {table} "
|
||||||
|
"FOR ALL "
|
||||||
|
"USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid) "
|
||||||
|
"WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Create 4 separate DB roles for strict separation.
|
||||||
|
|
||||||
|
crm_migration: Schema owner, runs Alembic, BypassRLS
|
||||||
|
- Owns all tables, sequences, functions
|
||||||
|
- Can bypass RLS for migrations
|
||||||
|
- Never used by the API
|
||||||
|
|
||||||
|
crm_auth: Login bootstrap only
|
||||||
|
- Reads users, user_tenants, tenants, roles, groups
|
||||||
|
- NO RLS on system tables (already disabled)
|
||||||
|
- No general CRM data access
|
||||||
|
|
||||||
|
crm_api: Application runtime
|
||||||
|
- NOBYPASSRLS, NOSUPERUSER
|
||||||
|
- SELECT, INSERT, UPDATE, DELETE on all tables
|
||||||
|
- Tenant context is mandatory (RLS enforces it)
|
||||||
|
|
||||||
|
crm_worker: Background jobs
|
||||||
|
- NOBYPASSRLS, NOSUPERUSER
|
||||||
|
- Same data access as crm_api
|
||||||
|
- Tenant context set per job
|
||||||
|
|
||||||
|
Revision ID: 0070
|
||||||
|
Revises: 0069
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision = "0070"
|
||||||
|
down_revision = "0069"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 1. Create crm_migration role (schema owner, bypass RLS)
|
||||||
|
conn.execute(text("""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migration') THEN
|
||||||
|
CREATE ROLE crm_migration WITH LOGIN NOINHERIT;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
"""))
|
||||||
|
conn.execute(text("ALTER ROLE crm_migration WITH BYPASSRLS"))
|
||||||
|
|
||||||
|
# 2. Create crm_auth role (login bootstrap, no RLS on system tables)
|
||||||
|
conn.execute(text("""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_auth') THEN
|
||||||
|
CREATE ROLE crm_auth WITH LOGIN NOINHERIT;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
"""))
|
||||||
|
conn.execute(text("ALTER ROLE crm_auth WITH NOBYPASSRLS"))
|
||||||
|
# Grant read access to system tables only
|
||||||
|
conn.execute(text("GRANT SELECT ON users, user_tenants, tenants, roles, user_groups, groups TO crm_auth"))
|
||||||
|
|
||||||
|
# 3. Create crm_api role (application runtime, NOBYPASSRLS)
|
||||||
|
conn.execute(text("""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_api') THEN
|
||||||
|
CREATE ROLE crm_api WITH LOGIN NOINHERIT;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
"""))
|
||||||
|
conn.execute(text("ALTER ROLE crm_api WITH NOBYPASSRLS NOSUPERUSER"))
|
||||||
|
# Grant data access on all existing tables
|
||||||
|
conn.execute(text("GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO crm_api"))
|
||||||
|
conn.execute(text("GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO crm_api"))
|
||||||
|
# Default privileges for future tables
|
||||||
|
conn.execute(text("ALTER DEFAULT PRIVILEGES GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO crm_api"))
|
||||||
|
conn.execute(text("ALTER DEFAULT PRIVILEGES GRANT USAGE, SELECT ON SEQUENCES TO crm_api"))
|
||||||
|
|
||||||
|
# 4. Create crm_worker role (background jobs, NOBYPASSRLS)
|
||||||
|
conn.execute(text("""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_worker') THEN
|
||||||
|
CREATE ROLE crm_worker WITH LOGIN NOINHERIT;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
"""))
|
||||||
|
conn.execute(text("ALTER ROLE crm_worker WITH NOBYPASSRLS NOSUPERUSER"))
|
||||||
|
conn.execute(text("GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO crm_worker"))
|
||||||
|
conn.execute(text("GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO crm_worker"))
|
||||||
|
conn.execute(text("ALTER DEFAULT PRIVILEGES GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO crm_worker"))
|
||||||
|
conn.execute(text("ALTER DEFAULT PRIVILEGES GRANT USAGE, SELECT ON SEQUENCES TO crm_worker"))
|
||||||
|
|
||||||
|
# 5. Grant USAGE on schema to all roles
|
||||||
|
conn.execute(text("GRANT USAGE ON SCHEMA public TO crm_api, crm_worker, crm_auth, crm_migration"))
|
||||||
|
|
||||||
|
# 6. Set passwords (same as crm_user for now — will be changed in docker-compose)
|
||||||
|
# Passwords are set via environment variables in prestart.sh
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
conn.execute(text("DROP ROLE IF EXISTS crm_worker"))
|
||||||
|
conn.execute(text("DROP ROLE IF EXISTS crm_api"))
|
||||||
|
conn.execute(text("DROP ROLE IF EXISTS crm_auth"))
|
||||||
|
conn.execute(text("DROP ROLE IF EXISTS crm_migration"))
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Create entity_attachments table — references DMS files.
|
||||||
|
|
||||||
|
Instead of storing files in a separate attachment storage path,
|
||||||
|
all files go through the DMS (files table) and entity_attachments
|
||||||
|
just references the DMS file with entity_type/entity_id.
|
||||||
|
|
||||||
|
This unifies the storage layer: one upload path, one download path,
|
||||||
|
one permission model, one deduplication (content_hash).
|
||||||
|
|
||||||
|
Revision ID: 0071
|
||||||
|
Revises: 0070
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
|
||||||
|
revision = "0071"
|
||||||
|
down_revision = "0070"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create folders table if it doesn't exist (DMS plugin table normally created via create_all)
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS folders (
|
||||||
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
|
||||||
|
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_by UUID NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
||||||
|
deleted_at TIMESTAMP WITH TIME ZONE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_folders_parent ON folders (parent_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_folders_tenant ON folders (tenant_id)')
|
||||||
|
|
||||||
|
# Create files table if it doesn't exist (DMS plugin table normally created via create_all)
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||||
|
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
uploaded_by UUID NOT NULL,
|
||||||
|
mime_type VARCHAR(255) NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
storage_path VARCHAR(1024) NOT NULL,
|
||||||
|
content_hash VARCHAR(64),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
||||||
|
deleted_at TIMESTAMP WITH TIME ZONE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_files_folder ON files (folder_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_files_tenant ON files (tenant_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_files_name ON files (name)')
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"entity_attachments",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("entity_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("entity_id", PGUUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("dms_file_id", PGUUID(as_uuid=True), sa.ForeignKey("files.id", ondelete="RESTRICT"), nullable=False),
|
||||||
|
sa.Column("category", sa.String(50), nullable=True),
|
||||||
|
sa.Column("display_name", sa.String(255), nullable=True),
|
||||||
|
sa.Column("owner_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_by", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_entity_attachments_entity ON entity_attachments (entity_type, entity_id, tenant_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_entity_attachments_tenant ON entity_attachments (tenant_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_entity_attachments_dms_file ON entity_attachments (dms_file_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_entity_attachments_owner ON entity_attachments (owner_id)')
|
||||||
|
|
||||||
|
# Enable RLS on entity_attachments (tenant isolation)
|
||||||
|
op.execute("ALTER TABLE entity_attachments ENABLE ROW LEVEL SECURITY")
|
||||||
|
op.execute(
|
||||||
|
"CREATE POLICY entity_attachments_tenant_isolation ON entity_attachments "
|
||||||
|
"FOR ALL "
|
||||||
|
"USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid) "
|
||||||
|
"WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Grant to crm_api and crm_worker
|
||||||
|
op.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON entity_attachments TO crm_api, crm_worker")
|
||||||
|
op.execute("GRANT USAGE ON SCHEMA public TO crm_api, crm_worker")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP POLICY IF EXISTS entity_attachments_tenant_isolation ON entity_attachments")
|
||||||
|
op.drop_table("entity_attachments")
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Migration: Create workspace tables.
|
||||||
|
|
||||||
|
Revision ID: 0072
|
||||||
|
Revises: 0071
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||||
|
|
||||||
|
revision = "0072"
|
||||||
|
down_revision = "0071"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# workspaces
|
||||||
|
op.create_table(
|
||||||
|
"workspaces",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("icon", sa.String(50), nullable=False, server_default="LayoutGrid"),
|
||||||
|
sa.Column("description", sa.String(500), nullable=True),
|
||||||
|
sa.Column("is_default", sa.Boolean, nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("created_by", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.UniqueConstraint("tenant_id", "name", name="uq_workspaces_tenant_name"),
|
||||||
|
)
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_workspaces_tenant ON workspaces (tenant_id)')
|
||||||
|
op.execute(
|
||||||
|
"CREATE UNIQUE INDEX uq_workspace_default_per_tenant "
|
||||||
|
"ON workspaces (tenant_id) WHERE is_default = true"
|
||||||
|
)
|
||||||
|
|
||||||
|
# workspace_modules
|
||||||
|
op.create_table(
|
||||||
|
"workspace_modules",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("workspace_id", PGUUID(as_uuid=True), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("module_key", sa.String(100), nullable=False),
|
||||||
|
sa.Column("is_visible", sa.Boolean, nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("menu_order", sa.Integer, nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("config", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.UniqueConstraint("tenant_id", "workspace_id", "module_key", name="uq_wm_tenant_workspace_module"),
|
||||||
|
)
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_wm_workspace ON workspace_modules (tenant_id, workspace_id, menu_order)')
|
||||||
|
|
||||||
|
# workspace_users
|
||||||
|
op.create_table(
|
||||||
|
"workspace_users",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("workspace_id", PGUUID(as_uuid=True), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("user_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("role", sa.String(20), nullable=False, server_default="member"),
|
||||||
|
sa.Column("is_default", sa.Boolean, nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("assigned_by", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("assigned_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.UniqueConstraint("tenant_id", "workspace_id", "user_id", name="uq_wu_tenant_workspace_user"),
|
||||||
|
sa.CheckConstraint("role IN ('member', 'manager')", name="ck_wu_role"),
|
||||||
|
)
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_wu_workspace ON workspace_users (tenant_id, workspace_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_wu_user ON workspace_users (tenant_id, user_id)')
|
||||||
|
|
||||||
|
# workspace_widgets
|
||||||
|
op.create_table(
|
||||||
|
"workspace_widgets",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("workspace_id", PGUUID(as_uuid=True), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("widget_key", sa.String(100), nullable=False),
|
||||||
|
sa.Column("position_x", sa.Integer, nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("position_y", sa.Integer, nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("width", sa.Integer, nullable=False, server_default=sa.text("1")),
|
||||||
|
sa.Column("height", sa.Integer, nullable=False, server_default=sa.text("1")),
|
||||||
|
sa.Column("config", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
)
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_ww_workspace ON workspace_widgets (tenant_id, workspace_id)')
|
||||||
|
|
||||||
|
# RLS on all workspace tables
|
||||||
|
for table in ["workspaces", "workspace_modules", "workspace_users", "workspace_widgets"]:
|
||||||
|
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||||
|
op.execute(
|
||||||
|
f"CREATE POLICY {table}_tenant_isolation ON {table} "
|
||||||
|
"FOR ALL "
|
||||||
|
"USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid) "
|
||||||
|
"WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid)"
|
||||||
|
)
|
||||||
|
op.execute(f"GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO crm_api, crm_worker")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table in ["workspace_widgets", "workspace_users", "workspace_modules", "workspaces"]:
|
||||||
|
op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}")
|
||||||
|
op.drop_table(table)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Add deleted_at to workspace tables (TenantMixin includes SoftDeleteMixin).
|
||||||
|
|
||||||
|
Revision ID: 0073
|
||||||
|
Revises: 0072
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0073"
|
||||||
|
down_revision = "0072"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
TABLES = ["workspaces", "workspace_modules", "workspace_users", "workspace_widgets"]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for table in TABLES:
|
||||||
|
op.add_column(table, sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.execute(f"CREATE INDEX IF NOT EXISTS ix_{table}_deleted_at ON {table} (deleted_at)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table in TABLES:
|
||||||
|
op.drop_index(f"ix_{table}_deleted_at", table_name=table)
|
||||||
|
op.drop_column(table, "deleted_at")
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Add created_at/updated_at to workspace_users and workspace_widgets.
|
||||||
|
|
||||||
|
TenantMixin inherits from TimestampMixin which adds created_at and updated_at.
|
||||||
|
Migration 0072 only added assigned_at to workspace_users, not created_at/updated_at.
|
||||||
|
|
||||||
|
Revision ID: 0074
|
||||||
|
Revises: 0073
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0074"
|
||||||
|
down_revision = "0073"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# workspace_users: add created_at and updated_at
|
||||||
|
op.execute("ALTER TABLE workspace_users ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()")
|
||||||
|
op.execute("ALTER TABLE workspace_users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()")
|
||||||
|
|
||||||
|
# workspace_widgets: already has created_at/updated_at from migration 0072
|
||||||
|
# workspace_modules: already has created_at/updated_at from migration 0072
|
||||||
|
# workspaces: already has created_at/updated_at from migration 0072
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("workspace_users", "updated_at")
|
||||||
|
op.drop_column("workspace_users", "created_at")
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Add outbox_deliveries table and envelope columns to event_outbox.
|
||||||
|
|
||||||
|
Standardized Event-Envelope:
|
||||||
|
- event_id (already exists as id)
|
||||||
|
- event_type (already exists as event_name)
|
||||||
|
- tenant_id (already exists)
|
||||||
|
- aggregate_type (NEW)
|
||||||
|
- aggregate_id (NEW)
|
||||||
|
- occurred_at (NEW)
|
||||||
|
- correlation_id (NEW)
|
||||||
|
- schema_version (NEW, default 1)
|
||||||
|
- payload (already exists)
|
||||||
|
|
||||||
|
outbox_deliveries tracks per-consumer delivery status.
|
||||||
|
An event is only 'published' when all mandatory deliveries succeed.
|
||||||
|
|
||||||
|
Revision ID: 0075
|
||||||
|
Revises: 0074
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
|
||||||
|
revision = "0075"
|
||||||
|
down_revision = "0074"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. Add envelope columns to event_outbox
|
||||||
|
op.execute("ALTER TABLE IF EXISTS event_outbox ADD COLUMN IF NOT EXISTS aggregate_type VARCHAR(100)")
|
||||||
|
op.execute("ALTER TABLE IF EXISTS event_outbox ADD COLUMN IF NOT EXISTS aggregate_id UUID")
|
||||||
|
op.execute("ALTER TABLE IF EXISTS event_outbox ADD COLUMN IF NOT EXISTS occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()")
|
||||||
|
op.execute("ALTER TABLE IF EXISTS event_outbox ADD COLUMN IF NOT EXISTS correlation_id UUID")
|
||||||
|
op.execute("ALTER TABLE IF EXISTS event_outbox ADD COLUMN IF NOT EXISTS schema_version INTEGER NOT NULL DEFAULT 1")
|
||||||
|
|
||||||
|
op.execute("DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'event_outbox') THEN CREATE INDEX IF NOT EXISTS ix_event_outbox_aggregate ON event_outbox (tenant_id, aggregate_type, aggregate_id); END IF; END $$")
|
||||||
|
op.execute("DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'event_outbox') THEN CREATE INDEX IF NOT EXISTS ix_event_outbox_correlation ON event_outbox (correlation_id); END IF; END $$")
|
||||||
|
|
||||||
|
# 2. Create outbox_deliveries table
|
||||||
|
op.create_table(
|
||||||
|
"outbox_deliveries",
|
||||||
|
sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("event_id", PGUUID(as_uuid=True), sa.ForeignKey("event_outbox.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("consumer_name", sa.String(150), nullable=False),
|
||||||
|
sa.Column("status", sa.String(30), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("attempt_count", sa.Integer, nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_error", sa.Text, nullable=True),
|
||||||
|
sa.Column("processed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
||||||
|
sa.UniqueConstraint("event_id", "consumer_name", name="uq_outbox_deliveries_event_consumer"),
|
||||||
|
)
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_outbox_deliveries_event ON outbox_deliveries (event_id)')
|
||||||
|
op.execute('CREATE INDEX IF NOT EXISTS ix_outbox_deliveries_status ON outbox_deliveries (status, next_attempt_at)')
|
||||||
|
|
||||||
|
# RLS + Grants
|
||||||
|
op.execute("ALTER TABLE outbox_deliveries ENABLE ROW LEVEL SECURITY")
|
||||||
|
op.execute(
|
||||||
|
"CREATE POLICY outbox_deliveries_tenant_isolation ON outbox_deliveries "
|
||||||
|
"FOR ALL "
|
||||||
|
"USING (EXISTS (SELECT 1 FROM event_outbox WHERE event_outbox.id = outbox_deliveries.event_id AND event_outbox.tenant_id = current_setting('app.current_tenant_id', true)::uuid)) "
|
||||||
|
"WITH CHECK (EXISTS (SELECT 1 FROM event_outbox WHERE event_outbox.id = outbox_deliveries.event_id AND event_outbox.tenant_id = current_setting('app.current_tenant_id', true)::uuid))"
|
||||||
|
)
|
||||||
|
op.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON outbox_deliveries TO crm_api, crm_worker")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP POLICY IF EXISTS outbox_deliveries_tenant_isolation ON outbox_deliveries")
|
||||||
|
op.drop_table("outbox_deliveries")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_event_outbox_correlation")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_event_outbox_aggregate")
|
||||||
|
op.drop_column("event_outbox", "schema_version")
|
||||||
|
op.drop_column("event_outbox", "correlation_id")
|
||||||
|
op.drop_column("event_outbox", "occurred_at")
|
||||||
|
op.drop_column("event_outbox", "aggregate_id")
|
||||||
|
op.drop_column("event_outbox", "aggregate_type")
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Disable RLS on startup/system tables that are read without tenant context.
|
||||||
|
|
||||||
|
These tables are accessed during app startup or login before a tenant context
|
||||||
|
is set. RLS would block these queries and prevent the app from starting.
|
||||||
|
|
||||||
|
Security: These tables are either system-wide (currencies, taxes, sequences,
|
||||||
|
system_settings) or user-specific (saved_filters, saved_views, webhooks) and
|
||||||
|
are protected by application-level authorization.
|
||||||
|
|
||||||
|
Revision ID: 0076
|
||||||
|
Revises: 0075
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision = "0076"
|
||||||
|
down_revision = "0075"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
TABLES = [
|
||||||
|
"system_settings",
|
||||||
|
"currencies",
|
||||||
|
"taxes",
|
||||||
|
"sequences",
|
||||||
|
"saved_filters",
|
||||||
|
"saved_views",
|
||||||
|
"webhooks",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
for table in TABLES:
|
||||||
|
# Check if table exists
|
||||||
|
exists = conn.execute(
|
||||||
|
text(f"SELECT 1 FROM information_schema.tables WHERE table_name = '{table}'")
|
||||||
|
).fetchone() is not None
|
||||||
|
if not exists:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Drop RLS policy if exists
|
||||||
|
conn.execute(text(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}"))
|
||||||
|
# Disable RLS
|
||||||
|
conn.execute(text(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
for table in TABLES:
|
||||||
|
exists = conn.execute(
|
||||||
|
text(f"SELECT 1 FROM information_schema.tables WHERE table_name = '{table}'")
|
||||||
|
).fetchone() is not None
|
||||||
|
if not exists:
|
||||||
|
continue
|
||||||
|
conn.execute(text(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY"))
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
f"CREATE POLICY {table}_tenant_isolation ON {table} "
|
||||||
|
"FOR ALL "
|
||||||
|
"USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid) "
|
||||||
|
"WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid)"
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Disable RLS on tax_rates table (read at startup without tenant context).
|
||||||
|
|
||||||
|
Revision ID: 0077
|
||||||
|
Revises: 0076
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision = "0077"
|
||||||
|
down_revision = "0076"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("DROP POLICY IF EXISTS tax_rates_tenant_isolation ON tax_rates")
|
||||||
|
op.execute("ALTER TABLE tax_rates DISABLE ROW LEVEL SECURITY")
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE tax_rates ENABLE ROW LEVEL SECURITY")
|
||||||
|
op.execute(
|
||||||
|
"CREATE POLICY tax_rates_tenant_isolation ON tax_rates "
|
||||||
|
"FOR ALL "
|
||||||
|
"USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid) "
|
||||||
|
"WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid)"
|
||||||
|
)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Disable RLS on automation tables (written at startup without tenant context).
|
||||||
|
|
||||||
|
The automation plugin registers cron jobs and definitions during plugin
|
||||||
|
activation, which happens at startup before a tenant context is set.
|
||||||
|
RLS blocks these INSERTs because app.current_tenant_id is a dummy default.
|
||||||
|
|
||||||
|
Revision ID: 0078
|
||||||
|
Revises: 0077
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision = "0078"
|
||||||
|
down_revision = "0077"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
TABLES = [
|
||||||
|
"automation_agent_definitions",
|
||||||
|
"automation_agent_runs",
|
||||||
|
"automation_agent_versions",
|
||||||
|
"automation_cron_jobs",
|
||||||
|
"automation_definitions",
|
||||||
|
"automation_runs",
|
||||||
|
"automation_versions",
|
||||||
|
]
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
for table in TABLES:
|
||||||
|
exists = conn.execute(
|
||||||
|
text(f"SELECT 1 FROM information_schema.tables WHERE table_name = '{table}'")
|
||||||
|
).fetchone() is not None
|
||||||
|
if not exists:
|
||||||
|
continue
|
||||||
|
conn.execute(text(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}"))
|
||||||
|
conn.execute(text(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY"))
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
for table in TABLES:
|
||||||
|
exists = conn.execute(
|
||||||
|
text(f"SELECT 1 FROM information_schema.tables WHERE table_name = '{table}'")
|
||||||
|
).fetchone() is not None
|
||||||
|
if not exists:
|
||||||
|
continue
|
||||||
|
conn.execute(text(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY"))
|
||||||
|
conn.execute(text(
|
||||||
|
f"CREATE POLICY {table}_tenant_isolation ON {table} "
|
||||||
|
"FOR ALL "
|
||||||
|
"USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid) "
|
||||||
|
"WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid)"
|
||||||
|
))
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Disable RLS on all system/auth/config tables needed at startup and login.
|
||||||
|
|
||||||
|
These tables are read before a tenant context is set (startup, login,
|
||||||
|
plugin activation). RLS must be disabled on them to allow unprivileged
|
||||||
|
(crm_api) access without tenant context.
|
||||||
|
|
||||||
|
Revision ID: 0079
|
||||||
|
Revises: 0078
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision = "0079"
|
||||||
|
down_revision = "0078"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# All tables that need to be read WITHOUT tenant context
|
||||||
|
SYSTEM_TABLES = [
|
||||||
|
# Auth tables
|
||||||
|
"user_tenants",
|
||||||
|
"sessions",
|
||||||
|
"password_reset_tokens",
|
||||||
|
"api_tokens",
|
||||||
|
"user_groups",
|
||||||
|
"user_preferences",
|
||||||
|
# RBAC tables
|
||||||
|
"roles",
|
||||||
|
"groups",
|
||||||
|
"permissions",
|
||||||
|
# Config tables
|
||||||
|
"system_settings",
|
||||||
|
"currencies",
|
||||||
|
"tax_rates",
|
||||||
|
"sequences",
|
||||||
|
"saved_filters",
|
||||||
|
"saved_views",
|
||||||
|
"webhooks",
|
||||||
|
"notification_preferences",
|
||||||
|
# Plugin tables
|
||||||
|
"tenant_plugin_activation",
|
||||||
|
# Workspace tables (needed for workspace context before tenant filter)
|
||||||
|
"workspaces",
|
||||||
|
"workspace_modules",
|
||||||
|
"workspace_users",
|
||||||
|
"workspace_widgets",
|
||||||
|
]
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
for table in SYSTEM_TABLES:
|
||||||
|
exists = conn.execute(
|
||||||
|
text(f"SELECT 1 FROM information_schema.tables WHERE table_name = '{table}'")
|
||||||
|
).fetchone() is not None
|
||||||
|
if not exists:
|
||||||
|
continue
|
||||||
|
# Drop all RLS policies on this table
|
||||||
|
policies = conn.execute(text(
|
||||||
|
f"SELECT policyname FROM pg_policies WHERE tablename = '{table}'"
|
||||||
|
)).fetchall()
|
||||||
|
for (policyname,) in policies:
|
||||||
|
conn.execute(text(f"DROP POLICY IF EXISTS {policyname} ON {table}"))
|
||||||
|
conn.execute(text(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY"))
|
||||||
|
print(f" Disabled RLS on {table}")
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Re-enabling RLS on system tables would break startup with crm_api
|
||||||
|
# This is intentionally a no-op
|
||||||
|
pass
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Disable RLS on audit_log and sessions (written during login before tenant context).
|
||||||
|
|
||||||
|
Revision ID: 0080
|
||||||
|
Revises: 0079
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision = "0080"
|
||||||
|
down_revision = "0079"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
TABLES = ["audit_log", "sessions", "password_reset_tokens", "api_tokens"]
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
for table in TABLES:
|
||||||
|
exists = conn.execute(
|
||||||
|
text(f"SELECT 1 FROM information_schema.tables WHERE table_name = '{table}'")
|
||||||
|
).fetchone() is not None
|
||||||
|
if not exists:
|
||||||
|
continue
|
||||||
|
policies = conn.execute(text(
|
||||||
|
f"SELECT policyname FROM pg_policies WHERE tablename = '{table}'"
|
||||||
|
)).fetchall()
|
||||||
|
for (policyname,) in policies:
|
||||||
|
conn.execute(text(f"DROP POLICY IF EXISTS {policyname} ON {table}"))
|
||||||
|
conn.execute(text(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY"))
|
||||||
|
print(f" Disabled RLS on {table}")
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Disable RLS on all system/auth/config/plugin tables for crm_api startup.
|
||||||
|
|
||||||
|
This migration disables RLS on all tables that are accessed during
|
||||||
|
startup, login, or plugin activation — before a tenant context is set.
|
||||||
|
RLS remains active only on business-data tables (contacts, addresses,
|
||||||
|
attachments, etc.) where tenant context is always set before access.
|
||||||
|
|
||||||
|
Revision ID: 0081
|
||||||
|
Revises: 0080
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision = "0081"
|
||||||
|
down_revision = "0080"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
TABLES = [
|
||||||
|
"users", "tenants", "user_tenants", "sessions",
|
||||||
|
"audit_log", "user_groups", "permissions",
|
||||||
|
"password_reset_tokens", "api_tokens",
|
||||||
|
"groups", "roles", "system_settings",
|
||||||
|
"currencies", "tax_rates", "sequences",
|
||||||
|
"saved_filters", "saved_views", "webhooks",
|
||||||
|
"notification_preferences", "tenant_plugin_activation",
|
||||||
|
"workspaces", "workspace_modules", "workspace_users", "workspace_widgets",
|
||||||
|
"automation_cron_jobs", "automation_definitions",
|
||||||
|
"automation_runs", "automation_versions",
|
||||||
|
"automation_agent_definitions", "automation_agent_runs",
|
||||||
|
"automation_agent_versions", "plugins",
|
||||||
|
"user_preferences", "custom_field_definitions",
|
||||||
|
"deletion_log", "backups", "share_links",
|
||||||
|
"unified_search_index_log", "unified_search_providers",
|
||||||
|
"mcp_server_configs", "plugin_test_data",
|
||||||
|
"report_templates", "report_instances",
|
||||||
|
"resource_bookings", "resources",
|
||||||
|
"vacation_sent_log", "pgp_keys",
|
||||||
|
"contact_pgp_keys", "contact_merge_history",
|
||||||
|
"entity_links", "entity_history",
|
||||||
|
"contact_folder_permissions", "contact_folders",
|
||||||
|
"guest_users", "guest_invitations",
|
||||||
|
"permission_delegations", "permission_templates",
|
||||||
|
"consumer_inbox", "outbox_deliveries",
|
||||||
|
"event_outbox", "entity_permissions", "entity_policies",
|
||||||
|
"entity_attachments", "files", "folders",
|
||||||
|
"tags", "tag_assignments", "tasks", "subtasks",
|
||||||
|
]
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
for table in TABLES:
|
||||||
|
exists = conn.execute(
|
||||||
|
text(f"SELECT 1 FROM information_schema.tables WHERE table_name = '{table}'")
|
||||||
|
).fetchone() is not None
|
||||||
|
if not exists:
|
||||||
|
continue
|
||||||
|
# Drop all RLS policies
|
||||||
|
policies = conn.execute(text(
|
||||||
|
f"SELECT policyname FROM pg_policies WHERE tablename = '{table}'"
|
||||||
|
)).fetchall()
|
||||||
|
for (policyname,) in policies:
|
||||||
|
conn.execute(text(f"DROP POLICY IF EXISTS {policyname} ON {table}"))
|
||||||
|
conn.execute(text(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Add sensitivity column to custom_field_definitions.
|
||||||
|
|
||||||
|
Revision ID: 0082
|
||||||
|
Revises: 0081
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0082"
|
||||||
|
down_revision = "0081"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE IF EXISTS custom_field_definitions ADD COLUMN IF NOT EXISTS sensitivity VARCHAR(20) NOT NULL DEFAULT 'normal'")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("custom_field_definitions", "sensitivity")
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Add missing deleted_at columns to TenantMixin tables.
|
||||||
|
|
||||||
|
Several models inherit TenantMixin (which includes SoftDeleteMixin)
|
||||||
|
but their DB tables were never migrated to include the deleted_at column.
|
||||||
|
This causes 500 errors when SQLAlchemy tries to SELECT deleted_at.
|
||||||
|
|
||||||
|
Revision ID: 0083
|
||||||
|
Revises: 0082
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0083"
|
||||||
|
down_revision = "0082"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# Tables that use TenantMixin (and therefore SoftDeleteMixin) in their models
|
||||||
|
# but are missing the deleted_at column in the database.
|
||||||
|
TABLES_NEEDING_DELETED_AT = [
|
||||||
|
"contact_folder_permissions",
|
||||||
|
"permission_delegations",
|
||||||
|
"guest_users",
|
||||||
|
"entity_policies",
|
||||||
|
"notification_types",
|
||||||
|
"password_reset_tokens",
|
||||||
|
"api_tokens",
|
||||||
|
"permission_templates",
|
||||||
|
"user_groups",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
for table_name in TABLES_NEEDING_DELETED_AT:
|
||||||
|
# Check if column already exists before adding
|
||||||
|
result = conn.execute(sa.text(
|
||||||
|
"SELECT 1 FROM information_schema.columns "
|
||||||
|
"WHERE table_name = :t AND column_name = 'deleted_at'"
|
||||||
|
), {"t": table_name})
|
||||||
|
if result.scalar() is None:
|
||||||
|
op.add_column(
|
||||||
|
table_name,
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
print(f" Added deleted_at to {table_name}")
|
||||||
|
else:
|
||||||
|
print(f" Skipped {table_name} (already has deleted_at)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table_name in reversed(TABLES_NEEDING_DELETED_AT):
|
||||||
|
op.drop_column(table_name, "deleted_at")
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Re-enable RLS fail-closed on all tenant tables.
|
||||||
|
|
||||||
|
This migration reverses the RLS disabling from migrations 0078-0081.
|
||||||
|
RLS is re-enabled with FORCE and fail-closed policies:
|
||||||
|
|
||||||
|
- Tenant context set (app.current_tenant_id): only own tenant rows visible
|
||||||
|
- Tenant context missing: NO rows visible (fail-closed, not fail-open)
|
||||||
|
|
||||||
|
Global tables (users, tenants, user_tenants, sessions, plugins) remain
|
||||||
|
without RLS — they are accessed via a separate bootstrap/auth connection
|
||||||
|
and filtered at the application layer.
|
||||||
|
|
||||||
|
Bootstrap and startup must use:
|
||||||
|
1. A separate connection (crm_auth/crm_bootstrap) for global tables
|
||||||
|
2. Per-tenant initialization with explicit tenant context:
|
||||||
|
SELECT set_config('app.current_tenant_id', :tenant_id, true);
|
||||||
|
|
||||||
|
Revision ID: 0084
|
||||||
|
Revises: 0083
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
revision = "0084"
|
||||||
|
down_revision = "0083"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# Tables WITH tenant_id column — get fail-closed RLS
|
||||||
|
TENANT_TABLES = [
|
||||||
|
"groups", "roles", "system_settings", "currencies", "tax_rates", "sequences",
|
||||||
|
"saved_filters", "saved_views", "webhooks", "workspaces", "workspace_modules",
|
||||||
|
"workspace_users", "workspace_widgets", "user_preferences", "custom_field_definitions",
|
||||||
|
"backups", "share_links", "entity_links", "entity_history",
|
||||||
|
"contact_folder_permissions", "contact_folders", "guest_users", "guest_invitations",
|
||||||
|
"permission_delegations", "permission_templates", "entity_permissions", "entity_policies",
|
||||||
|
"entity_attachments", "files", "folders", "tags", "tag_assignments", "tasks", "subtasks",
|
||||||
|
"notification_preferences", "audit_log",
|
||||||
|
"automation_cron_jobs", "automation_definitions",
|
||||||
|
"automation_runs", "automation_versions", "automation_agent_definitions",
|
||||||
|
"automation_agent_runs", "automation_agent_versions",
|
||||||
|
"report_templates", "report_instances",
|
||||||
|
"consumer_inbox", "event_outbox", "outbox_deliveries",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
for table in TENANT_TABLES:
|
||||||
|
# Check if table exists
|
||||||
|
exists = conn.execute(
|
||||||
|
text(f"SELECT 1 FROM information_schema.tables WHERE table_name = '{table}'")
|
||||||
|
).fetchone() is not None
|
||||||
|
if not exists:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if table has tenant_id column
|
||||||
|
has_tenant_id = conn.execute(
|
||||||
|
text(f"SELECT 1 FROM information_schema.columns WHERE table_name = '{table}' AND column_name = 'tenant_id'")
|
||||||
|
).fetchone() is not None
|
||||||
|
if not has_tenant_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Drop any existing policies
|
||||||
|
policies = conn.execute(text(
|
||||||
|
f"SELECT policyname FROM pg_policies WHERE tablename = '{table}'"
|
||||||
|
)).fetchall()
|
||||||
|
for (policyname,) in policies:
|
||||||
|
conn.execute(text(f"DROP POLICY IF EXISTS {policyname} ON {table}"))
|
||||||
|
|
||||||
|
# Enable RLS and FORCE it (table owner cannot bypass)
|
||||||
|
conn.execute(text(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY"))
|
||||||
|
conn.execute(text(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY"))
|
||||||
|
|
||||||
|
# Fail-closed tenant isolation policy
|
||||||
|
# NULLIF converts empty string to NULL -> comparison yields NULL -> no rows returned
|
||||||
|
# This is fail-closed: missing tenant context = no access
|
||||||
|
policy_sql = (
|
||||||
|
"CREATE POLICY " + table + "_tenant_isolation "
|
||||||
|
"ON " + table + " "
|
||||||
|
"AS PERMISSIVE "
|
||||||
|
"FOR ALL "
|
||||||
|
"TO crm_api "
|
||||||
|
"USING ("
|
||||||
|
"tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid"
|
||||||
|
") "
|
||||||
|
"WITH CHECK ("
|
||||||
|
"tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
conn.execute(text(policy_sql))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
for table in TENANT_TABLES:
|
||||||
|
exists = conn.execute(
|
||||||
|
text(f"SELECT 1 FROM information_schema.tables WHERE table_name = '{table}'")
|
||||||
|
).fetchone() is not None
|
||||||
|
if not exists:
|
||||||
|
continue
|
||||||
|
conn.execute(text(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}"))
|
||||||
|
conn.execute(text(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY"))
|
||||||
|
conn.execute(text(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY"))
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""Restore tenant RLS, transfer ownership, fix roles and grants.
|
||||||
|
|
||||||
|
This migration implements Phase 1 of the Sanierungsplan:
|
||||||
|
|
||||||
|
1. Transfer ALL table ownership from crm_user (SUPERUSER) to crm_migration (NOSUPERUSER, NOBYPASSRLS)
|
||||||
|
2. ALTER ROLE crm_migration NOBYPASSRLS
|
||||||
|
3. Enable RLS + FORCE on ALL tenant tables (tables with tenant_id column)
|
||||||
|
4. Drop ALL old policies (scoped to {public} or using non-NULLIF patterns)
|
||||||
|
5. Create new fail-closed policies scoped to {crm_api, crm_worker}
|
||||||
|
6. Revoke excessive grants from crm_runtime, crm_worker, crm_api, crm_auth
|
||||||
|
7. Grant proper minimal permissions to crm_auth (identity tables only)
|
||||||
|
8. Grant CRUD to crm_api and crm_worker on tenant tables
|
||||||
|
9. Revoke alembic_version access from crm_api and crm_worker
|
||||||
|
10. Set default privileges for crm_migration owner
|
||||||
|
11. Drop crm_runtime legacy role
|
||||||
|
12. Create crm_platform_admin role (for one-time infrastructure only)
|
||||||
|
|
||||||
|
Revision ID: 0085
|
||||||
|
Revises: 0084
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0085"
|
||||||
|
down_revision = "0084"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
TENANT_TABLES = [
|
||||||
|
"addresses", "ai_agents", "ai_chat_attachments", "ai_chat_folders",
|
||||||
|
"ai_chat_messages", "ai_chat_sessions", "ai_conversations", "ai_messages",
|
||||||
|
"ai_models", "ai_presets", "ai_proactive_context_log", "ai_proactive_settings",
|
||||||
|
"ai_proactive_suggestions", "ai_providers", "attachments", "audit_log",
|
||||||
|
"automation_agent_definitions", "automation_agent_runs",
|
||||||
|
"automation_agent_versions", "automation_cron_jobs",
|
||||||
|
"automation_definitions", "automation_runs", "automation_versions",
|
||||||
|
"backups", "bank_accounts", "calendar_entries", "calendar_entry_links",
|
||||||
|
"calendar_shares", "calendars", "comm_conversation_mutes",
|
||||||
|
"comm_conversation_pins", "comm_conversations", "comm_message_attachments",
|
||||||
|
"comm_message_blocks", "comm_message_edits", "comm_message_reactions",
|
||||||
|
"comm_message_reads", "comm_messages", "comm_participants",
|
||||||
|
"contact_folder_permissions", "contact_folders", "contact_merge_history",
|
||||||
|
"contact_pgp_keys", "contactpersons", "contacts", "currencies",
|
||||||
|
"custom_field_definitions", "deletion_log", "entity_attachments",
|
||||||
|
"entity_history", "entity_links", "entity_permissions", "entity_policies",
|
||||||
|
"event_outbox", "files", "folders", "groups", "guest_users",
|
||||||
|
"mail_account_delegates", "mail_account_send_permissions",
|
||||||
|
"mail_accounts", "mail_attachments", "mail_folders",
|
||||||
|
"mail_label_assignments", "mail_labels", "mail_rules", "mail_seen_by",
|
||||||
|
"mail_signatures", "mail_sync_queue", "mail_templates", "mails",
|
||||||
|
"mcp_server_configs", "notification_preferences", "notifications",
|
||||||
|
"password_reset_tokens", "permission_delegations", "permission_templates",
|
||||||
|
"permissions", "pgp_keys", "plugin_test_data", "report_instances",
|
||||||
|
"report_templates", "resource_bookings", "resources", "roles",
|
||||||
|
"saved_filters", "saved_views", "share_links", "subtasks",
|
||||||
|
"system_settings", "tag_assignments", "tags", "tasks", "tax_rates",
|
||||||
|
"unified_search_index_log", "unified_search_providers",
|
||||||
|
"user_calendar_visibility", "user_groups", "user_preferences",
|
||||||
|
"vacation_sent_log", "webhooks", "workflow_instances",
|
||||||
|
"workflow_step_history", "workflows", "workspace_modules",
|
||||||
|
"workspace_users", "workspace_widgets", "workspaces",
|
||||||
|
]
|
||||||
|
|
||||||
|
GLOBAL_TABLES = [
|
||||||
|
"users", "tenants", "user_tenants", "sessions", "plugins",
|
||||||
|
"plugin_allowlist", "plugin_migrations", "tenant_plugin_activation",
|
||||||
|
"alembic_version", "notification_types", "api_tokens",
|
||||||
|
"consumer_inbox", "outbox_deliveries", "guest_invitations",
|
||||||
|
"sequences",
|
||||||
|
]
|
||||||
|
|
||||||
|
AUTH_TABLES = {
|
||||||
|
"users": ["SELECT"],
|
||||||
|
"user_tenants": ["SELECT"],
|
||||||
|
"tenants": ["SELECT"],
|
||||||
|
"password_reset_tokens": ["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||||
|
"sessions": ["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||||
|
}
|
||||||
|
|
||||||
|
WORKER_GLOBAL_TABLES = {
|
||||||
|
"event_outbox": ["SELECT", "INSERT", "UPDATE"],
|
||||||
|
"outbox_deliveries": ["SELECT", "INSERT", "UPDATE"],
|
||||||
|
"consumer_inbox": ["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||||
|
}
|
||||||
|
|
||||||
|
ALL_TABLES = TENANT_TABLES + GLOBAL_TABLES
|
||||||
|
|
||||||
|
|
||||||
|
def _exec(sql: str) -> None:
|
||||||
|
op.execute(sql)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Step 1: Create crm_platform_admin role
|
||||||
|
_exec("DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_platform_admin') THEN CREATE ROLE crm_platform_admin NOSUPERUSER NOBYPASSRLS NOLOGIN; END IF; END $$;")
|
||||||
|
|
||||||
|
# Step 2: crm_migration keeps BYPASSRLS for data migrations (NOSUPERUSER)
|
||||||
|
# crm_migration is the table owner and needs to run tenant-wide data migrations
|
||||||
|
_exec("ALTER ROLE crm_migration NOSUPERUSER BYPASSRLS")
|
||||||
|
|
||||||
|
# Step 3: Transfer ALL table ownership to crm_migration (only for tables that exist)
|
||||||
|
for table in ALL_TABLES:
|
||||||
|
_exec(f"DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '{table}') THEN ALTER TABLE public.{table} OWNER TO crm_migration; END IF; END $$")
|
||||||
|
|
||||||
|
# Transfer sequence ownership
|
||||||
|
_exec("DO $$ DECLARE r RECORD; BEGIN FOR r IN SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'public' LOOP EXECUTE format('ALTER SEQUENCE public.%I OWNER TO crm_migration', r.sequence_name); END LOOP; END $$;")
|
||||||
|
|
||||||
|
# Step 4: Revoke ALL grants from runtime roles
|
||||||
|
for role in ("crm_runtime", "crm_worker", "crm_api", "crm_auth"):
|
||||||
|
_exec(f"REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM {role}")
|
||||||
|
_exec(f"REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM {role}")
|
||||||
|
_exec(f"REVOKE ALL PRIVILEGES ON SCHEMA public FROM {role}")
|
||||||
|
|
||||||
|
# Step 5: Drop crm_runtime role — revoke default privileges first, then drop
|
||||||
|
_exec("ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM crm_runtime")
|
||||||
|
_exec("ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE USAGE, SELECT ON SEQUENCES FROM crm_runtime")
|
||||||
|
_exec("DO $$ BEGIN DROP ROLE IF EXISTS crm_runtime; EXCEPTION WHEN insufficient_privilege THEN NULL; WHEN dependent_objects_still_exist THEN NULL; END $$")
|
||||||
|
|
||||||
|
# Step 6: Grant schema USAGE to runtime roles
|
||||||
|
_exec("GRANT USAGE ON SCHEMA public TO crm_api")
|
||||||
|
_exec("GRANT USAGE ON SCHEMA public TO crm_worker")
|
||||||
|
_exec("GRANT USAGE ON SCHEMA public TO crm_auth")
|
||||||
|
|
||||||
|
# Step 7: Grant permissions to crm_auth (identity tables only)
|
||||||
|
for table, privs in AUTH_TABLES.items():
|
||||||
|
priv_str = ", ".join(privs)
|
||||||
|
_exec(f"DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '{table}') THEN GRANT {priv_str} ON public.{table} TO crm_auth; END IF; END $$")
|
||||||
|
|
||||||
|
# Step 8: Grant CRUD on tenant tables to crm_api and crm_worker (only for tables that exist)
|
||||||
|
for table in TENANT_TABLES:
|
||||||
|
_exec(f"DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '{table}') THEN GRANT SELECT, INSERT, UPDATE, DELETE ON public.{table} TO crm_api; GRANT SELECT, INSERT, UPDATE, DELETE ON public.{table} TO crm_worker; END IF; END $$")
|
||||||
|
|
||||||
|
# Grant sequence USAGE to crm_api and crm_worker
|
||||||
|
_exec("GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO crm_api")
|
||||||
|
_exec("GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO crm_worker")
|
||||||
|
|
||||||
|
# Step 9: Grant global table access to crm_api (except alembic_version)
|
||||||
|
api_global_tables = [t for t in GLOBAL_TABLES if t != "alembic_version"]
|
||||||
|
for table in api_global_tables:
|
||||||
|
_exec(f"DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '{table}') THEN GRANT SELECT, INSERT, UPDATE, DELETE ON public.{table} TO crm_api; END IF; END $$")
|
||||||
|
|
||||||
|
# Step 10: Grant worker global table access
|
||||||
|
for table, privs in WORKER_GLOBAL_TABLES.items():
|
||||||
|
priv_str = ", ".join(privs)
|
||||||
|
_exec(f"DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '{table}') THEN GRANT {priv_str} ON public.{table} TO crm_worker; END IF; END $$")
|
||||||
|
|
||||||
|
worker_global_tables = [
|
||||||
|
t for t in GLOBAL_TABLES
|
||||||
|
if t != "alembic_version" and t not in WORKER_GLOBAL_TABLES
|
||||||
|
]
|
||||||
|
for table in worker_global_tables:
|
||||||
|
_exec(f"DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '{table}') THEN GRANT SELECT, INSERT, UPDATE, DELETE ON public.{table} TO crm_worker; END IF; END $$")
|
||||||
|
|
||||||
|
# Step 11: Drop ALL old RLS policies and create new fail-closed ones
|
||||||
|
policy_template = (
|
||||||
|
"CREATE POLICY {table}_tenant_isolation "
|
||||||
|
"ON public.{table} "
|
||||||
|
"FOR ALL "
|
||||||
|
"TO crm_api, crm_worker "
|
||||||
|
"USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid) "
|
||||||
|
"WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)"
|
||||||
|
)
|
||||||
|
|
||||||
|
for table in TENANT_TABLES:
|
||||||
|
_exec(f"DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '{table}') THEN DROP POLICY IF EXISTS tenant_isolation ON public.{table}; DROP POLICY IF EXISTS {table}_tenant_isolation ON public.{table}; ALTER TABLE public.{table} ENABLE ROW LEVEL SECURITY; ALTER TABLE public.{table} FORCE ROW LEVEL SECURITY; {policy_template.format(table=table)}; END IF; END $$")
|
||||||
|
|
||||||
|
# Step 12: Disable RLS on global tables
|
||||||
|
for table in GLOBAL_TABLES:
|
||||||
|
_exec(f"DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '{table}') THEN DROP POLICY IF EXISTS tenant_isolation ON public.{table}; DROP POLICY IF EXISTS {table}_tenant_isolation ON public.{table}; ALTER TABLE public.{table} DISABLE ROW LEVEL SECURITY; END IF; END $$")
|
||||||
|
|
||||||
|
# Step 13: Set default privileges for crm_migration owner
|
||||||
|
_exec("ALTER DEFAULT PRIVILEGES FOR ROLE crm_migration IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO crm_api")
|
||||||
|
_exec("ALTER DEFAULT PRIVILEGES FOR ROLE crm_migration IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO crm_worker")
|
||||||
|
_exec("ALTER DEFAULT PRIVILEGES FOR ROLE crm_migration IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO crm_api")
|
||||||
|
_exec("ALTER DEFAULT PRIVILEGES FOR ROLE crm_migration IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO crm_worker")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Fix FORCE RLS on global tables.
|
||||||
|
|
||||||
|
Migration 0085 disabled RLS on global tables but did not remove
|
||||||
|
FORCE ROW LEVEL SECURITY from 5 tables that had it enabled from
|
||||||
|
older migrations. This migration removes FORCE RLS from all
|
||||||
|
global tables (tables without tenant_id).
|
||||||
|
|
||||||
|
Revision ID: 0086
|
||||||
|
Revises: 0085
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0086"
|
||||||
|
down_revision = "0085"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
GLOBAL_TABLES_WITH_FORCE_RLS = [
|
||||||
|
"api_tokens",
|
||||||
|
"sequences",
|
||||||
|
"sessions",
|
||||||
|
"tenant_plugin_activation",
|
||||||
|
"user_tenants",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for table in GLOBAL_TABLES_WITH_FORCE_RLS:
|
||||||
|
op.execute(f"ALTER TABLE public.{table} NO FORCE ROW LEVEL SECURITY")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table in GLOBAL_TABLES_WITH_FORCE_RLS:
|
||||||
|
op.execute(f"ALTER TABLE public.{table} FORCE ROW LEVEL SECURITY")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Add created_at and updated_at to password_reset_tokens.
|
||||||
|
|
||||||
|
The PasswordResetToken model uses TenantMixin which includes
|
||||||
|
TimestampMixin (created_at, updated_at), but the DB table was
|
||||||
|
missing these columns. This migration adds them.
|
||||||
|
|
||||||
|
Revision ID: 0087
|
||||||
|
Revises: 0086
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0087"
|
||||||
|
down_revision = "0086"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE password_reset_tokens ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()")
|
||||||
|
op.execute("ALTER TABLE password_reset_tokens ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("password_reset_tokens", "updated_at")
|
||||||
|
op.drop_column("password_reset_tokens", "created_at")
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Auth RLS policies for password_reset_tokens and audit_log.
|
||||||
|
|
||||||
|
Allows crm_auth to:
|
||||||
|
- SELECT/UPDATE/INSERT on password_reset_tokens (for password reset flow)
|
||||||
|
- INSERT on audit_log (for audit logging during auth)
|
||||||
|
- UPDATE on users (for password hash update during reset)
|
||||||
|
|
||||||
|
The tenant_isolation policy for crm_api/crm_worker is preserved.
|
||||||
|
crm_auth gets scoped access without full tenant context for token lookup,
|
||||||
|
but INSERT/UPDATE on tenant tables still requires tenant context.
|
||||||
|
|
||||||
|
Revision ID: 0088
|
||||||
|
Revises: 0087
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0088"
|
||||||
|
down_revision = "0087"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ── password_reset_tokens: replace policy for crm_auth access ──
|
||||||
|
op.execute("DROP POLICY IF EXISTS password_reset_tokens_tenant_isolation ON public.password_reset_tokens")
|
||||||
|
op.execute("DROP POLICY IF EXISTS password_reset_tokens_auth_lookup ON public.password_reset_tokens")
|
||||||
|
op.execute("DROP POLICY IF EXISTS password_reset_tokens_auth_update ON public.password_reset_tokens")
|
||||||
|
op.execute("DROP POLICY IF EXISTS password_reset_tokens_auth_insert ON public.password_reset_tokens")
|
||||||
|
|
||||||
|
# crm_auth: SELECT without tenant context (token lookup)
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY password_reset_tokens_auth_lookup
|
||||||
|
ON public.password_reset_tokens
|
||||||
|
FOR SELECT TO crm_auth
|
||||||
|
USING (true)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# crm_auth: UPDATE without tenant context (mark token used)
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY password_reset_tokens_auth_update
|
||||||
|
ON public.password_reset_tokens
|
||||||
|
FOR UPDATE TO crm_auth
|
||||||
|
USING (true)
|
||||||
|
WITH CHECK (true)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# crm_auth: INSERT with tenant context (create new token)
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY password_reset_tokens_auth_insert
|
||||||
|
ON public.password_reset_tokens
|
||||||
|
FOR INSERT TO crm_auth
|
||||||
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# crm_api, crm_worker: full tenant isolation
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY password_reset_tokens_tenant_isolation
|
||||||
|
ON public.password_reset_tokens
|
||||||
|
FOR ALL TO crm_api, crm_worker
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
|
||||||
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# ── Grants for crm_auth ──
|
||||||
|
op.execute("GRANT SELECT, INSERT, UPDATE ON public.password_reset_tokens TO crm_auth")
|
||||||
|
op.execute("GRANT UPDATE ON public.users TO crm_auth")
|
||||||
|
|
||||||
|
# ── audit_log: allow crm_auth INSERT with tenant context ──
|
||||||
|
op.execute("DROP POLICY IF EXISTS audit_log_auth_insert ON public.audit_log")
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY audit_log_auth_insert
|
||||||
|
ON public.audit_log
|
||||||
|
FOR INSERT TO crm_auth
|
||||||
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
|
||||||
|
""")
|
||||||
|
op.execute("GRANT INSERT ON public.audit_log TO crm_auth")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP POLICY IF EXISTS password_reset_tokens_auth_lookup ON public.password_reset_tokens")
|
||||||
|
op.execute("DROP POLICY IF EXISTS password_reset_tokens_auth_update ON public.password_reset_tokens")
|
||||||
|
op.execute("DROP POLICY IF EXISTS password_reset_tokens_auth_insert ON public.password_reset_tokens")
|
||||||
|
op.execute("DROP POLICY IF EXISTS audit_log_auth_insert ON public.audit_log")
|
||||||
|
op.execute("REVOKE SELECT, INSERT, UPDATE ON public.password_reset_tokens FROM crm_auth")
|
||||||
|
op.execute("REVOKE UPDATE ON public.users FROM crm_auth")
|
||||||
|
op.execute("REVOKE INSERT ON public.audit_log FROM crm_auth")
|
||||||
|
|
||||||
|
# Restore original tenant isolation policy
|
||||||
|
op.execute("""
|
||||||
|
CREATE POLICY password_reset_tokens_tenant_isolation
|
||||||
|
ON public.password_reset_tokens
|
||||||
|
FOR ALL TO crm_api, crm_worker, crm_auth
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
|
||||||
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
|
||||||
|
""")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Add updated_at column to sessions table.
|
||||||
|
|
||||||
|
The Session model uses TimestampMixin which includes updated_at,
|
||||||
|
but the sessions table was created without it in migration 0001.
|
||||||
|
This causes an error on session creation (login).
|
||||||
|
|
||||||
|
Revision ID: 0089
|
||||||
|
Revises: 0088
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0089"
|
||||||
|
down_revision = "0088"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE sessions DROP COLUMN IF EXISTS updated_at")
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Fix legacy app.tenant_id policies on _old tables.
|
||||||
|
|
||||||
|
Migration 0021 renamed old tables (contacts, companies, company_contacts) to *_old
|
||||||
|
but their RLS policies still reference the old app.tenant_id variable.
|
||||||
|
This migration drops those legacy policies and creates new ones using
|
||||||
|
app.current_tenant_id to maintain consistency.
|
||||||
|
|
||||||
|
Revision ID: 0090
|
||||||
|
Revises: 0089
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0090"
|
||||||
|
down_revision = "0089"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
LEGACY_TABLES = ["companies_old", "company_contacts_old", "contacts_old"]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for table in LEGACY_TABLES:
|
||||||
|
op.execute(f"""
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = '{table}'
|
||||||
|
) THEN
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON public.{table};
|
||||||
|
CREATE POLICY {table}_tenant_isolation
|
||||||
|
ON public.{table}
|
||||||
|
FOR ALL
|
||||||
|
TO crm_api, crm_worker
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
|
||||||
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table in LEGACY_TABLES:
|
||||||
|
op.execute(f"""
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = '{table}'
|
||||||
|
) THEN
|
||||||
|
DROP POLICY IF EXISTS {table}_tenant_isolation ON public.{table};
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
""")
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Add tenant_id FK constraints to all tenant-scoped tables.
|
||||||
|
|
||||||
|
Phase 2 Data Integrity: Adds FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||||
|
ON DELETE CASCADE to all tenant-scoped tables that have a tenant_id column
|
||||||
|
but no FK constraint yet.
|
||||||
|
|
||||||
|
Global tables (sequences, system_settings, currencies, tax_rates, permissions,
|
||||||
|
permission_templates, unified_search_providers, unified_search_index_log,
|
||||||
|
mcp_server_configs, plugin_test_data) are excluded because they use tenant_id
|
||||||
|
for filtering but are not owned by a single tenant.
|
||||||
|
|
||||||
|
Revision ID: 0091
|
||||||
|
Revises: 0090
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0091"
|
||||||
|
down_revision = "0090"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# All 74 tenant-scoped tables that need FK constraints.
|
||||||
|
# Excludes 10 global tables that use tenant_id but are not tenant-owned.
|
||||||
|
TENANT_TABLES = [
|
||||||
|
"addresses",
|
||||||
|
"ai_agents",
|
||||||
|
"ai_chat_attachments",
|
||||||
|
"ai_chat_folders",
|
||||||
|
"ai_chat_messages",
|
||||||
|
"ai_chat_sessions",
|
||||||
|
"ai_models",
|
||||||
|
"ai_presets",
|
||||||
|
"ai_proactive_context_log",
|
||||||
|
"ai_proactive_settings",
|
||||||
|
"ai_proactive_suggestions",
|
||||||
|
"ai_providers",
|
||||||
|
"attachments",
|
||||||
|
"automation_agent_definitions",
|
||||||
|
"automation_agent_runs",
|
||||||
|
"automation_agent_versions",
|
||||||
|
"automation_cron_jobs",
|
||||||
|
"automation_definitions",
|
||||||
|
"automation_runs",
|
||||||
|
"automation_versions",
|
||||||
|
"backups",
|
||||||
|
"bank_accounts",
|
||||||
|
"calendar_entries",
|
||||||
|
"calendar_entry_links",
|
||||||
|
"calendar_shares",
|
||||||
|
"calendars",
|
||||||
|
"comm_conversation_mutes",
|
||||||
|
"comm_conversation_pins",
|
||||||
|
"comm_conversations",
|
||||||
|
"comm_message_attachments",
|
||||||
|
"comm_message_blocks",
|
||||||
|
"comm_message_edits",
|
||||||
|
"comm_message_reactions",
|
||||||
|
"comm_message_reads",
|
||||||
|
"comm_messages",
|
||||||
|
"comm_participants",
|
||||||
|
"contact_folders",
|
||||||
|
"contact_pgp_keys",
|
||||||
|
"contacts",
|
||||||
|
"custom_field_definitions",
|
||||||
|
"entity_links",
|
||||||
|
"entity_policies",
|
||||||
|
"event_outbox",
|
||||||
|
"files",
|
||||||
|
"folders",
|
||||||
|
"mail_account_delegates",
|
||||||
|
"mail_account_send_permissions",
|
||||||
|
"mail_accounts",
|
||||||
|
"mail_attachments",
|
||||||
|
"mail_folders",
|
||||||
|
"mail_label_assignments",
|
||||||
|
"mail_labels",
|
||||||
|
"mail_rules",
|
||||||
|
"mail_seen_by",
|
||||||
|
"mail_signatures",
|
||||||
|
"mail_sync_queue",
|
||||||
|
"mail_templates",
|
||||||
|
"mails",
|
||||||
|
"permission_delegations",
|
||||||
|
"pgp_keys",
|
||||||
|
"report_instances",
|
||||||
|
"report_templates",
|
||||||
|
"resource_bookings",
|
||||||
|
"resources",
|
||||||
|
"saved_filters",
|
||||||
|
"saved_views",
|
||||||
|
"share_links",
|
||||||
|
"subtasks",
|
||||||
|
"tag_assignments",
|
||||||
|
"tags",
|
||||||
|
"tasks",
|
||||||
|
"user_calendar_visibility",
|
||||||
|
"vacation_sent_log",
|
||||||
|
"webhooks",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Step 1: Clean orphaned tenant_id references before adding FK constraints.
|
||||||
|
# Set tenant_id = NULL where the referenced tenant does not exist.
|
||||||
|
for table in TENANT_TABLES:
|
||||||
|
op.execute(f"""
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = '{table}'
|
||||||
|
) AND EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = '{table}'
|
||||||
|
AND column_name = 'tenant_id'
|
||||||
|
) THEN
|
||||||
|
UPDATE public.{table}
|
||||||
|
SET tenant_id = NULL
|
||||||
|
WHERE tenant_id IS NOT NULL
|
||||||
|
AND tenant_id NOT IN (SELECT id FROM public.tenants);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Step 2: Add FK constraints idempotently.
|
||||||
|
for table in TENANT_TABLES:
|
||||||
|
constraint_name = f"fk_{table}_tenant_id"
|
||||||
|
op.execute(f"""
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = '{table}'
|
||||||
|
) AND EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = '{table}'
|
||||||
|
AND column_name = 'tenant_id'
|
||||||
|
) AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
|
WHERE constraint_schema = 'public'
|
||||||
|
AND constraint_name = '{constraint_name}'
|
||||||
|
AND constraint_type = 'FOREIGN KEY'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.{table}
|
||||||
|
ADD CONSTRAINT {constraint_name}
|
||||||
|
FOREIGN KEY (tenant_id)
|
||||||
|
REFERENCES public.tenants(id)
|
||||||
|
ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table in TENANT_TABLES:
|
||||||
|
constraint_name = f"fk_{table}_tenant_id"
|
||||||
|
op.execute(f"""
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
|
WHERE constraint_schema = 'public'
|
||||||
|
AND constraint_name = '{constraint_name}'
|
||||||
|
AND constraint_type = 'FOREIGN KEY'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.{table}
|
||||||
|
DROP CONSTRAINT {constraint_name};
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
""")
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Add DLQ columns to event_outbox and fix consumer_inbox RLS policy.
|
||||||
|
|
||||||
|
Phase 5: Dead-Letter-Queue support.
|
||||||
|
- Adds error_message TEXT and failed_at TIMESTAMPTZ to event_outbox
|
||||||
|
- Adds partial index for failed events
|
||||||
|
- Fixes consumer_inbox RLS policy (previous 0085 policy referenced
|
||||||
|
tenant_id column which does not exist on consumer_inbox; the
|
||||||
|
correct policy uses the event_id FK to event_outbox.tenant_id)
|
||||||
|
|
||||||
|
Revision ID: 0092
|
||||||
|
Revises: 0091
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0092"
|
||||||
|
down_revision = "0091"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. Add DLQ columns to event_outbox
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE IF EXISTS event_outbox "
|
||||||
|
"ADD COLUMN IF NOT EXISTS error_message TEXT"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE IF EXISTS event_outbox "
|
||||||
|
"ADD COLUMN IF NOT EXISTS failed_at TIMESTAMPTZ"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Partial index for efficient failed-event queries
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_outbox_failed "
|
||||||
|
"ON event_outbox (status, failed_at) WHERE status = 'failed'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Fix consumer_inbox RLS policy
|
||||||
|
# Migration 0085 created a policy using tenant_id, but consumer_inbox
|
||||||
|
# has no tenant_id column. Drop the broken policy and create one
|
||||||
|
# that follows the same pattern as outbox_deliveries (0075): use the
|
||||||
|
# event_id FK to check event_outbox.tenant_id.
|
||||||
|
op.execute(
|
||||||
|
"DROP POLICY IF EXISTS consumer_inbox_tenant_isolation ON consumer_inbox"
|
||||||
|
)
|
||||||
|
op.execute("DROP POLICY IF EXISTS tenant_isolation ON consumer_inbox")
|
||||||
|
op.execute("ALTER TABLE consumer_inbox ENABLE ROW LEVEL SECURITY")
|
||||||
|
op.execute("ALTER TABLE consumer_inbox FORCE ROW LEVEL SECURITY")
|
||||||
|
op.execute(
|
||||||
|
"CREATE POLICY consumer_inbox_tenant_isolation ON consumer_inbox "
|
||||||
|
"FOR ALL TO crm_api, crm_worker "
|
||||||
|
"USING (EXISTS (SELECT 1 FROM event_outbox "
|
||||||
|
"WHERE event_outbox.id = consumer_inbox.event_id "
|
||||||
|
"AND event_outbox.tenant_id = "
|
||||||
|
"NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)) "
|
||||||
|
"WITH CHECK (EXISTS (SELECT 1 FROM event_outbox "
|
||||||
|
"WHERE event_outbox.id = consumer_inbox.event_id "
|
||||||
|
"AND event_outbox.tenant_id = "
|
||||||
|
"NULLIF(current_setting('app.current_tenant_id', true), '')::uuid))"
|
||||||
|
)
|
||||||
|
# Ensure grants are in place
|
||||||
|
op.execute(
|
||||||
|
"GRANT SELECT, INSERT, UPDATE, DELETE ON consumer_inbox TO crm_api, crm_worker"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_outbox_failed")
|
||||||
|
op.execute("ALTER TABLE event_outbox DROP COLUMN IF EXISTS failed_at")
|
||||||
|
op.execute("ALTER TABLE event_outbox DROP COLUMN IF EXISTS error_message")
|
||||||
|
# Restore the broken policy state (consumer_inbox RLS remains enabled)
|
||||||
|
op.execute(
|
||||||
|
"DROP POLICY IF EXISTS consumer_inbox_tenant_isolation ON consumer_inbox"
|
||||||
|
)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Example command: CreateContact using the Command Pattern.
|
||||||
|
|
||||||
|
This is a reference implementation for new modules.
|
||||||
|
Existing contact_service.py is NOT changed — this is an alternative path.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@router.post("/contacts-v2")
|
||||||
|
async def create_contact_v2(
|
||||||
|
body: CreateContactDTO,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("contacts:write")),
|
||||||
|
):
|
||||||
|
ctx = RequestContext(
|
||||||
|
user_id=uuid.UUID(current_user["user_id"]),
|
||||||
|
tenant_id=uuid.UUID(current_user["tenant_id"]),
|
||||||
|
is_system_admin=current_user.get("is_system_admin", False),
|
||||||
|
permissions=set(current_user.get("permissions", [])),
|
||||||
|
)
|
||||||
|
cmd = CreateContactCommand(
|
||||||
|
firstname=body.firstname,
|
||||||
|
surname=body.surname,
|
||||||
|
email=body.email,
|
||||||
|
)
|
||||||
|
handler = CreateContactHandler()
|
||||||
|
return await handler.execute(cmd, ctx, db)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.commands import CommandHandler, RequestContext, UnitOfWork
|
||||||
|
from app.models.contact import Contact
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CreateContactCommand:
|
||||||
|
"""Command to create a new contact."""
|
||||||
|
firstname: str
|
||||||
|
surname: str
|
||||||
|
email: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
company: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateContactHandler(CommandHandler[CreateContactCommand, dict[str, Any]]):
|
||||||
|
"""Handler for CreateContactCommand.
|
||||||
|
|
||||||
|
Demonstrates the Command Pattern:
|
||||||
|
1. Authorization check (ctx.require)
|
||||||
|
2. Domain operation (create Contact)
|
||||||
|
3. Outbox event (crm.contact.created.v1)
|
||||||
|
4. Audit log (contact.created)
|
||||||
|
5. Single commit via UoW
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def handle(self, cmd: CreateContactCommand, ctx: RequestContext, uow: UnitOfWork) -> dict[str, Any]:
|
||||||
|
# 1. Authorization
|
||||||
|
ctx.require("contacts:write")
|
||||||
|
|
||||||
|
# 2. Domain operation
|
||||||
|
contact = Contact(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
firstname=cmd.firstname,
|
||||||
|
surname=cmd.surname,
|
||||||
|
email_1=cmd.email,
|
||||||
|
phone_1=cmd.phone,
|
||||||
|
company=cmd.company,
|
||||||
|
owner_id=ctx.user_id,
|
||||||
|
created_by=ctx.user_id,
|
||||||
|
updated_by=ctx.user_id,
|
||||||
|
)
|
||||||
|
uow.add(contact)
|
||||||
|
|
||||||
|
# 3. Outbox event (standardized envelope)
|
||||||
|
uow.outbox_add(
|
||||||
|
event_name="crm.contact.created.v1",
|
||||||
|
aggregate_id=contact.id, # Will be set after flush
|
||||||
|
aggregate_type="contact",
|
||||||
|
payload={
|
||||||
|
"firstname": cmd.firstname,
|
||||||
|
"surname": cmd.surname,
|
||||||
|
"email": cmd.email,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Audit log
|
||||||
|
uow.audit_record(
|
||||||
|
action="create",
|
||||||
|
entity_id=contact.id,
|
||||||
|
entity_type="contact",
|
||||||
|
changes={"firstname": cmd.firstname, "surname": cmd.surname, "email": cmd.email},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Return dict (will be populated after flush in commit)
|
||||||
|
return {
|
||||||
|
"id": str(contact.id),
|
||||||
|
"firstname": contact.firstname,
|
||||||
|
"surname": contact.surname,
|
||||||
|
"email_1": contact.email_1,
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.commands.base import BaseCommand, CommandResult
|
from app.commands.base import BaseCommand, CommandResult
|
||||||
from app.core.outbox import enqueue_outbox_event
|
from app.core.outbox import enqueue_outbox_event
|
||||||
|
from app.plugins.builtins.mail.services import sanitize_html
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ class SendMailCommand(BaseCommand):
|
|||||||
cc_addr=",".join(self.cc) if self.cc else None,
|
cc_addr=",".join(self.cc) if self.cc else None,
|
||||||
subject=self.subject,
|
subject=self.subject,
|
||||||
body_text=self.body_text,
|
body_text=self.body_text,
|
||||||
body_html_sanitized=self.body_html,
|
body_html_sanitized=sanitize_html(self.body_html) if self.body_html else None,
|
||||||
direction="outgoing",
|
direction="outgoing",
|
||||||
received_at=datetime.now(UTC),
|
received_at=datetime.now(UTC),
|
||||||
is_read=True,
|
is_read=True,
|
||||||
|
|||||||
+12
-5
@@ -22,8 +22,11 @@ class Settings(BaseSettings):
|
|||||||
environment: Literal["development", "production", "testing"] = "development"
|
environment: Literal["development", "production", "testing"] = "development"
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
|
|
||||||
# Database
|
# Database — separate connections for auth, API, worker, and migrations
|
||||||
database_url: str = "postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm_test"
|
database_url: str = "postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm_test"
|
||||||
|
auth_database_url: str = "" # Falls back to database_url if empty
|
||||||
|
worker_database_url: str = "" # Falls back to database_url if empty
|
||||||
|
migration_database_url: str = "" # Falls back to database_url if empty
|
||||||
db_pool_size: int = 10
|
db_pool_size: int = 10
|
||||||
db_max_overflow: int = 20
|
db_max_overflow: int = 20
|
||||||
db_echo: bool = False
|
db_echo: bool = False
|
||||||
@@ -36,7 +39,7 @@ class Settings(BaseSettings):
|
|||||||
bcrypt_rounds: int = 12
|
bcrypt_rounds: int = 12
|
||||||
session_cookie_name: str = "leocrm_session"
|
session_cookie_name: str = "leocrm_session"
|
||||||
session_cookie_secure: bool = True # Secure by default — set to False only for local HTTP development
|
session_cookie_secure: bool = True # Secure by default — set to False only for local HTTP development
|
||||||
session_cookie_samesite: str = "strict"
|
session_cookie_samesite: str = "strict" # Strict blocks WebSocket cookies; use Lax only if WS needed
|
||||||
session_cookie_httponly: bool = True
|
session_cookie_httponly: bool = True
|
||||||
password_reset_expiry_hours: int = 1
|
password_reset_expiry_hours: int = 1
|
||||||
|
|
||||||
@@ -83,12 +86,16 @@ class Settings(BaseSettings):
|
|||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
"""Get cached settings instance."""
|
"""Get cached settings instance."""
|
||||||
s = Settings()
|
s = Settings()
|
||||||
# Production safety checks
|
# Safety checks — always validate critical settings
|
||||||
|
_DEFAULT_KEY = "change-me-in-production-use-a-secure-random-string"
|
||||||
|
if s.secret_key == _DEFAULT_KEY:
|
||||||
|
raise RuntimeError("SECRET_KEY must be changed from default value")
|
||||||
|
if len(s.secret_key) < 32:
|
||||||
|
raise RuntimeError("SECRET_KEY must be at least 32 characters long")
|
||||||
|
# Production-only checks
|
||||||
if s.environment == "production":
|
if s.environment == "production":
|
||||||
if not s.session_cookie_secure:
|
if not s.session_cookie_secure:
|
||||||
raise RuntimeError("SESSION_COOKIE_SECURE must be True in production")
|
raise RuntimeError("SESSION_COOKIE_SECURE must be True in production")
|
||||||
if s.secret_key == "change-me-in-production-use-a-secure-random-string":
|
|
||||||
raise RuntimeError("SECRET_KEY must be changed from default in production")
|
|
||||||
if s.storage_path == "/tmp":
|
if s.storage_path == "/tmp":
|
||||||
raise RuntimeError("STORAGE_PATH must not be /tmp in production")
|
raise RuntimeError("STORAGE_PATH must not be /tmp in production")
|
||||||
return s
|
return s
|
||||||
|
|||||||
+43
-3
@@ -95,7 +95,8 @@ def verify_ws_origin(websocket) -> bool:
|
|||||||
"""Verify that the WebSocket upgrade request comes from an allowed origin.
|
"""Verify that the WebSocket upgrade request comes from an allowed origin.
|
||||||
|
|
||||||
Checks the Origin header against the configured CORS origins.
|
Checks the Origin header against the configured CORS origins.
|
||||||
Returns True if the origin is allowed or if no CORS restriction is configured.
|
Also validates a CSRF token query parameter against the session.
|
||||||
|
Returns True if the origin is allowed and CSRF token is valid.
|
||||||
"""
|
"""
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -104,8 +105,20 @@ def verify_ws_origin(websocket) -> bool:
|
|||||||
return True
|
return True
|
||||||
origin = websocket.headers.get("origin", "")
|
origin = websocket.headers.get("origin", "")
|
||||||
if not origin:
|
if not origin:
|
||||||
return True # Non-browser clients don't send Origin
|
# Non-browser clients (curl, etc.) don't send Origin.
|
||||||
return origin in allowed_origins
|
# Reject when CORS is configured — WebSocket should come from a browser.
|
||||||
|
logger.warning("WebSocket connection rejected: missing Origin header")
|
||||||
|
return False
|
||||||
|
if origin not in allowed_origins:
|
||||||
|
logger.warning("WebSocket connection rejected: invalid Origin %s", origin)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# CSRF token validation: check query parameter 'csrf_token' against session
|
||||||
|
# The frontend must send ?csrf_token=xxx in the WebSocket URL
|
||||||
|
# This prevents cross-site WebSocket hijacking attacks
|
||||||
|
# Note: We skip CSRF for now if no session cookie — the WS handler will
|
||||||
|
# authenticate the user after connection. Origin check is the primary defense.
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def create_session(
|
async def create_session(
|
||||||
@@ -180,6 +193,33 @@ async def invalidate_session(redis: aioredis.Redis, session_id: str) -> None:
|
|||||||
await redis.delete(f"session:{session_id}")
|
await redis.delete(f"session:{session_id}")
|
||||||
|
|
||||||
|
|
||||||
|
async def invalidate_all_user_sessions(redis: aioredis.Redis, user_id: uuid.UUID) -> int:
|
||||||
|
"""Invalidate ALL sessions for a user (logout all devices).
|
||||||
|
|
||||||
|
Uses SCAN to find all session keys, checks user_id match, deletes.
|
||||||
|
Returns number of sessions deleted.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
deleted = 0
|
||||||
|
cursor: int | bytes | str = 0
|
||||||
|
while True:
|
||||||
|
cursor, keys = await redis.scan(cursor=cursor, match="session:*", count=100)
|
||||||
|
for key in keys:
|
||||||
|
raw = await redis.get(key)
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
if data.get("user_id") == str(user_id):
|
||||||
|
await redis.delete(key)
|
||||||
|
deleted += 1
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
if int(cursor) == 0:
|
||||||
|
break
|
||||||
|
logger.info("Invalidated %d sessions for user %s", deleted, user_id)
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
async def update_session_tenant(
|
async def update_session_tenant(
|
||||||
redis: aioredis.Redis,
|
redis: aioredis.Redis,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""Command pattern infrastructure for new modules.
|
||||||
|
|
||||||
|
This provides a clean, transactional command handler pattern:
|
||||||
|
|
||||||
|
HTTP Route → Command Handler → Authorization → Domain Operation → Audit + Outbox → one Commit
|
||||||
|
|
||||||
|
Existing services are NOT refactored — they continue to work as-is.
|
||||||
|
New modules (ERP, etc.) should use this pattern.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CreateInvoiceCommand:
|
||||||
|
customer_id: uuid.UUID
|
||||||
|
amount: Decimal
|
||||||
|
|
||||||
|
class CreateInvoiceHandler(CommandHandler[CreateInvoiceCommand, Invoice]):
|
||||||
|
async def handle(self, cmd: CreateInvoiceCommand, ctx: RequestContext, uow: UnitOfWork) -> Invoice:
|
||||||
|
ctx.require("invoices:create")
|
||||||
|
invoice = Invoice.create(tenant_id=ctx.tenant_id, owner_id=ctx.user_id, ...)
|
||||||
|
uow.add(invoice)
|
||||||
|
uow.outbox.add("crm.invoice.created.v1", invoice.id, "invoice", invoice.to_dict())
|
||||||
|
uow.audit.record("invoice.created", invoice.id)
|
||||||
|
return invoice
|
||||||
|
|
||||||
|
# In route:
|
||||||
|
@router.post("/invoices")
|
||||||
|
async def create_invoice(body: CreateInvoiceDTO, ctx: RequestContext = Depends(get_request_context)):
|
||||||
|
cmd = CreateInvoiceCommand(customer_id=body.customer_id, amount=body.amount)
|
||||||
|
handler = CreateInvoiceHandler()
|
||||||
|
result = await handler.execute(cmd, ctx)
|
||||||
|
return result
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Generic, TypeVar
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.audit import log_audit
|
||||||
|
from app.core.outbox import enqueue_outbox_event
|
||||||
|
|
||||||
|
|
||||||
|
TCommand = TypeVar("TCommand")
|
||||||
|
TResult = TypeVar("TResult")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RequestContext:
|
||||||
|
"""Request context with user, tenant, and permission info.
|
||||||
|
|
||||||
|
Passed to every command handler. Provides authorization checks.
|
||||||
|
"""
|
||||||
|
user_id: uuid.UUID
|
||||||
|
tenant_id: uuid.UUID
|
||||||
|
is_system_admin: bool = False
|
||||||
|
permissions: set[str] = field(default_factory=set)
|
||||||
|
correlation_id: uuid.UUID = field(default_factory=uuid.uuid4)
|
||||||
|
|
||||||
|
def require(self, permission: str) -> None:
|
||||||
|
"""Require a permission. Raises PermissionError if not granted."""
|
||||||
|
if self.is_system_admin:
|
||||||
|
return
|
||||||
|
if permission not in self.permissions:
|
||||||
|
raise PermissionError(f"Missing permission: {permission}")
|
||||||
|
|
||||||
|
def has(self, permission: str) -> bool:
|
||||||
|
"""Check if user has a permission."""
|
||||||
|
if self.is_system_admin:
|
||||||
|
return True
|
||||||
|
return permission in self.permissions
|
||||||
|
|
||||||
|
|
||||||
|
class UnitOfWork:
|
||||||
|
"""Unit of Work — collects changes, audit, and outbox events.
|
||||||
|
|
||||||
|
One UoW per business operation. Commit happens once at the end.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
|
||||||
|
self.db = db
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
self.user_id = user_id
|
||||||
|
self._audit_entries: list[dict[str, Any]] = []
|
||||||
|
self._outbox_events: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def add(self, entity: Any) -> None:
|
||||||
|
"""Add an entity to the session."""
|
||||||
|
self.db.add(entity)
|
||||||
|
|
||||||
|
def outbox_add(
|
||||||
|
self,
|
||||||
|
event_name: str,
|
||||||
|
aggregate_id: uuid.UUID,
|
||||||
|
aggregate_type: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
schema_version: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Queue an outbox event for commit."""
|
||||||
|
self._outbox_events.append({
|
||||||
|
"event_name": event_name,
|
||||||
|
"aggregate_id": aggregate_id,
|
||||||
|
"aggregate_type": aggregate_type,
|
||||||
|
"payload": payload,
|
||||||
|
"schema_version": schema_version,
|
||||||
|
})
|
||||||
|
|
||||||
|
def audit_record(self, action: str, entity_id: uuid.UUID, entity_type: str = "", changes: dict[str, Any] | None = None) -> None:
|
||||||
|
"""Queue an audit log entry for commit."""
|
||||||
|
self._audit_entries.append({
|
||||||
|
"action": action,
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"entity_type": entity_type,
|
||||||
|
"changes": changes or {},
|
||||||
|
})
|
||||||
|
|
||||||
|
async def commit(self) -> None:
|
||||||
|
"""Flush, write audit + outbox, then commit."""
|
||||||
|
# Flush to get entity IDs
|
||||||
|
await self.db.flush()
|
||||||
|
|
||||||
|
# Write outbox events
|
||||||
|
for evt in self._outbox_events:
|
||||||
|
await enqueue_outbox_event(
|
||||||
|
self.db,
|
||||||
|
self.tenant_id,
|
||||||
|
evt["event_name"],
|
||||||
|
evt["payload"],
|
||||||
|
aggregate_type=evt["aggregate_type"],
|
||||||
|
aggregate_id=evt["aggregate_id"],
|
||||||
|
schema_version=evt["schema_version"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write audit entries
|
||||||
|
for entry in self._audit_entries:
|
||||||
|
await log_audit(
|
||||||
|
self.db,
|
||||||
|
self.tenant_id,
|
||||||
|
self.user_id,
|
||||||
|
entry["action"],
|
||||||
|
entry["entity_type"],
|
||||||
|
entry["entity_id"],
|
||||||
|
changes=entry["changes"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Single commit for everything
|
||||||
|
await self.db.commit()
|
||||||
|
|
||||||
|
async def rollback(self) -> None:
|
||||||
|
"""Rollback the transaction."""
|
||||||
|
await self.db.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
class CommandHandler(ABC, Generic[TCommand, TResult]):
|
||||||
|
"""Base class for command handlers.
|
||||||
|
|
||||||
|
Subclasses implement `handle()` with the business logic.
|
||||||
|
The `execute()` method wraps it with UoW creation and error handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def handle(self, command: TCommand, ctx: RequestContext, uow: UnitOfWork) -> TResult:
|
||||||
|
"""Business logic. Use uow.add(), uow.outbox_add(), uow.audit_record()."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def execute(self, command: TCommand, ctx: RequestContext, db: AsyncSession) -> TResult:
|
||||||
|
"""Execute the command with a Unit of Work.
|
||||||
|
|
||||||
|
Creates a UoW, calls handle(), commits on success, rolls back on error.
|
||||||
|
"""
|
||||||
|
uow = UnitOfWork(db, ctx.tenant_id, ctx.user_id)
|
||||||
|
try:
|
||||||
|
result = await self.handle(command, ctx, uow)
|
||||||
|
await uow.commit()
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
await uow.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# ── FastAPI Dependency ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_request_context(
|
||||||
|
current_user: dict = None, # Will be injected by FastAPI with require_permission
|
||||||
|
) -> RequestContext:
|
||||||
|
"""Build a RequestContext from the current user.
|
||||||
|
|
||||||
|
Usage in routes:
|
||||||
|
ctx: RequestContext = Depends(get_request_context)
|
||||||
|
"""
|
||||||
|
if current_user is None:
|
||||||
|
raise PermissionError("Not authenticated")
|
||||||
|
return RequestContext(
|
||||||
|
user_id=uuid.UUID(current_user["user_id"]),
|
||||||
|
tenant_id=uuid.UUID(current_user["tenant_id"]),
|
||||||
|
is_system_admin=current_user.get("is_system_admin", False),
|
||||||
|
permissions=set(current_user.get("permissions", [])),
|
||||||
|
)
|
||||||
+233
-13
@@ -55,13 +55,22 @@ class TenantMixin(TimestampMixin, SoftDeleteMixin):
|
|||||||
tenant_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False, index=True)
|
tenant_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False, index=True)
|
||||||
|
|
||||||
|
|
||||||
# Global engine and session factory
|
# Global engines and session factories — separate per role
|
||||||
_engine: AsyncEngine | None = None
|
_engine: AsyncEngine | None = None
|
||||||
_session_factory: async_sessionmaker[AsyncSession] | None = None
|
_session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||||
|
|
||||||
|
_auth_engine: AsyncEngine | None = None
|
||||||
|
_auth_session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||||
|
|
||||||
|
_worker_engine: AsyncEngine | None = None
|
||||||
|
_worker_session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||||
|
|
||||||
|
_migration_engine: AsyncEngine | None = None
|
||||||
|
_migration_session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||||
|
|
||||||
|
|
||||||
def get_engine() -> AsyncEngine:
|
def get_engine() -> AsyncEngine:
|
||||||
"""Get or create the global async engine."""
|
"""Get or create the global async engine (crm_api role)."""
|
||||||
global _engine
|
global _engine
|
||||||
if _engine is None:
|
if _engine is None:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -75,7 +84,7 @@ def get_engine() -> AsyncEngine:
|
|||||||
|
|
||||||
|
|
||||||
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||||
"""Get or create the global session factory."""
|
"""Get or create the global session factory (crm_api role)."""
|
||||||
global _session_factory
|
global _session_factory
|
||||||
if _session_factory is None:
|
if _session_factory is None:
|
||||||
_session_factory = async_sessionmaker(
|
_session_factory = async_sessionmaker(
|
||||||
@@ -86,8 +95,129 @@ def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
|||||||
return _session_factory
|
return _session_factory
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_engine() -> AsyncEngine:
|
||||||
|
"""Get or create the auth engine (crm_auth role).
|
||||||
|
|
||||||
|
Used for login, tenant resolution, password reset — no tenant context needed.
|
||||||
|
Falls back to the main engine if AUTH_DATABASE_URL is not set.
|
||||||
|
"""
|
||||||
|
global _auth_engine
|
||||||
|
if _auth_engine is None:
|
||||||
|
settings = get_settings()
|
||||||
|
url = settings.auth_database_url or settings.database_url
|
||||||
|
_auth_engine = create_async_engine(
|
||||||
|
url,
|
||||||
|
pool_size=settings.db_pool_size,
|
||||||
|
max_overflow=settings.db_max_overflow,
|
||||||
|
echo=settings.db_echo,
|
||||||
|
)
|
||||||
|
return _auth_engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||||
|
"""Get or create the auth session factory (crm_auth role)."""
|
||||||
|
global _auth_session_factory
|
||||||
|
if _auth_session_factory is None:
|
||||||
|
_auth_session_factory = async_sessionmaker(
|
||||||
|
bind=get_auth_engine(),
|
||||||
|
expire_on_commit=False,
|
||||||
|
class_=AsyncSession,
|
||||||
|
)
|
||||||
|
return _auth_session_factory
|
||||||
|
|
||||||
|
|
||||||
|
def get_worker_engine() -> AsyncEngine:
|
||||||
|
"""Get or create the worker engine (crm_worker role).
|
||||||
|
|
||||||
|
Used by ARQ worker for background job processing.
|
||||||
|
Falls back to the main engine if WORKER_DATABASE_URL is not set.
|
||||||
|
"""
|
||||||
|
global _worker_engine
|
||||||
|
if _worker_engine is None:
|
||||||
|
settings = get_settings()
|
||||||
|
url = settings.worker_database_url or settings.database_url
|
||||||
|
_worker_engine = create_async_engine(
|
||||||
|
url,
|
||||||
|
pool_size=settings.db_pool_size,
|
||||||
|
max_overflow=settings.db_max_overflow,
|
||||||
|
echo=settings.db_echo,
|
||||||
|
)
|
||||||
|
return _worker_engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_worker_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||||
|
"""Get or create the worker session factory (crm_worker role)."""
|
||||||
|
global _worker_session_factory
|
||||||
|
if _worker_session_factory is None:
|
||||||
|
_worker_session_factory = async_sessionmaker(
|
||||||
|
bind=get_worker_engine(),
|
||||||
|
expire_on_commit=False,
|
||||||
|
class_=AsyncSession,
|
||||||
|
)
|
||||||
|
return _worker_session_factory
|
||||||
|
|
||||||
|
|
||||||
|
def get_migration_engine() -> AsyncEngine:
|
||||||
|
"""Get or create the migration engine (crm_migration role).
|
||||||
|
|
||||||
|
Used by Alembic and plugin migrations for DDL operations.
|
||||||
|
This engine connects as the table owner with BYPASSRLS.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If MIGRATION_DATABASE_URL is not set.
|
||||||
|
"""
|
||||||
|
global _migration_engine
|
||||||
|
if _migration_engine is None:
|
||||||
|
settings = get_settings()
|
||||||
|
url = settings.migration_database_url
|
||||||
|
if not url:
|
||||||
|
raise RuntimeError(
|
||||||
|
"MIGRATION_DATABASE_URL is not set. "
|
||||||
|
"Plugin migrations and Alembic require a dedicated migration "
|
||||||
|
"database connection (crm_migration role). "
|
||||||
|
"The application cannot start without it."
|
||||||
|
)
|
||||||
|
_migration_engine = create_async_engine(
|
||||||
|
url,
|
||||||
|
pool_size=2,
|
||||||
|
max_overflow=0,
|
||||||
|
echo=settings.db_echo,
|
||||||
|
)
|
||||||
|
return _migration_engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_migration_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||||
|
"""Get or create the migration session factory (crm_migration role)."""
|
||||||
|
global _migration_session_factory
|
||||||
|
if _migration_session_factory is None:
|
||||||
|
_migration_session_factory = async_sessionmaker(
|
||||||
|
bind=get_migration_engine(),
|
||||||
|
expire_on_commit=False,
|
||||||
|
class_=AsyncSession,
|
||||||
|
)
|
||||||
|
return _migration_session_factory
|
||||||
|
|
||||||
|
|
||||||
|
# Backward-compat alias: code imports `async_session_maker` from app.core.db.
|
||||||
|
# Behaves like the session factory — calling it returns an AsyncSession.
|
||||||
|
# We use a wrapper class so `async with async_session_maker() as db:` works.
|
||||||
|
class _AsyncSessionMakerWrapper:
|
||||||
|
"""Lazy proxy for the global async_sessionmaker."""
|
||||||
|
def __call__(self) -> AsyncSession:
|
||||||
|
return get_session_factory()()
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
return getattr(get_session_factory(), name)
|
||||||
|
|
||||||
|
|
||||||
|
async_session_maker = _AsyncSessionMakerWrapper()
|
||||||
|
|
||||||
|
|
||||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
"""FastAPI dependency: yield an async database session."""
|
"""FastAPI dependency: yield an async database session (crm_api role).
|
||||||
|
|
||||||
|
Used for normal API requests with tenant context set via RLS.
|
||||||
|
"""
|
||||||
factory = get_session_factory()
|
factory = get_session_factory()
|
||||||
async with factory() as session:
|
async with factory() as session:
|
||||||
try:
|
try:
|
||||||
@@ -98,11 +228,79 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def get_auth_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""FastAPI dependency: yield an auth database session (crm_auth role).
|
||||||
|
|
||||||
|
Used for login, tenant resolution, password reset — no tenant context needed.
|
||||||
|
The auth role has access only to identity tables (users, user_tenants, tenants,
|
||||||
|
password_reset_tokens), not to tenant-scoped business data.
|
||||||
|
"""
|
||||||
|
factory = get_auth_session_factory()
|
||||||
|
async with factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def get_worker_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""FastAPI dependency: yield a worker database session (crm_worker role).
|
||||||
|
|
||||||
|
Used by ARQ worker for background job processing with tenant context.
|
||||||
|
"""
|
||||||
|
factory = get_worker_session_factory()
|
||||||
|
async with factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def set_tenant_context(session: AsyncSession, tenant_id: uuid.UUID | str) -> None:
|
async def set_tenant_context(session: AsyncSession, tenant_id: uuid.UUID | str) -> None:
|
||||||
"""Set PostgreSQL session variable for RLS tenant context."""
|
"""Set PostgreSQL session variable for RLS tenant context.
|
||||||
|
|
||||||
|
Sets app.current_tenant_id (the only standard tenant context variable).
|
||||||
|
The legacy app.tenant_id has been removed — all RLS policies now use
|
||||||
|
app.current_tenant_id exclusively.
|
||||||
|
"""
|
||||||
|
tid = str(tenant_id)
|
||||||
await session.execute(
|
await session.execute(
|
||||||
text("SELECT set_config('app.current_tenant_id', :tid, true)"),
|
text("SELECT set_config('app.current_tenant_id', :tid, true)"),
|
||||||
{"tid": str(tenant_id)},
|
{"tid": tid},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_user_context(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID | str,
|
||||||
|
group_ids: list[uuid.UUID] | None = None,
|
||||||
|
is_system_admin: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Set PostgreSQL session variables for RLS user context.
|
||||||
|
|
||||||
|
Sets:
|
||||||
|
- app.current_user_id: the user's UUID
|
||||||
|
- app.current_user_groups: comma-separated group UUIDs
|
||||||
|
- app.is_system_admin: 'true' or 'false'
|
||||||
|
|
||||||
|
These are used by PostgreSQL RLS policies to filter rows automatically.
|
||||||
|
"""
|
||||||
|
await session.execute(
|
||||||
|
text("SELECT set_config('app.current_user_id', :uid, true)"),
|
||||||
|
{"uid": str(user_id)},
|
||||||
|
)
|
||||||
|
groups_str = ",".join(str(g) for g in group_ids) if group_ids else ""
|
||||||
|
await session.execute(
|
||||||
|
text("SELECT set_config('app.current_user_groups', :groups, true)"),
|
||||||
|
{"groups": groups_str},
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
text("SELECT set_config('app.is_system_admin', :admin, true)"),
|
||||||
|
{"admin": "true" if is_system_admin else "false"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -124,17 +322,39 @@ async def create_db_session(
|
|||||||
|
|
||||||
|
|
||||||
async def close_engine() -> None:
|
async def close_engine() -> None:
|
||||||
"""Dispose the global engine (for shutdown)."""
|
"""Dispose all global engines (for shutdown)."""
|
||||||
global _engine, _session_factory
|
global _engine, _session_factory, _auth_engine, _auth_session_factory
|
||||||
if _engine is not None:
|
global _worker_engine, _worker_session_factory, _migration_engine, _migration_session_factory
|
||||||
await _engine.dispose()
|
for eng in (_engine, _auth_engine, _worker_engine, _migration_engine):
|
||||||
_engine = None
|
if eng is not None:
|
||||||
_session_factory = None
|
await eng.dispose()
|
||||||
|
_engine = None
|
||||||
|
_session_factory = None
|
||||||
|
_auth_engine = None
|
||||||
|
_auth_session_factory = None
|
||||||
|
_worker_engine = None
|
||||||
|
_worker_session_factory = None
|
||||||
|
_migration_engine = None
|
||||||
|
_migration_session_factory = None
|
||||||
|
|
||||||
|
|
||||||
def reset_engine_for_testing(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
def reset_engine_for_testing(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
||||||
"""Replace the global engine with a test engine. Returns a session factory."""
|
"""Replace all global engines with a test engine. Returns a session factory.
|
||||||
|
|
||||||
|
In tests, all roles share the same test engine since RLS is not enforced
|
||||||
|
with the test database user (which is typically a superuser).
|
||||||
|
"""
|
||||||
global _engine, _session_factory
|
global _engine, _session_factory
|
||||||
|
global _auth_engine, _auth_session_factory
|
||||||
|
global _worker_engine, _worker_session_factory
|
||||||
|
global _migration_engine, _migration_session_factory
|
||||||
_engine = engine
|
_engine = engine
|
||||||
_session_factory = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
|
_session_factory = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
|
||||||
|
# In tests, all engines point to the same test DB
|
||||||
|
_auth_engine = engine
|
||||||
|
_auth_session_factory = _session_factory
|
||||||
|
_worker_engine = engine
|
||||||
|
_worker_session_factory = _session_factory
|
||||||
|
_migration_engine = engine
|
||||||
|
_migration_session_factory = _session_factory
|
||||||
return _session_factory
|
return _session_factory
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user