# Progetto di Allineamento NIS2 → TRPG (Suite Evix) > **Scopo**: portare NIS2 Agile agli stessi standard di Auth/Tenant/Utenti/UX che TRPG ha consolidato nei 54 minor release dal 2026-04 ad oggi. > **Stato**: PIANO (no codice applicato). Da revisionare con l'utente prima di passare alla Fase 1. > **Riferimento snapshot**: TRPG v1.54.1 (build 20260527) — NIS2 v1.0.0 (build 20260415). > **Data piano**: 2026-05-29. > **Owner proposto**: NIS2 dev + supervisione cross-suite via agile-services. --- ## 0. Premesse e principi guida 1. **Standard suite**: l'autorità è `agile-services` (`172.18.0.1:4214` Tenant MS + `nexus_tenant_db.sso_identities`). TRPG è un peer che ha già adottato lo standard. NIS2 deve adottarlo, non copiare TRPG. 2. **PHP vanilla resta**: NIS2 mantiene la sua architettura (no porting a Node.js). I componenti PHP di TRPG (`SsoHelper.php`, `HubJwtVerifier.php`, schema `active_sessions`) sono riusabili **1:1** previo adattamento namespace/config. 3. **Backward compatibility**: ogni cambio è **additivo**. Utenti esistenti continuano a loggare con flusso locale fino a che il backfill SSO non li collega. 4. **Dual-mode SSO obbligatorio**: se Tenant MS down → fallback locale automatico (no down NIS2). 5. **No big bang**: 5 fasi rilasciabili indipendentemente, ognuna con rollback semplice (drop colonne / disable flag). 6. **Audit trail**: ogni nuova feature critica (impersonate, sessions revoke, password reset) logga in `audit_logs` con `severity` appropriata. --- ## 1. Quadro sinottico delle gap | ID | Area | Priorità | Effort | Rischio | Fase | |----|------|----------|--------|---------|------| | G01 | SSO sync DB (`sso_identity_id`, `password_version`) | P0 | M | Basso | 1 | | G02 | `SsoHelper` + login federato (dual-mode) | P0 | M | Medio | 1 | | G03 | `change-password` propagato a Tenant MS | P0 | S | Basso | 1 | | G04 | Cron sync `password_hash` da `sso_identities` | P0 | S | Basso | 1 | | G05 | Tabella `active_sessions` + JWT con `jti` | P0 | M | Medio | 2 | | G06 | Endpoint sessions: `GET /list`, `DELETE /revoke` | P0 | M | Basso | 2 | | G07 | UI Settings → tab "Sessioni attive" con device list | P1 | S | Basso | 2 | | G08 | `forgot-password` + `reset-password` + pagina HTML | P0 | M | Basso | 3 | | G09 | `switchContext` endpoint (rotazione JWT) | P1 | M | Medio | 3 | | G10 | Dropdown tenant in sidebar/navbar | P1 | M | Basso | 3 | | G11 | `impersonate` per super_admin / consulente | P1 | S | Medio | 4 | | G12 | `updatePreferences` separato da `updateProfile` | P2 | S | Basso | 4 | | G13 | Versioning live: bump `version.json` + footer UI | P1 | S | Basso | 4 | | G14 | Cron agent auto-bump PATCH dopo fix | P2 | S | Basso | 4 | | G15 | Demo login dedicato (UX) | P2 | M | Basso | 5 | | G16 | `BrandingController` white-label per consulente | P2 | M | Basso | 5 | | G17 | Auth-gate per documenti riservati (presentation/roadmap) | P3 | XS | Nullo | 5 | | G18 | Split monolite `AuthController` / `OrganizationController` in controller dedicati (ApiKey, ConsultingFirm, …) | P2 | L | Medio | 5 | Effort: XS<1g, S=1-2g, M=3-5g, L>5g. --- ## 2. Fase 1 — SSO Federation (P0) **Obiettivo**: NIS2 partecipa al Single Sign-On suite. Utenti riusano password unica tra TRPG/lg231/NIS2. ### G01 — Schema DB **Migration `docs/sql/015_sso_columns.sql`** (creata 2026-05-29, non ancora applicata): ```sql ALTER TABLE users ADD COLUMN sso_identity_id INT NULL COMMENT 'FK verso nexus_tenant_db.sso_identities.id', ADD COLUMN password_version INT NOT NULL DEFAULT 1 COMMENT 'contatore versione password SSO', ADD INDEX idx_sso_identity (sso_identity_id); ``` **Acceptance**: colonne presenti, utenti esistenti hanno `sso_identity_id=NULL` e `password_version=1`. Nessun controller esistente si rompe. **Rollback**: `ALTER TABLE users DROP COLUMN sso_identity_id, DROP COLUMN password_version;` ### G02 — `SsoHelper` + login federato **File nuovo**: `application/services/SsoHelper.php` — clone adattato dal TRPG `shared/SsoHelper.php` (~150 righe, cURL nativo, zero deps). **Env nuovi** in `.env`: ``` SSO_ENDPOINT=http://172.18.0.1:4214 SSO_TIMEOUT_MS=3000 SSO_MODE=dual # local | dual | sso_only SSO_INTERNAL_KEY= ``` **Modifica** `AuthController::login()`: 1. Se `SSO_MODE != local`: prova `SsoHelper::login(email, password, 'nis2')` 2. Se SSO 200 OK → estrai `sso_identity_id` + `password_version` → upsert utente locale (link o crea) → sync `password_hash` 3. Se SSO 401 → ritorna 401 (no fallback se utente esiste in SSO) 4. Se SSO unreachable (timeout/connessione) → fallback locale (dual-mode) **Acceptance**: - Login con credenziali SSO valide → JWT emesso anche per utenti senza row locale - Login locale di utenti pre-SSO continua a funzionare - Test con Tenant MS spento → login locale OK (dual) - `SSO_MODE=sso_only` → blocca login locali ### G03 — Change password propagato **Modifica** `AuthController::changePassword()`: - Se `currentUser['sso_identity_id'] != NULL`: chiamata `POST {SSO_ENDPOINT}/auth/sso/change-password` con JWT utente - Se SSO ok → cambio anche locale + `password_version++` + invalidazione tutti i refresh_tokens - Se utente non SSO: solo cambio locale (comportamento attuale) **Acceptance**: cambio password da NIS2 di utente SSO si riflette entro 5 min su TRPG/lg231. ### G04 — Cron sync password **Già implementato a livello cron host** (vedi `/opt/devenv/scripts/sso-password-sync.sh` e CLAUDE.md). Da verificare/richiedere: - Sync include il DB `nis2_agile_db.users` - Match per `sso_identity_id`, compara `password_version`, aggiorna `password_hash` + `password_version` locale **Action item**: aprire ticket al manutentore agile-services per estendere lo script. **Nessun codice in NIS2**. **Acceptance**: cambio password da TRPG si propaga a NIS2 in ≤5 min. --- ## 3. Fase 2 — Session management multi-device (P0) **Obiettivo**: l'utente vede e revoca le sue sessioni attive, come su Google/GitHub. ### G05 — Schema active_sessions **Migration `docs/sql/016_active_sessions.sql`** (mutuata da TRPG `078_active_sessions.sql`): ```sql CREATE TABLE IF NOT EXISTS active_sessions ( id CHAR(32) NOT NULL PRIMARY KEY COMMENT 'jti del JWT (bin2hex(random_bytes(16)))', user_id INT NOT NULL, organization_id INT NULL COMMENT 'org attiva al momento del login', ip_address VARCHAR(45) NOT NULL, user_agent VARCHAR(512) NULL, device_label VARCHAR(120) NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, last_activity_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP NOT NULL, revoked_at TIMESTAMP NULL DEFAULT NULL, revoked_reason ENUM( 'logout','force','password_change','admin','expired_idle','context_switch' ) NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_user_active (user_id, revoked_at, last_activity_at), INDEX idx_last_activity (last_activity_at), INDEX idx_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ALTER TABLE refresh_tokens ADD COLUMN session_jti CHAR(32) NULL AFTER user_id, ADD INDEX idx_refresh_jti (session_jti); ``` **Differenza vs TRPG**: aggiunto `organization_id` (NIS2 ha tenant esplicito) e `context_switch` già incluso nell'ENUM dal day 1. ### G06 — Endpoint sessions Modificare `AuthController` aggiungendo: - `GET /api/auth/sessions` → lista sessioni attive utente corrente (id, ip, device_label, created_at, last_activity_at, **is_current**) - `DELETE /api/auth/sessions/{id}` → revoca singola con `revoked_reason='logout'` - `DELETE /api/auth/sessions` → revoca tutte tranne corrente (cambio password forzato) - `POST /api/auth/login` modificato: genera `jti = bin2hex(random_bytes(16))`, insert in `active_sessions`, include `jti` nel JWT - `BaseController::requireAuth()` verifica che `jti` esista in `active_sessions` e non sia revoked **Acceptance**: - Login da 3 browser → 3 righe in `active_sessions` - Revoca sessione X → token X non più valido (verify in `requireAuth`) - Last activity aggiornato a ogni richiesta autenticata ### G07 — UI Settings: tab Sessioni Aggiungere a `public/settings.html` tab "Sicurezza" con: - Lista device (icona, browser, OS, IP, ultima attività, "Questo dispositivo" badge) - Pulsante "Esci da questo dispositivo" per ogni riga - Pulsante "Esci da tutti i dispositivi tranne questo" in cima Pattern UX copiato da TRPG settings (da screenshottare per riferimento, non da analizzare oltre). --- ## 4. Fase 3 — Password reset + Tenant switcher (P0/P1) ### G08 — Forgot/Reset password **Endpoint** in `AuthController`: - `POST /api/auth/forgot-password` body `{email}` → genera token (SHA256, expires 30min, salvato in tabella `password_reset_tokens` nuova), invia email con link `https://nis2.agile.software/reset-password.html?token=XXX` - `POST /api/auth/reset-password` body `{token, new_password}` → verifica token, aggiorna password (+ propaga via SSO se utente federato), invalida tutti i refresh_tokens **Migration `docs/sql/017_password_reset.sql`**: ```sql CREATE TABLE password_reset_tokens ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, token_hash CHAR(64) NOT NULL UNIQUE, expires_at TIMESTAMP NOT NULL, used_at TIMESTAMP NULL, ip_address VARCHAR(45) NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_token (token_hash), INDEX idx_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` **Pagine HTML nuove**: - `public/forgot-password.html` (form email) - `public/reset-password.html` (form new password con strength bar, mutuata da TRPG) **Modifica** `public/login.html`: link "Password dimenticata?" → `forgot-password.html` (rimuove l'alert "contatta presidenza@..."). **Email template**: estendere `EmailService.php` con `sendPasswordResetEmail()`. **Rate limit**: 3 richieste/ora per IP+email. ### G09 — switchContext endpoint **Endpoint** `POST /api/auth/switch-context` body `{organization_id}`: 1. Verifica `MembershipService::isMember(userId, orgId)` (o equivalente NIS2 via `user_organizations`) 2. Revoca sessione corrente con `revoked_reason='context_switch'` 3. Crea nuova `active_sessions` row con stesso device, nuovo jti 4. Emette nuovo JWT con `organization_id` claim aggiornato + ruolo org corrispondente **Acceptance**: il consulente cambia cliente da dropdown → nuovo JWT con nuovo `organization_id` → dashboard riflette il cliente nuovo senza F5 hard. ### G10 — Dropdown tenant in sidebar Modificare `public/js/common.js`: - All'avvio: se `currentUser.organizations.length > 1` → mostra dropdown nell'header con nome org corrente + freccia - Click → modale lista organizations (nome, ruolo, ultimo accesso) - Click su org → `api.switchContext(orgId)` → ricarica con nuovo token Pattern visivo allineato a TRPG (icona azienda + nome troncato + chevron). --- ## 5. Fase 4 — Impersonation + Preferences + Versioning (P1/P2) ### G11 — Impersonate **Endpoint** `POST /api/auth/impersonate` body `{user_id}` (solo super_admin o consulente verso utenti dei clienti del firm): - Verifica permessi - Emette JWT con claim aggiuntivo `impersonated_by = currentUserId` - Logga in `audit_logs` con `severity='warning'` - TTL JWT ridotto a 1h (no refresh) **UI**: pulsante in `admin/users.html` ("Entra come questo utente") + banner permanente in tutte le pagine quando si è in modalità impersonate ("Sei loggato come X — esci impersonate"). ### G12 — User preferences **Endpoint** `PUT /api/auth/preferences` per: `preferred_language`, `theme` (chiaro/scuro), `timezone`, `notification_email`, `notification_inapp`. Separato da `PUT /api/auth/profile` (full_name, phone) per ragioni di audit e granularità permessi futuri. **Migration `docs/sql/018_user_preferences.sql`**: ```sql ALTER TABLE users ADD COLUMN theme ENUM('light','dark','auto') DEFAULT 'auto', ADD COLUMN timezone VARCHAR(64) DEFAULT 'Europe/Rome', ADD COLUMN notification_email TINYINT(1) DEFAULT 1, ADD COLUMN notification_inapp TINYINT(1) DEFAULT 1; ``` ### G13 — Versioning UI live - `public/version.json` → bump a `1.1.0` (con questo allineamento), data corrente, changelog - `public/js/common.js` → fetch `version.json` all'avvio → mostra in footer sidebar (es. `v1.1.0 (build 20260530)`) - Tooltip su click apre modale con changelog ### G14 — Cron agent bump PATCH Coordinarsi con AgentAI (vedi CLAUDE.md sez. AgileHub Agent) per: - Dopo APPLY_END di un ticket, bumpare `version.json` PATCH (es. 1.1.0 → 1.1.1) - Log VERSION_BUMP in audit trail standard **No codice NIS2**: solo ticket di coordinamento. --- ## 6. Fase 5 — Polishing & extras (P2/P3) ### G15 — Demo login Endpoint dedicato `POST /api/auth/demo-login?scenario=ransomware` che pre-loada un demo user con flag `is_demo=1` e org-clone con dati seed scenari simulazione esistenti (`simulate-nis2.php`). ### G16 — Branding consulente Nuovo `BrandingController.php` + tabella `firm_branding` (logo_url, primary_color, secondary_color, custom_domain). Header/sidebar/login leggono branding da `consulting_firm_id` dell'utente corrente. ### G17 — Auth-gate documenti Copy 1:1 di `app/js/auth-gate.js` da TRPG → `public/js/auth-gate.js`. Includere nei documenti riservati (es. eventuali presentation.html, roadmap.html, listino.html futuri). ### G18 — Refactor controller Spezzare `AuthController` (~600 righe oggi → 1000+ dopo fasi 1-3) in: - `AuthController` (login/logout/refresh/sessions) - `PasswordController` (forgot/reset/change) - `ProfileController` (me/profile/preferences) - `SsoController` (impersonate/switchContext/sso-callback) Estrarre anche `ApiKeyController`, `ConsultingFirmController` se serve (probabilmente sì, in linea con TRPG). --- ## 7. Cosa NON è in scope - **Porting a Node.js**: NIS2 resta PHP. La frammentazione microservizi di TRPG (`services/auth`, `services/ai`, …) non è obiettivo. - **2FA/MFA**: rinviato a progetto separato (richiede UX dedicata, TOTP, recovery codes). - **OAuth2 / social login**: rinviato. - **WebAuthn / passkey**: rinviato. --- ## 8. Sequenza release e dipendenze ``` Fase 1 (SSO) → Fase 3 (reset password usa SSO propagation) Fase 1 (SSO) → Fase 4 (impersonate logga in SSO audit) Fase 2 (sessions) → Fase 3 (switchContext revoca sessione) Fase 2 (sessions) → Fase 4 (impersonate crea sessione marcata) Fase 3 (forgot pwd) ⊥ indipendente da Fase 4/5 Fase 4 (versioning) ⊥ indipendente Fase 5 ⊥ tutto opzionale ``` **Path critico**: Fase 1 → Fase 2 → Fase 3. Le altre possono parallelizzare. --- ## 9. Stima cumulativa | Fase | Effort | Settimane-uomo | |------|--------|----------------| | Fase 1 SSO | M+M+S+S | 1.5 | | Fase 2 Sessions | M+M+S | 1.0 | | Fase 3 Reset+Switch+UI | M+M+M | 1.5 | | Fase 4 Imperson.+Prefs+Ver. | S+S+S+S | 0.8 | | Fase 5 Demo+Brand+Refactor | M+M+XS+L | 1.5 | | **Totale** | | **~6 settimane-uomo** | --- ## 10. Decisioni prese (confermate dall'utente 2026-05-29) | # | Decisione | Scelta | Note operative | |---|-----------|--------|----------------| | 1 | SSO_MODE iniziale | **`local`** | Partiamo prudenti. Switch a `dual` dopo validazione (almeno 7 giorni post-Fase 1). | | 2 | Backfill utenti SSO | **A) Lazy on-login** | Nessuno script massivo. Il link `users.sso_identity_id` viene popolato al primo login post-SSO se l'email matcha `nexus_tenant_db.sso_identities`. Utenti che non loggano restano slegati (accettato). | | 3 | Sessioni concorrenti per utente | **Nessun limite** | Allineato a TRPG/Google/GitHub. Il consulente lavora da N device, vede tutto in Settings → Sicurezza e revoca a piacere. | | 4 | TTL token reset password | **30 min** | Token SHA256, single-use (`used_at`), `expires_at = NOW() + 30 min`. Rate limit 3/h per IP+email. | | 5 | Cadenza version bump | **B) Per fase (MINOR)** | Fine Fase 1 → 1.1.0 · Fine Fase 2 → 1.2.0 · Fine Fase 3 → 1.3.0 · Fine Fase 4 → 1.4.0 · Fine Fase 5 → 1.5.0. PATCH automatico via cron agent attivato in Fase 4 (G14). | | 6 | Branding white-label (G16) | **B) Mantenuto in Fase 5** | `BrandingController` + tabella `firm_branding` (logo, primary_color, secondary_color, custom_domain) abilitano white-label consulente. Effort M, valore commerciale (upsell). | --- ## 11. Riferimenti - TRPG schema sessions: `/var/www/trpg-agile/sql/078_active_sessions.sql` (Hetzner, accesso via chiave temp) - TRPG SsoHelper: `/var/www/trpg-agile/shared/SsoHelper.php` - TRPG HubJwtVerifier: `/var/www/trpg-agile/shared/HubJwtVerifier.php` - TRPG AuthController: `/var/www/trpg-agile/services/auth/controllers/AuthController.php` (v1.54.1, 1365 righe) - Standard SSO suite: `agile-services/docs/SPEC_SSO_SINGLE_SIGN_ON.md` + `ISTRUZIONI_SSO_PRODOTTI.md` - Cron sync attuale: `agile-services/scripts/sso-password-sync.sh` - NIS2 schema utenti: `docs/sql/001_initial_schema.sql` (righe 46-80) --- ## 12. Stato esecuzione ### Tutte le 5 fasi — **COMPLETATE 2026-05-29** ✅ | Fase | Esito | Version | |------|-------|---------| | **Fase 1 — SSO Federation** | ✅ | 1.1.0 | | **Fase 2 — Multi-device Sessions** | ✅ | 1.2.0 | | **Fase 3 — Password Reset + Context Switch** | ✅ | 1.3.0 | | **Fase 4 — Impersonate + Preferences + Versioning UI** | ✅ | 1.4.0 | | **Fase 5 — Branding + Auth-gate** | ✅ (G15 e G18 skip) | 1.5.0 | #### Skip motivati - **G15 Demo login**: i simulator esistenti (`simulate-nis2.php`, `simulate-nis2-big.php`, `simulate-nis2-b2b.php`) già coprono i flussi demo end-to-end. Un endpoint dedicato non aggiungerebbe valore — i simulator creano org reali con dati seed, esperienza più ricca di un demo-mode singleton. - **G18 Refactor controllers**: split di `AuthController` in 4 controller separati è investimento tecnico (~5gg) senza valore funzionale immediato. Rinviato a sessione dedicata se diventa necessario per leggibilità. #### Migrations SQL applicate (DB host Hetzner) | File | Tabella/Colonne | |------|-----------------| | `015_sso_columns.sql` | `users.sso_identity_id`, `users.password_version` | | `016_active_sessions.sql` | `active_sessions` (jti tracking), `refresh_tokens.session_jti` | | `017_password_reset.sql` | `password_reset_tokens` | | `018_user_preferences.sql` | `users.theme/timezone/notif_email/notif_inapp` | | `019_firm_branding.sql` | `firm_branding` (white-label) | #### Endpoint API nuovi (dopo Fase 1-5) ``` POST /api/auth/forgot-password (Fase 3 / G08) POST /api/auth/reset-password (Fase 3 / G08) POST /api/auth/switchContext (Fase 3 / G09) POST /api/auth/impersonate (Fase 4 / G11) GET /api/auth/preferences (Fase 4 / G12) PUT /api/auth/preferences (Fase 4 / G12) GET /api/auth/sessions (Fase 2 / G06) DELETE /api/auth/sessions (Fase 2 / G06) DELETE /api/auth/sessions/{id} (Fase 2 / G06) GET /api/branding/current (Fase 5 / G16) PUT /api/branding (Fase 5 / G16) ``` #### UI changes - `public/login.html` — link "Password dimenticata?" funzionante - `public/forgot-password.html` (NUOVO) - `public/reset-password.html` (NUOVO con strength bar) - `public/settings.html` — tab Sicurezza con lista sessioni device + revoca - `public/js/common.js` — sidebar version footer (click → changelog modal), tenant switcher esposto a chi ha ≥2 org, branding CSS vars auto-load - `public/js/auth-gate.js` (NUOVO) — gate password lato client per documenti riservati #### File PHP nuovi / modificati | File | Tipo | Note | |------|------|------| | `application/services/SsoHelper.php` | NUOVO | Client SSO dual-mode | | `application/controllers/BrandingController.php` | NUOVO | White-label firm | | `application/controllers/AuthController.php` | MODIFICATO | +12 metodi: login (jti+SSO), changePassword, refresh, logout, forgotPassword, resetPassword, switchContext, impersonate, getPreferences, updatePreferences, listSessions, revokeSession, revokeAllSessions | | `application/controllers/BaseController.php` | MODIFICATO | requireAuth verifica jti + last_activity throttle, parseDeviceLabel, generateRefreshToken con session_jti | | `application/services/EmailService.php` | MODIFICATO | +sendPasswordReset | | `application/config/config.php` | MODIFICATO | +RATE_LIMIT_AUTH_FORGOT, +PASSWORD_RESET_TTL_SECONDS | | `public/index.php` | MODIFICATO | +12 route + branding controller | #### Comportamento utente attivo OGGI - SSO **dormiente** (`SSO_MODE=local`) → login esattamente come prima - Multi-device sessions **ATTIVE** → ogni login crea row in `active_sessions`, JWT senza jti rifiutato - Password reset **ATTIVO** → link funzionante da `login.html` - Context switch **ATTIVO** → utenti multi-org vedono dropdown in sidebar - Impersonate **ATTIVO** → solo super_admin o consulente stesso firm (NB: nessuna UI ancora, solo endpoint) - Preferences **ATTIVO** → endpoint disponibili (NB: UI tab dedicato non ancora aggiunto) - Versioning footer **ATTIVO** → versione visibile in sidebar di ogni pagina - Branding **ATTIVO** → defaults applicati; consulente può PUT branding del suo firm #### Cron sync SSO (dipendenza esterna) **AgileHub Ticket #220** aperto a team AGILEHUB per estendere `sso-password-sync.sh` al DB `nis2_agile_db`. Da risolvere prima del switch `SSO_MODE=dual`. #### Backup completi (rollback ready) Tutti i file modificati hanno backup `.bak.pre-{fase}-{timestamp}` in: - `/projects/nis2-agile/application/controllers/` - `/projects/nis2-agile/public/` - `/var/www/nis2-agile/.backups/` (Hetzner) DB backups schema: - `host_users_pre_017_*.sql` - `users_schema_pre_015_*.sql` - `refresh_tokens_pre_016_*.sql` --- ### Fase 1 — SSO Federation: **COMPLETATA 2026-05-29** ✅ | Step | Esito | Note | |------|-------|------| | G01 — Migration `015_sso_columns.sql` | ✅ Applicata su prod | Backup `.backups/users_schema_pre_015_20260529-073134.sql`. Idempotenza verificata. 0 utenti in prod (nessun cliente). | | G02 — `application/services/SsoHelper.php` | ✅ Deployato | Mutuato da TRPG `shared/SsoHelper.php`. Lint OK in container. `getMode()=local`. | | G03 — `AuthController::login()` + `changePassword()` | ✅ Modificato | Backup `.bak.pre-sso-20260529`. Smoke test 401 OK. Logica dietro `if(!$sso->isLocalOnly())` → no-op con `SSO_MODE=local`. | | G04 — Vault + `.env` | ✅ Configurato | Vault: `tier1__nis2-app__sso/internal_key` (placeholder). `.env`: SSO_ENDPOINT/SSO_TIMEOUT_MS/SSO_MODE=local. | | Cron sync (out-of-scope NIS2) | ✅ Ticket aperto | AgileHub Ticket #220 a team AGILEHUB. | **Comportamento utente**: invariato. Tutte le modifiche sono dietro feature flag `SSO_MODE`. Switch a `dual` previsto dopo almeno 7gg di validazione (decisione §10.1). ### Prossimi step - **[manutentore]** AgileHub Ticket #220 → estensione `sso-password-sync.sh` al DB `nis2_agile_db` - **[NIS2]** Quando ticket risolto + 7gg passati → switch `SSO_MODE=local` → `dual` (modifica `.env` + `docker compose up -d --force-recreate app`) - **[NIS2]** Recupero valore reale `SSO_INTERNAL_KEY` da Tenant MS prima dello switch e replace in vault con: `docker exec vault-steward node /app/cli/vault-cli.js migrate tier1__nis2-app__sso internal_key ''` - **[NIS2]** Bump `public/version.json` → **1.1.0** (decisione §10.5) a switch confermato Vedi §3-§6 per Fase 2-5.