emilsoftware-utilities 2.0.0-dev.77 → 2.0.0-dev.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/accessi/client/generated/admin.d.ts +21 -1
- package/dist/accessi/client/generated/admin.js +35 -1
- package/dist/accessi/client/generated/admin.js.map +1 -1
- package/dist/accessi/resource-server/token.js +2 -0
- package/dist/accessi/resource-server/token.js.map +1 -1
- package/dist/accessi/server/standalone.js +2 -1
- package/dist/accessi/server/standalone.js.map +1 -1
- package/dist/accessi-module/AccessiModule.d.ts +6 -0
- package/dist/accessi-module/AccessiModule.js +4 -0
- package/dist/accessi-module/AccessiModule.js.map +1 -1
- package/dist/accessi-module/Console/console.js +2 -0
- package/dist/accessi-module/Console/generated/emilsoftware-utilities.d.ts +21 -1
- package/dist/accessi-module/Console/generated/emilsoftware-utilities.js +35 -1
- package/dist/accessi-module/Console/generated/emilsoftware-utilities.js.map +1 -1
- package/dist/accessi-module/Console/generated/model/exportAuditOrder.d.ts +11 -0
- package/dist/accessi-module/Console/generated/model/exportAuditOrder.js +15 -0
- package/dist/accessi-module/Console/generated/model/exportAuditOrder.js.map +1 -0
- package/dist/accessi-module/Console/generated/model/exportAuditParams.d.ts +37 -0
- package/dist/accessi-module/Console/generated/model/exportAuditParams.js +3 -0
- package/dist/accessi-module/Console/generated/model/exportAuditParams.js.map +1 -0
- package/dist/accessi-module/Console/generated/model/index.d.ts +4 -0
- package/dist/accessi-module/Console/generated/model/index.js +4 -0
- package/dist/accessi-module/Console/generated/model/index.js.map +1 -1
- package/dist/accessi-module/Console/generated/model/queryAuditOrder.d.ts +11 -0
- package/dist/accessi-module/Console/generated/model/queryAuditOrder.js +15 -0
- package/dist/accessi-module/Console/generated/model/queryAuditOrder.js.map +1 -0
- package/dist/accessi-module/Console/generated/model/queryAuditParams.d.ts +37 -0
- package/dist/accessi-module/Console/generated/model/queryAuditParams.js +3 -0
- package/dist/accessi-module/Console/generated/model/queryAuditParams.js.map +1 -0
- package/dist/accessi-module/Console/wiki.js +2 -0
- package/dist/accessi-module/Console/wiki.js.map +1 -1
- package/dist/accessi-module/Controllers/AccessiAuditController.d.ts +16 -0
- package/dist/accessi-module/Controllers/AccessiAuditController.js +155 -0
- package/dist/accessi-module/Controllers/AccessiAuditController.js.map +1 -0
- package/dist/accessi-module/Controllers/AccessiIntrospectionController.js +9 -0
- package/dist/accessi-module/Controllers/AccessiIntrospectionController.js.map +1 -1
- package/dist/accessi-module/Controllers/AuthController.js +82 -8
- package/dist/accessi-module/Controllers/AuthController.js.map +1 -1
- package/dist/accessi-module/Controllers/ConsoleEntryController.js +15 -0
- package/dist/accessi-module/Controllers/ConsoleEntryController.js.map +1 -1
- package/dist/accessi-module/Dtos/AuditDtos.d.ts +10 -0
- package/dist/accessi-module/Dtos/AuditDtos.js +68 -0
- package/dist/accessi-module/Dtos/AuditDtos.js.map +1 -0
- package/dist/accessi-module/Dtos/index.d.ts +1 -0
- package/dist/accessi-module/Dtos/index.js +1 -0
- package/dist/accessi-module/Dtos/index.js.map +1 -1
- package/dist/accessi-module/Services/AuthService/AuthService.js +8 -2
- package/dist/accessi-module/Services/AuthService/AuthService.js.map +1 -1
- package/dist/accessi-module/index.d.ts +9 -1
- package/dist/accessi-module/index.js +27 -2
- package/dist/accessi-module/index.js.map +1 -1
- package/dist/accessi-module/observability/metrics.d.ts +79 -0
- package/dist/accessi-module/observability/metrics.js +216 -0
- package/dist/accessi-module/observability/metrics.js.map +1 -0
- package/dist/accessi-module/observability/redisMetrics.d.ts +12 -0
- package/dist/accessi-module/observability/redisMetrics.js +44 -0
- package/dist/accessi-module/observability/redisMetrics.js.map +1 -0
- package/dist/accessi-module/security/accessTokenRevocation.js +5 -3
- package/dist/accessi-module/security/accessTokenRevocation.js.map +1 -1
- package/dist/accessi-module/security/audit.d.ts +68 -3
- package/dist/accessi-module/security/audit.js +177 -4
- package/dist/accessi-module/security/audit.js.map +1 -1
- package/dist/accessi-module/security/auditSinks.d.ts +55 -0
- package/dist/accessi-module/security/auditSinks.js +228 -0
- package/dist/accessi-module/security/auditSinks.js.map +1 -0
- package/dist/accessi-module/security/consoleEntry.js +5 -3
- package/dist/accessi-module/security/consoleEntry.js.map +1 -1
- package/dist/accessi-module/security/edgeRateLimit.js +3 -0
- package/dist/accessi-module/security/edgeRateLimit.js.map +1 -1
- package/dist/accessi-module/security/jwks.js +3 -0
- package/dist/accessi-module/security/jwks.js.map +1 -1
- package/dist/accessi-module/security/jwtTokenOptions.js +4 -0
- package/dist/accessi-module/security/jwtTokenOptions.js.map +1 -1
- package/dist/accessi-module/security/publicAuthRateLimit.js +10 -0
- package/dist/accessi-module/security/publicAuthRateLimit.js.map +1 -1
- package/dist/accessi-module/security/rateLimitStore.js +4 -2
- package/dist/accessi-module/security/rateLimitStore.js.map +1 -1
- package/dist/accessi-module/security/refreshTokenRotation.js +7 -5
- package/dist/accessi-module/security/refreshTokenRotation.js.map +1 -1
- package/dist/accessi-module/security/serviceTokenRateLimit.js +3 -0
- package/dist/accessi-module/security/serviceTokenRateLimit.js.map +1 -1
- package/dist/accessi-module/security/sessionRevocation.js +7 -3
- package/dist/accessi-module/security/sessionRevocation.js.map +1 -1
- package/dist/emilsoftware-utilities-2.0.0-dev.79.tgz +0 -0
- package/docs/audit.md +136 -0
- package/docs/metrics.md +153 -0
- package/docs/redis-load-testing.md +88 -0
- package/package.json +4 -3
- package/dist/emilsoftware-utilities-2.0.0-dev.77.tgz +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serviceTokenRateLimit.js","sourceRoot":"","sources":["../../../src/accessi-module/security/serviceTokenRateLimit.ts"],"names":[],"mappings":";;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"serviceTokenRateLimit.js","sourceRoot":"","sources":["../../../src/accessi-module/security/serviceTokenRateLimit.ts"],"names":[],"mappings":";;;;;;;;;;;AAeA,gEAiBC;AAGD,gEAEC;AAGD,kEAYC;AApDD,2CAA2D;AAC3D,qDAA4D;AAE5D,sDAA+D;AAE/D;;;;;;GAMG;AACH,MAAM,SAAS,GAAG,KAAM,CAAC;AACzB,MAAM,YAAY,GAAG,GAAG,CAAC;AAEzB,SAAsB,0BAA0B;yDAC9C,UAAkB,EAClB,QAAwB,IAAA,yCAAwB,GAAE;QAElD,MAAM,GAAG,GAAG,UAAU,IAAI,UAAU,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;QACnF,IAAI,CAAC;YACH,OAAO,MAAM,KAAK,CAAC,OAAO,CAAC,iBAAiB,GAAG,EAAE,EAAE,SAAS,EAAE,YAAY,CAAC,CAAC;QAC9E,CAAC;QAAC,WAAM,CAAC;YACP,IAAA,6BAAmB,EAAC,4BAA4B,EAAE,CAAC,EAAE,EAAE,KAAK,EAAE,0BAA0B,EAAE,CAAC,CAAC;YAC5F,MAAM,IAAI,sBAAa,CACrB;gBACE,IAAI,EAAE,gCAAgC;gBACtC,OAAO,EAAE,gDAAgD;aAC1D,EACD,mBAAU,CAAC,mBAAmB,CAC/B,CAAC;QACJ,CAAC;IACH,CAAC;CAAA;AAED,+DAA+D;AAC/D,SAAgB,0BAA0B;IACxC,IAAA,yCAAwB,GAAE,CAAC,KAAK,EAAE,CAAC;AACrC,CAAC;AAED,gEAAgE;AAChE,SAAsB,2BAA2B,CAC/C,UAAkB,EAClB,KAAsB;;QAEtB,MAAM,QAAQ,GAAG,MAAM,0BAA0B,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;QACrE,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC;YACtB,IAAA,6BAAmB,EAAC,kCAAkC,EAAE,CAAC,EAAE,EAAE,KAAK,EAAE,eAAe,EAAE,CAAC,CAAC;YACvF,MAAM,IAAI,sBAAa,CACrB,EAAE,IAAI,EAAE,oCAAoC,EAAE,OAAO,EAAE,sCAAsC,EAAE,EAC/F,mBAAU,CAAC,iBAAiB,CAC7B,CAAC;QACJ,CAAC;IACH,CAAC;CAAA","sourcesContent":["import { HttpException, HttpStatus } from '@nestjs/common';\nimport { getDefaultRateLimitStore } from './rateLimitStore';\nimport type { RateLimitStore } from './rateLimitStore';\nimport { recordAccessiMetric } from '../observability/metrics';\n\n/**\n * Limite anti-abuso per la verifica dei token di servizio.\n *\n * Il segreto ha 256 bit di entropia (brute force non praticabile), ma il limite evita\n * che un client difettoso o malevolo martelli l'endpoint. Con `serviceTokens.rateLimitStore`\n * e possibile usare uno store condiviso in cluster.\n */\nconst WINDOW_MS = 60_000;\nconst MAX_ATTEMPTS = 120;\n\nexport async function checkServiceTokenRateLimit(\n identifier: string,\n store: RateLimitStore = getDefaultRateLimitStore(),\n): Promise<{ allowed: boolean; retryAfterSeconds: number }> {\n const key = identifier && identifier.trim() !== '' ? identifier.trim() : 'unknown';\n try {\n return await store.consume(`service-token|${key}`, WINDOW_MS, MAX_ATTEMPTS);\n } catch {\n recordAccessiMetric('accessi_store_errors_total', 1, { store: 'service_token_rate_limit' });\n throw new HttpException(\n {\n code: 'ACCESSI_RATE_LIMIT_UNAVAILABLE',\n message: 'Rate limit non disponibile. Riprova piu tardi.',\n },\n HttpStatus.SERVICE_UNAVAILABLE,\n );\n }\n}\n\n/** Svuota i bucket dello store di default (usato nei test). */\nexport function resetServiceTokenRateLimit(): void {\n getDefaultRateLimitStore().reset();\n}\n\n/** Lancia 429 con `Retry-After` quando il limite e superato. */\nexport async function assertServiceTokenRateLimit(\n identifier: string,\n store?: RateLimitStore,\n): Promise<void> {\n const decision = await checkServiceTokenRateLimit(identifier, store);\n if (!decision.allowed) {\n recordAccessiMetric('accessi_rate_limit_blocked_total', 1, { layer: 'service_token' });\n throw new HttpException(\n { code: 'ACCESSI_SERVICE_TOKEN_RATE_LIMITED', message: 'Troppi tentativi. Riprova piu tardi.' },\n HttpStatus.TOO_MANY_REQUESTS,\n );\n }\n}\n"]}
|
|
@@ -17,7 +17,9 @@ exports.isAccessiTokenRevoked = isAccessiTokenRevoked;
|
|
|
17
17
|
exports.revokeAccessiUserSessions = revokeAccessiUserSessions;
|
|
18
18
|
exports.revokeAccessiUserSessionsIfConfigured = revokeAccessiUserSessionsIfConfigured;
|
|
19
19
|
exports.clearDefaultSessionRevocations = clearDefaultSessionRevocations;
|
|
20
|
+
const redisMetrics_1 = require("../observability/redisMetrics");
|
|
20
21
|
const audit_1 = require("./audit");
|
|
22
|
+
const metrics_1 = require("../observability/metrics");
|
|
21
23
|
const accessTokenRevocation_1 = require("./accessTokenRevocation");
|
|
22
24
|
const refreshTokenRotation_1 = require("./refreshTokenRotation");
|
|
23
25
|
const REVOKE_USER_SCRIPT = "if tonumber(ARGV[2]) > 0 then return redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2]) else return redis.call('SET', KEYS[1], ARGV[1]) end";
|
|
@@ -59,15 +61,16 @@ function createRedisSessionRevocationStore(options) {
|
|
|
59
61
|
}
|
|
60
62
|
const keyPrefix = ((_a = options.keyPrefix) !== null && _a !== void 0 ? _a : 'accessi:revoked-user:').replace(/:+$/, ':');
|
|
61
63
|
const ttlMs = Math.max(0, Math.trunc((_b = options.ttlMs) !== null && _b !== void 0 ? _b : 365 * 24 * 60 * 60 * 1000));
|
|
64
|
+
const client = (0, redisMetrics_1.instrumentAccessiRedisClient)('session_revocation', options.client);
|
|
62
65
|
return {
|
|
63
66
|
revokeUser(codiceUtente, revokedAtMs) {
|
|
64
67
|
return __awaiter(this, void 0, void 0, function* () {
|
|
65
|
-
yield
|
|
68
|
+
yield client.eval(REVOKE_USER_SCRIPT, 1, `${keyPrefix}${codiceUtente}`, String(revokedAtMs), ttlMs);
|
|
66
69
|
});
|
|
67
70
|
},
|
|
68
71
|
getUserRevocation(codiceUtente) {
|
|
69
72
|
return __awaiter(this, void 0, void 0, function* () {
|
|
70
|
-
const value = yield
|
|
73
|
+
const value = yield client.eval(GET_USER_SCRIPT, 1, `${keyPrefix}${codiceUtente}`);
|
|
71
74
|
if (typeof value !== 'string' && typeof value !== 'number')
|
|
72
75
|
return null;
|
|
73
76
|
const timestamp = Number(value);
|
|
@@ -77,7 +80,7 @@ function createRedisSessionRevocationStore(options) {
|
|
|
77
80
|
healthCheck() {
|
|
78
81
|
return __awaiter(this, void 0, void 0, function* () {
|
|
79
82
|
try {
|
|
80
|
-
const result = yield
|
|
83
|
+
const result = yield client.eval("return redis.call('PING')", 0);
|
|
81
84
|
return String(result).toUpperCase() === 'PONG';
|
|
82
85
|
}
|
|
83
86
|
catch (_a) {
|
|
@@ -148,6 +151,7 @@ function revokeAccessiUserSessions(options_1, codiceUtente_1) {
|
|
|
148
151
|
}
|
|
149
152
|
yield store.revokeUser(codiceUtente, revokedAtMs);
|
|
150
153
|
(0, audit_1.auditAccessi)('accessi_sessions_revoked', { codiceUtente, revokedAtMs, reason });
|
|
154
|
+
(0, metrics_1.countAccessiMetric)(options, 'sessions_revoked_total', { reason });
|
|
151
155
|
});
|
|
152
156
|
}
|
|
153
157
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sessionRevocation.js","sourceRoot":"","sources":["../../../src/accessi-module/security/sessionRevocation.ts"],"names":[],"mappings":"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","sourcesContent":["import type { AccessiOptions } from '../AccessiModule';\nimport { auditAccessi } from './audit';\nimport { isAccessiAccessTokenRevoked } from './accessTokenRevocation';\nimport { isRefreshTokenFamilyRevoked } from './refreshTokenRotation';\n\n/**\n * Revoca delle sessioni per utente.\n *\n * La revoca e un timestamp: un token emesso prima del cutoff viene rifiutato.\n * I token emessi dopo il cutoff restano validi. I token includono `iatMs`\n * (millisecondi) per rendere la revoca deterministica anche quando il cambio\n * password e il login avvengono nello stesso secondo; per i token legacy senza\n * `iatMs` si usa `iat` in secondi.\n */\nexport interface SessionRevocationStore {\n revokeUser(codiceUtente: number, revokedAtMs: number): Promise<void>;\n getUserRevocation(codiceUtente: number): Promise<number | null>;\n /** Health check opzionale per readiness del servizio. */\n healthCheck?(): Promise<boolean>;\n}\n\nexport interface SessionRevocationOptions {\n /** Default: disabilitata se non viene fornito uno store. */\n enabled?: boolean;\n /** Store condiviso (es. Redis) per invalidare le sessioni tra istanze. */\n store?: SessionRevocationStore;\n /** TTL delle chiavi Redis. Default: 365 giorni. */\n ttlMs?: number;\n}\n\nexport interface MemorySessionRevocationStore extends SessionRevocationStore {\n clear(): void;\n}\n\nconst REVOKE_USER_SCRIPT =\n \"if tonumber(ARGV[2]) > 0 then return redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2]) else return redis.call('SET', KEYS[1], ARGV[1]) end\";\nconst GET_USER_SCRIPT = \"return redis.call('GET', KEYS[1])\";\n\nexport function createMemorySessionRevocationStore(): MemorySessionRevocationStore {\n const revokedAt = new Map<number, number>();\n return {\n async revokeUser(codiceUtente, timestamp) {\n revokedAt.set(codiceUtente, timestamp);\n },\n async getUserRevocation(codiceUtente) {\n return revokedAt.get(codiceUtente) ?? null;\n },\n async healthCheck() {\n return true;\n },\n clear() {\n revokedAt.clear();\n },\n };\n}\n\nexport interface SessionRevocationRedisClient {\n eval(script: string, numKeys: number, ...args: Array<string | number>): Promise<unknown>;\n}\n\nexport interface RedisSessionRevocationStoreOptions {\n client: SessionRevocationRedisClient;\n /** Default: `accessi:revoked-user:`. */\n keyPrefix?: string;\n /** Default: 365 giorni. */\n ttlMs?: number;\n}\n\n/**\n * Store Redis per la revoca sessioni.\n *\n * `revokeUser` scrive un timestamp con TTL; `getUserRevocation` lo legge.\n * Il TTL deve essere maggiore della durata massima dei token.\n */\nexport function createRedisSessionRevocationStore(\n options: RedisSessionRevocationStoreOptions,\n): SessionRevocationStore {\n if (!options?.client || typeof options.client.eval !== 'function') {\n throw new Error('Redis session revocation store: client.eval non configurato.');\n }\n\n const keyPrefix = (options.keyPrefix ?? 'accessi:revoked-user:').replace(/:+$/, ':');\n const ttlMs = Math.max(0, Math.trunc(options.ttlMs ?? 365 * 24 * 60 * 60 * 1000));\n\n return {\n async revokeUser(codiceUtente, revokedAtMs) {\n await options.client.eval(\n REVOKE_USER_SCRIPT,\n 1,\n `${keyPrefix}${codiceUtente}`,\n String(revokedAtMs),\n ttlMs,\n );\n },\n\n async getUserRevocation(codiceUtente) {\n const value = await options.client.eval(\n GET_USER_SCRIPT,\n 1,\n `${keyPrefix}${codiceUtente}`,\n );\n if (typeof value !== 'string' && typeof value !== 'number') return null;\n const timestamp = Number(value);\n return Number.isFinite(timestamp) ? timestamp : null;\n },\n\n async healthCheck() {\n try {\n const result = await options.client.eval(\"return redis.call('PING')\", 0);\n return String(result).toUpperCase() === 'PONG';\n } catch {\n return false;\n }\n },\n };\n}\n\nconst defaultMemoryStore = createMemorySessionRevocationStore();\n\n/** Store di default in memoria, usato quando `sessionRevocation.store` non e configurato ma la revoca e abilitata. */\nexport function getSessionRevocationStore(options: AccessiOptions): SessionRevocationStore | undefined {\n if (options.sessionRevocation?.enabled === false) return undefined;\n if (options.sessionRevocation?.store) return options.sessionRevocation.store;\n return options.sessionRevocation?.enabled === true ? defaultMemoryStore : undefined;\n}\n\n/**\n * Issued-at in millisecondi. I token emessi da questa versione includono `iatMs`;\n * per quelli legacy si ricade su `iat` (secondi) per restare compatibili.\n */\nexport function resolveAccessiTokenIssuedAtMs(payload: Record<string, unknown>): number | null {\n const issuedAtMs = Number(payload.iatMs);\n if (Number.isFinite(issuedAtMs) && issuedAtMs > 0) return issuedAtMs;\n\n const issuedAt = Number(payload.iat);\n if (!Number.isFinite(issuedAt)) return null;\n return issuedAt * 1000;\n}\n\n/**\n * True se il token e stato revocato: famiglia refresh revocata, `jti` revocato\n * oppure sessione utente revocata prima dell'emissione del token.\n */\nexport async function isAccessiTokenRevoked(\n options: AccessiOptions,\n payload: Record<string, unknown>,\n codiceUtente: number,\n): Promise<boolean> {\n if (await isRefreshTokenFamilyRevoked(options, payload.fid)) return true;\n if (await isAccessiAccessTokenRevoked(options, payload)) return true;\n\n const store = getSessionRevocationStore(options);\n if (!store) return false;\n\n const issuedAtMs = resolveAccessiTokenIssuedAtMs(payload);\n if (issuedAtMs === null) return true;\n\n const revokedAt = await store.getUserRevocation(codiceUtente);\n if (revokedAt === null) return false;\n\n // I token emessi da questa versione hanno `iatMs`: un token emesso nello\n // stesso millisecondo della revoca e considerato revocato. Per i token\n // legacy (solo `iat`, secondi) si mantiene il confronto per secondo.\n const preciseIssuedAtMs = Number(payload.iatMs);\n if (Number.isFinite(preciseIssuedAtMs) && preciseIssuedAtMs > 0) {\n return issuedAtMs <= revokedAt;\n }\n return issuedAtMs < Math.floor(revokedAt / 1000) * 1000;\n}\n\n/** Revoca tutte le sessioni dell'utente. Richiede uno store configurato. */\nexport async function revokeAccessiUserSessions(\n options: AccessiOptions,\n codiceUtente: number,\n revokedAtMs: number = Date.now(),\n reason: string = 'manual',\n): Promise<void> {\n const store = getSessionRevocationStore(options);\n if (!store) {\n throw new Error('Session revocation store non configurato.');\n }\n await store.revokeUser(codiceUtente, revokedAtMs);\n auditAccessi('accessi_sessions_revoked', { codiceUtente, revokedAtMs, reason });\n}\n\n/**\n * Variante per i flussi applicativi (cambio password, blocco, cancellazione):\n * se la revoca non e configurata non fallisce, perche quei flussi devono\n * continuare a funzionare anche senza store condiviso.\n */\nexport async function revokeAccessiUserSessionsIfConfigured(\n options: AccessiOptions,\n codiceUtente: number,\n reason: string = 'manual',\n revokedAtMs: number = Date.now(),\n): Promise<boolean> {\n if (!getSessionRevocationStore(options)) return false;\n await revokeAccessiUserSessions(options, codiceUtente, revokedAtMs, reason);\n return true;\n}\n\n/** Solo per test: azzera lo store in memoria di default. */\nexport function clearDefaultSessionRevocations(): void {\n defaultMemoryStore.clear();\n}\n"]}
|
|
1
|
+
{"version":3,"file":"sessionRevocation.js","sourceRoot":"","sources":["../../../src/accessi-module/security/sessionRevocation.ts"],"names":[],"mappings":"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","sourcesContent":["import type { AccessiOptions } from '../AccessiModule';\nimport { instrumentAccessiRedisClient } from '../observability/redisMetrics';\nimport { auditAccessi } from './audit';\nimport { countAccessiMetric } from '../observability/metrics';import { isAccessiAccessTokenRevoked } from './accessTokenRevocation';\nimport { isRefreshTokenFamilyRevoked } from './refreshTokenRotation';\n\n/**\n * Revoca delle sessioni per utente.\n *\n * La revoca e un timestamp: un token emesso prima del cutoff viene rifiutato.\n * I token emessi dopo il cutoff restano validi. I token includono `iatMs`\n * (millisecondi) per rendere la revoca deterministica anche quando il cambio\n * password e il login avvengono nello stesso secondo; per i token legacy senza\n * `iatMs` si usa `iat` in secondi.\n */\nexport interface SessionRevocationStore {\n revokeUser(codiceUtente: number, revokedAtMs: number): Promise<void>;\n getUserRevocation(codiceUtente: number): Promise<number | null>;\n /** Health check opzionale per readiness del servizio. */\n healthCheck?(): Promise<boolean>;\n}\n\nexport interface SessionRevocationOptions {\n /** Default: disabilitata se non viene fornito uno store. */\n enabled?: boolean;\n /** Store condiviso (es. Redis) per invalidare le sessioni tra istanze. */\n store?: SessionRevocationStore;\n /** TTL delle chiavi Redis. Default: 365 giorni. */\n ttlMs?: number;\n}\n\nexport interface MemorySessionRevocationStore extends SessionRevocationStore {\n clear(): void;\n}\n\nconst REVOKE_USER_SCRIPT =\n \"if tonumber(ARGV[2]) > 0 then return redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2]) else return redis.call('SET', KEYS[1], ARGV[1]) end\";\nconst GET_USER_SCRIPT = \"return redis.call('GET', KEYS[1])\";\n\nexport function createMemorySessionRevocationStore(): MemorySessionRevocationStore {\n const revokedAt = new Map<number, number>();\n return {\n async revokeUser(codiceUtente, timestamp) {\n revokedAt.set(codiceUtente, timestamp);\n },\n async getUserRevocation(codiceUtente) {\n return revokedAt.get(codiceUtente) ?? null;\n },\n async healthCheck() {\n return true;\n },\n clear() {\n revokedAt.clear();\n },\n };\n}\n\nexport interface SessionRevocationRedisClient {\n eval(script: string, numKeys: number, ...args: Array<string | number>): Promise<unknown>;\n}\n\nexport interface RedisSessionRevocationStoreOptions {\n client: SessionRevocationRedisClient;\n /** Default: `accessi:revoked-user:`. */\n keyPrefix?: string;\n /** Default: 365 giorni. */\n ttlMs?: number;\n}\n\n/**\n * Store Redis per la revoca sessioni.\n *\n * `revokeUser` scrive un timestamp con TTL; `getUserRevocation` lo legge.\n * Il TTL deve essere maggiore della durata massima dei token.\n */\nexport function createRedisSessionRevocationStore(\n options: RedisSessionRevocationStoreOptions,\n): SessionRevocationStore {\n if (!options?.client || typeof options.client.eval !== 'function') {\n throw new Error('Redis session revocation store: client.eval non configurato.');\n }\n\n const keyPrefix = (options.keyPrefix ?? 'accessi:revoked-user:').replace(/:+$/, ':');\n const ttlMs = Math.max(0, Math.trunc(options.ttlMs ?? 365 * 24 * 60 * 60 * 1000));\n const client = instrumentAccessiRedisClient('session_revocation', options.client);\n\n return {\n async revokeUser(codiceUtente, revokedAtMs) {\n await client.eval(\n REVOKE_USER_SCRIPT,\n 1,\n `${keyPrefix}${codiceUtente}`,\n String(revokedAtMs),\n ttlMs,\n );\n },\n\n async getUserRevocation(codiceUtente) {\n const value = await client.eval(\n GET_USER_SCRIPT,\n 1,\n `${keyPrefix}${codiceUtente}`,\n );\n if (typeof value !== 'string' && typeof value !== 'number') return null;\n const timestamp = Number(value);\n return Number.isFinite(timestamp) ? timestamp : null;\n },\n\n async healthCheck() {\n try {\n const result = await client.eval(\"return redis.call('PING')\", 0);\n return String(result).toUpperCase() === 'PONG';\n } catch {\n return false;\n }\n },\n };\n}\n\nconst defaultMemoryStore = createMemorySessionRevocationStore();\n\n/** Store di default in memoria, usato quando `sessionRevocation.store` non e configurato ma la revoca e abilitata. */\nexport function getSessionRevocationStore(options: AccessiOptions): SessionRevocationStore | undefined {\n if (options.sessionRevocation?.enabled === false) return undefined;\n if (options.sessionRevocation?.store) return options.sessionRevocation.store;\n return options.sessionRevocation?.enabled === true ? defaultMemoryStore : undefined;\n}\n\n/**\n * Issued-at in millisecondi. I token emessi da questa versione includono `iatMs`;\n * per quelli legacy si ricade su `iat` (secondi) per restare compatibili.\n */\nexport function resolveAccessiTokenIssuedAtMs(payload: Record<string, unknown>): number | null {\n const issuedAtMs = Number(payload.iatMs);\n if (Number.isFinite(issuedAtMs) && issuedAtMs > 0) return issuedAtMs;\n\n const issuedAt = Number(payload.iat);\n if (!Number.isFinite(issuedAt)) return null;\n return issuedAt * 1000;\n}\n\n/**\n * True se il token e stato revocato: famiglia refresh revocata, `jti` revocato\n * oppure sessione utente revocata prima dell'emissione del token.\n */\nexport async function isAccessiTokenRevoked(\n options: AccessiOptions,\n payload: Record<string, unknown>,\n codiceUtente: number,\n): Promise<boolean> {\n if (await isRefreshTokenFamilyRevoked(options, payload.fid)) return true;\n if (await isAccessiAccessTokenRevoked(options, payload)) return true;\n\n const store = getSessionRevocationStore(options);\n if (!store) return false;\n\n const issuedAtMs = resolveAccessiTokenIssuedAtMs(payload);\n if (issuedAtMs === null) return true;\n\n const revokedAt = await store.getUserRevocation(codiceUtente);\n if (revokedAt === null) return false;\n\n // I token emessi da questa versione hanno `iatMs`: un token emesso nello\n // stesso millisecondo della revoca e considerato revocato. Per i token\n // legacy (solo `iat`, secondi) si mantiene il confronto per secondo.\n const preciseIssuedAtMs = Number(payload.iatMs);\n if (Number.isFinite(preciseIssuedAtMs) && preciseIssuedAtMs > 0) {\n return issuedAtMs <= revokedAt;\n }\n return issuedAtMs < Math.floor(revokedAt / 1000) * 1000;\n}\n\n/** Revoca tutte le sessioni dell'utente. Richiede uno store configurato. */\nexport async function revokeAccessiUserSessions(\n options: AccessiOptions,\n codiceUtente: number,\n revokedAtMs: number = Date.now(),\n reason: string = 'manual',\n): Promise<void> {\n const store = getSessionRevocationStore(options);\n if (!store) {\n throw new Error('Session revocation store non configurato.');\n }\n await store.revokeUser(codiceUtente, revokedAtMs);\n auditAccessi('accessi_sessions_revoked', { codiceUtente, revokedAtMs, reason });\n countAccessiMetric(options, 'sessions_revoked_total', { reason });\n}\n\n/**\n * Variante per i flussi applicativi (cambio password, blocco, cancellazione):\n * se la revoca non e configurata non fallisce, perche quei flussi devono\n * continuare a funzionare anche senza store condiviso.\n */\nexport async function revokeAccessiUserSessionsIfConfigured(\n options: AccessiOptions,\n codiceUtente: number,\n reason: string = 'manual',\n revokedAtMs: number = Date.now(),\n): Promise<boolean> {\n if (!getSessionRevocationStore(options)) return false;\n await revokeAccessiUserSessions(options, codiceUtente, revokedAtMs, reason);\n return true;\n}\n\n/** Solo per test: azzera lo store in memoria di default. */\nexport function clearDefaultSessionRevocations(): void {\n defaultMemoryStore.clear();\n}\n"]}
|
|
Binary file
|
package/docs/audit.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Audit persistente
|
|
2
|
+
|
|
3
|
+
`auditAccessi` scrive un record strutturato per ogni evento di sicurezza. Di
|
|
4
|
+
default il record finisce nel log (`AccessiAudit`); con `audit.sinks` può essere
|
|
5
|
+
persistito su Redis, inoltrato a un collector HTTP o scritto su database e
|
|
6
|
+
OpenTelemetry tramite una callback.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
const accessiOptions: AccessiOptions = {
|
|
10
|
+
audit: {
|
|
11
|
+
retentionDays: 90,
|
|
12
|
+
logger: true,
|
|
13
|
+
store: createRedisAccessiAuditSink({
|
|
14
|
+
client: redisClient, // qualunque client con eval (ioredis, node-redis)
|
|
15
|
+
maxRecords: 100_000,
|
|
16
|
+
retentionMs: 90 * 24 * 60 * 60 * 1000,
|
|
17
|
+
}),
|
|
18
|
+
sinks: [
|
|
19
|
+
createHttpAccessiAuditSink({
|
|
20
|
+
url: 'https://siem.example.com/accessi/audit',
|
|
21
|
+
headers: { Authorization: `Bearer ${process.env.SIEM_TOKEN}` },
|
|
22
|
+
}),
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`store` è il sink interrogabile usato dagli endpoint amministrativi. Se non è
|
|
29
|
+
impostato, viene usato il primo sink con `query`/`purge` presente in `sinks`.
|
|
30
|
+
Senza sink interrogabile gli endpoint rispondono `501 ACCESSI_AUDIT_STORE_DISABLED`
|
|
31
|
+
e il log strutturato continua a funzionare.
|
|
32
|
+
|
|
33
|
+
## Cosa viene registrato
|
|
34
|
+
|
|
35
|
+
| Evento | Dettagli | Dove |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| `login_succeeded` / `login_failed` | `codiceUtente`, `ip`, motivo | AuthController |
|
|
38
|
+
| `two_factor_succeeded` / `two_factor_failed` | `codiceUtente`, `ip` | AuthController |
|
|
39
|
+
| `refresh_succeeded` / `refresh_failed` | `ip`, motivo (`reuse`, `invalid`, ...) | AuthController |
|
|
40
|
+
| `accessi_refresh_token_reuse` | `codiceUtente`, `jti`, `familyId` | AuthService |
|
|
41
|
+
| `access_token_revoked` | utente, `jti`, self | AuthController |
|
|
42
|
+
| `accessi_sessions_revoked` | utente, cutoff, motivo | sessionRevocation |
|
|
43
|
+
| `public_rate_limited` / `edge_rate_limited` | scope, IP, retry | rate limit |
|
|
44
|
+
| `console_ticket_issued` / `console_ticket_consumed` / `console_ticket_failed` | utente o esito | ConsoleEntryController |
|
|
45
|
+
| `jwt_unknown_key_id` | `kid`, `purpose`, algoritmo | verifica JWT |
|
|
46
|
+
|
|
47
|
+
Non vengono registrati token, password, header `Authorization`, cookie o chiavi
|
|
48
|
+
private: la redazione è applicata prima del logger e di ogni sink.
|
|
49
|
+
|
|
50
|
+
## Redazione
|
|
51
|
+
|
|
52
|
+
`redactAccessiAuditValue` normalizza le chiavi (case-insensitive, ignora `-`,
|
|
53
|
+
`_` e spazi) e sostituisce con `[REDACTED]`:
|
|
54
|
+
|
|
55
|
+
`token`, `accessToken`, `refreshToken`, `idToken`, `bearer`, `jwt`,
|
|
56
|
+
`authorization`, `cookie`, `set-cookie`, `password`, `passwordHash`,
|
|
57
|
+
`hashedPassword`, `resetToken`, `secret`, `clientSecret`, `privateKey`,
|
|
58
|
+
`apiKey`, `csrf`, `csrfToken`.
|
|
59
|
+
|
|
60
|
+
Inoltre:
|
|
61
|
+
|
|
62
|
+
- le stringhe oltre 512 caratteri vengono troncate;
|
|
63
|
+
- gli array oltre 50 elementi e gli oggetti oltre 100 chiavi vengono limitati;
|
|
64
|
+
- oltre 4 livelli di profondità il valore diventa `[TRUNCATED]`;
|
|
65
|
+
- gli `Error` diventano `{ name, message }` senza stack.
|
|
66
|
+
|
|
67
|
+
Aggiungere chiavi specifiche dell'host:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
audit: { redact: ['cardNumber', 'iban'] }
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Retention
|
|
74
|
+
|
|
75
|
+
`retentionDays` abilita la pulizia periodica (al massimo una volta all'ora,
|
|
76
|
+
pigra, al primo evento successivo) dei sink che implementano `purge`. I sink
|
|
77
|
+
in memoria hanno anche `maxRecords`. Il sink Redis usa `LTRIM` più `PEXPIRE`:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
createRedisAccessiAuditSink({
|
|
81
|
+
client: redisClient,
|
|
82
|
+
maxRecords: 100_000,
|
|
83
|
+
retentionMs: 90 * 24 * 60 * 60 * 1000,
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
La retention deve essere almeno pari agli obblighi di legge applicabili e alla
|
|
88
|
+
durata massima dei token, così un incidente resta ricostruibile.
|
|
89
|
+
|
|
90
|
+
## Endpoint amministrativi
|
|
91
|
+
|
|
92
|
+
Richiedono un JWT Accessi di un superutente o admin:
|
|
93
|
+
|
|
94
|
+
```http
|
|
95
|
+
GET /api/accessi/audit?event=login_failed&from=2026-01-01T00:00:00Z&limit=100
|
|
96
|
+
GET /api/accessi/audit/export?event=login_failed&from=2026-01-01T00:00:00Z
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
La query restituisce `{ records, total, limit, offset }`. L'export produce
|
|
100
|
+
NDJSON (`application/x-ndjson`) con gli stessi filtri, senza paginazione, fino a
|
|
101
|
+
100000 record per richiesta. I record sono già redatti.
|
|
102
|
+
|
|
103
|
+
## Sink disponibili
|
|
104
|
+
|
|
105
|
+
| Factory | Uso |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| `createMemoryAccessiAuditSink` | test, sviluppo, singola istanza |
|
|
108
|
+
| `createRedisAccessiAuditSink` | persistenza condivisa multi-istanza |
|
|
109
|
+
| `createHttpAccessiAuditSink` | SIEM/collector HTTP |
|
|
110
|
+
| `createCallbackAccessiAuditSink` | database applicativo, OpenTelemetry, coda |
|
|
111
|
+
|
|
112
|
+
Esempio database/OTel:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
const dbSink = createCallbackAccessiAuditSink(async (record) => {
|
|
116
|
+
await db.insert('AUDIT_EVENTS', {
|
|
117
|
+
EVENT: record.event,
|
|
118
|
+
OCCURRED_AT: record.timestamp,
|
|
119
|
+
PAYLOAD: JSON.stringify(record),
|
|
120
|
+
});
|
|
121
|
+
}, 'database');
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
La callback deve gestire i propri errori: un errore viene contato in
|
|
125
|
+
`accessi_audit_sink_errors_total{sink}` e non blocca la richiesta.
|
|
126
|
+
|
|
127
|
+
## Note
|
|
128
|
+
|
|
129
|
+
- I sink sono globali per processo: configurare un solo modulo Accessi per
|
|
130
|
+
processo, come già avviene per il logger Accessi.
|
|
131
|
+
- `audit.enabled: false` disattiva i sink ma **non** il log strutturato; usare
|
|
132
|
+
`logger: false` per silenziare anche quello.
|
|
133
|
+
- Non inserire payload applicativi completi nei dettagli degli eventi: la
|
|
134
|
+
redazione è una rete di sicurezza, non un invito a loggare dati sensibili.
|
|
135
|
+
- L'audit non sostituisce le metriche: usare `docs/metrics.md` per gli alert in
|
|
136
|
+
tempo reale.
|
package/docs/metrics.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Metriche e alert
|
|
2
|
+
|
|
3
|
+
Accessi espone metriche di dominio in formato Prometheus. Il servizio standalone
|
|
4
|
+
le include in `/health/metrics` insieme alle metriche HTTP di base; un host
|
|
5
|
+
Nest/Express può montare `renderAccessiMetrics()` su una propria rotta protetta.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { renderAccessiMetrics } from 'emilsoftware-utilities/accessi/server';
|
|
9
|
+
|
|
10
|
+
app.get('/internal/accessi-metrics', (_req, res) => {
|
|
11
|
+
res.type('text/plain; version=0.0.4').send(renderAccessiMetrics());
|
|
12
|
+
});
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Le metriche sono attive per default. Per disattivarle:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
const accessiOptions: AccessiOptions = {
|
|
19
|
+
// ...
|
|
20
|
+
metrics: { enabled: false },
|
|
21
|
+
};
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Per inviarle a OpenTelemetry, StatsD o un altro sistema:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
const accessiOptions: AccessiOptions = {
|
|
28
|
+
metrics: {
|
|
29
|
+
prefix: 'accessi',
|
|
30
|
+
sink: {
|
|
31
|
+
increment: (name, value, labels) => meter.createCounter(name).add(value, labels),
|
|
32
|
+
observe: (name, valueMs, labels) => histogram.record(valueMs, labels),
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`prefix` (default `accessi`) si applica alle metriche emesse dai controller; gli
|
|
39
|
+
store Redis usano il registry globale, sempre renderizzabile con
|
|
40
|
+
`renderAccessiMetrics()`.
|
|
41
|
+
|
|
42
|
+
## Metriche di dominio
|
|
43
|
+
|
|
44
|
+
| Metrica | Tipo | Label | Significato |
|
|
45
|
+
| --- | --- | --- | --- |
|
|
46
|
+
| `accessi_auth_login_total` | counter | `result` = `success`, `failure`, `challenge`, `password_required`, `password_expired`, `sso_only`, `rate_limited`, `error` | esito del login |
|
|
47
|
+
| `accessi_auth_login_duration_ms` | histogram | — | durata del login |
|
|
48
|
+
| `accessi_auth_two_factor_total` | counter | `action` = `verify`/`resend`, `result` | verifica e reinvio 2FA |
|
|
49
|
+
| `accessi_auth_two_factor_duration_ms` | histogram | `action` | durata verifica 2FA |
|
|
50
|
+
| `accessi_auth_refresh_total` | counter | `result` = `success`, `invalid`, `reuse`, `family_revoked`, `store_unavailable`, `rate_limited` | esito del refresh |
|
|
51
|
+
| `accessi_auth_refresh_duration_ms` | histogram | — | durata del refresh |
|
|
52
|
+
| `accessi_refresh_reuse_total` | counter | `user_sessions_revoked` | riuso di refresh token oltre la grace |
|
|
53
|
+
| `accessi_refresh_family_revoked_total` | counter | `reason` | famiglie revocate |
|
|
54
|
+
| `accessi_rate_limit_blocked_total` | counter | `scope`, `layer` = `public`/`edge`/`service_token` | richieste bloccate dal rate limit |
|
|
55
|
+
| `accessi_introspection_total` | counter | `result` = `active`, `inactive`, `bad_request`, `error` | introspection service-to-service |
|
|
56
|
+
| `accessi_introspection_duration_ms` | histogram | — | durata introspection |
|
|
57
|
+
| `accessi_jwks_requests_total` | counter | `result` = `ok`, `unavailable` | richieste JWKS |
|
|
58
|
+
| `accessi_jwt_unknown_key_id_total` | counter | `purpose`, `source`, `algorithm` | token con `kid` non riconosciuto |
|
|
59
|
+
| `accessi_sessions_revoked_total` | counter | `reason` = `manual`, `password_change`, `disabled`, `deleted`, `refresh_token_reuse`, ... | revoche sessioni |
|
|
60
|
+
| `accessi_console_ticket_total` | counter | `action` = `create`/`exchange`, `result` | ticket di ingresso console |
|
|
61
|
+
| `accessi_store_errors_total` | counter | `store` | errori degli store Redis |
|
|
62
|
+
| `accessi_store_operation_duration_ms` | histogram | `store` | latenza degli script Lua Redis |
|
|
63
|
+
|
|
64
|
+
Gli store strumentati sono `rate_limit`, `edge_rate_limit`, `service_token_rate_limit`,
|
|
65
|
+
`console_entry`, `session_revocation`, `access_token_revocation` e
|
|
66
|
+
`refresh_token_rotation`.
|
|
67
|
+
|
|
68
|
+
## Alert suggeriti
|
|
69
|
+
|
|
70
|
+
Esempio di regole Prometheus. Le soglie vanno tarate sul traffico reale.
|
|
71
|
+
|
|
72
|
+
```yaml
|
|
73
|
+
groups:
|
|
74
|
+
- name: accessi
|
|
75
|
+
rules:
|
|
76
|
+
- alert: AccessiLoginErrorRate
|
|
77
|
+
expr: |
|
|
78
|
+
sum(rate(accessi_auth_login_total{result=~"failure|error"}[5m]))
|
|
79
|
+
/
|
|
80
|
+
clamp_min(sum(rate(accessi_auth_login_total[5m])), 0.001)
|
|
81
|
+
> 0.3
|
|
82
|
+
for: 10m
|
|
83
|
+
labels:
|
|
84
|
+
severity: warning
|
|
85
|
+
annotations:
|
|
86
|
+
summary: "Oltre il 30% dei login fallisce da 10 minuti"
|
|
87
|
+
|
|
88
|
+
- alert: AccessiRefreshReuse
|
|
89
|
+
expr: increase(accessi_refresh_reuse_total[5m]) > 0
|
|
90
|
+
for: 1m
|
|
91
|
+
labels:
|
|
92
|
+
severity: critical
|
|
93
|
+
annotations:
|
|
94
|
+
summary: "Riuso di refresh token rilevato: verificare possibili furti"
|
|
95
|
+
|
|
96
|
+
- alert: AccessiRateLimitSpike
|
|
97
|
+
expr: sum(rate(accessi_rate_limit_blocked_total[5m])) > 20
|
|
98
|
+
for: 10m
|
|
99
|
+
labels:
|
|
100
|
+
severity: warning
|
|
101
|
+
annotations:
|
|
102
|
+
summary: "Molte richieste bloccate dal rate limit: possibile brute force"
|
|
103
|
+
|
|
104
|
+
- alert: AccessiUnknownJwtKid
|
|
105
|
+
expr: increase(accessi_jwt_unknown_key_id_total[5m]) > 0
|
|
106
|
+
for: 5m
|
|
107
|
+
labels:
|
|
108
|
+
severity: warning
|
|
109
|
+
annotations:
|
|
110
|
+
summary: "Token con kid sconosciuto: rotazione chiavi incompleta?"
|
|
111
|
+
|
|
112
|
+
- alert: AccessiRedisStoreDown
|
|
113
|
+
expr: increase(accessi_store_errors_total[5m]) > 3
|
|
114
|
+
for: 5m
|
|
115
|
+
labels:
|
|
116
|
+
severity: critical
|
|
117
|
+
annotations:
|
|
118
|
+
summary: "Errori Redis: revoca, rotazione o rate limit non affidabili"
|
|
119
|
+
|
|
120
|
+
- alert: AccessiStoreLatency
|
|
121
|
+
expr: |
|
|
122
|
+
histogram_quantile(
|
|
123
|
+
0.95,
|
|
124
|
+
sum by (le, store) (rate(accessi_store_operation_duration_ms_bucket[5m]))
|
|
125
|
+
) > 50
|
|
126
|
+
for: 10m
|
|
127
|
+
labels:
|
|
128
|
+
severity: warning
|
|
129
|
+
annotations:
|
|
130
|
+
summary: "p95 degli script Redis sopra 50 ms per {{ $labels.store }}"
|
|
131
|
+
|
|
132
|
+
- alert: AccessiIntrospectionErrors
|
|
133
|
+
expr: |
|
|
134
|
+
sum(rate(accessi_introspection_total{result="error"}[5m]))
|
|
135
|
+
/
|
|
136
|
+
clamp_min(sum(rate(accessi_introspection_total[5m])), 0.001)
|
|
137
|
+
> 0.05
|
|
138
|
+
for: 10m
|
|
139
|
+
labels:
|
|
140
|
+
severity: warning
|
|
141
|
+
annotations:
|
|
142
|
+
summary: "Errori di introspection oltre il 5%"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Note
|
|
146
|
+
|
|
147
|
+
- Non usare label ad alta cardinalità (email, `jti`, IP): le metriche Accessi
|
|
148
|
+
non le includono.
|
|
149
|
+
- `accessi_jwt_unknown_key_id_total` collega la rotazione chiavi descritta in
|
|
150
|
+
`docs/jwt-key-rotation.md`: un aumento dopo un deploy segnala che un
|
|
151
|
+
consumatore non ha aggiornato la JWKS o la chiave statica.
|
|
152
|
+
- Le metriche non sostituiscono l'audit: per gli eventi di sicurezza usare
|
|
153
|
+
`auditAccessi` e la sua persistenza.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Test di concorrenza e carico su Redis
|
|
2
|
+
|
|
3
|
+
`npm run test:load:redis` verifica che rate limit, ticket console e refresh
|
|
4
|
+
rotation restino corretti con più istanze Accessi sullo stesso Redis, e produce
|
|
5
|
+
un report di throughput e latenza.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
ACCESSI_TEST_REDIS_URL=redis://127.0.0.1:6379 npm run test:load:redis
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
La suite è opt-in: senza `ACCESSI_TEST_REDIS_URL` i test vengono saltati. In CI
|
|
12
|
+
si imposta `ACCESSI_TEST_REQUIRE_REDIS=1` per far fallire il job se Redis non è
|
|
13
|
+
raggiungibile. Usare un database Redis dedicato (es. `redis://127.0.0.1:6379/5`):
|
|
14
|
+
tutte le chiavi hanno un prefisso per processo e vengono cancellate a fine suite,
|
|
15
|
+
ma un database separato evita sorprese con Redis condivisi.
|
|
16
|
+
|
|
17
|
+
## Scenari
|
|
18
|
+
|
|
19
|
+
| Scenario | Cosa dimostra |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| Burst login/reset/2FA | il limite globale per IP è rispettato anche se le richieste sono distribuite su due store (due istanze) |
|
|
22
|
+
| Ticket console | due istanze consumano lo stesso ticket: una sola ottiene il record, l'altra riceve `null` |
|
|
23
|
+
| Refresh rotation | due istanze consumano lo stesso `jti`: una sola vince; la revoca di famiglia è visibile all'altra istanza |
|
|
24
|
+
| Misure | throughput, p50, p95 e massimo per ogni scenario |
|
|
25
|
+
|
|
26
|
+
Le "due istanze" sono simulate con due connessioni Redis indipendenti e due
|
|
27
|
+
oggetti store distinti. La correttezza dipende dagli script Lua atomici degli
|
|
28
|
+
store: il test non testa il processo Node in sé, ma il comportamento condiviso
|
|
29
|
+
su Redis.
|
|
30
|
+
|
|
31
|
+
## Parametri
|
|
32
|
+
|
|
33
|
+
| Variabile | Default | Note |
|
|
34
|
+
| --- | --- | --- |
|
|
35
|
+
| `ACCESSI_TEST_REDIS_URL` | assente | abilita la suite |
|
|
36
|
+
| `ACCESSI_TEST_REQUIRE_REDIS` | `0` | `1` trasforma lo skip in errore (CI) |
|
|
37
|
+
| `ACCESSI_TEST_LOAD_ITERATIONS` | `200` | operazioni per refresh rotation |
|
|
38
|
+
| `ACCESSI_TEST_LOAD_CONCURRENCY` | `50` | richieste concorrenti |
|
|
39
|
+
| `ACCESSI_TEST_REPORT_FILE` | assente | path del report Markdown |
|
|
40
|
+
|
|
41
|
+
Esempio con carico maggiore:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
ACCESSI_TEST_REDIS_URL=redis://127.0.0.1:6379/5 \
|
|
45
|
+
ACCESSI_TEST_LOAD_ITERATIONS=1000 \
|
|
46
|
+
ACCESSI_TEST_LOAD_CONCURRENCY=100 \
|
|
47
|
+
ACCESSI_TEST_REPORT_FILE=redis-load-report.md \
|
|
48
|
+
npm run test:load:redis
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Report
|
|
52
|
+
|
|
53
|
+
Il report viene sempre stampato a fine suite:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
| Scenario | Operazioni | Errori | Throughput (ops/s) | p50 (ms) | p95 (ms) | max (ms) |
|
|
57
|
+
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
|
58
|
+
| rate limit login | 60 | 0 | 3200.0 | 12.10 | 24.30 | 41.00 |
|
|
59
|
+
| console ticket (2 istanze) | 100 | 0 | 2800.0 | 14.00 | 30.10 | 55.00 |
|
|
60
|
+
| refresh rotation (2 istanze) | 200 | 0 | 3000.0 | 13.00 | 27.00 | 48.00 |
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Con `ACCESSI_TEST_REPORT_FILE` viene scritto anche su file; la CI lo pubblica
|
|
64
|
+
come artifact `redis-load-report`.
|
|
65
|
+
|
|
66
|
+
Le asserzioni verificano correttezza e un limite di sicurezza sulla latenza
|
|
67
|
+
(p95 sotto 1–2 secondi), non un throughput minimo: il throughput dipende
|
|
68
|
+
dall'hardware del runner e non deve rendere la CI instabile. Per una valutazione
|
|
69
|
+
di capacità usare un ambiente dedicato e confrontare i numeri tra release.
|
|
70
|
+
|
|
71
|
+
## Interpretazione
|
|
72
|
+
|
|
73
|
+
- **Errori > 0**: Redis irraggiungibile o script Lua in errore; il test fallisce.
|
|
74
|
+
- **p95 molto alto con throughput basso**: rete verso Redis o Redis saturo;
|
|
75
|
+
controllare `latency monitor` lato Redis e il pool di connessioni.
|
|
76
|
+
- **Più di un consumo riuscito**: violazione dell'atomicità; non distribuire la
|
|
77
|
+
release.
|
|
78
|
+
- **Limite per IP superato**: due istanze non stanno condividendo lo store o
|
|
79
|
+
stanno usando prefissi diversi.
|
|
80
|
+
|
|
81
|
+
## Limiti
|
|
82
|
+
|
|
83
|
+
- Non misura il costo del database Firebird né il flusso HTTP completo: per
|
|
84
|
+
quello usare i test di integrazione Firebird e un ambiente di staging.
|
|
85
|
+
- I tempi includono il client Redis di test (RESP2 minimale) e il round-trip di
|
|
86
|
+
rete: sono un limite superiore, non un benchmark dell'applicazione.
|
|
87
|
+
- Non usare questo test contro un Redis di produzione: scrive e cancella chiavi
|
|
88
|
+
con prefisso, ma è comunque carico reale.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "emilsoftware-utilities",
|
|
3
|
-
"version": "2.0.0-dev.
|
|
3
|
+
"version": "2.0.0-dev.79",
|
|
4
4
|
"description": "Utilities for EmilSoftware",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -139,12 +139,13 @@
|
|
|
139
139
|
"typecheck": "tsc --noEmit",
|
|
140
140
|
"lint": "node scripts/lint.cjs",
|
|
141
141
|
"test": "npm run typecheck && npm run lint && npm run test:unit",
|
|
142
|
-
"test:unit": "node --test test/angular-components.cjs test/menus-catalog.mjs test/menu-scopes.cjs test/accessi-regressions.cjs test/password-reset-regressions.cjs test/two-factor.cjs test/accessi-schema.cjs test/utilities-regressions.cjs test/service-token-regressions.cjs test/accessi-email-config.cjs test/accessi-catalog-scripts.cjs test/openapi-config.cjs test/resource-server.cjs test/accessi-security-standards.cjs test/accessi-service.cjs test/refresh-token.cjs test/token-revocation.cjs test/accessi-client.cjs test/accessi-client-scopes.cjs test/openapi-semantic-diff.cjs test/proxy-ip.cjs test/readiness.cjs test/jwt-key-rotation.cjs test/security-headers.cjs test/auth-cookies.cjs",
|
|
143
|
-
"test:coverage": "node --experimental-test-coverage --test-coverage-lines=50 --test-coverage-functions=60 --test-coverage-branches=60 --test test/angular-components.cjs test/menus-catalog.mjs test/menu-scopes.cjs test/accessi-regressions.cjs test/password-reset-regressions.cjs test/two-factor.cjs test/accessi-schema.cjs test/utilities-regressions.cjs test/service-token-regressions.cjs test/accessi-email-config.cjs test/accessi-catalog-scripts.cjs test/openapi-config.cjs test/resource-server.cjs test/accessi-security-standards.cjs test/accessi-service.cjs test/refresh-token.cjs test/token-revocation.cjs test/accessi-client.cjs test/accessi-client-scopes.cjs test/openapi-semantic-diff.cjs test/proxy-ip.cjs test/readiness.cjs test/jwt-key-rotation.cjs test/security-headers.cjs test/auth-cookies.cjs",
|
|
142
|
+
"test:unit": "node --test test/angular-components.cjs test/menus-catalog.mjs test/menu-scopes.cjs test/accessi-regressions.cjs test/password-reset-regressions.cjs test/two-factor.cjs test/accessi-schema.cjs test/utilities-regressions.cjs test/service-token-regressions.cjs test/accessi-email-config.cjs test/accessi-catalog-scripts.cjs test/openapi-config.cjs test/resource-server.cjs test/accessi-security-standards.cjs test/accessi-service.cjs test/refresh-token.cjs test/token-revocation.cjs test/accessi-client.cjs test/accessi-client-scopes.cjs test/openapi-semantic-diff.cjs test/proxy-ip.cjs test/readiness.cjs test/jwt-key-rotation.cjs test/security-headers.cjs test/auth-cookies.cjs test/metrics.cjs test/audit.cjs",
|
|
143
|
+
"test:coverage": "node --experimental-test-coverage --test-coverage-lines=50 --test-coverage-functions=60 --test-coverage-branches=60 --test test/angular-components.cjs test/menus-catalog.mjs test/menu-scopes.cjs test/accessi-regressions.cjs test/password-reset-regressions.cjs test/two-factor.cjs test/accessi-schema.cjs test/utilities-regressions.cjs test/service-token-regressions.cjs test/accessi-email-config.cjs test/accessi-catalog-scripts.cjs test/openapi-config.cjs test/resource-server.cjs test/accessi-security-standards.cjs test/accessi-service.cjs test/refresh-token.cjs test/token-revocation.cjs test/accessi-client.cjs test/accessi-client-scopes.cjs test/openapi-semantic-diff.cjs test/proxy-ip.cjs test/readiness.cjs test/jwt-key-rotation.cjs test/security-headers.cjs test/auth-cookies.cjs test/metrics.cjs test/audit.cjs",
|
|
144
144
|
"test:accessi": "npm run test:unit",
|
|
145
145
|
"test:all": "npm test && npm run test:integration && npm run test:integration:redis",
|
|
146
146
|
"test:integration": "node test/integration/run-firebird.cjs 5",
|
|
147
147
|
"test:integration:redis": "node --test test/integration/redis-stores.integration.cjs",
|
|
148
|
+
"test:load:redis": "node --test --test-concurrency=1 test/integration/redis-load.integration.cjs",
|
|
148
149
|
"test:integration:fb5": "node test/integration/run-firebird.cjs 5",
|
|
149
150
|
"test:integration:fb3": "node test/integration/run-firebird.cjs 3",
|
|
150
151
|
"test:integration:fb25": "node test/integration/run-firebird.cjs 2.5",
|
|
Binary file
|