feat(M2): Persönliche Dashboards — Tabelle, CRUD, Lazy-Seed, RLS (#360)
Check Cross-Plugin Imports / check (push) Has been cancelled

- dashboards-Tabelle (Layout JSONB, Tabs, is_default, partial unique name index)
- 6 CRUD-Endpoints /api/v1/dashboards, Owner-only (saved_views-Präzedenz), Audit
- Lazy Default-Seed aus MiniApp-Registry (permission-gefiltert, 12-Spalten-Flow)
- CORE_PERMISSIONS dashboard:read/write (fixt Phantom-Permission in dashboard.py)
- Migration 0144: RLS crm_api+crm_worker + konvergenter Fix der 3 Phase-L-Policies
- Tests: test_dashboards_backend.py 23/23 (TDD rot->grün); Regression 162/163
This commit is contained in:
Agent Zero
2026-08-30 16:21:34 +02:00
parent 7a755d32e6
commit b3e259fc25
14 changed files with 1155 additions and 17 deletions
+25 -3
View File
@@ -7,7 +7,7 @@
**Kürzlich abgeschlossen:** Phase L vollständig (L1 Block-System, L2 Drag&Drop-Editor, L3 Renderer, L4 KI-Steuerung, L5 XRechnung-Format-Layer) — Details siehe Phase-L-Sections unten.
**Offene Roadmap-Phasen (user-abgestimmt, startklar):**
- **Phase M** — MiniApp-Plattform & Dashboard-Builder (M1-M6). **M1 ✓ erledigt** (Universal-Registry, `/api/v1/miniapps`, permission fail-closed — siehe Phase-M1-Section). **Nächster Schritt: M2 Dashboard-Backend** (dashboards-Tabelle pro User, Tabs, Layout JSONB, RLS, Dual-Path).
- **Phase M** — MiniApp-Plattform & Dashboard-Builder (M1-M6). **M1 ✓** (Universal-Registry, `/api/v1/miniapps`), **M2 ✓ erledigt** (persönliche Dashboards: Tabelle, CRUD, Seed, RLS — siehe Phase-M2-Section). **Nächster Schritt: M3 Dashboard-Builder-Frontend** (Edit-Modus, Drag&Drop, Widget-Palette aus /api/v1/miniapps, Tabs, generisches Settings-Form).
- **Phase N** — Workspace-Scopes (N1-N4). 0 Umbau — Fundament (config JSONB, X-Workspace-ID, /context, Sidebar-Consumer) existiert bereits.
- **Phase O** — UI-Overhaul (umbenannt von Doppel-L, Bug-Verifikation steht im Roadmap-Eintrag: 5/7 Bugs bereits erledigt, offen: 1.2 Kontakte-Drag-Drop in Ordner, 1.3 MoveDialog)
@@ -83,7 +83,7 @@
**User-Vision:** Universelle MiniApps (Chat + Dashboard + Windows + AI-Agenten), Dashboard-Builder mit Edit-Modus/Drag&Drop/Resize/Tabs/pro-Widget-Settings, System-Dashboard-Teile zurück in Plugins (Core = reiner Host), Permission-Integration fail-closed.
**Status:** in_progress — M1 ✓ erledigt (siehe Phase-M1-Section unten). Offen: M2 (Dashboard-Backend), M3 (Builder-Frontend), M4 (System-Rückbau), M5 (Plugin-MiniApps), M6 (weitere Hosts). Details: PLATFORM_ROADMAP.md Phase M.
**Status:** in_progress — M1 ✓, M2 ✓ erledigt (siehe Sections unten). Offen: M3 (Builder-Frontend), M4 (System-Rückbau), M5 (Plugin-MiniApps), M6 (weitere Hosts). Details: PLATFORM_ROADMAP.md Phase M.
**Live-Bestand analysiert (2026-08-29):** miniapp_registry (kommunikation, 92 Z.), MiniAppContribution (LÜCKE: kein permission-Feld), FrontendDashboardWidget (LÜCKE: kein settings_schema), MiniAppBlock.tsx (Chat-Host fertig), DashboardGrid + 4 Widgets, Dashboard.tsx (170 Z.) mit hardcodierten StatCards/ActivityFeed/System-Metrics (Rückbau-Bestand für M4), @dnd-kit vorhanden.
@@ -102,7 +102,29 @@
- ✅ ruff clean (M1-Dateien); 2 Ruff-Funde in automation/knowledge = per Stash bewiesener Vorbestand
- ✅ Doku: api-documentation.md (2 Endpoints), plugin-development-guide.md (MiniApp-Beitrag-Muster)
**Offen in Phase M:** M2 Dashboard-Backend (dashboards-Tabelle), M3 Builder-Frontend (konsumiert /api/v1/miniapps), M4 System-Rückbau, M5 Plugin-MiniApps, M6 weitere Hosts.
**Offen in Phase M:** M3 Builder-Frontend (konsumiert /api/v1/dashboards + /api/v1/miniapps), M4 System-Rückbau, M5 Plugin-MiniApps, M6 weitere Hosts.
## Phase M2 — Dashboard-Backend (2026-08-30) ✅
**Spec:** [#360](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/360) | **Roadmap:** Phase M, M2
**Umgesetzt:**
- `app/models/dashboard.py`: `dashboards`-Tabelle (persönlich, saved_views-Präzedenz: user_id NOT NULL CASCADE, TenantMixin, kein OwnedMixin). Layout JSONB, `is_default`, partial unique index (tenant, user, name) WHERE deleted_at IS NULL — soft-deleted Boards geben Namen frei (Verbesserung ggü. saved_views-Wart).
- `app/schemas/dashboard.py`: DashboardLayout/Tab/Widget (12-Spalten-Grid: col/row ≥ 0, Spans 1-12) → 422 auf invalide Layouts, bevor persistiert wird.
- `app/routes/dashboards.py` (313 Z.): 6 Endpoints — GET (Liste + lazy Seed), POST (409 dup, erstes = default, ein leerer Start-Tab), GET/{id}, PUT/{id} (Name/Layout, db.refresh gegen MissingGreenlet), DELETE/{id} (Soft-Delete, Default-Promotion), POST/{id}/set-default (exakt ein Default). Owner-only (tenant + user_id Filter, fremde = 404), Audit-Log bei allen Mutationen.
- Lazy Default-Seed: erste GET-Abrufung erzeugt „Mein Dashboard“ aus MiniApp-Registry (permission-gefiltert via geteiltem `user_permits`, Registry-Order, 12-Spalten-Flow mit Wrap).
- `user_permits()` in miniapp_registry.py als geteilter Fail-Closed-Filter (miniapps.py behält `_user_permits`-Alias).
- CORE_PERMISSIONS: `dashboard:read`/`dashboard:write`**fixt Phantom-Permission** (app/routes/dashboard.py verlangte dashboard:read, nirgends registriert → Nicht-Admins konnten sie nie erhalten).
- Migration `0144_personal_dashboards.py`: dashboards-Tabelle + RLS im 0090-Muster (**crm_api + crm_worker**) + **konvergenter Fix der 3 Phase-L-Policies** (letterheads/print_templates/document_assets waren `TO crm_api`-only — live auf Produktion gemessen, s. Verifikation). Plugin-SQL 0003 ebenfalls auf beide Rollen korrigiert.
- Doku: api-documentation.md (neue Core-Section dashboards, 6 Endpoints).
**Verifiziert (2026-08-30):**
- TDD: Rot 21 failed/1 passed → ✅ Grün **23/23** (tests/test_dashboards_backend.py: Model/Permission-Unit 3, Layout-Validation 5, CRUD 9, Ownership/Isolation 4, RLS-Konvergenz 2)
- ✅ Live-Messung (psql): Produktion vor Fix — 3 Policies `{crm_api}`-only (letterheads, print_templates, document_assets); lokal nach 0144 — alle 4 Tabellen `{crm_api,crm_worker}`
- ✅ Regression: rls_coverage + miniapp_registry + dashboard + lifecycle + route_order 37/38 — 1 Failure (test_dashboard cross-tenant, POST /companies 405) = **per Stash bewiesener Vorbestand** (identischer Failure auf clean HEAD); solo 5/5 grün
- ✅ Regression Welle 2: rbac_comprehensive + arch_block_a **125/125**
- ✅ ruff clean (alle M2-Dateien inkl. Testdatei); create_app OK (85 Router-Routen)
- Ausstehend: Deploy + Produktions-Verifikation (wird nachgetragen)
## Phase N — Workspace-Scopes (2026-08-30 geplant, user-abgestimmt)
@@ -0,0 +1,111 @@
"""Personal dashboards table (Phase M2) + RLS policy-role convergence.
Revision ID: 0144
Revises: 0143
Create Date: 2026-08-30
Part 1 — dashboards: personal per-user dashboard layouts (JSONB tabs /
widgets). RLS follows the 0090 fail-closed pattern scoped to BOTH runtime
roles (crm_api, crm_worker).
Part 2 — convergence fix (measured live on production 2026-08-30):
migration 0143 created the letterheads/print_templates/document_assets
tenant-isolation policies with ``TO crm_api`` only, while the established
pattern (0090, verified by tests/test_rls_coverage.py) requires both
crm_api AND crm_worker. This migration recreates those policies with both
roles so both install paths (plugin-SQL 0003 / alembic 0143) converge.
"""
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from alembic import op
revision = "0144"
down_revision = "0143"
branch_labels = None
depends_on = None
_TENANT_USING = (
"tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid"
)
def _table_exists(conn, table_name: str) -> bool:
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def _create_policy(table: str) -> None:
op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}")
op.execute(
f"CREATE POLICY {table}_tenant_isolation ON {table} AS PERMISSIVE "
f"FOR ALL TO crm_api, crm_worker "
f"USING ({_TENANT_USING}) "
f"WITH CHECK ({_TENANT_USING})"
)
def _rls(table: str) -> None:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
_create_policy(table)
def upgrade() -> None:
conn = op.get_bind()
# ── Part 1: dashboards table ──
if not _table_exists(conn, "dashboards"):
op.create_table(
"dashboards",
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("layout", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")),
sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("user_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("tenant_id", PGUUID(as_uuid=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=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()),
)
op.create_index(
"uq_dashboards_tenant_user_name",
"dashboards",
["tenant_id", "user_id", "name"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
op.create_index("ix_dashboards_tenant_user", "dashboards", ["tenant_id", "user_id"])
_rls("dashboards")
else:
# Dual-path convergence: table exists (plugin SQL), ensure policy roles
_create_policy("dashboards")
# ── Part 2: converge Phase L policies to crm_api + crm_worker ──
for table in ("letterheads", "print_templates", "document_assets"):
if _table_exists(conn, table):
_create_policy(table)
def downgrade() -> None:
conn = op.get_bind()
# Revert the convergence fix to the (buggy) Phase L state first…
for table in ("letterheads", "print_templates", "document_assets"):
if _table_exists(conn, table):
op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}")
op.execute(
f"CREATE POLICY {table}_tenant_isolation ON {table} AS PERMISSIVE "
f"FOR ALL TO crm_api "
f"USING ({_TENANT_USING}) "
f"WITH CHECK ({_TENANT_USING})"
)
if _table_exists(conn, "dashboards"):
op.execute("DROP POLICY IF EXISTS dashboards_tenant_isolation ON dashboards")
op.drop_index("ix_dashboards_tenant_user", table_name="dashboards")
op.drop_index("uq_dashboards_tenant_user_name", table_name="dashboards")
op.drop_table("dashboards")
+3 -1
View File
@@ -64,9 +64,11 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
{"key": "workspaces:delete", "label": "Workspaces: Delete", "category": "core", "module": "workspaces"},
{"key": "workspaces:assign_users", "label": "Workspaces: Assign Users", "category": "core", "module": "workspaces"},
{"key": "workspaces:configure_modules", "label": "Workspaces: Configure Modules", "category": "core", "module": "workspaces"},
{"key": "dashboard:read", "label": "Dashboard: Read", "category": "core", "module": "dashboard"},
{"key": "dashboard:write", "label": "Dashboard: Write", "category": "core", "module": "dashboard"},
{"key": "system:admin", "label": "System: Admin (cross-tenant)", "category": "system", "module": "system"},
# NOTE: Plugin permissions (calendar, dms, mail, tasks, comm, automation, ai,
# tags, entity_links, reports, search, mcp, permissions, agents, dashboard)
# tags, entity_links, reports, search, mcp, permissions, agents)
# are registered dynamically via register_plugin_permissions() from plugin
# manifests at activation time. They are NOT hardcoded here (P0-4 fix).
]
+2
View File
@@ -46,6 +46,7 @@ from app.routes import ( # noqa: E402
currencies,
custom_field_definitions,
dashboard,
dashboards,
delegations,
entity_history,
entity_permissions,
@@ -566,6 +567,7 @@ def create_app() -> FastAPI:
# require_active_plugin("contacts") protection.
app.include_router(entity_permissions.router)
app.include_router(dashboard.router)
app.include_router(dashboards.router)
app.include_router(entity_history.router)
app.include_router(import_export.router)
app.include_router(plugins.router)
+3 -1
View File
@@ -17,12 +17,13 @@ from app.models.contact_folder import ContactFolder
from app.models.contact_merge import ContactMergeHistory
from app.models.currency import Currency
from app.models.custom_field_definition import CustomFieldDefinition
from app.models.dashboard import Dashboard
from app.models.entity_history import EntityHistory
from app.models.entity_permission import EntityPermission
from app.models.entity_policy import EntityPolicy
from app.models.group import Group, UserGroup
from app.models.notification import Notification, NotificationPreference, NotificationType
from app.models.outbox import EventOutbox, OutboxDelivery
from app.models.outbox import EventOutbox, OutboxDelivery # noqa: F401
from app.models.owned_mixin import OwnedMixin
from app.models.permission_delegation import PermissionDelegation
from app.models.permission_template import PermissionTemplate
@@ -80,6 +81,7 @@ __all__ = [
"WorkflowInstance",
"WorkflowStepHistory",
"SavedView",
"Dashboard",
]
from app.models.entity_attachment import EntityAttachment # noqa: F401
from app.models.workspace import ( # noqa: F401
+52
View File
@@ -0,0 +1,52 @@
"""Dashboard model — personal per-user dashboards (Phase M2).
Dashboards are personal (user-owned) layouts of MiniApp instances: tabs,
widget placements and per-instance settings, stored as JSONB. Access is
owner-only (saved_views precedent) the active workspace limits only the
available widget types (Phase N), never this personal layout.
"""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import Boolean, ForeignKey, Index, String, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class Dashboard(Base, TenantMixin):
"""Personal dashboard — per-user layout of MiniApp instances."""
__tablename__ = "dashboards"
__table_args__ = (
# Partial unique: soft-deleted dashboards free their name (unlike the
# saved_views plain constraint, which keeps names occupied forever).
Index(
"uq_dashboards_tenant_user_name",
"tenant_id",
"user_id",
"name",
unique=True,
postgresql_where=text("deleted_at IS NULL"),
),
Index("ix_dashboards_tenant_user", "tenant_id", "user_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
# Layout JSONB (validated by app.schemas.dashboard.DashboardLayout):
# {version, tabs: [{id, name, widgets: [{app_id, settings, col, row, spans}]}]}
layout: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, default=dict
)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
@@ -63,7 +63,7 @@ BEGIN
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t);
EXECUTE format('DROP POLICY IF EXISTS %I ON %I', t || '_tenant_isolation', t);
EXECUTE format(
'CREATE POLICY %I ON %I 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)',
'CREATE POLICY %I ON %I AS PERMISSIVE 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)',
t || '_tenant_isolation', t
);
EXCEPTION WHEN OTHERS THEN
+17
View File
@@ -152,3 +152,20 @@ def reset_miniapp_registry() -> None:
"""Reset the singleton instance (useful for tests)."""
global _registry
_registry = None
def user_permits(current_user: dict[str, Any], app: dict[str, Any]) -> bool:
"""Check whether *current_user* may see/use the MiniApp *app*.
Empty permission = visible to everyone; otherwise fail-closed check
(system admins always pass). Shared by /api/v1/miniapps and the
personal dashboard seed (Phase M2) so both apply identical rules.
"""
from app.core.permissions import check_permission
required = app.get("permission") or ""
if not required:
return True
if current_user.get("is_system_admin"):
return True
return check_permission(current_user, required)
+1
View File
@@ -9,6 +9,7 @@ from app.routes import (
compliance, # noqa: F401
currencies, # noqa: F401
dashboard, # noqa: F401
dashboards, # noqa: F401
entity_history, # noqa: F401
guests, # noqa: F401 # ⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
health, # noqa: F401
+314
View File
@@ -0,0 +1,314 @@
"""Personal dashboards routes — CRUD for per-user dashboard layouts (Phase M2).
Dashboards are personal (saved_views precedent): every query is scoped to
the current tenant AND user, so foreign dashboards answer 404. The lazy
default seed on first GET derives the widget list from the MiniApp registry
(permission-filtered, registry order, 12-column flow).
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.db import get_db
from app.deps import require_permission
from app.models.dashboard import Dashboard
from app.plugins.miniapp_registry import get_miniapp_registry, user_permits
from app.schemas.dashboard import DashboardCreate, DashboardUpdate
router = APIRouter(prefix="/api/v1/dashboards", tags=["dashboards"])
SEED_NAME = "Mein Dashboard"
def _to_dict(d: Dashboard) -> dict[str, Any]:
return {
"id": str(d.id),
"name": d.name,
"layout": d.layout,
"is_default": d.is_default,
"user_id": str(d.user_id),
"created_at": d.created_at.isoformat() if d.created_at else None,
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
}
def _parse_uuid(value: str, field: str) -> uuid.UUID:
try:
return uuid.UUID(value)
except (ValueError, TypeError):
raise HTTPException(
400, detail={"detail": f"Invalid {field}", "code": "invalid_id"}
) from None
async def _get_owned(
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, dash_id: str
) -> Dashboard:
did = _parse_uuid(dash_id, "dashboard_id")
result = await db.execute(
select(Dashboard).where(
Dashboard.id == did,
Dashboard.tenant_id == tenant_id,
Dashboard.user_id == user_id,
Dashboard.deleted_at.is_(None),
)
)
dashboard = result.scalar_one_or_none()
if dashboard is None:
raise HTTPException(404, detail={"detail": "Dashboard not found", "code": "not_found"})
return dashboard
async def _count_active(db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID) -> int:
result = await db.execute(
select(func.count()).select_from(Dashboard).where(
Dashboard.tenant_id == tenant_id,
Dashboard.user_id == user_id,
Dashboard.deleted_at.is_(None),
)
)
return int(result.scalar() or 0)
async def _name_taken(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
name: str,
exclude_id: uuid.UUID | None = None,
) -> bool:
query = select(func.count()).select_from(Dashboard).where(
Dashboard.tenant_id == tenant_id,
Dashboard.user_id == user_id,
Dashboard.name == name,
Dashboard.deleted_at.is_(None),
)
if exclude_id is not None:
query = query.where(Dashboard.id != exclude_id)
result = await db.execute(query)
return int(result.scalar() or 0) > 0
async def _seed_default_dashboard(
db: AsyncSession, current_user: dict[str, Any]
) -> Dashboard:
"""Create the first personal dashboard from the MiniApp registry.
Widgets are placed in registry order using a 12-column flow: an app
that no longer fits into the current row wraps to the next one.
Apps the user has no permission for are filtered out (fail-closed).
"""
registry = get_miniapp_registry()
apps = [
a
for a in registry.list_apps(host="dashboard")
if user_permits(current_user, a) and "dashboard" in (a.get("hosts") or [])
]
apps.sort(key=lambda a: a.get("order", 100))
widgets: list[dict[str, Any]] = []
col = 0
row = 0
for app in apps:
col_span = max(1, min(12, int(app.get("col_span") or 1)))
row_span = max(1, min(12, int(app.get("row_span") or 1)))
if col + col_span > 12:
row += 1
col = 0
widgets.append(
{
"app_id": app["app_id"],
"settings": {},
"col": col,
"row": row,
"col_span": col_span,
"row_span": row_span,
}
)
col += col_span
dashboard = Dashboard(
tenant_id=uuid.UUID(current_user["tenant_id"]),
user_id=uuid.UUID(current_user["user_id"]),
name=SEED_NAME,
layout={
"version": 1,
"tabs": [{"id": "start", "name": "Start", "widgets": widgets}],
},
is_default=True,
)
db.add(dashboard)
await db.flush()
await log_audit(
db,
dashboard.tenant_id,
dashboard.user_id,
"create",
"dashboard",
dashboard.id,
details={"seeded": True, "widgets": len(widgets)},
)
return dashboard
@router.get("")
async def list_dashboards(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("dashboard:read")),
):
"""List the current user's dashboards (lazy seed when none exist)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
result = await db.execute(
select(Dashboard)
.where(
Dashboard.tenant_id == tenant_id,
Dashboard.user_id == user_id,
Dashboard.deleted_at.is_(None),
)
.order_by(Dashboard.created_at, Dashboard.id)
)
dashboards = list(result.scalars().all())
if not dashboards:
dashboards = [await _seed_default_dashboard(db, current_user)]
return [_to_dict(d) for d in dashboards]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_dashboard(
body: DashboardCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("dashboard:write")),
):
"""Create a new personal dashboard (starts with one empty tab)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
if await _name_taken(db, tenant_id, user_id, body.name):
raise HTTPException(
409, detail={"detail": "Dashboard name already exists", "code": "duplicate"}
)
is_default = await _count_active(db, tenant_id, user_id) == 0
dashboard = Dashboard(
tenant_id=tenant_id,
user_id=user_id,
name=body.name,
layout={"version": 1, "tabs": [{"id": "start", "name": "Start", "widgets": []}]},
is_default=is_default,
)
db.add(dashboard)
await db.flush()
await log_audit(db, tenant_id, user_id, "create", "dashboard", dashboard.id)
return _to_dict(dashboard)
@router.get("/{dashboard_id}")
async def get_dashboard(
dashboard_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("dashboard:read")),
):
"""Get one dashboard (owner-only; foreign ids answer 404)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
dashboard = await _get_owned(db, tenant_id, user_id, dashboard_id)
return _to_dict(dashboard)
@router.put("/{dashboard_id}")
async def update_dashboard(
dashboard_id: str,
body: DashboardUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("dashboard:write")),
):
"""Update name and/or layout (layout is validated -> 422 on bad input)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
dashboard = await _get_owned(db, tenant_id, user_id, dashboard_id)
if body.name is not None and body.name != dashboard.name:
if await _name_taken(db, tenant_id, user_id, body.name, exclude_id=dashboard.id):
raise HTTPException(
409, detail={"detail": "Dashboard name already exists", "code": "duplicate"}
)
dashboard.name = body.name
if body.layout is not None:
dashboard.layout = body.layout.model_dump()
await db.flush()
await db.refresh(dashboard) # onupdate expires attributes (Phase L fix)
await log_audit(db, tenant_id, user_id, "update", "dashboard", dashboard.id)
return _to_dict(dashboard)
@router.delete("/{dashboard_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_dashboard(
dashboard_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("dashboard:write")),
):
"""Soft-delete a dashboard; deleting the default promotes the next one."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
dashboard = await _get_owned(db, tenant_id, user_id, dashboard_id)
was_default = dashboard.is_default
dashboard.deleted_at = datetime.now(UTC)
await db.flush()
if was_default:
result = await db.execute(
select(Dashboard)
.where(
Dashboard.tenant_id == tenant_id,
Dashboard.user_id == user_id,
Dashboard.deleted_at.is_(None),
)
.order_by(Dashboard.created_at, Dashboard.id)
.limit(1)
)
successor = result.scalar_one_or_none()
if successor is not None:
successor.is_default = True
await db.flush()
await log_audit(db, tenant_id, user_id, "delete", "dashboard", dashboard.id)
@router.post("/{dashboard_id}/set-default")
async def set_default_dashboard(
dashboard_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("dashboard:write")),
):
"""Mark a dashboard as the user's default (exactly one default)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
dashboard = await _get_owned(db, tenant_id, user_id, dashboard_id)
if not dashboard.is_default:
result = await db.execute(
select(Dashboard).where(
Dashboard.tenant_id == tenant_id,
Dashboard.user_id == user_id,
Dashboard.deleted_at.is_(None),
Dashboard.is_default.is_(True),
)
)
for other in result.scalars().all():
other.is_default = False
dashboard.is_default = True
await db.flush()
await db.refresh(dashboard) # onupdate expires updated_at (Phase L fix)
await log_audit(db, tenant_id, user_id, "update", "dashboard", dashboard.id)
return _to_dict(dashboard)
+4 -11
View File
@@ -9,21 +9,14 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from app.core.permissions import check_permission
from app.deps import get_current_user
from app.plugins.miniapp_registry import get_miniapp_registry
from app.plugins.miniapp_registry import get_miniapp_registry, user_permits
router = APIRouter(prefix="/api/v1/miniapps", tags=["miniapps"])
def _user_permits(current_user: dict, app: dict) -> bool:
"""Empty permission = visible to everyone; otherwise fail-closed check."""
required = app.get("permission") or ""
if not required:
return True
if current_user.get("is_system_admin"):
return True
return check_permission(current_user, required)
# Backward-compatible alias (the canonical helper lives in the registry
# module since Phase M2 — shared with the personal dashboard seed).
_user_permits = user_permits
@router.get("")
+59
View File
@@ -0,0 +1,59 @@
"""Schemas for personal dashboards (Phase M2).
Layout is validated structurally (12-column grid bounds) before it is
persisted; MiniApp existence and permissions are enforced at seed time and
when the frontend resolves widget components via /api/v1/miniapps.
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class DashboardWidget(BaseModel):
"""A MiniApp instance placed on a dashboard tab."""
app_id: str = Field(..., min_length=1, max_length=80)
settings: dict[str, Any] = Field(default_factory=dict)
col: int = Field(default=0, ge=0)
row: int = Field(default=0, ge=0)
col_span: int = Field(default=1, ge=1, le=12)
row_span: int = Field(default=1, ge=1, le=12)
class DashboardTab(BaseModel):
"""A tab of a dashboard holding widget placements."""
id: str = Field(..., min_length=1, max_length=80)
name: str = Field(..., min_length=1, max_length=100)
widgets: list[DashboardWidget] = Field(default_factory=list)
class DashboardLayout(BaseModel):
"""Full persisted layout of a personal dashboard."""
version: int = Field(default=1, ge=1)
tabs: list[DashboardTab] = Field(default_factory=list)
class DashboardCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
class DashboardUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
layout: DashboardLayout | None = None
class DashboardResponse(BaseModel):
id: str
name: str
layout: dict[str, Any]
is_default: bool
user_id: str
created_at: str | None = None
updated_at: str | None = None
model_config = {"from_attributes": True}
+13
View File
@@ -283,6 +283,19 @@ Generic CRUD for tenant-wide custom field definitions per entity type (W4b patte
| GET | `/api/v1/system/dashboard` | Comprehensive system dashboard (admin-only). Returns: health, DB stats, Redis stats, worker stats, API stats, plugin stats, storage stats. |
| GET | `/api/v1/system/alerts` | Active system alerts (admin-only). Returns alerts from Communication-System. |
### dashboards (6 endpoints)
Personal per-user dashboards (Phase M2). Owner-only: every query is scoped to tenant + current user; foreign dashboards answer 404. First GET lazily seeds a default dashboard from the MiniApp registry (permission-filtered, registry order, 12-column flow). Requires `dashboard:read` / `dashboard:write` (core permissions).
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/dashboards` | List own dashboards (lazy default seed on first call). |
| POST | `/api/v1/dashboards` | Create dashboard (409 duplicate name; first one becomes default; starts with one empty tab). |
| GET | `/api/v1/dashboards/{id}` | Get one dashboard (owner-only, 404 foreign). |
| PUT | `/api/v1/dashboards/{id}` | Update name/layout (layout validated -> 422 on bad grid bounds). |
| DELETE | `/api/v1/dashboards/{id}` | Soft-delete (deleting the default promotes the next one). |
| POST | `/api/v1/dashboards/{id}/set-default` | Mark as the user's default (exactly one). |
---
## Plugin Routes
+550
View File
@@ -0,0 +1,550 @@
"""M2 — Dashboard-Backend tests.
Personal dashboards: per-user dashboards with tabs, JSONB layout,
CRUD + set-default endpoints (owner-only, saved_views precedent),
lazy default seed from the MiniApp registry (permission-filtered),
RLS fail-closed with the 0090 pattern (crm_api + crm_worker), and a
convergent fix for the Phase L policies that were created with only
crm_api (measured live on production 2026-08-30).
"""
from __future__ import annotations
import os
import uuid
import pytest
from httpx import AsyncClient
from pydantic import ValidationError
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
os.environ.setdefault("RLS_TEST_ADMIN_DB_URL", "postgresql+asyncpg://postgres@localhost:5432/leocrm_test")
@pytest.fixture(autouse=True)
def _clean_miniapp_registry():
"""Fresh MiniApp registry per test (same pattern as test_miniapp_registry)."""
from app.plugins.miniapp_registry import reset_miniapp_registry
reset_miniapp_registry()
yield
reset_miniapp_registry()
def _register_apps(*specs: tuple[str, str, int, int]) -> None:
"""Register test MiniApps: (app_id, permission, order, col_span)."""
from app.plugins.miniapp_registry import get_miniapp_registry
reg = get_miniapp_registry()
for app_id, permission, order, col_span in specs:
reg.register(
app_id=app_id,
name=app_id.replace("_", " ").title(),
plugin_name="test",
permission=permission,
col_span=col_span,
row_span=1,
hosts=["chat", "dashboard", "window"],
order=order,
)
async def _make_user_with_dashboard_perms(db_session: AsyncSession, seed: dict, email: str, role_name: str):
"""Create a tenant-A user whose role grants only dashboard:read/write."""
from app.core.auth import hash_password
from app.models.role import Role
from app.models.user import User, UserTenant
user = User(
email=email,
name=email.split("@")[0].title(),
password_hash=hash_password("TestPass123!"),
is_active=True,
preferences={},
)
db_session.add(user)
await db_session.flush()
role = Role(
tenant_id=seed["tenant_a"].id,
name=role_name,
permissions={"dashboard": {"read": True, "write": True}},
denied_permissions=[],
field_permissions={},
)
db_session.add(role)
await db_session.flush()
db_session.add(
UserTenant(
user_id=user.id,
tenant_id=seed["tenant_a"].id,
is_default=True,
role=role_name,
role_id=role.id,
)
)
await db_session.commit()
return user
# ═══════════════════════════════════════════════════════════════
# Unit: model, permissions, layout validation
# ═══════════════════════════════════════════════════════════════
class TestModelUnit:
def test_dashboard_model_importable(self):
from app.models.dashboard import Dashboard
assert Dashboard.__tablename__ == "dashboards"
def test_dashboard_model_in_models_package(self):
import app.models as m
from app.models.dashboard import Dashboard
assert m.Dashboard is Dashboard
assert "Dashboard" in m.__all__
def test_dashboard_permissions_registered_in_core(self):
"""dashboard:read/write must be valid core permissions (phantom fix).
Before M2, app/routes/dashboard.py required ``dashboard:read`` but it
was registered nowhere non-admin users could never be granted it.
"""
from app.core.permission_registry import (
CORE_PERMISSIONS,
PermissionRegistry,
)
keys = {p["key"] for p in CORE_PERMISSIONS}
assert "dashboard:read" in keys
assert "dashboard:write" in keys
reg = PermissionRegistry()
reg.initialize()
assert reg.is_valid("dashboard:read")
assert reg.is_valid("dashboard:write")
class TestLayoutValidation:
def test_valid_layout(self):
from app.schemas.dashboard import DashboardLayout
layout = DashboardLayout.model_validate(
{
"version": 1,
"tabs": [
{
"id": "tab-1",
"name": "Start",
"widgets": [
{
"app_id": "recent_contacts",
"settings": {"limit": 5},
"col": 0,
"row": 0,
"col_span": 2,
"row_span": 1,
}
],
}
],
}
)
assert layout.tabs[0].widgets[0].app_id == "recent_contacts"
def test_empty_layout_ok(self):
from app.schemas.dashboard import DashboardLayout
layout = DashboardLayout.model_validate({"version": 1, "tabs": []})
assert layout.tabs == []
def test_invalid_span_rejected(self):
from app.schemas.dashboard import DashboardLayout
with pytest.raises(ValidationError):
DashboardLayout.model_validate(
{
"version": 1,
"tabs": [
{"id": "t", "name": "T", "widgets": [{"app_id": "a", "col_span": 13}]}
],
}
)
with pytest.raises(ValidationError):
DashboardLayout.model_validate(
{
"version": 1,
"tabs": [{"id": "t", "name": "T", "widgets": [{"app_id": "a", "col_span": 0}]}],
}
)
def test_negative_col_rejected(self):
from app.schemas.dashboard import DashboardLayout
with pytest.raises(ValidationError):
DashboardLayout.model_validate(
{
"version": 1,
"tabs": [{"id": "t", "name": "T", "widgets": [{"app_id": "a", "col": -1}]}],
}
)
def test_missing_app_id_rejected(self):
from app.schemas.dashboard import DashboardLayout
with pytest.raises(ValidationError):
DashboardLayout.model_validate(
{"version": 1, "tabs": [{"id": "t", "name": "T", "widgets": [{"col": 0, "row": 0}]}]}
)
# ═══════════════════════════════════════════════════════════════
# API: CRUD + defaults + ownership
# ═══════════════════════════════════════════════════════════════
@pytest.mark.asyncio
class TestDashboardCrud:
async def test_requires_auth(self, client: AsyncClient, db_session):
await seed_tenant_and_users(db_session)
resp = await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)
assert resp.status_code == 401
async def test_requires_dashboard_read_permission(self, client: AsyncClient, db_session):
"""Viewer role has no dashboard:read -> 403 (permission enforced)."""
await seed_tenant_and_users(db_session)
await login_client(client, "viewer@tenanta.com")
resp = await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)
assert resp.status_code == 403
async def test_create_and_list(self, client: AsyncClient, db_session):
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.post(
"/api/v1/dashboards",
json={"name": "Vertrieb"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["name"] == "Vertrieb"
assert data["is_default"] is True # first dashboard becomes default
assert data["layout"]["version"] == 1
tabs = data["layout"]["tabs"]
assert len(tabs) == 1 # new dashboards start with an empty "Start" tab
assert tabs[0]["name"] == "Start"
assert tabs[0]["widgets"] == []
resp = await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert len(resp.json()) == 1
async def test_lazy_seed_on_first_list(self, client: AsyncClient, db_session):
"""First GET seeds a default dashboard from the registry (order-sorted).
Seed layout is a 12-column flow: widgets are placed side by side in
registry order and wrap to the next row when the row is full.
"""
await seed_tenant_and_users(db_session)
_register_apps(
("app_b", "", 20, 2),
("app_a", "", 10, 2),
("app_wide", "", 30, 12),
)
await login_client(client, "admin@tenanta.com")
resp = await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)
assert resp.status_code == 200
items = resp.json()
assert len(items) == 1
seeded = items[0]
assert seeded["is_default"] is True
widgets = seeded["layout"]["tabs"][0]["widgets"]
app_ids = [w["app_id"] for w in widgets]
assert app_ids == ["app_a", "app_b", "app_wide"] # registry order
assert widgets[0]["col_span"] == 2
assert widgets[0]["col"] == 0 and widgets[0]["row"] == 0
assert widgets[1]["col"] == 2 and widgets[1]["row"] == 0
assert widgets[2]["col"] == 0 and widgets[2]["row"] == 1 # 12-span wraps
async def test_seed_filters_by_permission(self, client: AsyncClient, db_session):
"""Seed only includes MiniApps the user may see (fail-closed filter)."""
seed = await seed_tenant_and_users(db_session)
_register_apps(
("open_app", "", 10, 1),
("tasks_app", "tasks:read", 20, 1),
)
await _make_user_with_dashboard_perms(db_session, seed, "dash@tenanta.com", "dash_only")
await login_client(client, "dash@tenanta.com")
resp = await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)
assert resp.status_code == 200
widgets = resp.json()[0]["layout"]["tabs"][0]["widgets"]
app_ids = {w["app_id"] for w in widgets}
assert "open_app" in app_ids
assert "tasks_app" not in app_ids # no tasks:read -> filtered out
async def test_duplicate_name_409(self, client: AsyncClient, db_session):
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
body = {"name": "Vertrieb"}
r1 = await client.post("/api/v1/dashboards", json=body, headers=ORIGIN_HEADER)
assert r1.status_code == 201
r2 = await client.post("/api/v1/dashboards", json=body, headers=ORIGIN_HEADER)
assert r2.status_code == 409
async def test_get_update_delete(self, client: AsyncClient, db_session):
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
created = (
await client.post("/api/v1/dashboards", json={"name": "Eins"}, headers=ORIGIN_HEADER)
).json()
dash_id = created["id"]
resp = await client.get(f"/api/v1/dashboards/{dash_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json()["name"] == "Eins"
new_layout = {
"version": 1,
"tabs": [
{
"id": "tab-1",
"name": "Start",
"widgets": [{"app_id": "recent_contacts", "col": 0, "row": 0}],
}
],
}
resp = await client.put(
f"/api/v1/dashboards/{dash_id}",
json={"name": "Eins Neu", "layout": new_layout},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200, resp.text
updated = resp.json()
assert updated["name"] == "Eins Neu"
assert updated["layout"]["tabs"][0]["widgets"][0]["app_id"] == "recent_contacts"
assert updated["layout"]["tabs"][0]["widgets"][0]["col_span"] == 1
# invalid layout -> 422
bad_layout = {"version": 1, "tabs": [{"id": "t", "name": "T", "widgets": [{"app_id": "x", "col_span": 99}]}]}
resp = await client.put(
f"/api/v1/dashboards/{dash_id}",
json={"layout": bad_layout},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 422
# invalid uuid -> 400
resp = await client.get("/api/v1/dashboards/not-a-uuid", headers=ORIGIN_HEADER)
assert resp.status_code == 400
# unknown uuid -> 404
resp = await client.get(f"/api/v1/dashboards/{uuid.uuid4()}", headers=ORIGIN_HEADER)
assert resp.status_code == 404
resp = await client.delete(f"/api/v1/dashboards/{dash_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 204
resp = await client.get(f"/api/v1/dashboards/{dash_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 404
async def test_set_default_and_reassignment(self, client: AsyncClient, db_session):
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
d1 = (await client.post("/api/v1/dashboards", json={"name": "Eins"}, headers=ORIGIN_HEADER)).json()
d2 = (await client.post("/api/v1/dashboards", json={"name": "Zwei"}, headers=ORIGIN_HEADER)).json()
assert d1["is_default"] is True
assert d2["is_default"] is False
resp = await client.post(f"/api/v1/dashboards/{d2['id']}/set-default", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json()["is_default"] is True
items = {d["id"]: d for d in (await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)).json()}
assert items[d1["id"]]["is_default"] is False
assert items[d2["id"]]["is_default"] is True
# deleting the default promotes the remaining dashboard
resp = await client.delete(f"/api/v1/dashboards/{d2['id']}", headers=ORIGIN_HEADER)
assert resp.status_code == 204
items = (await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)).json()
assert len(items) == 1
assert items[0]["id"] == d1["id"]
assert items[0]["is_default"] is True
async def test_delete_last_reseeds_on_next_list(self, client: AsyncClient, db_session):
"""Empty list state is re-seeded on next GET (documented behaviour)."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
items = (await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)).json()
assert len(items) == 1
dash_id = items[0]["id"]
await client.delete(f"/api/v1/dashboards/{dash_id}", headers=ORIGIN_HEADER)
items = (await client.get("/api/v1/dashboards", headers=ORIGIN_HEADER)).json()
assert len(items) == 1 # re-seeded
assert items[0]["is_default"] is True
async def test_create_audit_logged(self, client: AsyncClient, db_session: AsyncSession):
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.post("/api/v1/dashboards", json={"name": "Audit"}, headers=ORIGIN_HEADER)
assert resp.status_code == 201
result = await db_session.execute(
text("SELECT action, entity_type FROM audit_log WHERE entity_type = 'dashboard'")
)
rows = result.fetchall()
assert any(r[0] == "create" for r in rows)
@pytest.mark.asyncio
class TestOwnershipAndIsolation:
async def test_other_user_gets_404(self, client: AsyncClient, db_session):
"""Dashboards are personal (saved_views precedent): other users with
dashboard:read see 404 on foreign dashboards, never their content."""
import httpx
from httpx import ASGITransport
import app.main
seed = await seed_tenant_and_users(db_session)
await _make_user_with_dashboard_perms(db_session, seed, "second@tenanta.com", "dash_second")
await login_client(client, "admin@tenanta.com")
created = (
await client.post("/api/v1/dashboards", json={"name": "Mein Board"}, headers=ORIGIN_HEADER)
).json()
async with httpx.AsyncClient(
transport=ASGITransport(app=app.main.app), base_url="http://test"
) as other:
await login_client(other, "second@tenanta.com")
resp = await other.get(f"/api/v1/dashboards/{created['id']}", headers=ORIGIN_HEADER)
assert resp.status_code == 404
# second user only sees their own (seeded) dashboard
resp = await other.get("/api/v1/dashboards", headers=ORIGIN_HEADER)
assert resp.status_code == 200
items = resp.json()
assert all(d["id"] != created["id"] for d in items)
async def test_editor_without_permission_gets_403(self, client: AsyncClient, db_session):
"""Editor role has no dashboard:read at all -> 403 on every endpoint."""
import httpx
from httpx import ASGITransport
import app.main
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
created = (
await client.post("/api/v1/dashboards", json={"name": "Mein Board"}, headers=ORIGIN_HEADER)
).json()
async with httpx.AsyncClient(
transport=ASGITransport(app=app.main.app), base_url="http://test"
) as other:
await login_client(other, "editor@tenanta.com")
resp = await other.get(f"/api/v1/dashboards/{created['id']}", headers=ORIGIN_HEADER)
assert resp.status_code == 403
resp = await other.get("/api/v1/dashboards", headers=ORIGIN_HEADER)
assert resp.status_code == 403
async def test_cross_tenant_isolation(self, client: AsyncClient, db_session):
"""Tenant B admin never sees tenant A dashboards (RLS + owner filter)."""
import httpx
from httpx import ASGITransport
import app.main
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
created = (
await client.post("/api/v1/dashboards", json={"name": "Tenant A Board"}, headers=ORIGIN_HEADER)
).json()
async with httpx.AsyncClient(
transport=ASGITransport(app=app.main.app), base_url="http://test"
) as other:
await login_client(other, "admin@tenantb.com")
resp = await other.get(f"/api/v1/dashboards/{created['id']}", headers=ORIGIN_HEADER)
assert resp.status_code == 404
items = (await other.get("/api/v1/dashboards", headers=ORIGIN_HEADER)).json()
assert all(d["id"] != created["id"] for d in items)
# ═══════════════════════════════════════════════════════════════
# Migration 0144: RLS convergence (crm_api + crm_worker)
# ═══════════════════════════════════════════════════════════════
def _admin_db_available() -> bool:
try:
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
url = os.environ.get(
"RLS_TEST_ADMIN_DB_URL",
"postgresql+asyncpg://postgres@localhost:5432/leocrm_test",
)
eng = create_async_engine(url, echo=False)
async def _check():
async with eng.connect() as conn:
await conn.execute(text("SELECT 1"))
asyncio.run(_check())
return True
except Exception:
return False
@pytest.mark.asyncio
@pytest.mark.skipif(not _admin_db_available(), reason="Admin DB not available")
class TestRlsConvergence:
async def test_dashboards_policy_scoped_to_both_roles(self):
from sqlalchemy.ext.asyncio import create_async_engine
eng = create_async_engine(
os.environ["RLS_TEST_ADMIN_DB_URL"], echo=False
)
async with eng.connect() as conn:
result = await conn.execute(
text(
"SELECT roles FROM pg_policies "
"WHERE tablename = 'dashboards' "
"AND policyname = 'dashboards_tenant_isolation'"
)
)
row = result.fetchone()
assert row is not None, "dashboards_tenant_isolation policy missing"
roles = set(row[0])
assert "crm_api" in roles
assert "crm_worker" in roles
await eng.dispose()
async def test_phase_l_policies_converged_to_both_roles(self):
"""0143 created letterheads/print_templates/document_assets with only
crm_api (measured on production 2026-08-30). Migration 0144 converges
them to the 0090 pattern (crm_api + crm_worker)."""
from sqlalchemy.ext.asyncio import create_async_engine
eng = create_async_engine(
os.environ["RLS_TEST_ADMIN_DB_URL"], echo=False
)
async with eng.connect() as conn:
result = await conn.execute(
text(
"SELECT tablename, roles FROM pg_policies "
"WHERE tablename IN ('letterheads','print_templates','document_assets') "
"AND policyname LIKE '%tenant_isolation%'"
)
)
rows = result.fetchall()
assert len(rows) == 3
for tablename, roles in rows:
assert "crm_api" in set(roles), f"{tablename}: crm_api missing"
assert "crm_worker" in set(roles), f"{tablename}: crm_worker missing"
await eng.dispose()