# 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=<da vault-steward>
```

**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 '<real>'`
- **[NIS2]** Bump `public/version.json` → **1.1.0** (decisione §10.5) a switch confermato

Vedi §3-§6 per Fase 2-5.
