@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,157 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { approvalRequestSchema } from "../domain/types.js";
3
+ function toIso(value) {
4
+ if (value === null) {
5
+ return undefined;
6
+ }
7
+ return value instanceof Date ? value.toISOString() : value;
8
+ }
9
+ function mapRow(row) {
10
+ return approvalRequestSchema.parse({
11
+ id: row.id,
12
+ tenantId: row.tenant_id,
13
+ createdAt: toIso(row.created_at),
14
+ expiresAt: toIso(row.expires_at),
15
+ status: row.status,
16
+ requestedBy: row.requested_by,
17
+ requestedRoles: row.requested_roles,
18
+ credentialId: row.credential_id,
19
+ operation: row.operation,
20
+ targetUrl: row.target_url,
21
+ targetHost: row.target_host,
22
+ reason: row.reason,
23
+ ruleId: row.rule_id ?? undefined,
24
+ correlationId: row.correlation_id,
25
+ fingerprint: row.fingerprint,
26
+ requiredApprovals: row.required_approvals,
27
+ approvalCount: row.approval_count,
28
+ denialCount: row.denial_count,
29
+ reviews: Array.isArray(row.reviews) ? row.reviews : [],
30
+ reviewedBy: row.reviewed_by ?? undefined,
31
+ reviewedAt: toIso(row.reviewed_at),
32
+ reviewNote: row.review_note ?? undefined,
33
+ });
34
+ }
35
+ export class PgApprovalRepository {
36
+ database;
37
+ constructor(database) {
38
+ this.database = database;
39
+ }
40
+ async create(input) {
41
+ const parsed = approvalRequestSchema.parse(input);
42
+ await this.database.query(`INSERT INTO approval_requests (
43
+ id, tenant_id, created_at, expires_at, status, requested_by, requested_roles,
44
+ credential_id, operation, target_url, target_host, reason, rule_id,
45
+ correlation_id, fingerprint, required_approvals, approval_count, denial_count, reviews,
46
+ reviewed_by, reviewed_at, review_note
47
+ ) VALUES (
48
+ $1, $2, $3, $4, $5, $6, $7,
49
+ $8, $9, $10, $11, $12, $13,
50
+ $14, $15, $16, $17, $18, $19,
51
+ $20, $21, $22
52
+ )`, [
53
+ parsed.id,
54
+ parsed.tenantId,
55
+ parsed.createdAt,
56
+ parsed.expiresAt,
57
+ parsed.status,
58
+ parsed.requestedBy,
59
+ parsed.requestedRoles,
60
+ parsed.credentialId,
61
+ parsed.operation,
62
+ parsed.targetUrl,
63
+ parsed.targetHost,
64
+ parsed.reason,
65
+ parsed.ruleId ?? null,
66
+ parsed.correlationId,
67
+ parsed.fingerprint,
68
+ parsed.requiredApprovals,
69
+ parsed.approvalCount,
70
+ parsed.denialCount,
71
+ JSON.stringify(parsed.reviews),
72
+ parsed.reviewedBy ?? null,
73
+ parsed.reviewedAt ?? null,
74
+ parsed.reviewNote ?? null,
75
+ ]);
76
+ return parsed;
77
+ }
78
+ async expireStale() {
79
+ const result = await this.database.query(`WITH expired AS (
80
+ UPDATE approval_requests
81
+ SET status = 'expired'
82
+ WHERE status = 'pending' AND expires_at <= NOW()
83
+ RETURNING 1
84
+ )
85
+ SELECT COUNT(*)::text AS count FROM expired`);
86
+ return Number.parseInt(result.rows[0]?.count ?? "0", 10);
87
+ }
88
+ async getById(id) {
89
+ const result = await this.database.query("SELECT * FROM approval_requests WHERE id = $1", [id]);
90
+ return result.rows[0] ? mapRow(result.rows[0]) : undefined;
91
+ }
92
+ async list(status, tenantId) {
93
+ const clauses = [];
94
+ const params = [];
95
+ if (status) {
96
+ params.push(status);
97
+ clauses.push(`status = $${params.length}`);
98
+ }
99
+ if (tenantId) {
100
+ params.push(tenantId);
101
+ clauses.push(`tenant_id = $${params.length}`);
102
+ }
103
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
104
+ const result = await this.database.query(`SELECT * FROM approval_requests ${where} ORDER BY created_at DESC`, params);
105
+ return result.rows.map(mapRow);
106
+ }
107
+ async review(id, update) {
108
+ return this.database.withTransaction(async (client) => {
109
+ const currentResult = await client.query("SELECT * FROM approval_requests WHERE id = $1 FOR UPDATE", [id]);
110
+ const current = currentResult.rows[0];
111
+ if (!current) {
112
+ return undefined;
113
+ }
114
+ const parsed = mapRow(current);
115
+ if (parsed.status !== "pending") {
116
+ return undefined;
117
+ }
118
+ if (parsed.reviews.some((review) => review.reviewedBy === update.reviewedBy)) {
119
+ throw new Error("Reviewer has already reviewed this request.");
120
+ }
121
+ const review = {
122
+ reviewId: randomUUID(),
123
+ reviewedAt: new Date().toISOString(),
124
+ reviewedBy: update.reviewedBy,
125
+ decision: update.status,
126
+ note: update.reviewNote,
127
+ };
128
+ const reviews = [...parsed.reviews, review];
129
+ const approvalCount = parsed.approvalCount + (update.status === "approved" ? 1 : 0);
130
+ const denialCount = parsed.denialCount + (update.status === "denied" ? 1 : 0);
131
+ const nextStatus = denialCount > 0
132
+ ? "denied"
133
+ : approvalCount >= parsed.requiredApprovals
134
+ ? "approved"
135
+ : "pending";
136
+ const result = await client.query(`UPDATE approval_requests
137
+ SET status = $2,
138
+ approval_count = $3,
139
+ denial_count = $4,
140
+ reviews = $5::jsonb,
141
+ reviewed_by = $6,
142
+ reviewed_at = NOW(),
143
+ review_note = $7
144
+ WHERE id = $1
145
+ RETURNING *`, [
146
+ id,
147
+ nextStatus,
148
+ approvalCount,
149
+ denialCount,
150
+ JSON.stringify(reviews),
151
+ update.reviewedBy,
152
+ update.reviewNote ?? null,
153
+ ]);
154
+ return result.rows[0] ? mapRow(result.rows[0]) : undefined;
155
+ });
156
+ }
157
+ }
@@ -0,0 +1,62 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { auditEventSchema } from "../domain/types.js";
3
+ function mapRow(row) {
4
+ return auditEventSchema.parse({
5
+ eventId: row.event_id,
6
+ occurredAt: row.occurred_at instanceof Date
7
+ ? row.occurred_at.toISOString()
8
+ : row.occurred_at,
9
+ tenantId: row.tenant_id,
10
+ type: row.type,
11
+ action: row.action,
12
+ outcome: row.outcome,
13
+ principal: row.principal,
14
+ correlationId: row.correlation_id,
15
+ metadata: row.metadata,
16
+ });
17
+ }
18
+ export class PgAuditLogService {
19
+ database;
20
+ constructor(database) {
21
+ this.database = database;
22
+ }
23
+ async record(input) {
24
+ const event = auditEventSchema.parse({
25
+ eventId: randomUUID(),
26
+ occurredAt: new Date().toISOString(),
27
+ tenantId: input.tenantId ?? "default",
28
+ type: input.type,
29
+ action: input.action,
30
+ outcome: input.outcome,
31
+ principal: input.principal,
32
+ correlationId: input.correlationId ?? randomUUID(),
33
+ metadata: input.metadata ?? {},
34
+ });
35
+ await this.database.query(`INSERT INTO audit_events (
36
+ event_id, occurred_at, tenant_id, type, action, outcome, principal, correlation_id, metadata
37
+ ) VALUES (
38
+ $1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb
39
+ )`, [
40
+ event.eventId,
41
+ event.occurredAt,
42
+ event.tenantId,
43
+ event.type,
44
+ event.action,
45
+ event.outcome,
46
+ event.principal,
47
+ event.correlationId,
48
+ JSON.stringify(event.metadata),
49
+ ]);
50
+ return event;
51
+ }
52
+ async listRecent(limit = 20, tenantId) {
53
+ const result = tenantId
54
+ ? await this.database.query(`SELECT * FROM audit_events WHERE tenant_id = $2 ORDER BY occurred_at DESC LIMIT $1`, [limit, tenantId])
55
+ : await this.database.query(`SELECT * FROM audit_events ORDER BY occurred_at DESC LIMIT $1`, [limit]);
56
+ return result.rows.map(mapRow);
57
+ }
58
+ async count() {
59
+ const result = await this.database.query("SELECT COUNT(*)::text AS count FROM audit_events");
60
+ return Number.parseInt(result.rows[0]?.count ?? "0", 10);
61
+ }
62
+ }
@@ -0,0 +1,98 @@
1
+ import { authClientRecordSchema, publicJwkSchema, } from "../domain/types.js";
2
+ function normalizeJwks(value) {
3
+ if (Array.isArray(value)) {
4
+ return value;
5
+ }
6
+ if (value && typeof value === "object" && "kty" in value) {
7
+ return [value];
8
+ }
9
+ return [];
10
+ }
11
+ function mapRow(row) {
12
+ const base = authClientRecordSchema.parse({
13
+ clientId: row.client_id,
14
+ tenantId: row.tenant_id,
15
+ displayName: row.display_name,
16
+ roles: row.roles,
17
+ allowedScopes: row.allowed_scopes,
18
+ status: row.status,
19
+ tokenEndpointAuthMethod: row.token_endpoint_auth_method,
20
+ grantTypes: row.grant_types,
21
+ redirectUris: row.redirect_uris,
22
+ jwks: normalizeJwks(row.jwks).map((entry) => publicJwkSchema.parse(entry)),
23
+ });
24
+ return {
25
+ ...base,
26
+ secretHash: row.secret_hash ?? undefined,
27
+ secretSalt: row.secret_salt ?? undefined,
28
+ };
29
+ }
30
+ function stripSecrets(client) {
31
+ return authClientRecordSchema.parse({
32
+ clientId: client.clientId,
33
+ tenantId: client.tenantId,
34
+ displayName: client.displayName,
35
+ roles: client.roles,
36
+ allowedScopes: client.allowedScopes,
37
+ status: client.status,
38
+ tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
39
+ grantTypes: client.grantTypes,
40
+ redirectUris: client.redirectUris,
41
+ jwks: client.jwks,
42
+ });
43
+ }
44
+ export class PgAuthClientRepository {
45
+ database;
46
+ constructor(database) {
47
+ this.database = database;
48
+ }
49
+ async ensureInitialized() {
50
+ await this.database.healthcheck();
51
+ }
52
+ async count() {
53
+ const result = await this.database.query("SELECT COUNT(*)::text AS count FROM oauth_clients");
54
+ return Number.parseInt(result.rows[0]?.count ?? "0", 10);
55
+ }
56
+ async list() {
57
+ const result = await this.database.query("SELECT * FROM oauth_clients ORDER BY client_id ASC");
58
+ return result.rows.map((row) => stripSecrets(mapRow(row)));
59
+ }
60
+ async getByClientId(clientId) {
61
+ const result = await this.database.query("SELECT * FROM oauth_clients WHERE client_id = $1", [clientId]);
62
+ return result.rows[0] ? mapRow(result.rows[0]) : undefined;
63
+ }
64
+ async upsert(client) {
65
+ await this.database.query(`INSERT INTO oauth_clients (
66
+ client_id, tenant_id, display_name, secret_hash, secret_salt, roles, allowed_scopes, status,
67
+ token_endpoint_auth_method, grant_types, redirect_uris, jwks
68
+ ) VALUES (
69
+ $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12
70
+ )
71
+ ON CONFLICT (client_id) DO UPDATE SET
72
+ tenant_id = EXCLUDED.tenant_id,
73
+ display_name = EXCLUDED.display_name,
74
+ secret_hash = EXCLUDED.secret_hash,
75
+ secret_salt = EXCLUDED.secret_salt,
76
+ roles = EXCLUDED.roles,
77
+ allowed_scopes = EXCLUDED.allowed_scopes,
78
+ status = EXCLUDED.status,
79
+ token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method,
80
+ grant_types = EXCLUDED.grant_types,
81
+ redirect_uris = EXCLUDED.redirect_uris,
82
+ jwks = EXCLUDED.jwks,
83
+ updated_at = NOW()`, [
84
+ client.clientId,
85
+ client.tenantId,
86
+ client.displayName,
87
+ client.secretHash ?? null,
88
+ client.secretSalt ?? null,
89
+ client.roles,
90
+ client.allowedScopes,
91
+ client.status,
92
+ client.tokenEndpointAuthMethod,
93
+ client.grantTypes,
94
+ client.redirectUris,
95
+ client.jwks,
96
+ ]);
97
+ }
98
+ }
@@ -0,0 +1,95 @@
1
+ function toIso(value) {
2
+ if (value === null) {
3
+ return undefined;
4
+ }
5
+ return value instanceof Date ? value.toISOString() : value;
6
+ }
7
+ function mapRow(row) {
8
+ return {
9
+ codeId: row.code_id,
10
+ clientId: row.client_id,
11
+ tenantId: row.tenant_id,
12
+ subject: row.subject,
13
+ scopes: row.scopes,
14
+ roles: row.roles,
15
+ resource: row.resource ?? undefined,
16
+ redirectUri: row.redirect_uri,
17
+ codeChallenge: row.code_challenge,
18
+ codeChallengeMethod: row.code_challenge_method,
19
+ expiresAt: toIso(row.expires_at) ?? new Date(0).toISOString(),
20
+ status: row.status,
21
+ createdAt: toIso(row.created_at) ?? new Date(0).toISOString(),
22
+ consumedAt: toIso(row.consumed_at),
23
+ };
24
+ }
25
+ export class PgAuthorizationCodeRepository {
26
+ database;
27
+ constructor(database) {
28
+ this.database = database;
29
+ }
30
+ async create(input) {
31
+ await this.database.query(`INSERT INTO oauth_authorization_codes (
32
+ code_id, code_hash, client_id, tenant_id, subject, scopes, roles, resource,
33
+ redirect_uri, code_challenge, code_challenge_method, expires_at, status
34
+ ) VALUES (
35
+ $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'active'
36
+ )`, [
37
+ input.codeId,
38
+ input.codeHash,
39
+ input.clientId,
40
+ input.tenantId,
41
+ input.subject,
42
+ input.scopes,
43
+ input.roles,
44
+ input.resource ?? null,
45
+ input.redirectUri,
46
+ input.codeChallenge,
47
+ input.codeChallengeMethod,
48
+ input.expiresAt,
49
+ ]);
50
+ }
51
+ async consumeByHash(codeHash) {
52
+ return this.database.withTransaction(async (client) => {
53
+ const result = await client.query("SELECT * FROM oauth_authorization_codes WHERE code_hash = $1 FOR UPDATE", [codeHash]);
54
+ const row = result.rows[0];
55
+ if (!row) {
56
+ return undefined;
57
+ }
58
+ const record = mapRow(row);
59
+ const expired = new Date(record.expiresAt).getTime() <= Date.now();
60
+ if (record.status !== "active" || expired) {
61
+ if (expired && record.status === "active") {
62
+ await client.query("UPDATE oauth_authorization_codes SET status = 'revoked' WHERE code_hash = $1", [codeHash]);
63
+ }
64
+ return undefined;
65
+ }
66
+ await client.query(`UPDATE oauth_authorization_codes
67
+ SET status = 'consumed', consumed_at = NOW()
68
+ WHERE code_hash = $1`, [codeHash]);
69
+ return {
70
+ ...record,
71
+ status: "consumed",
72
+ consumedAt: new Date().toISOString(),
73
+ };
74
+ });
75
+ }
76
+ async cleanup() {
77
+ const result = await this.database.query(`WITH cleaned AS (
78
+ DELETE FROM oauth_authorization_codes
79
+ WHERE expires_at <= NOW() OR status <> 'active'
80
+ RETURNING 1
81
+ )
82
+ SELECT COUNT(*)::text AS count FROM cleaned`);
83
+ return Number.parseInt(result.rows[0]?.count ?? "0", 10);
84
+ }
85
+ async revokeByClientId(clientId) {
86
+ const result = await this.database.query(`WITH revoked AS (
87
+ UPDATE oauth_authorization_codes
88
+ SET status = 'revoked'
89
+ WHERE client_id = $1 AND status = 'active'
90
+ RETURNING 1
91
+ )
92
+ SELECT COUNT(*)::text AS count FROM revoked`, [clientId]);
93
+ return Number.parseInt(result.rows[0]?.count ?? "0", 10);
94
+ }
95
+ }
@@ -0,0 +1,174 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { breakGlassRequestSchema } from "../domain/types.js";
3
+ function toIso(value) {
4
+ if (value === null) {
5
+ return undefined;
6
+ }
7
+ return value instanceof Date ? value.toISOString() : value;
8
+ }
9
+ function mapRow(row) {
10
+ return breakGlassRequestSchema.parse({
11
+ id: row.id,
12
+ tenantId: row.tenant_id,
13
+ createdAt: toIso(row.created_at),
14
+ expiresAt: toIso(row.expires_at),
15
+ status: row.status,
16
+ requestedBy: row.requested_by,
17
+ requestedRoles: row.requested_roles,
18
+ credentialId: row.credential_id,
19
+ operation: row.operation,
20
+ targetUrl: row.target_url,
21
+ targetHost: row.target_host,
22
+ justification: row.justification,
23
+ requestedDurationSeconds: row.requested_duration_seconds,
24
+ correlationId: row.correlation_id,
25
+ fingerprint: row.fingerprint,
26
+ requiredApprovals: row.required_approvals,
27
+ approvalCount: row.approval_count,
28
+ denialCount: row.denial_count,
29
+ reviews: Array.isArray(row.reviews) ? row.reviews : [],
30
+ reviewedBy: row.reviewed_by ?? undefined,
31
+ reviewedAt: toIso(row.reviewed_at),
32
+ reviewNote: row.review_note ?? undefined,
33
+ revokedBy: row.revoked_by ?? undefined,
34
+ revokedAt: toIso(row.revoked_at),
35
+ revokeNote: row.revoke_note ?? undefined,
36
+ });
37
+ }
38
+ export class PgBreakGlassRepository {
39
+ database;
40
+ constructor(database) {
41
+ this.database = database;
42
+ }
43
+ async create(input) {
44
+ const parsed = breakGlassRequestSchema.parse(input);
45
+ await this.database.query(`INSERT INTO break_glass_requests (
46
+ id, tenant_id, created_at, expires_at, status, requested_by, requested_roles,
47
+ credential_id, operation, target_url, target_host, justification, requested_duration_seconds,
48
+ correlation_id, fingerprint, required_approvals, approval_count, denial_count, reviews, reviewed_by, reviewed_at, review_note,
49
+ revoked_by, revoked_at, revoke_note
50
+ ) VALUES (
51
+ $1, $2, $3, $4, $5, $6, $7,
52
+ $8, $9, $10, $11, $12, $13,
53
+ $14, $15, $16, $17, $18, $19, $20, $21, $22,
54
+ $23, $24, $25
55
+ )`, [
56
+ parsed.id,
57
+ parsed.tenantId,
58
+ parsed.createdAt,
59
+ parsed.expiresAt,
60
+ parsed.status,
61
+ parsed.requestedBy,
62
+ parsed.requestedRoles,
63
+ parsed.credentialId,
64
+ parsed.operation,
65
+ parsed.targetUrl,
66
+ parsed.targetHost,
67
+ parsed.justification,
68
+ parsed.requestedDurationSeconds,
69
+ parsed.correlationId,
70
+ parsed.fingerprint,
71
+ parsed.requiredApprovals,
72
+ parsed.approvalCount,
73
+ parsed.denialCount,
74
+ JSON.stringify(parsed.reviews),
75
+ parsed.reviewedBy ?? null,
76
+ parsed.reviewedAt ?? null,
77
+ parsed.reviewNote ?? null,
78
+ parsed.revokedBy ?? null,
79
+ parsed.revokedAt ?? null,
80
+ parsed.revokeNote ?? null,
81
+ ]);
82
+ return parsed;
83
+ }
84
+ async expireStale() {
85
+ const result = await this.database.query(`WITH expired AS (
86
+ UPDATE break_glass_requests
87
+ SET status = 'expired'
88
+ WHERE status IN ('pending', 'active') AND expires_at <= NOW()
89
+ RETURNING 1
90
+ )
91
+ SELECT COUNT(*)::text AS count FROM expired`);
92
+ return Number.parseInt(result.rows[0]?.count ?? "0", 10);
93
+ }
94
+ async getById(id) {
95
+ const result = await this.database.query("SELECT * FROM break_glass_requests WHERE id = $1", [id]);
96
+ return result.rows[0] ? mapRow(result.rows[0]) : undefined;
97
+ }
98
+ async list(filter) {
99
+ const clauses = [];
100
+ const params = [];
101
+ if (filter?.status) {
102
+ params.push(filter.status);
103
+ clauses.push(`status = $${params.length}`);
104
+ }
105
+ if (filter?.requestedBy) {
106
+ params.push(filter.requestedBy);
107
+ clauses.push(`requested_by = $${params.length}`);
108
+ }
109
+ if (filter?.tenantId) {
110
+ params.push(filter.tenantId);
111
+ clauses.push(`tenant_id = $${params.length}`);
112
+ }
113
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
114
+ const result = await this.database.query(`SELECT * FROM break_glass_requests ${where} ORDER BY created_at DESC`, params);
115
+ return result.rows.map(mapRow);
116
+ }
117
+ async review(id, update) {
118
+ return this.database.withTransaction(async (client) => {
119
+ const currentResult = await client.query("SELECT * FROM break_glass_requests WHERE id = $1 FOR UPDATE", [id]);
120
+ const current = currentResult.rows[0];
121
+ if (!current) {
122
+ return undefined;
123
+ }
124
+ const parsed = mapRow(current);
125
+ if (parsed.status !== "pending") {
126
+ return undefined;
127
+ }
128
+ if (parsed.reviews.some((review) => review.reviewedBy === update.reviewedBy)) {
129
+ throw new Error("Reviewer has already reviewed this request.");
130
+ }
131
+ const review = {
132
+ reviewId: randomUUID(),
133
+ reviewedAt: new Date().toISOString(),
134
+ reviewedBy: update.reviewedBy,
135
+ decision: update.status === "active" ? "approved" : "denied",
136
+ note: update.reviewNote,
137
+ };
138
+ const reviews = [...parsed.reviews, review];
139
+ const approvalCount = parsed.approvalCount + (update.status === "active" ? 1 : 0);
140
+ const denialCount = parsed.denialCount + (update.status === "denied" ? 1 : 0);
141
+ const nextStatus = denialCount > 0
142
+ ? "denied"
143
+ : approvalCount >= parsed.requiredApprovals
144
+ ? "active"
145
+ : "pending";
146
+ const result = await client.query(`UPDATE break_glass_requests
147
+ SET status = $2,
148
+ approval_count = $3,
149
+ denial_count = $4,
150
+ reviews = $5::jsonb,
151
+ reviewed_by = $6,
152
+ reviewed_at = NOW(),
153
+ review_note = $7
154
+ WHERE id = $1
155
+ RETURNING *`, [
156
+ id,
157
+ nextStatus,
158
+ approvalCount,
159
+ denialCount,
160
+ JSON.stringify(reviews),
161
+ update.reviewedBy,
162
+ update.reviewNote ?? null,
163
+ ]);
164
+ return result.rows[0] ? mapRow(result.rows[0]) : undefined;
165
+ });
166
+ }
167
+ async revoke(id, update) {
168
+ const result = await this.database.query(`UPDATE break_glass_requests
169
+ SET status = 'revoked', revoked_by = $2, revoked_at = NOW(), revoke_note = $3
170
+ WHERE id = $1 AND status = 'active'
171
+ RETURNING *`, [id, update.revokedBy, update.revokeNote ?? null]);
172
+ return result.rows[0] ? mapRow(result.rows[0]) : undefined;
173
+ }
174
+ }