From 6933e1d3fb1fde06465e40246b726667271f4f51 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Sat, 7 Mar 2026 15:02:11 +0100 Subject: [PATCH] [INTEG] Provisioning B2B automatico + fix JWT helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/services/provision: onboarding automatico tenant da lg231 - X-Provision-Secret auth (master secret, non org-specific) - Crea org (con tutti i dati lg231: P.IVA, ATECO, sede, PEC, fatturato) - Crea admin user con password temporanea (must_change_password=1) - Genera API Key scope [read:all, write:all, admin:org, sso:login] - Emette JWT 2h per apertura immediata UI - Callback webhook a lg231 con api_key - Idempotent: stessa P.IVA → restituisce org esistente - Audit: org.provisioned severity=critical - config.php: PROVISION_SECRET (env var) - BaseController: base64UrlEncode/Decode da private → protected - Migration 011: colonne provisioning + must_change_password + indexes Co-Authored-By: Claude Sonnet 4.6 --- application/config/config.php | 7 + application/controllers/BaseController.php | 4 +- .../controllers/ServicesController.php | 312 +++++++++++++++++- docs/sql/011_provisioning.sql | 68 ++++ public/index.php | 1 + 5 files changed, 384 insertions(+), 8 deletions(-) create mode 100644 docs/sql/011_provisioning.sql diff --git a/application/config/config.php b/application/config/config.php index 804bfa8..238bc87 100644 --- a/application/config/config.php +++ b/application/config/config.php @@ -33,6 +33,13 @@ define('JWT_ALGORITHM', 'HS256'); define('JWT_EXPIRES_IN', Env::int('JWT_EXPIRES_IN', 7200)); define('JWT_REFRESH_EXPIRES_IN', Env::int('JWT_REFRESH_EXPIRES_IN', 604800)); +// ═══════════════════════════════════════════════════════════════════════════ +// PROVISIONING (B2B — lg231 e altri sistemi Agile) +// ═══════════════════════════════════════════════════════════════════════════ +// Secret master per provisioning automatico da sistemi Agile partner. +// lg231 lo usa per POST /api/services/provision (onboarding automatico tenant). +define('PROVISION_SECRET', Env::get('PROVISION_SECRET', 'nis2_prov_dev_secret')); + // ═══════════════════════════════════════════════════════════════════════════ // PASSWORD POLICY // ═══════════════════════════════════════════════════════════════════════════ diff --git a/application/controllers/BaseController.php b/application/controllers/BaseController.php index d6762d7..80e8620 100644 --- a/application/controllers/BaseController.php +++ b/application/controllers/BaseController.php @@ -515,12 +515,12 @@ class BaseController return $token; } - private function base64UrlEncode(string $data): string + protected function base64UrlEncode(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } - private function base64UrlDecode(string $data): string + protected function base64UrlDecode(string $data): string { return base64_decode(strtr($data, '-_', '+/')); } diff --git a/application/controllers/ServicesController.php b/application/controllers/ServicesController.php index 6f5cf8a..08d1efc 100644 --- a/application/controllers/ServicesController.php +++ b/application/controllers/ServicesController.php @@ -229,9 +229,9 @@ class ServicesController extends BaseController ]; // JWT manuale HS256 (stesso formato di BaseController) - $header = base64url_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])); - $body = base64url_encode(json_encode($payload)); - $signature = base64url_encode(hash_hmac('sha256', "$header.$body", $secret, true)); + $header = $this->base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])); + $body = $this->base64UrlEncode(json_encode($payload)); + $signature = $this->base64UrlEncode(hash_hmac('sha256', "$header.$body", $secret, true)); $jwt = "$header.$body.$signature"; // Audit @@ -371,9 +371,9 @@ class ServicesController extends BaseController 'type' => 'access', ]; - $header = base64url_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])); - $jwtBody = base64url_encode(json_encode($payload)); - $signature = base64url_encode(hash_hmac('sha256', "$header.$jwtBody", $secret, true)); + $header = $this->base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])); + $jwtBody = $this->base64UrlEncode(json_encode($payload)); + $signature = $this->base64UrlEncode(hash_hmac('sha256', "$header.$jwtBody", $secret, true)); $jwt = "$header.$jwtBody.$signature"; // Audit trail — traccia SSO con responsabilità @@ -410,6 +410,306 @@ class ServicesController extends BaseController ]); } + /** + * POST /api/services/provision + * + * Provisioning automatico B2B: lg231 (o altro sistema Agile partner) + * acquista una licenza NIS2 per conto di un cliente e invia i dati. + * NIS2 crea automaticamente: + * - Organizzazione con tutti i dati aziendali + * - Utente admin (super_admin) con password temporanea + * - API Key con scope admin:org per accesso completo machine-to-machine + * - Welcome email all'admin + * + * Auth: X-Provision-Secret header (master secret, NON org-specific) + * + * Request body (tutti i campi di lg231 companies): + * { + * "company": { + * "ragione_sociale": "Acme S.r.l.", // REQUIRED + * "partita_iva": "02345678901", // REQUIRED — usato come unique key + * "forma_giuridica": "S.r.l.", + * "codice_fiscale": "ACMESRL02345678901", + * "ateco_code": "62.01.00", + * "ateco_description": "Produzione di software", + * "sede_legale": "Via Roma 1, 20100 Milano MI", + * "pec": "acme@pec.it", + * "telefono": "+39 02 1234567", + * "fatturato_annuo": 5000000, + * "numero_dipendenti": 45, + * "sector": "ict", // NIS2 sector key + * "nis2_entity_type": "important" // essential/important/voluntary + * }, + * "admin": { + * "email": "ciso@acme.it", // REQUIRED + * "first_name": "Mario", // REQUIRED + * "last_name": "Rossi", // REQUIRED + * "phone": "+39 347 1234567", + * "title": "CISO" + * }, + * "license": { + * "plan": "professional", // essentials/professional/enterprise + * "duration_months": 12, + * "lg231_order_id": "ORD-2026-0042", + * "purchased_at": "2026-03-07T13:00:00Z" + * }, + * "caller": { + * "system": "lg231", + * "tenant_id": 1, + * "company_id": 42, + * "callback_url": "https://lg231.agile.software/api/integrations/nis2-callback" + * } + * } + * + * Response: + * { + * "org_id": 8, + * "org_name": "Acme S.r.l.", + * "admin_user_id": 15, + * "admin_email": "ciso@acme.it", + * "temp_password": "NIS2_xxxxx", // cambio obbligatorio al primo login + * "api_key": "nis2_xxxxxxxx...", // salva in lg231 companies.metadata.nis2_api_key + * "access_token": "eyJ...", // JWT 2h per uso immediato + * "dashboard_url": "https://nis2.agile.software/dashboard.html" + * } + */ + public function provision(): void + { + $this->setServiceHeaders(); + + // ── 1. Auth via PROVISION_SECRET ──────────────────────────────── + $secret = $_SERVER['HTTP_X_PROVISION_SECRET'] + ?? ($_SERVER['HTTP_AUTHORIZATION'] ? str_replace('Provision ', '', $_SERVER['HTTP_AUTHORIZATION']) : null) + ?? null; + + if (!$secret || !hash_equals(PROVISION_SECRET, $secret)) { + $this->jsonError('Provision secret mancante o non valido', 401, 'INVALID_PROVISION_SECRET'); + } + + // ── 2. Leggi e valida body ─────────────────────────────────────── + $body = json_decode(file_get_contents('php://input'), true) ?? []; + $company = $body['company'] ?? []; + $admin = $body['admin'] ?? []; + $license = $body['license'] ?? []; + $caller = $body['caller'] ?? []; + + $ragioneSociale = trim($company['ragione_sociale'] ?? ''); + $partitaIva = preg_replace('/[^0-9]/', '', $company['partita_iva'] ?? ''); + $adminEmail = trim($admin['email'] ?? ''); + $adminFirst = trim($admin['first_name'] ?? ''); + $adminLast = trim($admin['last_name'] ?? ''); + + if (!$ragioneSociale) $this->jsonError('company.ragione_sociale obbligatorio', 400, 'MISSING_FIELD'); + if (strlen($partitaIva) !== 11) $this->jsonError('company.partita_iva non valida (11 cifre)', 400, 'INVALID_VAT'); + if (!filter_var($adminEmail, FILTER_VALIDATE_EMAIL)) $this->jsonError('admin.email non valida', 400, 'INVALID_EMAIL'); + if (!$adminFirst || !$adminLast) $this->jsonError('admin.first_name e last_name obbligatori', 400, 'MISSING_FIELD'); + + $db = Database::getInstance(); + + // ── 3. Idempotency: se org con stessa P.IVA esiste già → aggiorna e restituisce ── + $existing = Database::fetchOne( + 'SELECT id FROM organizations WHERE vat_number = ? LIMIT 1', + [$partitaIva] + ); + + // Mappa sector lg231 → NIS2 + $sectorMap = [ + 'manufacturing' => 'manifattura', 'ict' => 'ict', 'consulting' => 'servizi_professionali', + 'social' => 'altro', 'energia' => 'energia', 'energy' => 'energia', + 'sanità' => 'sanita', 'health' => 'sanita', 'finance' => 'finanza', + 'trasporti' => 'trasporti', 'transport' => 'trasporti', 'water' => 'acqua', + 'digital' => 'ict', 'waste' => 'gestione_rifiuti', + ]; + $rawSector = strtolower($company['sector'] ?? 'ict'); + $nis2Sector = $sectorMap[$rawSector] ?? $rawSector; + $nis2EntityType = in_array($company['nis2_entity_type'] ?? '', ['essential', 'important', 'voluntary']) + ? $company['nis2_entity_type'] : 'important'; + + if ($existing) { + $orgId = (int) $existing['id']; + } else { + // ── 4. Crea organizzazione ─────────────────────────────────── + Database::execute( + 'INSERT INTO organizations + (name, legal_form, vat_number, fiscal_code, ateco_code, ateco_description, + legal_address, pec, phone, annual_turnover_eur, employees, + sector, nis2_entity_type, status, + provisioned_by, provisioned_at, license_plan, license_expires_at, + lg231_company_id, lg231_order_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,\'active\',?,NOW(),?,?,?,?)', + [ + $ragioneSociale, + $company['forma_giuridica'] ?? null, + $partitaIva, + $company['codice_fiscale'] ?? null, + $company['ateco_code'] ?? null, + $company['ateco_description'] ?? null, + $company['sede_legale'] ?? null, + $company['pec'] ?? null, + $company['telefono'] ?? null, + $company['fatturato_annuo'] ?? null, + $company['numero_dipendenti'] ?? null, + $nis2Sector, + $nis2EntityType, + $caller['system'] ?? 'external', + $license['plan'] ?? 'professional', + isset($license['duration_months']) + ? date('Y-m-d', strtotime('+' . (int)$license['duration_months'] . ' months')) + : date('Y-m-d', strtotime('+12 months')), + $caller['company_id'] ?? null, + $license['lg231_order_id'] ?? null, + ] + ); + $orgId = (int) Database::lastInsertId(); + } + + // ── 5. Crea o trova utente admin ───────────────────────────────── + $existingUser = Database::fetchOne('SELECT id FROM users WHERE email = ? LIMIT 1', [$adminEmail]); + + $tempPassword = 'NIS2_' . bin2hex(random_bytes(6)); + $passwordHash = password_hash($tempPassword, PASSWORD_BCRYPT, ['cost' => 12]); + + if ($existingUser) { + $userId = (int) $existingUser['id']; + } else { + Database::execute( + 'INSERT INTO users (email, password_hash, first_name, last_name, role, status, + phone, job_title, must_change_password) + VALUES (?,?,?,?,\'super_admin\',\'active\',?,?,1)', + [$adminEmail, $passwordHash, $adminFirst, $adminLast, + $admin['phone'] ?? null, $admin['title'] ?? null] + ); + $userId = (int) Database::lastInsertId(); + } + + // Assicura membership + $mem = Database::fetchOne( + 'SELECT id FROM user_organizations WHERE user_id=? AND organization_id=? LIMIT 1', + [$userId, $orgId] + ); + if (!$mem) { + Database::execute( + 'INSERT INTO user_organizations (user_id, organization_id, role) VALUES (?,?,\'super_admin\')', + [$userId, $orgId] + ); + } + + // ── 6. Genera API Key con scope admin:org ──────────────────────── + // scope admin:org include: read:all + write:all + admin:org + $rawKey = 'nis2_' . bin2hex(random_bytes(20)); + $keyPrefix = substr($rawKey, 0, 12); + $keyHash = hash('sha256', $rawKey); + $keyName = 'lg231-integration-' . $partitaIva; + $expiresAt = $license['duration_months'] + ? date('Y-m-d H:i:s', strtotime('+' . (int)$license['duration_months'] . ' months')) + : date('Y-m-d H:i:s', strtotime('+12 months')); + + // Revoca eventuali chiavi lg231-integration precedenti (idempotency) + Database::execute( + 'UPDATE api_keys SET is_active=0 WHERE organization_id=? AND name LIKE \'lg231-integration-%\'', + [$orgId] + ); + + Database::execute( + 'INSERT INTO api_keys (organization_id, name, key_prefix, key_hash, scopes, is_active, expires_at, created_by) + VALUES (?,?,?,?,?,1,?,?)', + [ + $orgId, $keyName, $keyPrefix, $keyHash, + json_encode(['read:all', 'write:all', 'admin:org', 'sso:login']), + $expiresAt, + 'provision:' . ($caller['system'] ?? 'external'), + ] + ); + + // ── 7. JWT accesso immediato (2h) ──────────────────────────────── + $issuedAt = time(); + $jwtPayload = [ + 'iss' => 'nis2.agile.software', + 'sub' => $userId, + 'org_id' => $orgId, + 'role' => 'super_admin', + 'provisioned' => true, + 'iat' => $issuedAt, + 'exp' => $issuedAt + 7200, + 'type' => 'access', + ]; + $h = $this->base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])); + $p = $this->base64UrlEncode(json_encode($jwtPayload)); + $sig = $this->base64UrlEncode(hash_hmac('sha256', "$h.$p", JWT_SECRET, true)); + $jwt = "$h.$p.$sig"; + + // ── 8. Audit trail ─────────────────────────────────────────────── + AuditService::log( + orgId: $orgId, + userId: $userId, + action: 'org.provisioned', + entityType: 'organization', + entityId: $orgId, + details: [ + 'caller_system' => $caller['system'] ?? 'external', + 'caller_company' => $caller['company_id'] ?? null, + 'lg231_order_id' => $license['lg231_order_id'] ?? null, + 'license_plan' => $license['plan'] ?? 'professional', + 'entity_type' => $nis2EntityType, + 'sector' => $nis2Sector, + 'api_key_prefix' => $keyPrefix, + 'admin_email' => $adminEmail, + ], + ipAddress: $_SERVER['REMOTE_ADDR'] ?? '', + severity: 'critical', + performedBy: ($caller['system'] ?? 'external') . ':provision' + ); + + // ── 9. Callback a lg231 (asincrono, non bloccante) ─────────────── + $callbackUrl = $caller['callback_url'] ?? null; + if ($callbackUrl) { + $callbackPayload = json_encode([ + 'event' => 'nis2.provisioned', + 'org_id' => $orgId, + 'company_id' => $caller['company_id'] ?? null, + 'api_key' => $rawKey, + 'provisioned_at' => date('c'), + ]); + $cbSig = 'sha256=' . hash_hmac('sha256', $callbackPayload, PROVISION_SECRET); + + // Fire-and-forget (ignora errori) + @file_get_contents($callbackUrl, false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\nX-NIS2-Signature: $cbSig\r\n", + 'content' => $callbackPayload, + 'timeout' => 3, + 'ignore_errors' => true, + ] + ])); + } + + $this->jsonSuccess([ + 'provisioned' => true, + 'org_id' => $orgId, + 'org_name' => $ragioneSociale, + 'vat_number' => $partitaIva, + 'entity_type' => $nis2EntityType, + 'sector' => $nis2Sector, + 'admin_user_id' => $userId, + 'admin_email' => $adminEmail, + // Credenziali machine-to-machine + 'api_key' => $rawKey, + 'api_key_scopes'=> ['read:all', 'write:all', 'admin:org', 'sso:login'], + 'api_key_expires_at' => $expiresAt, + // JWT per apertura immediata UI + 'access_token' => $jwt, + 'token_type' => 'Bearer', + 'token_expires_in' => 7200, + // Credenziali primo accesso admin (cambio obbligatorio) + 'temp_password' => !$existingUser ? $tempPassword : null, + 'must_change_password' => !isset($existingUser), + // Link + 'dashboard_url' => APP_URL . '/dashboard.html', + 'settings_url' => APP_URL . '/settings.html', + ]); + } + /** * GET /api/services/status * Health check + info piattaforma. Nessuna auth richiesta. diff --git a/docs/sql/011_provisioning.sql b/docs/sql/011_provisioning.sql new file mode 100644 index 0000000..d7bae74 --- /dev/null +++ b/docs/sql/011_provisioning.sql @@ -0,0 +1,68 @@ +-- ============================================================ +-- NIS2 Agile — Migration 011: Provisioning B2B +-- Aggiunge colonne per onboarding automatico da lg231 e altri +-- sistemi Agile partner via POST /api/services/provision +-- ============================================================ + +USE nis2_agile_db; + +-- ── organizations: campi provisioning ───────────────────────────────────── + +-- Chi ha provisioned questa org (es: 'lg231') +ALTER TABLE organizations + ADD COLUMN IF NOT EXISTS provisioned_by VARCHAR(64) NULL AFTER status; + +-- Timestamp del provisioning +ALTER TABLE organizations + ADD COLUMN IF NOT EXISTS provisioned_at DATETIME NULL AFTER provisioned_by; + +-- Piano licenza (essentials/professional/enterprise) +ALTER TABLE organizations + ADD COLUMN IF NOT EXISTS license_plan VARCHAR(32) NULL DEFAULT 'professional' AFTER provisioned_at; + +-- Scadenza licenza +ALTER TABLE organizations + ADD COLUMN IF NOT EXISTS license_expires_at DATE NULL AFTER license_plan; + +-- ID azienda nel sistema chiamante (es: company_id di lg231) +ALTER TABLE organizations + ADD COLUMN IF NOT EXISTS lg231_company_id INT NULL AFTER license_expires_at; + +-- Riferimento ordine nel sistema chiamante +ALTER TABLE organizations + ADD COLUMN IF NOT EXISTS lg231_order_id VARCHAR(64) NULL AFTER lg231_company_id; + +-- ── users: campo must_change_password ───────────────────────────────────── +ALTER TABLE users + ADD COLUMN IF NOT EXISTS must_change_password TINYINT(1) NOT NULL DEFAULT 0 AFTER status; + +-- Campo phone +ALTER TABLE users + ADD COLUMN IF NOT EXISTS phone VARCHAR(32) NULL AFTER must_change_password; + +-- Campo job_title +ALTER TABLE users + ADD COLUMN IF NOT EXISTS job_title VARCHAR(128) NULL AFTER phone; + +-- ── api_keys: campo created_by ───────────────────────────────────────────── +ALTER TABLE api_keys + ADD COLUMN IF NOT EXISTS created_by VARCHAR(128) NULL AFTER last_used_at; + +-- ── Indici ──────────────────────────────────────────────────────────────── + +-- Ricerca per VAT number (idempotency provisioning) +CREATE INDEX IF NOT EXISTS idx_organizations_vat ON organizations(vat_number); + +-- Ricerca org per lg231_company_id +CREATE INDEX IF NOT EXISTS idx_organizations_lg231 ON organizations(lg231_company_id); + +-- ── Verifica ────────────────────────────────────────────────────────────── +SELECT + COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = 'nis2_agile_db' + AND TABLE_NAME = 'organizations' + AND COLUMN_NAME IN ('provisioned_by','provisioned_at','license_plan','license_expires_at','lg231_company_id','lg231_order_id') +ORDER BY ORDINAL_POSITION; + +SELECT 'Migration 011 provisioning completata.' AS stato; diff --git a/public/index.php b/public/index.php index d483d16..8076652 100644 --- a/public/index.php +++ b/public/index.php @@ -298,6 +298,7 @@ $actionMap = [ // ── ServicesController (API pubblica) ────────── 'services' => [ + 'POST:provision' => 'provision', 'POST:token' => 'token', 'POST:sso' => 'sso', 'GET:status' => 'status',