@simonsbs/keylore 1.0.0-rc4

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 (81) hide show
  1. package/.env.example +64 -0
  2. package/LICENSE +176 -0
  3. package/NOTICE +5 -0
  4. package/README.md +424 -0
  5. package/bin/keylore-http.js +3 -0
  6. package/bin/keylore-stdio.js +3 -0
  7. package/data/auth-clients.json +54 -0
  8. package/data/catalog.json +53 -0
  9. package/data/policies.json +25 -0
  10. package/dist/adapters/adapter-registry.js +143 -0
  11. package/dist/adapters/aws-secrets-manager-adapter.js +99 -0
  12. package/dist/adapters/command-runner.js +17 -0
  13. package/dist/adapters/env-secret-adapter.js +42 -0
  14. package/dist/adapters/gcp-secret-manager-adapter.js +129 -0
  15. package/dist/adapters/local-secret-adapter.js +54 -0
  16. package/dist/adapters/onepassword-secret-adapter.js +83 -0
  17. package/dist/adapters/reference-utils.js +44 -0
  18. package/dist/adapters/types.js +1 -0
  19. package/dist/adapters/vault-secret-adapter.js +103 -0
  20. package/dist/app.js +132 -0
  21. package/dist/cli/args.js +51 -0
  22. package/dist/cli/run.js +483 -0
  23. package/dist/cli.js +18 -0
  24. package/dist/config.js +295 -0
  25. package/dist/domain/types.js +967 -0
  26. package/dist/http/admin-ui.js +3010 -0
  27. package/dist/http/server.js +1210 -0
  28. package/dist/index.js +40 -0
  29. package/dist/mcp/create-server.js +388 -0
  30. package/dist/mcp/stdio.js +7 -0
  31. package/dist/repositories/credential-repository.js +109 -0
  32. package/dist/repositories/interfaces.js +1 -0
  33. package/dist/repositories/json-file.js +20 -0
  34. package/dist/repositories/pg-access-token-repository.js +118 -0
  35. package/dist/repositories/pg-approval-repository.js +157 -0
  36. package/dist/repositories/pg-audit-log.js +62 -0
  37. package/dist/repositories/pg-auth-client-repository.js +98 -0
  38. package/dist/repositories/pg-authorization-code-repository.js +95 -0
  39. package/dist/repositories/pg-break-glass-repository.js +174 -0
  40. package/dist/repositories/pg-credential-repository.js +163 -0
  41. package/dist/repositories/pg-oauth-client-assertion-repository.js +25 -0
  42. package/dist/repositories/pg-policy-repository.js +62 -0
  43. package/dist/repositories/pg-refresh-token-repository.js +125 -0
  44. package/dist/repositories/pg-rotation-run-repository.js +127 -0
  45. package/dist/repositories/pg-tenant-repository.js +56 -0
  46. package/dist/repositories/policy-repository.js +24 -0
  47. package/dist/runtime/sandbox-runner.js +114 -0
  48. package/dist/services/access-fingerprint.js +13 -0
  49. package/dist/services/approval-service.js +148 -0
  50. package/dist/services/audit-log.js +38 -0
  51. package/dist/services/auth-context.js +43 -0
  52. package/dist/services/auth-secrets.js +14 -0
  53. package/dist/services/auth-service.js +784 -0
  54. package/dist/services/backup-service.js +610 -0
  55. package/dist/services/break-glass-service.js +207 -0
  56. package/dist/services/broker-service.js +557 -0
  57. package/dist/services/core-mode-service.js +154 -0
  58. package/dist/services/egress-policy.js +119 -0
  59. package/dist/services/local-secret-store.js +119 -0
  60. package/dist/services/maintenance-service.js +99 -0
  61. package/dist/services/notification-service.js +83 -0
  62. package/dist/services/policy-engine.js +85 -0
  63. package/dist/services/rate-limit-service.js +80 -0
  64. package/dist/services/rotation-service.js +271 -0
  65. package/dist/services/telemetry.js +149 -0
  66. package/dist/services/tenant-service.js +127 -0
  67. package/dist/services/trace-export-service.js +126 -0
  68. package/dist/services/trace-service.js +87 -0
  69. package/dist/storage/bootstrap.js +68 -0
  70. package/dist/storage/database.js +39 -0
  71. package/dist/storage/in-memory-database.js +40 -0
  72. package/dist/storage/migrations.js +27 -0
  73. package/migrations/001_init.sql +49 -0
  74. package/migrations/002_phase2_auth.sql +53 -0
  75. package/migrations/003_v05_operations.sql +9 -0
  76. package/migrations/004_v07_security.sql +28 -0
  77. package/migrations/005_v08_reviews.sql +11 -0
  78. package/migrations/006_v09_auth_trace_rotation.sql +51 -0
  79. package/migrations/007_v010_multi_tenant.sql +32 -0
  80. package/migrations/008_v011_auth_tenant_ops.sql +95 -0
  81. package/package.json +78 -0
@@ -0,0 +1,154 @@
1
+ import { coreCredentialCreateInputSchema, } from "../domain/types.js";
2
+ export class CoreModeService {
3
+ broker;
4
+ policies;
5
+ localSecrets;
6
+ defaultPrincipal;
7
+ coreRoles = ["admin", "operator"];
8
+ reconcileScopes = ["catalog:read", "catalog:write"];
9
+ constructor(broker, policies, localSecrets, defaultPrincipal) {
10
+ this.broker = broker;
11
+ this.policies = policies;
12
+ this.localSecrets = localSecrets;
13
+ this.defaultPrincipal = defaultPrincipal;
14
+ }
15
+ coreAllowRuleId(tenantId, credentialId) {
16
+ const safeTenant = tenantId.replace(/[^a-zA-Z0-9_-]+/g, "-");
17
+ const safeCredential = credentialId.replace(/[^a-zA-Z0-9_-]+/g, "-");
18
+ return `core-allow-${safeTenant}-${safeCredential}`;
19
+ }
20
+ buildCoreAllowRule(credential) {
21
+ return {
22
+ id: this.coreAllowRuleId(credential.tenantId, credential.id),
23
+ tenantId: credential.tenantId,
24
+ effect: "allow",
25
+ description: `Core mode auto-allow rule for ${credential.displayName}.`,
26
+ principals: ["*"],
27
+ principalRoles: this.coreRoles,
28
+ credentialIds: [credential.id],
29
+ services: [credential.service],
30
+ operations: credential.permittedOperations,
31
+ domainPatterns: credential.allowedDomains,
32
+ environments: ["development", "test"],
33
+ };
34
+ }
35
+ async syncCoreAllowRule(credential) {
36
+ const policies = await this.policies.read();
37
+ const ruleId = this.coreAllowRuleId(credential.tenantId, credential.id);
38
+ const nextRules = policies.rules.filter((rule) => rule.id !== ruleId);
39
+ nextRules.push(this.buildCoreAllowRule(credential));
40
+ await this.policies.replaceAll({
41
+ version: policies.version,
42
+ rules: nextRules,
43
+ });
44
+ }
45
+ async deleteCoreAllowRule(tenantId, credentialId) {
46
+ const policies = await this.policies.read();
47
+ const ruleId = this.coreAllowRuleId(tenantId, credentialId);
48
+ await this.policies.replaceAll({
49
+ version: policies.version,
50
+ rules: policies.rules.filter((rule) => rule.id !== ruleId),
51
+ });
52
+ }
53
+ async reconcileLocalCredentialPolicies() {
54
+ const credentials = await this.broker.listCredentials({
55
+ principal: this.defaultPrincipal,
56
+ clientId: this.defaultPrincipal,
57
+ tenantId: "default",
58
+ roles: this.coreRoles,
59
+ scopes: this.reconcileScopes,
60
+ });
61
+ const localCredentials = credentials.filter((credential) => credential.owner === "local");
62
+ for (const credential of localCredentials) {
63
+ await this.syncCoreAllowRule(credential);
64
+ }
65
+ }
66
+ async createCredential(context, input) {
67
+ const parsed = coreCredentialCreateInputSchema.parse(input);
68
+ const existing = await this.broker.getCredential(context, parsed.credentialId);
69
+ if (existing) {
70
+ throw new Error(`Credential ${parsed.credentialId} already exists.`);
71
+ }
72
+ const binding = parsed.secretSource.adapter === "local"
73
+ ? {
74
+ adapter: "local",
75
+ ref: `local:${parsed.tenantId}:${parsed.credentialId}`,
76
+ authType: parsed.authType,
77
+ headerName: parsed.headerName,
78
+ headerPrefix: parsed.headerPrefix,
79
+ injectionEnvName: parsed.injectionEnvName,
80
+ }
81
+ : {
82
+ adapter: "env",
83
+ ref: parsed.secretSource.ref,
84
+ authType: parsed.authType,
85
+ headerName: parsed.headerName,
86
+ headerPrefix: parsed.headerPrefix,
87
+ injectionEnvName: parsed.injectionEnvName,
88
+ };
89
+ const record = {
90
+ id: parsed.credentialId,
91
+ tenantId: parsed.tenantId,
92
+ displayName: parsed.displayName,
93
+ service: parsed.service,
94
+ owner: parsed.owner,
95
+ scopeTier: parsed.scopeTier,
96
+ sensitivity: parsed.sensitivity,
97
+ allowedDomains: parsed.allowedDomains,
98
+ permittedOperations: parsed.permittedOperations,
99
+ expiresAt: parsed.expiresAt,
100
+ rotationPolicy: parsed.rotationPolicy,
101
+ lastValidatedAt: null,
102
+ selectionNotes: parsed.selectionNotes,
103
+ binding,
104
+ tags: parsed.tags,
105
+ status: parsed.status,
106
+ };
107
+ if (parsed.secretSource.adapter === "local") {
108
+ await this.localSecrets.put(binding.ref, parsed.secretSource.secretValue);
109
+ try {
110
+ const created = await this.broker.createCredential(context, record);
111
+ try {
112
+ await this.syncCoreAllowRule(created);
113
+ }
114
+ catch (error) {
115
+ await this.broker.deleteCredential(context, created.id);
116
+ await this.localSecrets.delete(binding.ref);
117
+ throw error;
118
+ }
119
+ return created;
120
+ }
121
+ catch (error) {
122
+ await this.localSecrets.delete(binding.ref);
123
+ throw error;
124
+ }
125
+ }
126
+ const created = await this.broker.createCredential(context, record);
127
+ try {
128
+ await this.syncCoreAllowRule(created);
129
+ }
130
+ catch (error) {
131
+ await this.broker.deleteCredential(context, created.id);
132
+ throw error;
133
+ }
134
+ return created;
135
+ }
136
+ async updateCredentialContext(context, credentialId, patch) {
137
+ const updated = await this.broker.updateCredential(context, credentialId, patch);
138
+ await this.syncCoreAllowRule(updated);
139
+ return updated;
140
+ }
141
+ async deleteCredential(context, credentialId) {
142
+ const existing = await this.broker.getCredential(context, credentialId);
143
+ if (!existing) {
144
+ return false;
145
+ }
146
+ const deleted = await this.broker.deleteCredential(context, credentialId);
147
+ if (!deleted) {
148
+ return false;
149
+ }
150
+ await this.localSecrets.delete(`local:${existing.tenantId}:${existing.id}`);
151
+ await this.deleteCoreAllowRule(existing.tenantId, existing.id);
152
+ return true;
153
+ }
154
+ }
@@ -0,0 +1,119 @@
1
+ import dns from "node:dns/promises";
2
+ import net from "node:net";
3
+ function matchPattern(value, pattern) {
4
+ const normalizedValue = value.toLowerCase();
5
+ const normalizedPattern = pattern.toLowerCase();
6
+ if (normalizedPattern === "*") {
7
+ return true;
8
+ }
9
+ if (normalizedPattern.startsWith("*.")) {
10
+ return (normalizedValue === normalizedPattern.slice(2) ||
11
+ normalizedValue.endsWith(normalizedPattern.slice(1)));
12
+ }
13
+ return normalizedValue === normalizedPattern;
14
+ }
15
+ function isLoopbackHostname(hostname) {
16
+ return hostname === "localhost";
17
+ }
18
+ function isLoopbackIp(ip) {
19
+ return ip === "::1" || ip === "127.0.0.1" || ip.startsWith("127.");
20
+ }
21
+ function isBlockedIpv4(ip) {
22
+ const octets = ip.split(".").map((value) => Number.parseInt(value, 10));
23
+ if (octets.length !== 4 || octets.some((value) => Number.isNaN(value) || value < 0 || value > 255)) {
24
+ return true;
25
+ }
26
+ const [a, b, c] = octets;
27
+ if (a === 0 || a === 10 || a === 127) {
28
+ return true;
29
+ }
30
+ if (a === 100 && b >= 64 && b <= 127) {
31
+ return true;
32
+ }
33
+ if (a === 169 && b === 254) {
34
+ return true;
35
+ }
36
+ if (a === 172 && b >= 16 && b <= 31) {
37
+ return true;
38
+ }
39
+ if (a === 192 && b === 0) {
40
+ return true;
41
+ }
42
+ if (a === 192 && b === 168) {
43
+ return true;
44
+ }
45
+ if (a === 192 && b === 0 && c === 2) {
46
+ return true;
47
+ }
48
+ if (a === 198 && (b === 18 || b === 19 || b === 51) && c === 100) {
49
+ return true;
50
+ }
51
+ if (a === 203 && b === 0 && c === 113) {
52
+ return true;
53
+ }
54
+ return a >= 224;
55
+ }
56
+ function isBlockedIpv6(ip) {
57
+ const normalized = ip.toLowerCase();
58
+ return (normalized === "::" ||
59
+ normalized === "::1" ||
60
+ normalized.startsWith("fc") ||
61
+ normalized.startsWith("fd") ||
62
+ normalized.startsWith("fe8") ||
63
+ normalized.startsWith("fe9") ||
64
+ normalized.startsWith("fea") ||
65
+ normalized.startsWith("feb") ||
66
+ normalized.startsWith("::ffff:127.") ||
67
+ normalized.startsWith("::ffff:10.") ||
68
+ normalized.startsWith("::ffff:192.168.") ||
69
+ normalized.startsWith("::ffff:172.16.") ||
70
+ normalized.startsWith("::ffff:172.17.") ||
71
+ normalized.startsWith("::ffff:172.18.") ||
72
+ normalized.startsWith("::ffff:172.19.") ||
73
+ normalized.startsWith("::ffff:172.2") ||
74
+ normalized.startsWith("::ffff:169.254."));
75
+ }
76
+ function isBlockedIp(ip) {
77
+ if (net.isIPv4(ip)) {
78
+ return isBlockedIpv4(ip);
79
+ }
80
+ if (net.isIPv6(ip)) {
81
+ return isBlockedIpv6(ip);
82
+ }
83
+ return true;
84
+ }
85
+ async function resolveAddresses(hostname) {
86
+ try {
87
+ const records = await dns.lookup(hostname, { all: true, verbatim: true });
88
+ return records.map((record) => record.address);
89
+ }
90
+ catch {
91
+ return [];
92
+ }
93
+ }
94
+ export async function validateEgressTarget(rawUrl, config) {
95
+ const targetUrl = new URL(rawUrl);
96
+ const hostname = targetUrl.hostname;
97
+ const hostAllowlisted = config.egressAllowedHosts.some((pattern) => matchPattern(hostname, pattern));
98
+ const isLoopbackHost = isLoopbackHostname(hostname);
99
+ const isLoopbackHttp = targetUrl.protocol === "http:" && isLoopbackHost;
100
+ if (targetUrl.protocol !== "https:" && !isLoopbackHttp) {
101
+ throw new Error("Only HTTPS targets are allowed, except localhost for local development.");
102
+ }
103
+ if (targetUrl.protocol === "https:" &&
104
+ targetUrl.port.length > 0 &&
105
+ !config.egressAllowedHttpsPorts.includes(Number.parseInt(targetUrl.port, 10)) &&
106
+ !hostAllowlisted) {
107
+ throw new Error("HTTPS target port is not allowlisted by egress policy.");
108
+ }
109
+ if (config.egressAllowPrivateIps || hostAllowlisted || isLoopbackHost) {
110
+ return targetUrl;
111
+ }
112
+ const addresses = net.isIP(hostname) > 0
113
+ ? [hostname]
114
+ : await resolveAddresses(hostname);
115
+ if (addresses.some((address) => isLoopbackIp(address) || isBlockedIp(address))) {
116
+ throw new Error("Target resolves to a blocked private, loopback, or link-local address.");
117
+ }
118
+ return targetUrl;
119
+ }
@@ -0,0 +1,119 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ function encode(value) {
5
+ return value.toString("base64");
6
+ }
7
+ function decode(value) {
8
+ return Buffer.from(value, "base64");
9
+ }
10
+ const EMPTY_STORE = {
11
+ version: 1,
12
+ entries: {},
13
+ };
14
+ export class LocalSecretStore {
15
+ secretsFilePath;
16
+ keyFilePath;
17
+ constructor(secretsFilePath, keyFilePath) {
18
+ this.secretsFilePath = secretsFilePath;
19
+ this.keyFilePath = keyFilePath;
20
+ }
21
+ async ensureParentDirs() {
22
+ await fs.mkdir(path.dirname(this.secretsFilePath), { recursive: true });
23
+ await fs.mkdir(path.dirname(this.keyFilePath), { recursive: true });
24
+ }
25
+ async loadKey() {
26
+ await this.ensureParentDirs();
27
+ try {
28
+ const existing = await fs.readFile(this.keyFilePath, "utf8");
29
+ return decode(existing.trim());
30
+ }
31
+ catch (error) {
32
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
33
+ throw error;
34
+ }
35
+ }
36
+ const key = randomBytes(32);
37
+ await fs.writeFile(this.keyFilePath, encode(key), { mode: 0o600 });
38
+ return key;
39
+ }
40
+ async loadStore() {
41
+ await this.ensureParentDirs();
42
+ try {
43
+ const raw = await fs.readFile(this.secretsFilePath, "utf8");
44
+ const parsed = JSON.parse(raw);
45
+ if (parsed.version !== 1 || typeof parsed.entries !== "object" || !parsed.entries) {
46
+ throw new Error("Local secret store file is invalid.");
47
+ }
48
+ return parsed;
49
+ }
50
+ catch (error) {
51
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
52
+ throw error;
53
+ }
54
+ return { ...EMPTY_STORE, entries: {} };
55
+ }
56
+ }
57
+ async saveStore(store) {
58
+ await this.ensureParentDirs();
59
+ const tempPath = `${this.secretsFilePath}.tmp`;
60
+ await fs.writeFile(tempPath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
61
+ await fs.rename(tempPath, this.secretsFilePath);
62
+ }
63
+ async encrypt(secret) {
64
+ const key = await this.loadKey();
65
+ const iv = randomBytes(12);
66
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
67
+ const ciphertext = Buffer.concat([cipher.update(secret, "utf8"), cipher.final()]);
68
+ const tag = cipher.getAuthTag();
69
+ return {
70
+ iv: encode(iv),
71
+ tag: encode(tag),
72
+ ciphertext: encode(ciphertext),
73
+ updatedAt: new Date().toISOString(),
74
+ };
75
+ }
76
+ async decrypt(entry) {
77
+ const key = await this.loadKey();
78
+ const decipher = createDecipheriv("aes-256-gcm", key, decode(entry.iv));
79
+ decipher.setAuthTag(decode(entry.tag));
80
+ const secret = Buffer.concat([
81
+ decipher.update(decode(entry.ciphertext)),
82
+ decipher.final(),
83
+ ]);
84
+ return secret.toString("utf8");
85
+ }
86
+ async put(ref, secret) {
87
+ const store = await this.loadStore();
88
+ store.entries[ref] = await this.encrypt(secret);
89
+ await this.saveStore(store);
90
+ }
91
+ async get(ref) {
92
+ const store = await this.loadStore();
93
+ const entry = store.entries[ref];
94
+ if (!entry) {
95
+ return undefined;
96
+ }
97
+ return this.decrypt(entry);
98
+ }
99
+ async delete(ref) {
100
+ const store = await this.loadStore();
101
+ if (!store.entries[ref]) {
102
+ return;
103
+ }
104
+ delete store.entries[ref];
105
+ await this.saveStore(store);
106
+ }
107
+ async inspect(ref) {
108
+ const store = await this.loadStore();
109
+ const entry = store.entries[ref];
110
+ return {
111
+ resolved: Boolean(entry),
112
+ updatedAt: entry?.updatedAt,
113
+ };
114
+ }
115
+ async healthcheck() {
116
+ await this.loadKey();
117
+ await this.loadStore();
118
+ }
119
+ }
@@ -0,0 +1,99 @@
1
+ export class MaintenanceService {
2
+ enabled;
3
+ intervalMs;
4
+ approvals;
5
+ breakGlass;
6
+ tokens;
7
+ refreshTokens;
8
+ rateLimits;
9
+ authorizationCodes;
10
+ assertions;
11
+ telemetry;
12
+ timer;
13
+ running = false;
14
+ statusSnapshot;
15
+ constructor(enabled, intervalMs, approvals, breakGlass, tokens, refreshTokens, rateLimits, authorizationCodes, assertions, telemetry) {
16
+ this.enabled = enabled;
17
+ this.intervalMs = intervalMs;
18
+ this.approvals = approvals;
19
+ this.breakGlass = breakGlass;
20
+ this.tokens = tokens;
21
+ this.refreshTokens = refreshTokens;
22
+ this.rateLimits = rateLimits;
23
+ this.authorizationCodes = authorizationCodes;
24
+ this.assertions = assertions;
25
+ this.telemetry = telemetry;
26
+ this.statusSnapshot = {
27
+ enabled,
28
+ intervalMs,
29
+ running: false,
30
+ consecutiveFailures: 0,
31
+ };
32
+ }
33
+ start() {
34
+ if (!this.enabled || this.timer) {
35
+ return;
36
+ }
37
+ this.timer = setInterval(() => {
38
+ void this.runOnce("scheduled");
39
+ }, this.intervalMs);
40
+ this.timer.unref();
41
+ }
42
+ async stop() {
43
+ if (this.timer) {
44
+ clearInterval(this.timer);
45
+ this.timer = undefined;
46
+ }
47
+ }
48
+ status() {
49
+ return { ...this.statusSnapshot };
50
+ }
51
+ async runOnce(task = "manual") {
52
+ if (this.running) {
53
+ throw new Error("Maintenance is already running.");
54
+ }
55
+ this.running = true;
56
+ this.statusSnapshot.running = true;
57
+ this.statusSnapshot.lastRunAt = new Date().toISOString();
58
+ const startedAt = Date.now();
59
+ try {
60
+ const result = {
61
+ approvalsExpired: await this.approvals.expireStale(),
62
+ breakGlassExpired: await this.breakGlass.expireStale(),
63
+ accessTokensExpired: await this.tokens.expireStale(),
64
+ refreshTokensExpired: await this.refreshTokens.expireStale(),
65
+ rateLimitBucketsDeleted: await this.rateLimits.cleanup(),
66
+ authorizationCodesExpired: await this.authorizationCodes.cleanup(),
67
+ oauthClientAssertionsExpired: await this.assertions.cleanup(),
68
+ };
69
+ const durationMs = Date.now() - startedAt;
70
+ this.statusSnapshot = {
71
+ ...this.statusSnapshot,
72
+ running: false,
73
+ lastSuccessAt: new Date().toISOString(),
74
+ lastDurationMs: durationMs,
75
+ consecutiveFailures: 0,
76
+ lastError: undefined,
77
+ lastResult: result,
78
+ };
79
+ this.telemetry.recordMaintenanceRun(task, "success", durationMs);
80
+ return result;
81
+ }
82
+ catch (error) {
83
+ const durationMs = Date.now() - startedAt;
84
+ this.statusSnapshot = {
85
+ ...this.statusSnapshot,
86
+ running: false,
87
+ lastDurationMs: durationMs,
88
+ consecutiveFailures: this.statusSnapshot.consecutiveFailures + 1,
89
+ lastError: error instanceof Error ? error.message : "Maintenance failed.",
90
+ };
91
+ this.telemetry.recordMaintenanceRun(task, "error", durationMs);
92
+ throw error;
93
+ }
94
+ finally {
95
+ this.running = false;
96
+ this.statusSnapshot.running = false;
97
+ }
98
+ }
99
+ }
@@ -0,0 +1,83 @@
1
+ import { createHmac, randomUUID } from "node:crypto";
2
+ export class NotificationService {
3
+ webhookUrl;
4
+ signingSecret;
5
+ timeoutMs;
6
+ audit;
7
+ telemetry;
8
+ traces;
9
+ constructor(webhookUrl, signingSecret, timeoutMs, audit, telemetry, traces) {
10
+ this.webhookUrl = webhookUrl;
11
+ this.signingSecret = signingSecret;
12
+ this.timeoutMs = timeoutMs;
13
+ this.audit = audit;
14
+ this.telemetry = telemetry;
15
+ this.traces = traces;
16
+ }
17
+ async send(type, payload) {
18
+ if (!this.webhookUrl) {
19
+ this.telemetry.recordNotificationDelivery(type, "disabled");
20
+ return;
21
+ }
22
+ const webhookUrl = this.webhookUrl;
23
+ const envelope = {
24
+ id: randomUUID(),
25
+ type,
26
+ occurredAt: new Date().toISOString(),
27
+ traceId: this.traces.currentTraceId(),
28
+ payload,
29
+ };
30
+ const body = JSON.stringify(envelope);
31
+ const headers = {
32
+ "content-type": "application/json",
33
+ "x-keylore-event-type": type,
34
+ "x-keylore-event-id": envelope.id,
35
+ };
36
+ if (this.signingSecret) {
37
+ headers["x-keylore-signature"] = createHmac("sha256", this.signingSecret).update(body).digest("hex");
38
+ }
39
+ await this.traces.withSpan("notification.delivery", { eventType: type }, async () => {
40
+ const tenantId = typeof payload.tenantId === "string" && payload.tenantId.length > 0 ? payload.tenantId : undefined;
41
+ try {
42
+ const response = await fetch(webhookUrl, {
43
+ method: "POST",
44
+ headers,
45
+ body,
46
+ signal: AbortSignal.timeout(this.timeoutMs),
47
+ });
48
+ if (!response.ok) {
49
+ throw new Error(`Notification webhook responded with ${response.status}.`);
50
+ }
51
+ this.telemetry.recordNotificationDelivery(type, "success");
52
+ await this.audit.record({
53
+ type: "notification.delivery",
54
+ action: `notification.${type}`,
55
+ outcome: "success",
56
+ tenantId,
57
+ principal: "keylore-system",
58
+ metadata: {
59
+ tenantId: tenantId ?? null,
60
+ eventType: type,
61
+ webhookUrl,
62
+ },
63
+ });
64
+ }
65
+ catch (error) {
66
+ this.telemetry.recordNotificationDelivery(type, "error");
67
+ await this.audit.record({
68
+ type: "notification.delivery",
69
+ action: `notification.${type}`,
70
+ outcome: "error",
71
+ tenantId,
72
+ principal: "keylore-system",
73
+ metadata: {
74
+ tenantId: tenantId ?? null,
75
+ eventType: type,
76
+ webhookUrl,
77
+ error: error instanceof Error ? error.message : "Notification delivery failed.",
78
+ },
79
+ });
80
+ }
81
+ });
82
+ }
83
+ }
@@ -0,0 +1,85 @@
1
+ function matchPattern(value, pattern) {
2
+ if (pattern === "*") {
3
+ return true;
4
+ }
5
+ const normalizedValue = value.toLowerCase();
6
+ const normalizedPattern = pattern.toLowerCase();
7
+ if (normalizedPattern.startsWith("*.")) {
8
+ return (normalizedValue === normalizedPattern.slice(2) ||
9
+ normalizedValue.endsWith(normalizedPattern.slice(1)));
10
+ }
11
+ return normalizedValue === normalizedPattern;
12
+ }
13
+ function ruleMatches(rule, principal, roles, credential, operation, host, environment) {
14
+ const principalMatch = rule.principals.some((candidate) => matchPattern(principal, candidate));
15
+ const roleMatch = !rule.principalRoles ||
16
+ rule.principalRoles.some((requiredRole) => roles.includes(requiredRole));
17
+ const credentialMatch = !rule.credentialIds || rule.credentialIds.some((candidate) => candidate === credential.id);
18
+ const serviceMatch = !rule.services || rule.services.some((candidate) => matchPattern(credential.service, candidate));
19
+ const operationMatch = rule.operations.some((candidate) => matchPattern(operation, candidate));
20
+ const domainMatch = rule.domainPatterns.some((candidate) => matchPattern(host, candidate));
21
+ const environmentMatch = !rule.environments ||
22
+ rule.environments.some((candidate) => matchPattern(environment, candidate));
23
+ return (principalMatch &&
24
+ roleMatch &&
25
+ credentialMatch &&
26
+ serviceMatch &&
27
+ operationMatch &&
28
+ domainMatch &&
29
+ environmentMatch);
30
+ }
31
+ export class PolicyEngine {
32
+ evaluate(policies, principal, roles, credential, operation, host, environment) {
33
+ if (credential.status !== "active") {
34
+ return { decision: "deny", reason: "Credential is not active.", ruleId: undefined };
35
+ }
36
+ if (credential.expiresAt &&
37
+ new Date(credential.expiresAt).getTime() <= Date.now()) {
38
+ return { decision: "deny", reason: "Credential has expired.", ruleId: undefined };
39
+ }
40
+ if (!credential.permittedOperations.includes(operation)) {
41
+ return {
42
+ decision: "deny",
43
+ reason: "Operation is not permitted for this credential.",
44
+ ruleId: undefined,
45
+ };
46
+ }
47
+ if (!credential.allowedDomains.some((domain) => matchPattern(host, domain))) {
48
+ return {
49
+ decision: "deny",
50
+ reason: "Target domain is not allowlisted for this credential.",
51
+ ruleId: undefined,
52
+ };
53
+ }
54
+ const matchingRules = policies.rules.filter((rule) => ruleMatches(rule, principal, roles, credential, operation, host, environment));
55
+ const denyRule = matchingRules.find((rule) => rule.effect === "deny");
56
+ if (denyRule) {
57
+ return {
58
+ decision: "deny",
59
+ reason: denyRule.description,
60
+ ruleId: denyRule.id,
61
+ };
62
+ }
63
+ const approvalRule = matchingRules.find((rule) => rule.effect === "approval");
64
+ if (approvalRule) {
65
+ return {
66
+ decision: "approval",
67
+ reason: approvalRule.description,
68
+ ruleId: approvalRule.id,
69
+ };
70
+ }
71
+ const allowRule = matchingRules.find((rule) => rule.effect === "allow");
72
+ if (allowRule) {
73
+ return {
74
+ decision: "allow",
75
+ reason: allowRule.description,
76
+ ruleId: allowRule.id,
77
+ };
78
+ }
79
+ return {
80
+ decision: "deny",
81
+ reason: "No matching allow rule was found. KeyLore is default-deny.",
82
+ ruleId: undefined,
83
+ };
84
+ }
85
+ }