[INTEG] Provisioning B2B automatico + fix JWT helpers

- 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 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-03-07 15:02:11 +01:00
parent 29aaf5db88
commit 6933e1d3fb
5 changed files with 384 additions and 8 deletions

View File

@ -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
// ═══════════════════════════════════════════════════════════════════════════

View File

@ -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, '-_', '+/'));
}

View File

@ -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.

View File

@ -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;

View File

@ -298,6 +298,7 @@ $actionMap = [
// ── ServicesController (API pubblica) ──────────
'services' => [
'POST:provision' => 'provision',
'POST:token' => 'token',
'POST:sso' => 'sso',
'GET:status' => 'status',