From e594defc004d5311ababa283b793b4ec76d5cd3c Mon Sep 17 00:00:00 2001 From: idrainformatica Date: Thu, 19 Mar 2026 18:06:44 +0100 Subject: [PATCH] Multitenancy --- KnowledgeBaseCline.md | 15 +- backend/alembic/versions/0006_rls_complete.py | 153 +++++ backend/app/api/v1/tenants.py | 33 +- backend/app/config.py | 5 + backend/app/core/exceptions.py | 8 + backend/app/dependencies.py | 59 +- backend/app/schemas/tenant.py | 4 + backend/app/services/tenant_service.py | 65 +- database/init/01_app_role.sql | 38 ++ database/seeds/dev_tenant.sql | 64 +- frontend/src/App.tsx | 4 + frontend/src/api/tenants.api.ts | 28 + frontend/src/components/Layout/Sidebar.tsx | 37 +- .../src/pages/MultiTenant/MultiTenantPage.tsx | 577 ++++++++++++++++++ frontend/src/types/api.types.ts | 37 ++ 15 files changed, 1090 insertions(+), 37 deletions(-) create mode 100644 backend/alembic/versions/0006_rls_complete.py create mode 100644 database/init/01_app_role.sql create mode 100644 frontend/src/api/tenants.api.ts create mode 100644 frontend/src/pages/MultiTenant/MultiTenantPage.tsx diff --git a/KnowledgeBaseCline.md b/KnowledgeBaseCline.md index 86d6fc8..27e3624 100644 --- a/KnowledgeBaseCline.md +++ b/KnowledgeBaseCline.md @@ -1,8 +1,8 @@ Stiamo lavorando a un PEC Manager SaaS -Effettua tutti i test in locale +Non usare emoji -Ho docker installato, compose v2 (docker cmpose senza trattino) +Qualunque modifica deve essere deployata sul server remoto Non fare commit sul repository GitHub, ci penso io @@ -39,4 +39,13 @@ Ruolo Email Password Super Admin superadmin@pechub.it SuperAdmin@PEChub2026! Admin (tenant demo) admin@demo.pechub.it Demo@PEChub2026! Operator (tenant demo) operator@demo.pechub.it Oper@PEChub2026! -Per accedere all'applicazione usa le credenziali Admin del tenant demo. \ No newline at end of file +Per accedere all'applicazione usa le credenziali Admin del tenant demo. + +Il server su cui deployare è 212.83.140.21, porta 22222 +Username: root +Password: Ma212718! +Ho docker installato, compose v2 (docker cmpose senza trattino) + +Il progetto si trova in `/opt/pechub` + +Il servizio deve essere esposto su 0.0.0.0 e prevedere l'esposizione anche col dominio pechub.it \ No newline at end of file diff --git a/backend/alembic/versions/0006_rls_complete.py b/backend/alembic/versions/0006_rls_complete.py new file mode 100644 index 0000000..f11b203 --- /dev/null +++ b/backend/alembic/versions/0006_rls_complete.py @@ -0,0 +1,153 @@ +"""RLS completa su tutte le tabelle applicative + ruolo pechub_app + +Revision ID: 0006 +Revises: 0005 +Create Date: 2026-03-19 00:00:00.000000 + +Aggiunge: + - ENABLE ROW LEVEL SECURITY + policy USING (tenant_id = ...) su tutte le + tabelle applicative che mancavano (attachments, send_jobs, archival_batches, + archival_dips, labels, mailbox_permissions, virtual_boxes, + notification_channels, notification_rules, notification_log, tenant_settings) + - Crea il ruolo PostgreSQL applicativo 'pechub_app' (non-superuser) con + GRANT SELECT/INSERT/UPDATE/DELETE su tutte le tabelle. + Il ruolo viene usato dalla connessione applicativa (DATABASE_URL) mentre + Alembic continua ad usare l'utente superuser (DATABASE_URL_SYNC). + +Nota: la migrazione usa DO $$ BEGIN ... EXCEPTION WHEN ... END $$ per +rendere idempotente la creazione del ruolo e delle policy. +""" + +from alembic import op + +revision = "0006" +down_revision = "0005" +branch_labels = None +depends_on = None + +# Tabelle con tenant_id che devono avere RLS (escluse quelle già gestite in 0001) +_RLS_TABLES = [ + "attachments", + "send_jobs", + "archival_batches", + "archival_dips", + "labels", + "mailbox_permissions", + "virtual_boxes", + "notification_channels", + "notification_rules", + "notification_log", + "tenant_settings", +] + +# Tutte le tabelle su cui pechub_app ha SELECT/INSERT/UPDATE/DELETE +_ALL_APP_TABLES = [ + "tenants", + "users", + "refresh_tokens", + "mailboxes", + "messages", + "attachments", + "send_jobs", + "archival_batches", + "archival_batch_messages", + "archival_dips", + "labels", + "message_labels", + "mailbox_permissions", + "virtual_boxes", + "virtual_box_rules", + "virtual_box_assignments", + "virtual_box_mailboxes", + "notification_channels", + "notification_rules", + "notification_log", + "audit_log", + "tenant_settings", +] + + +def upgrade() -> None: + # ── 1. Abilita RLS + policy su tabelle mancanti ─────────────────────────── + for table in _RLS_TABLES: + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") + # Politica idempotente: elimina la policy se esiste e la ricrea + op.execute( + f""" + DO $$ + BEGIN + -- Elimina policy esistente (se presente da un run precedente) + DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}; + -- Crea la nuova policy + CREATE POLICY {table}_tenant_isolation ON {table} + USING ( + tenant_id = NULLIF( + current_setting('app.current_tenant_id', TRUE), '' + )::UUID + ); + END + $$; + """ + ) + + # ── 2. Crea ruolo applicativo pechub_app (non-superuser) ────────────────── + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'pechub_app' + ) THEN + CREATE USER pechub_app WITH PASSWORD 'pechub_app_password' + NOSUPERUSER NOCREATEDB NOCREATEROLE; + RAISE NOTICE 'Ruolo pechub_app creato'; + ELSE + RAISE NOTICE 'Ruolo pechub_app già esistente – skip'; + END IF; + END + $$; + """ + ) + + # ── 3. Permessi al ruolo applicativo ────────────────────────────────────── + op.execute("GRANT CONNECT ON DATABASE pechub TO pechub_app") + op.execute("GRANT USAGE ON SCHEMA public TO pechub_app") + + for table in _ALL_APP_TABLES: + op.execute( + f"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {table} TO pechub_app" + ) + + # Sequenze (per BIGSERIAL come audit_log.id) + op.execute("GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO pechub_app") + + # Default privileges: le tabelle create nelle migrazioni future ricevono + # automaticamente i permessi per pechub_app + op.execute( + """ + ALTER DEFAULT PRIVILEGES FOR ROLE pechub IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO pechub_app; + """ + ) + op.execute( + """ + ALTER DEFAULT PRIVILEGES FOR ROLE pechub IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO pechub_app; + """ + ) + + +def downgrade() -> None: + # Rimuovi policy e disabilita RLS + for table in _RLS_TABLES: + op.execute( + f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}" + ) + op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY") + + # Revoca permessi e rimuovi ruolo + op.execute("REVOKE ALL ON ALL TABLES IN SCHEMA public FROM pechub_app") + op.execute("REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM pechub_app") + op.execute("REVOKE USAGE ON SCHEMA public FROM pechub_app") + op.execute("REVOKE CONNECT ON DATABASE pechub FROM pechub_app") + op.execute("DROP USER IF EXISTS pechub_app") diff --git a/backend/app/api/v1/tenants.py b/backend/app/api/v1/tenants.py index 8a4cfb1..e053276 100644 --- a/backend/app/api/v1/tenants.py +++ b/backend/app/api/v1/tenants.py @@ -1,36 +1,44 @@ """ Router tenant – gestione organizzazioni (solo super_admin). +Protezione doppia: + 1. require_super_admin – JWT con ruolo super_admin + 2. verify_admin_key – Header X-Admin-Key (se configurata in produzione) + Endpoint: - GET /api/v1/tenants → lista tenant + GET /api/v1/tenants → lista tenant con statistiche POST /api/v1/tenants → crea tenant + admin - GET /api/v1/tenants/{id} → dettaglio tenant - PATCH /api/v1/tenants/{id} → modifica tenant + GET /api/v1/tenants/{id} → dettaglio tenant con statistiche + PATCH /api/v1/tenants/{id} → modifica tenant (incluso is_active per sospensione) """ import uuid +from typing import Annotated -from fastapi import APIRouter +from fastapi import APIRouter, Depends -from app.dependencies import SuperAdminUser, DB +from app.dependencies import DB, SuperAdminUser, verify_admin_key from app.schemas.tenant import TenantCreateRequest, TenantResponse, TenantUpdateRequest from app.services.tenant_service import TenantService -router = APIRouter(prefix="/tenants", tags=["Tenant (super-admin)"]) +router = APIRouter( + prefix="/tenants", + tags=["Tenant (super-admin)"], + dependencies=[Depends(verify_admin_key)], # X-Admin-Key su tutti gli endpoint +) @router.get( "", response_model=list[TenantResponse], - summary="Lista tutti i tenant", + summary="Lista tutti i tenant con statistiche", ) async def list_tenants( _: SuperAdminUser, db: DB, ) -> list[TenantResponse]: service = TenantService(db) - tenants = await service.list_tenants() - return [TenantResponse.model_validate(t) for t in tenants] + return await service.list_tenants_with_stats() @router.post( @@ -52,7 +60,7 @@ async def create_tenant( @router.get( "/{tenant_id}", response_model=TenantResponse, - summary="Dettaglio tenant", + summary="Dettaglio tenant con statistiche", ) async def get_tenant( tenant_id: uuid.UUID, @@ -60,14 +68,13 @@ async def get_tenant( db: DB, ) -> TenantResponse: service = TenantService(db) - tenant = await service.get_tenant(tenant_id) - return TenantResponse.model_validate(tenant) + return await service.get_tenant_with_stats(tenant_id) @router.patch( "/{tenant_id}", response_model=TenantResponse, - summary="Modifica tenant", + summary="Modifica tenant (nome, piano, limiti, sospensione)", ) async def update_tenant( tenant_id: uuid.UUID, diff --git a/backend/app/config.py b/backend/app/config.py index 5298bc5..e0ac2ca 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -47,6 +47,11 @@ class Settings(BaseSettings): minio_bucket: str = "pechub" minio_use_ssl: bool = False + # ── Admin sicurezza ─────────────────────────────────────────────────────── + # Header X-Admin-Key richiesto sugli endpoint /api/v1/tenants + # Se vuoto → protezione disabilitata (solo sviluppo) + admin_secret_key: str = "" + # ── CORS ────────────────────────────────────────────────────────────────── cors_origins: str = "http://localhost:3000,http://localhost:5173" diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py index b507b58..a2912be 100644 --- a/backend/app/core/exceptions.py +++ b/backend/app/core/exceptions.py @@ -53,6 +53,14 @@ class AccountDisabledError(HTTPException): ) +class TenantSuspendedError(HTTPException): + def __init__(self) -> None: + super().__init__( + status_code=status.HTTP_403_FORBIDDEN, + detail="Tenant sospeso", + ) + + class TOTPRequiredError(HTTPException): def __init__(self) -> None: super().__init__( diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 8de9748..771960f 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -1,19 +1,21 @@ """ -Dependency FastAPI – get_db, get_current_user, require_admin, RLS middleware. +Dependency FastAPI – get_db, get_current_user, get_current_tenant, role guards. """ import uuid from typing import Annotated -from fastapi import Depends, Request +from fastapi import Depends, Header, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from jose import JWTError from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession -from app.core.exceptions import ForbiddenError, TokenInvalidError +from app.config import get_settings +from app.core.exceptions import ForbiddenError, TenantSuspendedError, TokenInvalidError from app.core.security import decode_token from app.database import get_db +from app.models.tenant import Tenant from app.models.user import User from sqlalchemy import select @@ -58,7 +60,7 @@ async def get_current_user( ) -> User: """ Estrae e valida il JWT dall'header Authorization: Bearer . - Carica l'utente dal DB e imposta RLS. + Carica il tenant (verifica is_active), l'utente dal DB e imposta RLS. """ token = credentials.credentials @@ -85,6 +87,13 @@ async def get_current_user( # Imposta RLS per questo tenant (no-op su SQLite/test) await _set_rls_tenant_id(db, tenant_id) + # Verifica tenant esiste e non è sospeso + tenant = await db.get(Tenant, tenant_id) + if not tenant: + raise TokenInvalidError() + if not tenant.is_active: + raise TenantSuspendedError() + # Carica utente result = await db.execute( select(User).where(User.id == user_id, User.tenant_id == tenant_id) @@ -98,9 +107,32 @@ async def get_current_user( from app.core.exceptions import AccountDisabledError raise AccountDisabledError() + # Attacca il tenant all'utente per uso nei router (evita doppio caricamento) + user._current_tenant = tenant # type: ignore[attr-defined] + return user +# ─── Tenant corrente ────────────────────────────────────────────────────────── + +async def get_current_tenant( + current_user: Annotated[User, Depends(get_current_user)], + db: AsyncSession = Depends(get_db), +) -> Tenant: + """ + Restituisce l'oggetto Tenant dell'utente autenticato. + + Il Tenant è già stato validato in get_current_user() – questa dependency + utilizza la cache della sessione SQLAlchemy (identity map) per evitare + un secondo accesso al DB. + """ + # Usa la cache della sessione ORM (non emette una seconda query) + tenant = await db.get(Tenant, current_user.tenant_id) + if not tenant: + raise TokenInvalidError() + return tenant + + # ─── Role guards ────────────────────────────────────────────────────────────── async def require_admin( @@ -121,8 +153,27 @@ async def require_super_admin( return current_user +# ─── Protezione endpoint admin con X-Admin-Key header ───────────────────────── + +async def verify_admin_key( + x_admin_key: str = Header(default="", alias="X-Admin-Key"), +) -> None: + """ + Verifica l'header X-Admin-Key per gli endpoint di amministrazione tenant. + Se ADMIN_SECRET_KEY non è configurata (ambiente di sviluppo), il check + viene saltato per facilitare lo sviluppo locale. + """ + settings = get_settings() + if not settings.admin_secret_key: + # Sviluppo: nessuna chiave configurata → accesso libero + return + if x_admin_key != settings.admin_secret_key: + raise ForbiddenError("X-Admin-Key non valida o mancante") + + # ─── Tipo annotato per ridurre boilerplate negli endpoint ───────────────────── CurrentUser = Annotated[User, Depends(get_current_user)] +CurrentTenant = Annotated[Tenant, Depends(get_current_tenant)] AdminUser = Annotated[User, Depends(require_admin)] SuperAdminUser = Annotated[User, Depends(require_super_admin)] DB = Annotated[AsyncSession, Depends(get_db)] diff --git a/backend/app/schemas/tenant.py b/backend/app/schemas/tenant.py index 2221401..91721df 100644 --- a/backend/app/schemas/tenant.py +++ b/backend/app/schemas/tenant.py @@ -51,4 +51,8 @@ class TenantResponse(BaseModel): created_at: datetime updated_at: datetime + # Statistiche opzionali (popolate dalla lista) + user_count: int = 0 + mailbox_count: int = 0 + model_config = {"from_attributes": True} diff --git a/backend/app/services/tenant_service.py b/backend/app/services/tenant_service.py index debfd54..9a20527 100644 --- a/backend/app/services/tenant_service.py +++ b/backend/app/services/tenant_service.py @@ -4,14 +4,15 @@ Servizio tenant – gestione organizzazioni (solo super_admin). import uuid -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import ConflictError, NotFoundError from app.core.security import hash_password +from app.models.mailbox import Mailbox from app.models.tenant import Tenant from app.models.user import User -from app.schemas.tenant import TenantCreateRequest, TenantUpdateRequest +from app.schemas.tenant import TenantCreateRequest, TenantResponse, TenantUpdateRequest class TenantService: @@ -56,12 +57,72 @@ class TenantService: raise NotFoundError("tenant") return tenant + async def get_tenant_with_stats(self, tenant_id: uuid.UUID) -> TenantResponse: + """Restituisce il tenant con conteggi utenti e caselle.""" + tenant = await self.get_tenant(tenant_id) + + user_count = ( + await self.db.execute( + select(func.count(User.id)).where( + User.tenant_id == tenant_id, + User.is_active == True, # noqa: E712 + ) + ) + ).scalar_one() + + mailbox_count = ( + await self.db.execute( + select(func.count(Mailbox.id)).where( + Mailbox.tenant_id == tenant_id, + Mailbox.status != "deleted", + ) + ) + ).scalar_one() + + resp = TenantResponse.model_validate(tenant) + resp.user_count = user_count + resp.mailbox_count = mailbox_count + return resp + async def list_tenants(self) -> list[Tenant]: result = await self.db.execute( select(Tenant).order_by(Tenant.created_at.desc()) ) return list(result.scalars().all()) + async def list_tenants_with_stats(self) -> list[TenantResponse]: + """ + Restituisce tutti i tenant con conteggi utenti e caselle in una + singola query efficiente (LEFT JOIN con GROUP BY). + """ + stmt = ( + select( + Tenant, + func.count(User.id.distinct()).label("user_count"), + func.count(Mailbox.id.distinct()).label("mailbox_count"), + ) + .outerjoin( + User, + (User.tenant_id == Tenant.id) & (User.is_active == True), # noqa: E712 + ) + .outerjoin( + Mailbox, + (Mailbox.tenant_id == Tenant.id) & (Mailbox.status != "deleted"), + ) + .group_by(Tenant.id) + .order_by(Tenant.created_at.desc()) + ) + + rows = (await self.db.execute(stmt)).all() + result = [] + for row in rows: + tenant_obj, user_count, mailbox_count = row + resp = TenantResponse.model_validate(tenant_obj) + resp.user_count = user_count or 0 + resp.mailbox_count = mailbox_count or 0 + result.append(resp) + return result + async def update_tenant( self, tenant_id: uuid.UUID, data: TenantUpdateRequest ) -> Tenant: diff --git a/database/init/01_app_role.sql b/database/init/01_app_role.sql new file mode 100644 index 0000000..873f986 --- /dev/null +++ b/database/init/01_app_role.sql @@ -0,0 +1,38 @@ +-- ============================================================ +-- Ruolo PostgreSQL applicativo pechub_app +-- +-- Questo script viene eseguito al primo avvio del container DB. +-- Il ruolo viene creato PRIMA che Alembic esegua le migrazioni. +-- I GRANT sulle singole tabelle vengono gestiti dalla migration 0006. +-- +-- Scopo: +-- - pechub_app è un utente non-superuser su cui RLS è attiva +-- - Il backend e il worker usano DATABASE_URL con pechub_app +-- - Alembic usa DATABASE_URL_SYNC con l'utente pechub (superuser) +-- ============================================================ + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'pechub_app' + ) THEN + CREATE USER pechub_app WITH PASSWORD 'pechub_app_password' + NOSUPERUSER NOCREATEDB NOCREATEROLE; + RAISE NOTICE '[pechub] Ruolo pechub_app creato'; + ELSE + RAISE NOTICE '[pechub] Ruolo pechub_app già presente – skip'; + END IF; +END +$$; + +-- Permessi di base (i GRANT sulle tabelle vengono dopo le migrazioni) +GRANT CONNECT ON DATABASE pechub TO pechub_app; +GRANT USAGE ON SCHEMA public TO pechub_app; + +-- Default privileges: tutte le future tabelle create da pechub +-- ricevono automaticamente i permessi per pechub_app +ALTER DEFAULT PRIVILEGES FOR ROLE pechub IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO pechub_app; + +ALTER DEFAULT PRIVILEGES FOR ROLE pechub IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO pechub_app; diff --git a/database/seeds/dev_tenant.sql b/database/seeds/dev_tenant.sql index 665a1fc..6fd87cb 100644 --- a/database/seeds/dev_tenant.sql +++ b/database/seeds/dev_tenant.sql @@ -1,17 +1,23 @@ -- ============================================================ --- SEED: Tenant demo + utenti per sviluppo locale --- --- Credenziali: --- Admin: admin@demo.pechub.it / Demo@PEChub2026! +-- SEED: Tenant demo + Tenant acme + utenti per sviluppo locale +-- +-- Tenant demo: +-- Admin: admin@demo.pechub.it / Demo@PEChub2026! -- Operator: operator@demo.pechub.it / Oper@PEChub2026! -- +-- Tenant acme (secondo tenant per test isolamento): +-- Admin: admin@acme.pechub.it / Acme@PEChub2026! +-- +-- Super Admin (cross-tenant): +-- superadmin@pechub.it / SuperAdmin@PEChub2026! +-- -- Esegui con: make seed -- ============================================================ -- Disabilita RLS temporaneamente per il seed SET session_replication_role = replica; --- Tenant demo +-- ── Tenant demo ────────────────────────────────────────────────────────────── INSERT INTO tenants (id, slug, name, plan, is_active, max_mailboxes, max_users) VALUES ( '11111111-1111-1111-1111-111111111111', @@ -24,48 +30,75 @@ VALUES ( ) ON CONFLICT (slug) DO NOTHING; --- Utente super_admin (global, senza tenant specifico usa il tenant demo) +-- ── Tenant acme (secondo tenant per test isolamento) ───────────────────────── +INSERT INTO tenants (id, slug, name, plan, is_active, max_mailboxes, max_users) +VALUES ( + '22222222-2222-2222-2222-222222222222', + 'acme', + 'Acme Corp SpA', + 'starter', + TRUE, + 5, + 10 +) +ON CONFLICT (slug) DO NOTHING; + +-- ── Utente super_admin (ruolo globale, associato al tenant demo) ────────────── -- Password: SuperAdmin@PEChub2026! (bcrypt hash) INSERT INTO users (id, tenant_id, email, password_hash, full_name, role, is_active) VALUES ( '00000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'superadmin@pechub.it', - '$2b$12$y2yq6X2f3dZi22wqWZd1aumP03IU6OWrrevRMFj9054aGnUms116W', -- SuperAdmin@PEChub2026! + '$2b$12$XVHODc6nahpMm.XO5ifDku77IFDqCcMkJpSc7.uwElpML4wo3gfQu', 'Super Admin PEChub', 'super_admin', TRUE ) ON CONFLICT (tenant_id, email) DO NOTHING; --- Utente admin del tenant demo +-- ── Admin del tenant demo ───────────────────────────────────────────────────── -- Password: Demo@PEChub2026! (bcrypt hash) INSERT INTO users (id, tenant_id, email, password_hash, full_name, role, is_active) VALUES ( '11111111-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'admin@demo.pechub.it', - '$2b$12$PmyaJvF0i7ACFR39k6hfMO2.6U.FVPYma.7OyXyrGuGuokiJOfX8y', -- Demo@PEChub2026! + '$2b$12$xBbzU5vPAibZWx/jnEwJoO8aAAK9EdIBMzQbo7naD22t37EJeIy9q', 'Admin Demo', 'admin', TRUE ) ON CONFLICT (tenant_id, email) DO NOTHING; --- Utente operator del tenant demo +-- ── Operator del tenant demo ────────────────────────────────────────────────── -- Password: Oper@PEChub2026! (bcrypt hash) INSERT INTO users (id, tenant_id, email, password_hash, full_name, role, is_active) VALUES ( '11111111-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', 'operator@demo.pechub.it', - '$2b$12$Z0REc7flPCD3Sb8fZHsuW.Uk2X4JiJO7HhTajNSuPiQgzppkCDmLu', -- Oper@PEChub2026! + '$2b$12$8stUJfKKTB5Tqjrd3Aamm.sOrQe9T0kygbkTbN7raItjhD0exdyVm', 'Operatore Demo', 'operator', TRUE ) ON CONFLICT (tenant_id, email) DO NOTHING; +-- ── Admin del tenant acme ───────────────────────────────────────────────────── +-- Password: Acme@PEChub2026! (bcrypt hash) +INSERT INTO users (id, tenant_id, email, password_hash, full_name, role, is_active) +VALUES ( + '22222222-0000-0000-0000-000000000001', + '22222222-2222-2222-2222-222222222222', + 'admin@acme.pechub.it', + '$2b$12$lGN3ckunwsI2pS2VKPjLAemnvJgv3DzPddcv4W4KzwjeVvRQh.jhO', + 'Admin Acme Corp', + 'admin', + TRUE +) +ON CONFLICT (tenant_id, email) DO NOTHING; + -- Ripristina RLS SET session_replication_role = DEFAULT; @@ -73,8 +106,11 @@ SET session_replication_role = DEFAULT; DO $$ BEGIN RAISE NOTICE '✅ Seed completato!'; - RAISE NOTICE ' Tenant demo: 11111111-1111-1111-1111-111111111111'; - RAISE NOTICE ' Admin: admin@demo.pechub.it / Demo@PEChub2026!'; - RAISE NOTICE ' Operator: operator@demo.pechub.it / Oper@PEChub2026!'; + RAISE NOTICE ' Tenant demo: 11111111-1111-1111-1111-111111111111'; + RAISE NOTICE ' Tenant acme: 22222222-2222-2222-2222-222222222222'; + RAISE NOTICE ' SuperAdmin: superadmin@pechub.it / SuperAdmin@PEChub2026!'; + RAISE NOTICE ' Admin demo: admin@demo.pechub.it / Demo@PEChub2026!'; + RAISE NOTICE ' Operator demo: operator@demo.pechub.it / Oper@PEChub2026!'; + RAISE NOTICE ' Admin acme: admin@acme.pechub.it / Acme@PEChub2026!'; END $$; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 755a8ae..3d79137 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { PermissionsPage } from '@/pages/Permissions/PermissionsPage' import { SettingsPage } from '@/pages/Settings/SettingsPage' import { VirtualBoxesPage } from '@/pages/VirtualBoxes/VirtualBoxesPage' import { NotificationsPage } from '@/pages/Notifications/NotificationsPage' +import { MultiTenantPage } from '@/pages/MultiTenant/MultiTenantPage' /** * Routing principale dell'applicazione PEChub. @@ -70,6 +71,9 @@ export default function App() { } /> } /> + {/* Super Admin – Gestione Multi-Tenant */} + } /> + {/* Profilo utente */} } /> diff --git a/frontend/src/api/tenants.api.ts b/frontend/src/api/tenants.api.ts new file mode 100644 index 0000000..63b2114 --- /dev/null +++ b/frontend/src/api/tenants.api.ts @@ -0,0 +1,28 @@ +import { apiClient } from './client' +import type { TenantResponse, TenantCreateRequest, TenantUpdateRequest } from '@/types/api.types' + +export const tenantsApi = { + list(): Promise { + return apiClient.get('/tenants').then((r) => r.data) + }, + + get(id: string): Promise { + return apiClient.get(`/tenants/${id}`).then((r) => r.data) + }, + + create(data: TenantCreateRequest): Promise { + return apiClient.post('/tenants', data).then((r) => r.data) + }, + + update(id: string, data: TenantUpdateRequest): Promise { + return apiClient.patch(`/tenants/${id}`, data).then((r) => r.data) + }, + + suspend(id: string): Promise { + return apiClient.patch(`/tenants/${id}`, { is_active: false }).then((r) => r.data) + }, + + activate(id: string): Promise { + return apiClient.patch(`/tenants/${id}`, { is_active: true }).then((r) => r.data) + }, +} diff --git a/frontend/src/components/Layout/Sidebar.tsx b/frontend/src/components/Layout/Sidebar.tsx index d02a624..7c11e62 100644 --- a/frontend/src/components/Layout/Sidebar.tsx +++ b/frontend/src/components/Layout/Sidebar.tsx @@ -48,6 +48,7 @@ import { Bell, Star, Archive, + Building2, } from 'lucide-react' import { cn } from '@/lib/utils' import { useAuth } from '@/hooks/useAuth' @@ -71,7 +72,7 @@ export function Sidebar() { /** Set degli ID virtual box che l'utente ha esplicitamente chiuso. */ const [collapsedVboxes, setCollapsedVboxes] = useState>(new Set()) - const { user, isAdmin, logout } = useAuth() + const { user, isAdmin, isSuperAdmin, logout } = useAuth() const unreadCount = useInboxStore((s) => s.unreadCount) // Le caselle PEC vengono caricate qui e condivise via React Query cache @@ -387,6 +388,40 @@ export function Sidebar() { )} + + {/* ── Sezione Super Admin – visibile solo ai super_admin ── */} + {isSuperAdmin && ( +
+ {!collapsed && ( + <> +
+

+ Super Admin +

+ + )} + {collapsed &&
} + +
+ + cn( + 'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors', + isActive + ? 'bg-purple-700 text-white' + : 'text-purple-300 hover:bg-purple-900/40 hover:text-white', + collapsed && 'justify-center px-2', + ) + } + title={collapsed ? 'Multi-Tenant' : undefined} + > + + {!collapsed && Multi-Tenant} + +
+
+ )} {/* ── Profilo utente + logout ── */} diff --git a/frontend/src/pages/MultiTenant/MultiTenantPage.tsx b/frontend/src/pages/MultiTenant/MultiTenantPage.tsx new file mode 100644 index 0000000..f7720d8 --- /dev/null +++ b/frontend/src/pages/MultiTenant/MultiTenantPage.tsx @@ -0,0 +1,577 @@ +/** + * Pannello di gestione multi-tenant – accessibile solo ai super_admin. + * + * Funzionalita': + * - Lista tutti i tenant con statistiche (utenti, caselle, piano) + * - Crea nuovo tenant con admin iniziale + * - Modifica nome, piano e limiti + * - Sospendi / Riattiva tenant (toggle is_active) + * + * Route: /multitenant + * Guard: reindirizza a /inbox se l'utente non e' super_admin + */ + +import { useState } from 'react' +import { Navigate } from 'react-router-dom' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import toast from 'react-hot-toast' +import { + Building2, + Plus, + Pencil, + PauseCircle, + PlayCircle, + Users, + MailCheck, + RefreshCw, +} from 'lucide-react' + +import { useAuth } from '@/hooks/useAuth' +import { tenantsApi } from '@/api/tenants.api' +import type { TenantResponse, TenantCreateRequest, TenantUpdateRequest, TenantPlan } from '@/types/api.types' + +import { Button } from '@/components/ui/Button' +import { Input } from '@/components/ui/Input' +import { Label } from '@/components/ui/Label' +import { Card } from '@/components/ui/Card' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/Dialog' + +// ─── Select nativo stilizzato ───────────────────────────────────────────────── + +function NativeSelect({ + id, + value, + onChange, + children, +}: { + id?: string + value: string + onChange: (v: string) => void + children: React.ReactNode +}) { + return ( + + ) +} + +// ─── Badge piano ────────────────────────────────────────────────────────────── + +function PlanBadge({ plan }: { plan: TenantPlan }) { + const styles: Record = { + starter: 'bg-gray-100 text-gray-700', + pro: 'bg-blue-100 text-blue-700', + enterprise: 'bg-purple-100 text-purple-700', + } + const labels: Record = { + starter: 'Starter', + pro: 'Pro', + enterprise: 'Enterprise', + } + return ( + + {labels[plan]} + + ) +} + +// ─── Badge stato ───────────────────────────────────────────────────────────── + +function StatusBadge({ isActive }: { isActive: boolean }) { + return isActive ? ( + + + Attivo + + ) : ( + + + Sospeso + + ) +} + +// ─── Dialog: Crea tenant ────────────────────────────────────────────────────── + +interface CreateDialogProps { + open: boolean + onClose: () => void + onCreated: () => void +} + +function CreateTenantDialog({ open, onClose, onCreated }: CreateDialogProps) { + const emptyForm: TenantCreateRequest = { + slug: '', + name: '', + plan: 'starter', + max_mailboxes: 5, + max_users: 10, + admin_email: '', + admin_password: '', + admin_full_name: '', + } + const [form, setForm] = useState(emptyForm) + + const mutation = useMutation({ + mutationFn: () => tenantsApi.create(form), + onSuccess: () => { + toast.success('Tenant creato con successo') + onCreated() + onClose() + setForm(emptyForm) + }, + onError: (err: unknown) => { + const msg = (err as any)?.response?.data?.detail ?? 'Errore nella creazione del tenant' + toast.error(typeof msg === 'string' ? msg : 'Errore nella creazione') + }, + }) + + return ( + + + + Nuovo Tenant + + +
{ e.preventDefault(); mutation.mutate() }} + className="space-y-4 py-2" + > +
+
+ + setForm(f => ({ ...f, slug: e.target.value.toLowerCase() }))} + placeholder="acme-corp" + required + minLength={3} + maxLength={63} + /> +

Lettere minuscole, numeri e trattini

+
+
+ + setForm(f => ({ ...f, name: e.target.value }))} + placeholder="Acme Corp SpA" + required + minLength={2} + /> +
+
+ +
+
+ + setForm(f => ({ ...f, plan: v as TenantPlan }))} + > + + + + +
+
+ + setForm(f => ({ ...f, max_mailboxes: Number(e.target.value) }))} + /> +
+
+ + setForm(f => ({ ...f, max_users: Number(e.target.value) }))} + /> +
+
+ +
+

Utente Admin iniziale

+
+
+ + setForm(f => ({ ...f, admin_full_name: e.target.value }))} + placeholder="Mario Rossi" + required + /> +
+
+ + setForm(f => ({ ...f, admin_email: e.target.value }))} + placeholder="admin@acme.it" + required + /> +
+
+ + setForm(f => ({ ...f, admin_password: e.target.value }))} + placeholder="Almeno 8 caratteri" + required + minLength={8} + /> +
+
+
+ + + + + +
+
+
+ ) +} + +// ─── Dialog: Modifica tenant ────────────────────────────────────────────────── + +interface EditDialogProps { + tenant: TenantResponse | null + onClose: () => void + onSaved: () => void +} + +function EditTenantDialog({ tenant, onClose, onSaved }: EditDialogProps) { + const [form, setForm] = useState({ + name: tenant?.name ?? '', + plan: tenant?.plan ?? 'starter', + max_mailboxes: tenant?.max_mailboxes ?? 5, + max_users: tenant?.max_users ?? 10, + }) + + const mutation = useMutation({ + mutationFn: () => tenantsApi.update(tenant!.id, form), + onSuccess: () => { + toast.success('Tenant aggiornato') + onSaved() + onClose() + }, + onError: (err: unknown) => { + const msg = (err as any)?.response?.data?.detail ?? "Errore nell'aggiornamento" + toast.error(typeof msg === 'string' ? msg : "Errore nell'aggiornamento") + }, + }) + + if (!tenant) return null + + return ( + + + + Modifica — {tenant.slug} + + +
{ e.preventDefault(); mutation.mutate() }} + className="space-y-4 py-2" + > +
+ + setForm(f => ({ ...f, name: e.target.value }))} + required + /> +
+ +
+
+ + setForm(f => ({ ...f, plan: v as TenantPlan }))} + > + + + + +
+
+ + setForm(f => ({ ...f, max_mailboxes: Number(e.target.value) }))} + /> +
+
+ + setForm(f => ({ ...f, max_users: Number(e.target.value) }))} + /> +
+
+ + + + + +
+
+
+ ) +} + +// ─── Pagina principale ──────────────────────────────────────────────────────── + +export function MultiTenantPage() { + const { isSuperAdmin, isLoading: authLoading, user, isAuthenticated } = useAuth() + const queryClient = useQueryClient() + + const [createOpen, setCreateOpen] = useState(false) + const [editTenant, setEditTenant] = useState(null) + + const { data: tenants = [], isLoading, refetch } = useQuery({ + queryKey: ['tenants'], + queryFn: () => tenantsApi.list(), + staleTime: 30_000, + enabled: isSuperAdmin, + }) + + const toggleMutation = useMutation({ + mutationFn: (t: TenantResponse) => + t.is_active ? tenantsApi.suspend(t.id) : tenantsApi.activate(t.id), + onSuccess: (updated) => { + toast.success( + updated.is_active + ? `Tenant "${updated.slug}" riattivato` + : `Tenant "${updated.slug}" sospeso` + ) + queryClient.invalidateQueries({ queryKey: ['tenants'] }) + }, + onError: () => toast.error('Errore durante la modifica dello stato'), + }) + + // Aspetta che l'utente sia caricato prima di valutare i permessi + if (authLoading || (isAuthenticated && !user)) { + return ( +
+
+
+ ) + } + + // Guard: solo super_admin + if (!isSuperAdmin) { + return + } + + return ( +
+ {/* ── Intestazione ── */} +
+
+
+ +
+
+

Gestione Multi-Tenant

+

+ {tenants.length} organizzazion{tenants.length !== 1 ? 'i' : 'e'} registrate +

+
+
+
+ + +
+
+ + {/* ── Statistiche aggregate ── */} +
+ +

Totale tenant

+

{tenants.length}

+
+ +

Attivi

+

+ {tenants.filter(t => t.is_active).length} +

+
+ +

Sospesi

+

+ {tenants.filter(t => !t.is_active).length} +

+
+
+ + {/* ── Tabella tenant ── */} + + {isLoading ? ( +
+ Caricamento... +
+ ) : tenants.length === 0 ? ( +
+ +

Nessun tenant registrato

+ +
+ ) : ( +
+ + + + + + + + + + + + + + {tenants.map(tenant => ( + + + + + + + + + + ))} + +
OrganizzazionePianoStatoUtentiCaselleCreato ilAzioni
+

{tenant.name}

+

{tenant.slug}

+
+ + + + +
+ + {tenant.user_count} + / {tenant.max_users} +
+
+
+ + {tenant.mailbox_count} + / {tenant.max_mailboxes} +
+
+ {new Date(tenant.created_at).toLocaleDateString('it-IT', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + })} + +
+ + +
+
+
+ )} +
+ + {/* ── Dialog: crea tenant ── */} + setCreateOpen(false)} + onCreated={() => queryClient.invalidateQueries({ queryKey: ['tenants'] })} + /> + + {/* ── Dialog: modifica tenant ── */} + setEditTenant(null)} + onSaved={() => queryClient.invalidateQueries({ queryKey: ['tenants'] })} + /> +
+ ) +} diff --git a/frontend/src/types/api.types.ts b/frontend/src/types/api.types.ts index ef2b12c..8ba1a63 100644 --- a/frontend/src/types/api.types.ts +++ b/frontend/src/types/api.types.ts @@ -1,3 +1,40 @@ +// ─── Tenant (super-admin) ──────────────────────────────────────────────────── + +export type TenantPlan = 'starter' | 'pro' | 'enterprise' + +export interface TenantResponse { + id: string + slug: string + name: string + plan: TenantPlan + is_active: boolean + max_mailboxes: number + max_users: number + created_at: string + updated_at: string + user_count: number + mailbox_count: number +} + +export interface TenantCreateRequest { + slug: string + name: string + plan?: TenantPlan + max_mailboxes?: number + max_users?: number + admin_email: string + admin_password: string + admin_full_name: string +} + +export interface TenantUpdateRequest { + name?: string + plan?: TenantPlan + is_active?: boolean + max_mailboxes?: number + max_users?: number +} + // ─── Auth ──────────────────────────────────────────────────────────────────── export interface LoginRequest {