[INTEG] Token exchange + SSO federato + Audit trail chiamate esterne

ServicesController:
- POST /api/services/token: lg231 invia API key → riceve JWT 15min
- POST /api/services/sso: SSO federato con identità utente + responsabilità
  → crea/trova utente NIS2 + emette JWT 2h con ruolo e responsibilities
- Audit trail: ogni chiamata esterna autenticata loggata (api.external_call)
- SSO login loggato come auth.sso_login severity=warning con responsabilità

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-03-07 14:46:27 +01:00
parent 5ecdce7d47
commit 1f534db33a
2 changed files with 260 additions and 2 deletions

View File

@ -83,9 +83,10 @@ class ServicesController extends BaseController
$this->jsonError('API Key non valida o scaduta', 401, 'INVALID_API_KEY');
}
// Verifica scope
// Verifica scope — read:all è master scope che include tutto
$scopes = json_decode($record['scopes'], true) ?? [];
if (!in_array($scope, $scopes) && !in_array('read:all', $scopes)) {
$hasAll = in_array('read:all', $scopes);
if (!$hasAll && !in_array($scope, $scopes)) {
$this->jsonError("Scope '{$scope}' non autorizzato per questa chiave", 403, 'SCOPE_DENIED');
}
@ -104,6 +105,42 @@ class ServicesController extends BaseController
$this->apiKeyRecord = $record;
$this->currentOrgId = (int) $record['organization_id'];
// ── Audit trail: ogni chiamata esterna autenticata viene registrata ──
$this->logExternalCall($scope);
}
/**
* Registra la chiamata esterna nell'audit trail con AuditService.
* action: api.external_call severity: info (warning se scope sensibile)
*/
private function logExternalCall(string $scope): void
{
$rec = $this->apiKeyRecord;
$endpoint = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
$caller = $_SERVER['HTTP_X_CALLER'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? 'unknown');
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
AuditService::log(
orgId: $this->currentOrgId,
userId: null,
action: 'api.external_call',
entityType: 'api_key',
entityId: (int) $rec['id'],
details: [
'endpoint' => $endpoint,
'method' => $method,
'scope' => $scope,
'caller' => $caller,
'key_name' => $rec['name'] ?? 'n/a',
'key_prefix' => $rec['key_prefix'] ?? '',
'org_name' => $rec['org_name'] ?? '',
],
ipAddress: $_SERVER['REMOTE_ADDR'] ?? '',
userAgent: $caller,
severity: in_array($scope, ['read:incidents', 'read:all'], true) ? 'warning' : 'info',
performedBy: $rec['name'] ?? 'api_key'
);
}
/**
@ -154,6 +191,225 @@ class ServicesController extends BaseController
// ENDPOINT
// ══════════════════════════════════════════════════════════════════════
/**
* POST /api/services/token
*
* Token exchange: lg231 (e altri sistemi) inviano la loro API Key e
* ricevono un JWT temporaneo (TTL 15 min) per le chiamate successive.
* Pattern identico al CertiSource PAT session.
*
* Request: X-API-Key: nis2_xxx (o body JSON {"api_key":"nis2_xxx"})
* Response: {"token":"eyJ...", "expires_in":900, "org_id":5, "scopes":[...]}
*/
public function token(): void
{
$this->requireApiKey('read:all');
$this->setServiceHeaders();
$rec = $this->apiKeyRecord;
$orgId = $this->currentOrgId;
$scopes = json_decode($rec['scopes'], true) ?? ['read:all'];
$ttl = 900; // 15 minuti
// Carica JWT config dal config applicativo
require_once APP_PATH . '/config/config.php';
$secret = defined('JWT_SECRET') ? JWT_SECRET : ($_ENV['JWT_SECRET'] ?? 'changeme');
$issuedAt = time();
$payload = [
'iss' => 'nis2.agile.software',
'sub' => 'api_key:' . $rec['id'],
'org_id' => $orgId,
'key_id' => (int) $rec['id'],
'scopes' => $scopes,
'caller' => $_SERVER['HTTP_X_CALLER'] ?? 'external',
'iat' => $issuedAt,
'exp' => $issuedAt + $ttl,
'type' => 'service_token',
];
// 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));
$jwt = "$header.$body.$signature";
// Audit
AuditService::log(
orgId: $orgId,
userId: null,
action: 'api.token_issued',
entityType: 'api_key',
entityId: (int) $rec['id'],
details: [
'key_name' => $rec['name'] ?? 'n/a',
'caller' => $_SERVER['HTTP_X_CALLER'] ?? 'external',
'ttl' => $ttl,
'scopes' => $scopes,
],
ipAddress: $_SERVER['REMOTE_ADDR'] ?? '',
severity: 'info',
performedBy: $rec['name'] ?? 'api_key'
);
$this->jsonSuccess([
'token' => $jwt,
'token_type' => 'Bearer',
'expires_in' => $ttl,
'expires_at' => date('c', $issuedAt + $ttl),
'org_id' => $orgId,
'org_name' => $rec['org_name'] ?? '',
'scopes' => $scopes,
]);
}
/**
* POST /api/services/sso
*
* Single Sign-On federato: lg231 (o altro sistema Agile) invia
* l'identità del suo utente e NIS2 emette un JWT di sessione valido
* nell'app NIS2 — senza che l'utente debba fare login separato.
*
* Request (X-API-Key + JSON body):
* {
* "user_email": "tizio@azienda.it",
* "user_name": "Mario Rossi", // opzionale
* "user_role": "compliance_manager", // ruolo NIS2 desiderato
* "caller_system": "lg231", // sistema chiamante
* "caller_user_id": 42, // ID utente nel sistema chiamante
* "responsibilities": [ // facoltativo
* {"area": "MOG 231", "scope": "art.24-bis"},
* {"area": "OdV", "scope": "monitoraggio"}
* ]
* }
*
* Response:
* {
* "token": "eyJ...", // JWT NIS2 valido 2h (usa nell'Authorization header)
* "user_id": 12, // ID utente NIS2 (creato se non esiste)
* "org_id": 5,
* "role": "compliance_manager",
* "redirect_url": "https://nis2.agile.software/dashboard.html"
* }
*
* Audit: logga l'accesso SSO con identità completa e responsabilità.
*/
public function sso(): void
{
$this->requireApiKey('sso:login');
$this->setServiceHeaders();
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$email = trim($body['user_email'] ?? '');
$name = trim($body['user_name'] ?? '');
$role = $body['user_role'] ?? 'auditor';
$caller = $body['caller_system'] ?? ($_SERVER['HTTP_X_CALLER'] ?? 'external');
$callerUserId = $body['caller_user_id'] ?? null;
$responsibilities = $body['responsibilities'] ?? [];
if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->jsonError('user_email mancante o non valido', 400, 'INVALID_EMAIL');
}
// Ruoli NIS2 consentiti via SSO (non super_admin)
$allowedRoles = ['compliance_manager', 'auditor', 'board_member', 'employee', 'consultant'];
if (!in_array($role, $allowedRoles, true)) {
$role = 'auditor';
}
$db = Database::getInstance();
$orgId = $this->currentOrgId;
// Trova o crea l'utente NIS2 per questa email
$user = Database::fetchOne('SELECT * FROM users WHERE email = ? LIMIT 1', [$email]);
if (!$user) {
// Crea utente SSO (senza password — accede solo via token)
$parts = explode(' ', $name, 2);
$firstName = $parts[0] ?? $email;
$lastName = $parts[1] ?? '';
Database::execute(
'INSERT INTO users (email, password_hash, first_name, last_name, role, status)
VALUES (?, ?, ?, ?, ?, "active")',
[$email, '', $firstName, $lastName, $role]
);
$userId = (int) Database::lastInsertId();
} else {
$userId = (int) $user['id'];
}
// Assicura membership org
$membership = Database::fetchOne(
'SELECT id FROM user_organizations WHERE user_id = ? AND organization_id = ? LIMIT 1',
[$userId, $orgId]
);
if (!$membership) {
Database::execute(
'INSERT INTO user_organizations (user_id, organization_id, role) VALUES (?, ?, ?)',
[$userId, $orgId, $role]
);
}
// Emetti JWT NIS2 (TTL 2h, stesso formato BaseController)
require_once APP_PATH . '/config/config.php';
$secret = defined('JWT_SECRET') ? JWT_SECRET : ($_ENV['JWT_SECRET'] ?? 'changeme');
$issuedAt = time();
$ttl = 7200; // 2 ore
$payload = [
'iss' => 'nis2.agile.software',
'sub' => $userId,
'org_id' => $orgId,
'role' => $role,
'sso' => true,
'sso_caller' => $caller,
'sso_caller_uid' => $callerUserId,
'responsibilities'=> $responsibilities,
'iat' => $issuedAt,
'exp' => $issuedAt + $ttl,
'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));
$jwt = "$header.$jwtBody.$signature";
// Audit trail — traccia SSO con responsabilità
AuditService::log(
orgId: $orgId,
userId: $userId,
action: 'auth.sso_login',
entityType: 'user',
entityId: $userId,
details: [
'caller_system' => $caller,
'caller_user_id' => $callerUserId,
'role_granted' => $role,
'responsibilities' => $responsibilities,
'email' => $email,
'user_created' => !isset($user),
],
ipAddress: $_SERVER['REMOTE_ADDR'] ?? '',
severity: 'warning', // SSO login è sempre warning per audit
performedBy: $caller . ':' . $email
);
$this->jsonSuccess([
'token' => $jwt,
'token_type' => 'Bearer',
'expires_in' => $ttl,
'expires_at' => date('c', $issuedAt + $ttl),
'user_id' => $userId,
'user_email' => $email,
'org_id' => $orgId,
'role' => $role,
'redirect_url' => 'https://nis2.agile.software/dashboard.html',
'sso_caller' => $caller,
]);
}
/**
* GET /api/services/status
* Health check + info piattaforma. Nessuna auth richiesta.

View File

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