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.
Files changed (89) hide show
  1. package/dist/accessi/client/generated/admin.d.ts +21 -1
  2. package/dist/accessi/client/generated/admin.js +35 -1
  3. package/dist/accessi/client/generated/admin.js.map +1 -1
  4. package/dist/accessi/resource-server/token.js +2 -0
  5. package/dist/accessi/resource-server/token.js.map +1 -1
  6. package/dist/accessi/server/standalone.js +2 -1
  7. package/dist/accessi/server/standalone.js.map +1 -1
  8. package/dist/accessi-module/AccessiModule.d.ts +6 -0
  9. package/dist/accessi-module/AccessiModule.js +4 -0
  10. package/dist/accessi-module/AccessiModule.js.map +1 -1
  11. package/dist/accessi-module/Console/console.js +2 -0
  12. package/dist/accessi-module/Console/generated/emilsoftware-utilities.d.ts +21 -1
  13. package/dist/accessi-module/Console/generated/emilsoftware-utilities.js +35 -1
  14. package/dist/accessi-module/Console/generated/emilsoftware-utilities.js.map +1 -1
  15. package/dist/accessi-module/Console/generated/model/exportAuditOrder.d.ts +11 -0
  16. package/dist/accessi-module/Console/generated/model/exportAuditOrder.js +15 -0
  17. package/dist/accessi-module/Console/generated/model/exportAuditOrder.js.map +1 -0
  18. package/dist/accessi-module/Console/generated/model/exportAuditParams.d.ts +37 -0
  19. package/dist/accessi-module/Console/generated/model/exportAuditParams.js +3 -0
  20. package/dist/accessi-module/Console/generated/model/exportAuditParams.js.map +1 -0
  21. package/dist/accessi-module/Console/generated/model/index.d.ts +4 -0
  22. package/dist/accessi-module/Console/generated/model/index.js +4 -0
  23. package/dist/accessi-module/Console/generated/model/index.js.map +1 -1
  24. package/dist/accessi-module/Console/generated/model/queryAuditOrder.d.ts +11 -0
  25. package/dist/accessi-module/Console/generated/model/queryAuditOrder.js +15 -0
  26. package/dist/accessi-module/Console/generated/model/queryAuditOrder.js.map +1 -0
  27. package/dist/accessi-module/Console/generated/model/queryAuditParams.d.ts +37 -0
  28. package/dist/accessi-module/Console/generated/model/queryAuditParams.js +3 -0
  29. package/dist/accessi-module/Console/generated/model/queryAuditParams.js.map +1 -0
  30. package/dist/accessi-module/Console/wiki.js +2 -0
  31. package/dist/accessi-module/Console/wiki.js.map +1 -1
  32. package/dist/accessi-module/Controllers/AccessiAuditController.d.ts +16 -0
  33. package/dist/accessi-module/Controllers/AccessiAuditController.js +155 -0
  34. package/dist/accessi-module/Controllers/AccessiAuditController.js.map +1 -0
  35. package/dist/accessi-module/Controllers/AccessiIntrospectionController.js +9 -0
  36. package/dist/accessi-module/Controllers/AccessiIntrospectionController.js.map +1 -1
  37. package/dist/accessi-module/Controllers/AuthController.js +82 -8
  38. package/dist/accessi-module/Controllers/AuthController.js.map +1 -1
  39. package/dist/accessi-module/Controllers/ConsoleEntryController.js +15 -0
  40. package/dist/accessi-module/Controllers/ConsoleEntryController.js.map +1 -1
  41. package/dist/accessi-module/Dtos/AuditDtos.d.ts +10 -0
  42. package/dist/accessi-module/Dtos/AuditDtos.js +68 -0
  43. package/dist/accessi-module/Dtos/AuditDtos.js.map +1 -0
  44. package/dist/accessi-module/Dtos/index.d.ts +1 -0
  45. package/dist/accessi-module/Dtos/index.js +1 -0
  46. package/dist/accessi-module/Dtos/index.js.map +1 -1
  47. package/dist/accessi-module/Services/AuthService/AuthService.js +8 -2
  48. package/dist/accessi-module/Services/AuthService/AuthService.js.map +1 -1
  49. package/dist/accessi-module/index.d.ts +9 -1
  50. package/dist/accessi-module/index.js +27 -2
  51. package/dist/accessi-module/index.js.map +1 -1
  52. package/dist/accessi-module/observability/metrics.d.ts +79 -0
  53. package/dist/accessi-module/observability/metrics.js +216 -0
  54. package/dist/accessi-module/observability/metrics.js.map +1 -0
  55. package/dist/accessi-module/observability/redisMetrics.d.ts +12 -0
  56. package/dist/accessi-module/observability/redisMetrics.js +44 -0
  57. package/dist/accessi-module/observability/redisMetrics.js.map +1 -0
  58. package/dist/accessi-module/security/accessTokenRevocation.js +5 -3
  59. package/dist/accessi-module/security/accessTokenRevocation.js.map +1 -1
  60. package/dist/accessi-module/security/audit.d.ts +68 -3
  61. package/dist/accessi-module/security/audit.js +177 -4
  62. package/dist/accessi-module/security/audit.js.map +1 -1
  63. package/dist/accessi-module/security/auditSinks.d.ts +55 -0
  64. package/dist/accessi-module/security/auditSinks.js +228 -0
  65. package/dist/accessi-module/security/auditSinks.js.map +1 -0
  66. package/dist/accessi-module/security/consoleEntry.js +5 -3
  67. package/dist/accessi-module/security/consoleEntry.js.map +1 -1
  68. package/dist/accessi-module/security/edgeRateLimit.js +3 -0
  69. package/dist/accessi-module/security/edgeRateLimit.js.map +1 -1
  70. package/dist/accessi-module/security/jwks.js +3 -0
  71. package/dist/accessi-module/security/jwks.js.map +1 -1
  72. package/dist/accessi-module/security/jwtTokenOptions.js +4 -0
  73. package/dist/accessi-module/security/jwtTokenOptions.js.map +1 -1
  74. package/dist/accessi-module/security/publicAuthRateLimit.js +10 -0
  75. package/dist/accessi-module/security/publicAuthRateLimit.js.map +1 -1
  76. package/dist/accessi-module/security/rateLimitStore.js +4 -2
  77. package/dist/accessi-module/security/rateLimitStore.js.map +1 -1
  78. package/dist/accessi-module/security/refreshTokenRotation.js +7 -5
  79. package/dist/accessi-module/security/refreshTokenRotation.js.map +1 -1
  80. package/dist/accessi-module/security/serviceTokenRateLimit.js +3 -0
  81. package/dist/accessi-module/security/serviceTokenRateLimit.js.map +1 -1
  82. package/dist/accessi-module/security/sessionRevocation.js +7 -3
  83. package/dist/accessi-module/security/sessionRevocation.js.map +1 -1
  84. package/dist/emilsoftware-utilities-2.0.0-dev.79.tgz +0 -0
  85. package/docs/audit.md +136 -0
  86. package/docs/metrics.md +153 -0
  87. package/docs/redis-load-testing.md +88 -0
  88. package/package.json +4 -3
  89. 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":";;;;;;;;;;;AAcA,gEAgBC;AAGD,gEAEC;AAGD,kEAWC;AAjDD,2CAA2D;AAC3D,qDAA4D;AAG5D;;;;;;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,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,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';\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 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 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"]}
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 options.client.eval(REVOKE_USER_SCRIPT, 1, `${keyPrefix}${codiceUtente}`, String(revokedAtMs), ttlMs);
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 options.client.eval(GET_USER_SCRIPT, 1, `${keyPrefix}${codiceUtente}`);
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 options.client.eval("return redis.call('PING')", 0);
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"]}
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.
@@ -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.77",
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",