Compare commits
257 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5daeb8dfd | |||
| 485fbd9877 | |||
| f4364f30e0 | |||
| 0260f3410d | |||
| 8d82df3076 | |||
| 8b683c7da7 | |||
| 29d55cb187 | |||
| ff975ca0a6 | |||
| 4efdc8e036 | |||
| 8ad0a19f25 | |||
| ea797b033a | |||
| 07d4587499 | |||
| 3eb11b1745 | |||
| a760a759eb | |||
| 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
|
||||
|
||||
# === 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
|
||||
|
||||
# === REQUIRED for Docker/Production ===
|
||||
# Redis password (required in Docker)
|
||||
REDIS_PASSWORD=your_redis_password
|
||||
|
||||
# === OPTIONAL (with defaults) ===
|
||||
|
||||
# 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 --legacy-peer-deps
|
||||
- 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/
|
||||
coverage.xml
|
||||
.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/
|
||||
|
||||
# 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
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
@@ -20,13 +20,13 @@ pip install -e ".[dev]"
|
||||
|
||||
#### Run Dev Server
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
#### Database Migrations (Alembic)
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Generate migration after model changes
|
||||
alembic revision --autogenerate -m "description"
|
||||
# Apply migrations
|
||||
@@ -37,37 +37,37 @@ alembic downgrade -1
|
||||
|
||||
#### Run All Backend Tests
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
python -m pytest -v --tb=short
|
||||
```
|
||||
|
||||
#### Run Specific Test File
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
python -m pytest tests/test_auth.py -v --tb=short
|
||||
```
|
||||
|
||||
#### Run Tests with Coverage
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
python -m pytest --cov=app --cov-report=term-missing --cov-report=html
|
||||
```
|
||||
|
||||
#### Run Tests with Grep Filter
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
python -m pytest -k 'tenant or auth' -v
|
||||
```
|
||||
|
||||
#### Type Checking
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
mypy app/ --ignore-missing-imports
|
||||
```
|
||||
|
||||
#### Linting
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
ruff check app/
|
||||
ruff format app/
|
||||
```
|
||||
|
||||
+3
-3
@@ -12,7 +12,7 @@ WORKDIR /frontend
|
||||
|
||||
# Copy package files first for layer caching
|
||||
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/ ./
|
||||
@@ -76,7 +76,7 @@ COPY --chown=appuser:appuser . .
|
||||
COPY --from=frontend --chown=appuser:appuser /frontend/dist /app/frontend/dist
|
||||
|
||||
# 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
|
||||
RUN mkdir -p /data/storage && chown -R appuser:appuser /data
|
||||
@@ -86,6 +86,6 @@ USER appuser
|
||||
EXPOSE 8000
|
||||
|
||||
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"]
|
||||
|
||||
@@ -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).
|
||||
|
||||
**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`
|
||||
- **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`
|
||||
- **Problem:** WelcomeDialog hat `open={false}`. SavedFilterBar/EntityHistoryPanel/TagBadge/TagSelector sind gebaut aber nicht in Seiten integriert.
|
||||
- **Fix:**
|
||||
1. WelcomeDialog an User-Preferences (onboarding_completed) koppeln
|
||||
2. SavedFilterBar in ContactsList, Mail, Calendar integrieren
|
||||
3. EntityHistoryPanel in ContactDetail, Settings integrieren
|
||||
4. TagBadge/TagSelector in ContactsList, Mail, Calendar integrieren
|
||||
- **Aufwand:** 4 Std
|
||||
- **Status:** ✅ Implementiert — SavedFilterBar und TagSelector in ContactsList, Mail, Calendar integriert
|
||||
- **Implementiert:**
|
||||
1. SavedFilterBar in ContactsList (entityType="contacts"), Mail (entityType="mail"), Calendar (entityType="calendar") integriert
|
||||
2. TagSelector in ContactsList (entityType="contact"), Mail (entityType="file"), Calendar (entityType="calendar_entry") integriert
|
||||
3. Frontend TypeScript: 0 Errors (`npx tsc --noEmit`)
|
||||
- **Hinweis:** WelcomeDialog und EntityHistoryPanel bleiben für spätere Iteration offen
|
||||
|
||||
### M6. Frontend-Tests: QueryClientProvider
|
||||
- **Datei:** `frontend/src/test/setup.ts` oder einzelne Tests
|
||||
|
||||
+200
-3
@@ -1,9 +1,21 @@
|
||||
# LeoCRM Plugin-System — Kompletter Umbauplan
|
||||
|
||||
**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
|
||||
|
||||
**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
|
||||
@@ -15,7 +27,8 @@
|
||||
| Phase 3 | 5 | Plugin-Isolation (Linting) | 4 | 0,5 |
|
||||
| Phase 4 | 8 | Plugin-Versioning | 20 | 2,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
@@ -654,7 +843,8 @@ Woche 2 (Tag 6-8): Phase 1 — Contracts (Teil 2: Deaktivierung + Tests)
|
||||
Woche 3 (Tag 11): Phase 3 — Plugin-Isolation
|
||||
(Tag 12-14): Phase 4 — Plugin-Versioning
|
||||
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
|
||||
@@ -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 5 (Marketplace: braucht alles)
|
||||
│
|
||||
└──→ Phase 6 (Manifest: braucht Phase 4 + 5 Felder)
|
||||
```
|
||||
|
||||
### 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 4 kann nach Phase 1 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
|
||||
14. ✅ **Alle Tests bestanden**
|
||||
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
|
||||
|
||||
> 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)
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
ÜBERHOLT – NICHT ALS UMSETZUNGSANWEISUNG VERWENDEN
|
||||
# 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
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
1f59cbca47ea189432d25a9bd924ead13b6f285ce7740510714e01ccc4bb7dd8 0001_initial.py
|
||||
6e5af9bb75ea05893bcd929152dbea449c54e0df27a1cb450a86fd675089519c 0002_contacts_fts.py
|
||||
6e7ac65fce63d0fcea897a897abe527ce360ae747ab96be5e0439cf6ad1dbeff 0003_plugin_system.py
|
||||
129dca600710901612ff71dd409a40bedf50570cbc19419ebe38987516369991 0004_ai_workflows.py
|
||||
22187aa9158aa994b96b496475adf46c95db4c7c98aa99c3d39d27c00696d084 0005_user_role_fk.py
|
||||
e7d4bf646eb7e88807f9fa81ba014596f6f15386908887936dcd7c7f8db4233f 0006_add_addresses.py
|
||||
b125bdbf99b7f2239860a99258750941f6711a7082ae2391686f1a351abea18b 0007_currencies.py
|
||||
c15fa1c8883c27520624945cad88a052c7e1f35f524e8d5ebf9d9c7f46a9cba1 0008_tax_rates.py
|
||||
19da33700de8f512f4ed0b1761f525e66f2bc429620eff2ebea1533a5c1acbd3 0009_sequences.py
|
||||
10761f179cd5e51007ae5cf09ff72da5c31d2dd0f5b3f8a8b4a09c6d086f8c22 0010_system_settings.py
|
||||
90965449194517d7e9de4c4d9c81947632dcd0fdd392b545c775bcccf5f8b706 0011_attachments.py
|
||||
f60cc4ee0c2b5b1b963453d821910196422d488f94ddbaface7a5ebe8f998554 0012_soft_delete.py
|
||||
79d675096e1d546ea3bf2ccdb768ae0d50099c4cd10091796660ef0307326e0b 0013_addresses.py
|
||||
c327ac7e64becaecbb0d64639e65084ad79b7eddda3bdedc0c69b8db38749a2c 0014_currency_unique_fix.py
|
||||
bb764156af7ec85d3d157c85c7f4694296d124d1bddb8e9a92eb8afba7a3769a 0015_rls_policies.py
|
||||
a59265ece8e32886b447138d23203c2689dfe5a5bd3fcd06f853c027748f72e9 0016_plugin_is_core.py
|
||||
eef54bd0625d0d53463a22560cee2c18903bf83d72c377c948c7164e000570fa 0017_notification_preferences.py
|
||||
eb7789038fe80185e95c412a0011287fa8a1e15b96d0d858f2b59168eec2271e 0018_fix_notification_preferences_columns.py
|
||||
af2dbd9f06a2fa67c00417025088147463c58a5547ae90e80050c8adf972e0e5 0019_rbac_groups.py
|
||||
d6288d579085b64c688a01ed7e071705c0347f03d0af56de0c7e2554991496ae 0020_notifications_updated_at.py
|
||||
67f0f745af1f77b2db6e8f39c61e10d160b0c770a8eb0c748c342361c31bed87 0021_unified_contacts.py
|
||||
62f105366204bcb8bbfbb5537d3135725010873d1007323f0c8c4a10e1914f63 0022_contact_folders.py
|
||||
f6e266744c91465bc9cb5739e57bc69a575484b93dee49f7cecc5dc0d1faa746 0023_theme_customization.py
|
||||
56587cd59d6d7d39a5859c8707cdb0fc05b3dd5c34afc20caeb5391b89604afd 0024_heartbeat_config.py
|
||||
fe98eaa00e3de292ee23539399b62c847574d01743066b084a693d7ff22d84dd 0025_entity_history.py
|
||||
4ede1b730f8e00c8ad33d1f184b07fda333bfa55bab5ced2f35d05da2a4699e2 0026_mail_salt_security.py
|
||||
5fd05dbb6bc8a1f97d04f6dfff1491e002cea3a0fd1e6138f3a0a627ae8d7681 0027_unify_company_to_contact.py
|
||||
4f61886ec7649debc2a1d0ea65f35a8a13947c1faed14512712e28210644a20b 0028_rls_force.py
|
||||
92792e3fe5591a1de73605b1d1faefd7910fee41b4773757092fb8fcf6ebfca9 0028_user_preferences.py
|
||||
873484c820181b0190e8ca175eb16a6445eac399d614c7fdd81026c2ae88e399 0029_saved_filters.py
|
||||
d3b5fe559110b070cb642feb9801b48df600b5e11c469d4a6aa0fe04beddd4da 0030_contact_merge_history.py
|
||||
3ca8a3c626bead4e14da8ebf1adef5b34c21622662158ceb2db997256f8a240f 0031_permissions_soft_delete.py
|
||||
4f21f30045fa9b9798df26701bef88499d2f2f871727cffefd5f98ce7b344d91 0032_user_profile_fields.py
|
||||
e736f93427dd128b45007d351923af150c7093eec1f41e3dafb22900875084d1 0033_bank_accounts.py
|
||||
2eca394a15cb1bef34c4a3e3d60e58a9fdc46321715eefb74272e3079f94d516 0034_automation_config.py
|
||||
6f07d56fe2204ff181c61b16e71fa59f6270d6245045fd8ce5174570339b09d0 0035_comm_search_index.py
|
||||
c891187cbb5cee0281322855f4232134093e3ce26db20d142e29900c14a5b651 0036_cross_tenant_fk.py
|
||||
ac0239040a0f5695d4477dda2728297bfee15b0c090a13e91650d0c2a17922ba 0037_user_tenant_model.py
|
||||
19ecb258a0db97db3ecce0e21018a73602f680cdcdafc9203a778c256437fb29 0038_dms_content_hash.py
|
||||
a886a1c4b8c89fb1d244aef8559accfdc21209393bffd1c1d86ee6995bfb4d4b 0039_contact_normalize.py
|
||||
815899de164dc7b4418044ff8de3631449c7baec1c83b1f7ae683577becb185f 0040_outbox.py
|
||||
7af62a3ce31bcad2e5dbddae509194586b4f45f28b1fca47fd2365c9f288d695 0041_custom_field_definitions.py
|
||||
19ef4dfb877683bf794f7009e4cdb33a2674418a54d893a1120c742253e7eb3d 0042_webhooks.py
|
||||
cb04f579ad7fb1444446d6e06dcb5a5d9cb824d0fe71c46835d2243d92c2df8f 0043_backups.py
|
||||
0efd2a980f1e104b4cf7b3ea5ce4de776ca7d73a09d34834fd65a5de0c9a6b7e 0044_rls_repair_and_db_roles.py
|
||||
d1e8f1fd12237d8635918b89da34ef45c99af832b3f372e0bde876ca8314639d 0045_repair_contact_migration.py
|
||||
07fc01641d4dc30881f664e9c795466adaff864dc72d377ff1f6b6b7b5ba0b1c 0046_plugin_allowlist.py
|
||||
afc8c9f2b1392882cd41d8b28a98640167a162cd210beeb1bd64df5b649b6500 0047_saved_views.py
|
||||
4f3daeec7ae3a5ba3a40c4329d5e1664d29539608b13f101d8914b00a69cbb48 0048_contact_folder_permissions.py
|
||||
b352752857101f46779c0d9232a793af79f3850121fe9cc77c27fb08fc14e29a 0049_entity_permissions.py
|
||||
831551810e0ba27f186123c2e8113722a4ed664fdc5ffd014a1efd139f4c9bdf 0050_owner_id_all_tables.py
|
||||
17867264f7631016349293c1a38114446d4261516e8ed0e1bf181a105a828217 0051_migrate_folder_acls.py
|
||||
ee73eba6e99341380b8129da620f6a2d309af1d3ed8e300b11ee7740d1208b33 0052_rls_contacts.py
|
||||
49a0c541bdbd4b1a0e92e1487d502d8f330776aec60ce022b349ce6462fefd0e 0053_mail_owner_id.py
|
||||
1a4285967290c358130bac536ec9d0a40bca370639c4cac53b295e217ee7082b 0054_plugin_owner_id.py
|
||||
27ce5c11c3fb0c0b69b87f4499f7eae936f3035f3eca4696de9daef94610c219 0055_entity_policies.py
|
||||
690dd996dc2bf44777ed0d7ecb717d1af0a641aa58294f9e7092e2d94a9a3f16 0056_permission_templates.py
|
||||
b5389ab783714d9f391484b7dd1437088de06fe8b8dd753090f755ed62e61fb4 0057_permission_delegations.py
|
||||
0bdf3a15a532c0934c73c36a15a5367c4f69d92138e0155c255b8cde64f4a795 0058_resolution_strategy.py
|
||||
bb87f8836425f097c7d70e736896e9f6fd68c3e8ea80756065e74e45ebc77162 0059_guest_users.py
|
||||
240957a7bdc90bac008d8af3ffbc1c4205c0aa582fff6b89861655631c4670fa 0060_rls_contacts_secure.py
|
||||
f020ea4b687a148663c8da4188503e55ba3c5d2072408590767f5984512b9287 0061_db_roles_secure.py
|
||||
ad6876b5e15b44547cd91bebb54e977f985decc4b25e9c8c63cd9b1f000ae0a7 0062_guest_invitations_secure.py
|
||||
78db5dea0a068749b0e86c157d1fa92068e023d9605b32eec26fffe477a78e64 0063_notification_entity_fields.py
|
||||
c2a1669e0afa8f30bc1c2696fe2a20541507a515266f1f8d3416fd7daafaabe2 0064_rls_all_tenant_tables.py
|
||||
eafe25abb7cd7a493d590ae04a15326c8c4aa6ee22693f1599c72ebdf859b847 0065_consumer_inbox.py
|
||||
c69e5d22853555b79b2fc4632308a0520ddb6639f61fa1c39d912fce175d1ca2 0066_tenant_plugin_activation.py
|
||||
790fd62ee1523633720963802287bf31c607f0fcd2b8ec2a3d6dd1eb4e0951bb 0067_disable_rls_system_tables.py
|
||||
c9b22694060fa92a725c79c781988ff66b326301090c290062af7226dcbf84f2 0068_entity_permissions_deleted_at.py
|
||||
6e269eab56fa261bed460bedcf9fcb1dba55bfb36918cedd8adda36b6bddc20a 0069_rls_tenant_isolation_only.py
|
||||
4d93eb1c7d26d51a4f411041a6979c7f5dcaaa411d7bba23cc37aa27fa045374 0070_db_roles_separation.py
|
||||
1d750493a9d5d224952308c8903a6b86f6ca5dfe74e11a270888edea0d873005 0071_entity_attachments.py
|
||||
fce10ad1f18c0a383d1c4ab60d403f14298d8cb644c7e0637a2e56f349bbb4cb 0072_workspaces.py
|
||||
4a2409f12241c129f1e0a28219be9d2f6801a6d9a6b5d8671be376a9f7d0a622 0073_workspace_deleted_at.py
|
||||
a6256de26d248323e4f68d9b035fb42349aac98458dd15dec1597e2223e71e27 0074_workspace_users_timestamps.py
|
||||
5c48afc9032acdcb05cdd89fb650116dacac1662c7bf2605c28596b7d14d31d4 0075_outbox_envelope.py
|
||||
48558039eee96b6d4b0f687d5231ce7643460e64f5803112d3c330af654c3c7b 0076_disable_rls_startup_tables.py
|
||||
d15e524e257a738beb955ab891db35492089aaded7033f1e3d5d82f739cefe25 0077_disable_rls_tax_rates.py
|
||||
5e102c1ff963b5ddbefa96515a114ffa5bec25e9e41f53a555f743af06e2d24e 0078_disable_rls_automation.py
|
||||
2e72ed88053416b8525205ab0c71d416a4caed32ac475d3c539541b86e5ab683 0079_disable_rls_system_tables.py
|
||||
099b0259a865a8b9aff6c6af40c9481a813ed30d6cf9e061a054e85545e6ca75 0080_disable_rls_audit_sessions.py
|
||||
ba5b221f7ce0271a1b531eb441d2f0afe7b3d53bd44e602b8e839a3806059bfb 0081_disable_rls_all_system_tables.py
|
||||
1705c1788ea57085c2ffe99d985e077ffa2e2e45482a5b6af162a76dcbeda34c 0082_add_sensitivity_to_custom_field_definitions.py
|
||||
f8409a0e4952703b5a1a1ba064f8622071f12c657ad4e8ff1a09c2020d768762 0083_add_missing_deleted_at_columns.py
|
||||
d2bdad015bdf16f6c911f58a08103b1814f0f6d987b4ecd290732ee7a185a843 0084_rls_fail_closed_reactivate.py
|
||||
9d398d6997302ab5bc045bd655fdfba08fd617b087b86dd2a02356254244570e 0085_restore_tenant_rls.py
|
||||
b184eab067c0dfaa66712bd74471b4c65715e90a07521b17577ed15bac707259 0086_fix_global_tables_force_rls.py
|
||||
f0f33e314b52a849f1bad06cfa9ffb5da07890764bc8d22dcd43237293ed90db 0087_add_timestamps_to_password_reset_tokens.py
|
||||
38e3f4454e079faed2e6fc78cec632d6f78189c46750a7668a9c9c1a845f2bd4 0088_auth_rls_policies.py
|
||||
2e279fe7afd72b2093695249e16bdf7bf3be400935099fe21f3c4c3aa87059ba 0089_sessions_updated_at.py
|
||||
d7cabfb4c3d4665bd12aded82dc0727a55705bf9124c7e0b11574929dc806ab2 0090_fix_legacy_tenant_policies.py
|
||||
94d48243191c7fee0c2106afc9e4809fbc8ef3a38786b0e0582f2cce488a219d 0091_add_tenant_fk_constraints.py
|
||||
53d4c6e01d59da4fbf9785de05237d2656473a5c5fcccb08edf79be8284db4c4 0092_outbox_dlq.py
|
||||
@@ -29,7 +29,7 @@ def upgrade() -> None:
|
||||
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()),
|
||||
)
|
||||
op.create_index("ix_tenants_slug", "tenants", ["slug"])
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_tenants_slug ON tenants (slug)')
|
||||
|
||||
# users
|
||||
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.UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
|
||||
)
|
||||
op.create_index("ix_users_tenant_id", "users", ["tenant_id"])
|
||||
op.create_index("ix_users_email", "users", ["email"])
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_users_tenant_id ON users (tenant_id)")
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_users_email ON users (email)')
|
||||
|
||||
# user_tenants
|
||||
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("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
|
||||
op.create_table(
|
||||
@@ -80,8 +80,8 @@ def upgrade() -> None:
|
||||
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()),
|
||||
)
|
||||
op.create_index("ix_sessions_tenant_id", "sessions", ["tenant_id"])
|
||||
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_sessions_tenant_id ON sessions (tenant_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_sessions_user_id ON sessions (user_id)')
|
||||
|
||||
# audit_log
|
||||
op.create_table(
|
||||
@@ -95,10 +95,10 @@ def upgrade() -> None:
|
||||
sa.Column("changes", postgresql.JSONB, nullable=True),
|
||||
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.create_index("ix_audit_log_entity_type", "audit_log", ["entity_type"])
|
||||
op.create_index("ix_audit_log_user_id", "audit_log", ["user_id"])
|
||||
op.create_index("ix_audit_log_timestamp", "audit_log", ["timestamp"])
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_audit_log_tenant_id ON audit_log (tenant_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_audit_log_entity_type ON audit_log (entity_type)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_audit_log_user_id ON audit_log (user_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_audit_log_timestamp ON audit_log (timestamp)')
|
||||
|
||||
# deletion_log
|
||||
op.create_table(
|
||||
@@ -124,9 +124,9 @@ def upgrade() -> None:
|
||||
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()),
|
||||
)
|
||||
op.create_index("ix_notifications_tenant_id", "notifications", ["tenant_id"])
|
||||
op.create_index("ix_notifications_user_id", "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_id ON notifications (tenant_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_notifications_user_id ON notifications (user_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_notifications_tenant_user_read ON notifications (tenant_id, user_id, read_at)')
|
||||
|
||||
# password_reset_tokens
|
||||
op.create_table(
|
||||
@@ -138,9 +138,9 @@ def upgrade() -> None:
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
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.create_index("ix_password_reset_tokens_user_id", "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_tenant_id ON password_reset_tokens (tenant_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_password_reset_tokens_user_id ON password_reset_tokens (user_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_password_reset_tokens_token_hash ON password_reset_tokens (token_hash)')
|
||||
|
||||
# api_tokens
|
||||
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("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_api_tokens_tenant_id", "api_tokens", ["tenant_id"])
|
||||
op.create_index("ix_api_tokens_token_hash", "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_id ON api_tokens (tenant_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_api_tokens_token_hash ON api_tokens (token_hash)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_api_tokens_tenant_user ON api_tokens (tenant_id, user_id)')
|
||||
|
||||
# companies
|
||||
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("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_companies_tenant_id", "companies", ["tenant_id"])
|
||||
op.create_index("ix_companies_tenant_deleted", "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_id ON companies (tenant_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_companies_tenant_deleted ON companies (tenant_id, deleted_at)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_companies_tenant_name ON companies (tenant_id, name)')
|
||||
|
||||
# Enable RLS on tenant-scoped tables
|
||||
for table in ["companies", "users", "roles", "sessions", "audit_log", "notifications", "api_tokens"]:
|
||||
|
||||
@@ -34,17 +34,8 @@ def upgrade() -> None:
|
||||
) STORED
|
||||
"""
|
||||
)
|
||||
op.create_index(
|
||||
"ix_companies_search_vec",
|
||||
"companies",
|
||||
["search_tsv"],
|
||||
postgresql_using="gin",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_companies_industry",
|
||||
"companies",
|
||||
["tenant_id", "industry"],
|
||||
)
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_companies_search_vec ON companies (search_tsv)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_companies_industry ON companies (tenant_id, industry)')
|
||||
|
||||
# --- contacts ---
|
||||
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("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_contacts_tenant_id", "contacts", ["tenant_id"])
|
||||
op.create_index("ix_contacts_tenant_deleted", "contacts", ["tenant_id", "deleted_at"])
|
||||
op.create_index("ix_contacts_tenant_name", "contacts", ["tenant_id", "last_name", "first_name"])
|
||||
op.create_index("ix_contacts_email", "contacts", ["email"])
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_contacts_tenant_id ON contacts (tenant_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_contacts_tenant_deleted ON contacts (tenant_id, deleted_at)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_contacts_tenant_name ON contacts (tenant_id, last_name, first_name)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_contacts_email ON contacts (email)")
|
||||
|
||||
# --- company_contacts (N:M join) ---
|
||||
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.UniqueConstraint("company_id", "contact_id", "tenant_id", name="uq_company_contact_tenant"),
|
||||
)
|
||||
op.create_index("ix_cc_company", "company_contacts", ["company_id"])
|
||||
op.create_index("ix_cc_contact", "company_contacts", ["contact_id"])
|
||||
op.create_index("ix_company_contacts_tenant_id", "company_contacts", ["tenant_id"])
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_cc_company ON company_contacts (company_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_cc_contact ON company_contacts (contact_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_company_contacts_tenant_id ON company_contacts (tenant_id)')
|
||||
|
||||
# --- RLS on new tenant-scoped tables ---
|
||||
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("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) ---
|
||||
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.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:
|
||||
|
||||
@@ -31,8 +31,8 @@ def upgrade() -> None:
|
||||
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()),
|
||||
)
|
||||
op.create_index("ix_ai_conversations_tenant_id", "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_id ON ai_conversations (tenant_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) ---
|
||||
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("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.create_index("ix_ai_messages_tenant_conversation", "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_tenant_id ON ai_messages (tenant_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_ai_messages_tenant_conversation ON ai_messages (tenant_id, conversation_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_ai_messages_conversation_id ON ai_messages (conversation_id)')
|
||||
|
||||
# --- workflows table (tenant-scoped) ---
|
||||
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("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_workflows_tenant_id", "workflows", ["tenant_id"])
|
||||
op.create_index("ix_workflows_tenant_active", "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_id ON workflows (tenant_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_workflows_tenant_active ON workflows (tenant_id, is_active)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_workflows_tenant_trigger ON workflows (tenant_id, trigger_event)')
|
||||
|
||||
# --- workflow_instances table (tenant-scoped) ---
|
||||
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("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.create_index("ix_wf_instances_tenant_status", "workflow_instances", ["tenant_id", "status"])
|
||||
op.create_index("ix_wf_instances_tenant_workflow", "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_tenant_id ON workflow_instances (tenant_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_wf_instances_tenant_status ON workflow_instances (tenant_id, status)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_wf_instances_tenant_workflow ON workflow_instances (tenant_id, 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) ---
|
||||
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("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.create_index("ix_wf_step_history_tenant_instance", "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_tenant_id ON workflow_step_history (tenant_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_wf_step_history_tenant_instance ON workflow_step_history (tenant_id, instance_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_wf_step_history_instance_id ON workflow_step_history (instance_id)')
|
||||
|
||||
# --- RLS Policies ---
|
||||
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:
|
||||
op.add_column(
|
||||
"users",
|
||||
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"])
|
||||
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS role_id UUID REFERENCES roles(id) ON DELETE SET NULL")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_users_role_id ON users (role_id)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -20,18 +20,18 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add address columns to companies
|
||||
op.add_column("companies", sa.Column("address_street", sa.String(255), nullable=True))
|
||||
op.add_column("companies", sa.Column("address_city", sa.String(100), nullable=True))
|
||||
op.add_column("companies", sa.Column("address_zip", sa.String(20), nullable=True))
|
||||
op.add_column("companies", sa.Column("address_country", sa.String(2), nullable=True))
|
||||
op.add_column("companies", sa.Column("address_state", sa.String(100), nullable=True))
|
||||
op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS address_street VARCHAR(255)")
|
||||
op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS address_city VARCHAR(100)")
|
||||
op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS address_zip VARCHAR(20)")
|
||||
op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS address_country VARCHAR(2)")
|
||||
op.execute("ALTER TABLE companies ADD COLUMN IF NOT EXISTS address_state VARCHAR(100)")
|
||||
|
||||
# Add address columns to contacts
|
||||
op.add_column("contacts", sa.Column("address_street", sa.String(255), nullable=True))
|
||||
op.add_column("contacts", sa.Column("address_city", sa.String(100), nullable=True))
|
||||
op.add_column("contacts", sa.Column("address_zip", sa.String(20), nullable=True))
|
||||
op.add_column("contacts", sa.Column("address_country", sa.String(2), nullable=True))
|
||||
op.add_column("contacts", sa.Column("address_state", sa.String(100), nullable=True))
|
||||
op.execute("ALTER TABLE contacts ADD COLUMN IF NOT EXISTS address_street VARCHAR(255)")
|
||||
op.execute("ALTER TABLE contacts ADD COLUMN IF NOT EXISTS address_city VARCHAR(100)")
|
||||
op.execute("ALTER TABLE contacts ADD COLUMN IF NOT EXISTS address_zip VARCHAR(20)")
|
||||
op.execute("ALTER TABLE contacts ADD COLUMN IF NOT EXISTS address_country VARCHAR(2)")
|
||||
op.execute("ALTER TABLE contacts ADD COLUMN IF NOT EXISTS address_state VARCHAR(100)")
|
||||
|
||||
|
||||
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("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_currencies_tenant_code", "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_code ON currencies (tenant_id, code)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_currencies_tenant_default ON currencies (tenant_id, is_default)')
|
||||
|
||||
|
||||
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("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_tax_rates_tenant_name", "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_name ON tax_rates (tenant_id, name)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_tax_rates_tenant_default ON tax_rates (tenant_id, is_default)')
|
||||
|
||||
|
||||
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("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:
|
||||
|
||||
@@ -48,7 +48,7 @@ def upgrade() -> None:
|
||||
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.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:
|
||||
|
||||
@@ -36,7 +36,7 @@ def upgrade() -> None:
|
||||
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.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:
|
||||
|
||||
@@ -41,8 +41,8 @@ def upgrade() -> None:
|
||||
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.create_index("ix_addresses_tenant_type", "addresses", ["tenant_id", "address_type"])
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_addresses_tenant_entity ON addresses (tenant_id, entity_type, entity_id)')
|
||||
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)
|
||||
# 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("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
|
||||
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.UniqueConstraint("user_id", "type_key", name="uq_notif_pref_user_type"),
|
||||
)
|
||||
op.create_index("ix_notif_prefs_user", "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_user ON notification_preferences (user_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_notif_prefs_tenant ON notification_preferences (tenant_id)')
|
||||
|
||||
# Seed mail plugin notification types
|
||||
for nt in MAIL_NOTIFICATION_TYPES:
|
||||
|
||||
@@ -15,14 +15,8 @@ depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add missing columns from TimestampMixin and SoftDeleteMixin
|
||||
op.add_column(
|
||||
"notification_preferences",
|
||||
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),
|
||||
)
|
||||
op.execute("ALTER TABLE notification_preferences ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()")
|
||||
op.execute("ALTER TABLE notification_preferences ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -53,7 +53,7 @@ def upgrade() -> None:
|
||||
"user_tenants",
|
||||
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 ──
|
||||
op.add_column(
|
||||
@@ -65,14 +65,12 @@ def upgrade() -> None:
|
||||
sa.Column("permission_version", sa.Integer, nullable=False, server_default="1"),
|
||||
)
|
||||
# Add missing TimestampMixin + SoftDeleteMixin columns
|
||||
# Note: deleted_at may already exist if 0012_soft_delete ran first
|
||||
op.add_column(
|
||||
"roles",
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.add_column(
|
||||
"roles",
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.execute("ALTER TABLE roles ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE")
|
||||
|
||||
# ── Seed default roles per tenant ──
|
||||
# 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)
|
||||
|
||||
# ── 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(
|
||||
"contacts",
|
||||
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("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_contacts_tenant_deleted", "contacts", ["tenant_id", "deleted_at"])
|
||||
op.create_index("ix_contacts_tenant_type", "contacts", ["tenant_id", "type"])
|
||||
op.create_index("ix_contacts_tenant_name", "contacts", ["tenant_id", "name"])
|
||||
op.create_index("ix_contacts_tenant_displayname", "contacts", ["tenant_id", "displayname"])
|
||||
op.create_index("ix_contacts_email", "contacts", ["email_1"])
|
||||
op.create_index("ix_contacts_code", "contacts", ["code"])
|
||||
op.create_index("ix_contacts_search_vec", "contacts", ["search_tsv"], postgresql_using="gin")
|
||||
op.execute("DROP INDEX IF EXISTS ix_contacts_tenant_deleted")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_contacts_tenant_deleted ON contacts (tenant_id, deleted_at)")
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_contacts_tenant_type ON contacts (tenant_id, type)')
|
||||
op.execute("DROP INDEX IF EXISTS ix_contacts_tenant_name")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_contacts_tenant_name ON contacts (tenant_id, name)")
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_contacts_tenant_displayname ON contacts (tenant_id, displayname)')
|
||||
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 ────────────────────────────────
|
||||
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("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_contactpersons_tenant_deleted", "contactpersons", ["tenant_id", "deleted_at"])
|
||||
op.create_index("ix_contactpersons_contact", "contactpersons", ["contact_id"])
|
||||
op.create_index("ix_contactpersons_email", "contactpersons", ["email"])
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_contactpersons_tenant_deleted ON contactpersons (tenant_id, deleted_at)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_contactpersons_contact ON contactpersons (contact_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_contactpersons_email ON contactpersons (email)')
|
||||
|
||||
# ── 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.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 default_person_id UUID REFERENCES contactpersons(id) ON DELETE SET NULL")
|
||||
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 ────────────────────────────────
|
||||
|
||||
|
||||
@@ -27,15 +27,12 @@ def upgrade():
|
||||
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),
|
||||
)
|
||||
op.create_index("ix_contact_folders_tenant_parent", "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_tenant_parent ON contact_folders (tenant_id, parent_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_contact_folders_user ON contact_folders (user_id)')
|
||||
|
||||
# 2. Add folder_id column to contacts
|
||||
op.add_column(
|
||||
"contacts",
|
||||
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"])
|
||||
op.execute("ALTER TABLE contacts ADD COLUMN IF NOT EXISTS folder_id UUID REFERENCES contact_folders(id) ON DELETE SET NULL")
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_contacts_folder_id ON contacts (folder_id)')
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -13,10 +13,10 @@ down_revision = "0022_contact_folders"
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("system_settings", sa.Column("theme_primary_color", sa.String(20), nullable=False, server_default="#2563eb"))
|
||||
op.add_column("system_settings", sa.Column("theme_accent_color", sa.String(20), nullable=False, server_default="#d946ef"))
|
||||
op.add_column("system_settings", sa.Column("theme_font_family", sa.String(100), nullable=False, server_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_primary_color VARCHAR(20) NOT NULL DEFAULT '#2563eb'")
|
||||
op.execute("ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS theme_accent_color VARCHAR(20) NOT NULL DEFAULT '#d946ef'")
|
||||
op.execute("ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS theme_font_family VARCHAR(100) NOT NULL DEFAULT 'Inter'")
|
||||
op.execute("ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS theme_border_radius VARCHAR(20) NOT NULL DEFAULT '0.5rem'")
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -13,9 +13,15 @@ down_revision = "0023_theme_customization"
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("ai_proactive_settings", sa.Column("heartbeat_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")))
|
||||
op.add_column("ai_proactive_settings", sa.Column("heartbeat_interval_seconds", sa.Integer(), nullable=False, server_default=sa.text("300")))
|
||||
op.add_column("ai_proactive_settings", sa.Column("heartbeat_target_room", sa.String(200), nullable=False, server_default="Live KI"))
|
||||
op.execute("""
|
||||
DO $$ BEGIN
|
||||
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():
|
||||
|
||||
@@ -35,16 +35,12 @@ def upgrade() -> None:
|
||||
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.create_index("ix_entity_history_tenant_id", "entity_history", ["tenant_id"])
|
||||
op.create_index("ix_entity_history_entity_type", "entity_history", ["entity_type"])
|
||||
op.create_index("ix_entity_history_entity_id", "entity_history", ["entity_id"])
|
||||
op.create_index("ix_entity_history_user_id", "entity_history", ["user_id"])
|
||||
op.create_index("ix_entity_history_created_at", "entity_history", ["created_at"])
|
||||
op.create_index(
|
||||
"ix_entity_history_tenant_entity",
|
||||
"entity_history",
|
||||
["tenant_id", "entity_type", "entity_id", "created_at"],
|
||||
)
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_entity_history_tenant_id ON entity_history (tenant_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_entity_history_entity_type ON entity_history (entity_type)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_entity_history_entity_id ON entity_history (entity_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_entity_history_user_id ON entity_history (user_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_entity_history_created_at ON entity_history (created_at)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_entity_history_tenant_entity ON entity_history (tenant_id, entity_type, entity_id, created_at)')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -17,7 +17,7 @@ down_revision = "0025_entity_history"
|
||||
|
||||
|
||||
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():
|
||||
|
||||
@@ -34,9 +34,9 @@ def upgrade():
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
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.create_index("ix_user_prefs_user_id", "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_user ON user_preferences (tenant_id, user_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_user_prefs_user_id ON user_preferences (user_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_user_prefs_tenant_id ON user_preferences (tenant_id)')
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -30,8 +30,8 @@ def upgrade() -> None:
|
||||
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"),
|
||||
)
|
||||
op.create_index("ix_saved_filters_tenant_user", "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_user ON saved_filters (tenant_id, user_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_saved_filters_tenant_entity ON saved_filters (tenant_id, entity_type)')
|
||||
|
||||
|
||||
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("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_contact_merge_history_tenant", "contact_merge_history", ["tenant_id"])
|
||||
op.create_index("ix_contact_merge_history_target", "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_tenant ON contact_merge_history (tenant_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_contact_merge_history_target ON contact_merge_history (tenant_id, target_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:
|
||||
|
||||
@@ -21,15 +21,9 @@ depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add deleted_at to permissions table (if not exists)
|
||||
op.add_column(
|
||||
"permissions",
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.execute("ALTER TABLE IF EXISTS permissions ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE")
|
||||
# Add deleted_at to share_links table (if not exists)
|
||||
op.add_column(
|
||||
"share_links",
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.execute("ALTER TABLE IF EXISTS share_links ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP WITH TIME ZONE")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -14,9 +14,9 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("first_name", sa.String(100), nullable=True))
|
||||
op.add_column("users", sa.Column("last_name", sa.String(100), nullable=True))
|
||||
op.add_column("users", sa.Column("avatar_url", sa.String(500), nullable=True))
|
||||
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name VARCHAR(100)")
|
||||
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100)")
|
||||
op.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url VARCHAR(500)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -35,8 +35,8 @@ def upgrade() -> None:
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
op.create_index("ix_bank_accounts_tenant", "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 ON bank_accounts (tenant_id)')
|
||||
op.execute('CREATE INDEX IF NOT EXISTS ix_bank_accounts_tenant_default ON bank_accounts (tenant_id, is_default)')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -20,7 +20,7 @@ depends_on: Union[str, Sequence[str], None] = 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:
|
||||
|
||||
@@ -21,27 +21,29 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add search_tsv column for full-text search
|
||||
op.add_column(
|
||||
"comm_messages",
|
||||
sa.Column("search_tsv", TSVECTOR, nullable=True),
|
||||
)
|
||||
op.execute("ALTER TABLE IF EXISTS comm_messages ADD COLUMN IF NOT EXISTS search_tsv tsvector")
|
||||
# Add embedding column for vector search (768 dimensions matching pgvector)
|
||||
op.execute(
|
||||
"ALTER TABLE comm_messages ADD COLUMN 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)"
|
||||
"ALTER TABLE IF EXISTS comm_messages ADD COLUMN IF NOT EXISTS embedding vector(768)"
|
||||
)
|
||||
# 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:
|
||||
|
||||
@@ -165,7 +165,7 @@ def downgrade() -> None:
|
||||
tenant_col_result = conn.execute(sa.text(_column_exists("users", "tenant_id"))).fetchone()
|
||||
if tenant_col_result is None:
|
||||
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()
|
||||
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()
|
||||
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.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
|
||||
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:
|
||||
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()
|
||||
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:
|
||||
|
||||
@@ -24,7 +24,7 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
revision = "0044"
|
||||
down_revision = "0043"
|
||||
down_revision = "0043_backups"
|
||||
branch_labels = 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,34 @@
|
||||
"""Fix files.size_bytes type: INTEGER → BIGINT.
|
||||
|
||||
The DMS plugin migration (0001_initial.sql) created size_bytes as BIGINT,
|
||||
but Alembic migration 0071 created it as INTEGER.
|
||||
Production already has BIGINT (from plugin migration).
|
||||
This migration aligns Alembic with production.
|
||||
|
||||
Revision ID: 0093
|
||||
Revises: 0092
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0093"
|
||||
down_revision = "0092"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Align size_bytes with production (BIGINT)
|
||||
op.execute(
|
||||
"ALTER TABLE IF EXISTS files "
|
||||
"ALTER COLUMN size_bytes TYPE BIGINT"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE IF EXISTS files "
|
||||
"ALTER COLUMN size_bytes TYPE INTEGER"
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Fix GIN indexes and remove duplicate plugins.name index.
|
||||
|
||||
Alembic 0002 created search indexes without USING GIN.
|
||||
Production already has GIN indexes (corrected by later migrations or manual).
|
||||
This migration ensures GIN indexes exist for both fresh install and existing DBs.
|
||||
|
||||
Also removes the redundant ix_plugins_name unique index (plugins_name_key
|
||||
already enforces uniqueness from the column definition).
|
||||
|
||||
Revision ID: 0094
|
||||
Revises: 0093
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0094"
|
||||
down_revision = "0093"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# GIN indexes that should exist with USING GIN
|
||||
GIN_INDEXES = [
|
||||
("contacts", "ix_contacts_search_tsv", "search_tsv"),
|
||||
("audit_log", "ix_audit_log_search_tsv", "search_tsv"),
|
||||
("calendar_entries", "ix_cal_entries_search_tsv", "search_tsv"),
|
||||
("comm_messages", "ix_comm_messages_search_tsv", "search_tsv"),
|
||||
("files", "ix_files_content_tsv", "content_tsv"),
|
||||
("mails", "ix_mails_body_tsv", "body_tsv"),
|
||||
("tags", "ix_tags_search_tsv", "search_tsv"),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Fix GIN indexes: drop and recreate with USING GIN (idempotent)
|
||||
for table, index_name, column in GIN_INDEXES:
|
||||
op.execute(f"DROP INDEX IF EXISTS {index_name}")
|
||||
op.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS {index_name} "
|
||||
f"ON {table} USING gin ({column})"
|
||||
)
|
||||
|
||||
# Remove redundant plugins.name index (plugins_name_key already enforces uniqueness)
|
||||
op.execute("DROP INDEX IF EXISTS ix_plugins_name")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreate the dropped index without GIN (not truly reversible to wrong state)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_plugins_name ON plugins (name)"
|
||||
)
|
||||
# GIN indexes cannot be meaningfully downgraded to non-GIN
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Fix guest_users email+tenant_id unique index.
|
||||
|
||||
Alembic 0059 created ix_guest_users_email_tenant as a normal (non-unique) index.
|
||||
The SQLAlchemy model defines it as unique=True, and production already has
|
||||
a UNIQUE INDEX. This migration aligns Alembic with production.
|
||||
|
||||
Before creating the unique index, checks for duplicate (email, tenant_id) pairs.
|
||||
If duplicates exist, the migration aborts with a data cleanup report.
|
||||
|
||||
Revision ID: 0095
|
||||
Revises: 0094
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0095"
|
||||
down_revision = "0094"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Check for duplicates before creating unique index
|
||||
conn = op.get_bind()
|
||||
duplicates = conn.execute(
|
||||
sa.text(
|
||||
"SELECT email, tenant_id, count(*) FROM guest_users "
|
||||
"GROUP BY email, tenant_id HAVING count(*) > 1"
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
if duplicates:
|
||||
raise RuntimeError(
|
||||
f"Cannot create unique index: {len(duplicates)} duplicate (email, tenant_id) pairs found. "
|
||||
"Data cleanup required before migration."
|
||||
)
|
||||
|
||||
# Drop the non-unique index and recreate as unique
|
||||
op.execute("DROP INDEX IF EXISTS ix_guest_users_email_tenant")
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_guest_users_email_tenant "
|
||||
"ON guest_users (email, tenant_id)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_guest_users_email_tenant")
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_guest_users_email_tenant "
|
||||
"ON guest_users (email, tenant_id)"
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Workspace tenant integrity constraints.
|
||||
|
||||
Plan 4.3: Add tenant-bound foreign keys to workspace child tables.
|
||||
|
||||
- workspaces: UNIQUE (tenant_id, id)
|
||||
- workspace_modules: FK (tenant_id, workspace_id) → workspaces (tenant_id, id)
|
||||
- workspace_widgets: FK (tenant_id, workspace_id) → workspaces (tenant_id, id)
|
||||
- workspace_users: FK (tenant_id, workspace_id) → workspaces (tenant_id, id)
|
||||
- workspace_users: FK (tenant_id, user_id) → user_tenants (tenant_id, user_id)
|
||||
|
||||
Revision ID: 0096
|
||||
Revises: 0095
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0096"
|
||||
down_revision = "0095"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Add UNIQUE (tenant_id, id) on workspaces
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_workspaces_tenant_id "
|
||||
"ON workspaces (tenant_id, id)"
|
||||
)
|
||||
|
||||
# 2. Drop existing FKs on workspace_modules (workspace_id → workspaces.id)
|
||||
# and replace with tenant-bound FK
|
||||
op.execute("ALTER TABLE workspace_modules DROP CONSTRAINT IF EXISTS workspace_modules_workspace_id_fkey")
|
||||
op.execute(
|
||||
"ALTER TABLE workspace_modules "
|
||||
"ADD CONSTRAINT fk_wm_tenant_workspace "
|
||||
"FOREIGN KEY (tenant_id, workspace_id) "
|
||||
"REFERENCES workspaces (tenant_id, id) ON DELETE CASCADE"
|
||||
)
|
||||
|
||||
# 3. Drop existing FK on workspace_widgets and replace with tenant-bound FK
|
||||
op.execute("ALTER TABLE workspace_widgets DROP CONSTRAINT IF EXISTS workspace_widgets_workspace_id_fkey")
|
||||
op.execute(
|
||||
"ALTER TABLE workspace_widgets "
|
||||
"ADD CONSTRAINT fk_ww_tenant_workspace "
|
||||
"FOREIGN KEY (tenant_id, workspace_id) "
|
||||
"REFERENCES workspaces (tenant_id, id) ON DELETE CASCADE"
|
||||
)
|
||||
|
||||
# 4. Drop existing FK on workspace_users and replace with tenant-bound FK
|
||||
op.execute("ALTER TABLE workspace_users DROP CONSTRAINT IF EXISTS workspace_users_workspace_id_fkey")
|
||||
op.execute(
|
||||
"ALTER TABLE workspace_users "
|
||||
"ADD CONSTRAINT fk_wu_tenant_workspace "
|
||||
"FOREIGN KEY (tenant_id, workspace_id) "
|
||||
"REFERENCES workspaces (tenant_id, id) ON DELETE CASCADE"
|
||||
)
|
||||
|
||||
# 5. Add FK on workspace_users (tenant_id, user_id) → user_tenants (tenant_id, user_id)
|
||||
op.execute(
|
||||
"ALTER TABLE workspace_users "
|
||||
"ADD CONSTRAINT fk_wu_tenant_user "
|
||||
"FOREIGN KEY (tenant_id, user_id) "
|
||||
"REFERENCES user_tenants (tenant_id, user_id) ON DELETE CASCADE"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove tenant-bound FKs, restore simple FKs
|
||||
op.execute("ALTER TABLE workspace_users DROP CONSTRAINT IF EXISTS fk_wu_tenant_user")
|
||||
op.execute("ALTER TABLE workspace_users DROP CONSTRAINT IF EXISTS fk_wu_tenant_workspace")
|
||||
op.execute(
|
||||
"ALTER TABLE workspace_users "
|
||||
"ADD CONSTRAINT workspace_users_workspace_id_fkey "
|
||||
"FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE"
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE workspace_widgets DROP CONSTRAINT IF EXISTS fk_ww_tenant_workspace")
|
||||
op.execute(
|
||||
"ALTER TABLE workspace_widgets "
|
||||
"ADD CONSTRAINT workspace_widgets_workspace_id_fkey "
|
||||
"FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE"
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE workspace_modules DROP CONSTRAINT IF EXISTS fk_wm_tenant_workspace")
|
||||
op.execute(
|
||||
"ALTER TABLE workspace_modules "
|
||||
"ADD CONSTRAINT workspace_modules_workspace_id_fkey "
|
||||
"FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE"
|
||||
)
|
||||
|
||||
op.execute("DROP INDEX IF EXISTS uq_workspaces_tenant_id")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Fix api_tokens table: add updated_at column.
|
||||
|
||||
The ApiToken model inherits from TenantMixin which includes TimestampMixin
|
||||
(created_at, updated_at). Migration 0001 created api_tokens without updated_at.
|
||||
Migration 0083 added deleted_at but missed updated_at.
|
||||
|
||||
Revision ID: 0097
|
||||
Revises: 0096
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0097"
|
||||
down_revision = "0096"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE IF EXISTS api_tokens "
|
||||
"ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE IF EXISTS api_tokens DROP COLUMN IF EXISTS updated_at")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user