@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.
- package/.env.example +64 -0
- package/LICENSE +176 -0
- package/NOTICE +5 -0
- package/README.md +424 -0
- package/bin/keylore-http.js +3 -0
- package/bin/keylore-stdio.js +3 -0
- package/data/auth-clients.json +54 -0
- package/data/catalog.json +53 -0
- package/data/policies.json +25 -0
- package/dist/adapters/adapter-registry.js +143 -0
- package/dist/adapters/aws-secrets-manager-adapter.js +99 -0
- package/dist/adapters/command-runner.js +17 -0
- package/dist/adapters/env-secret-adapter.js +42 -0
- package/dist/adapters/gcp-secret-manager-adapter.js +129 -0
- package/dist/adapters/local-secret-adapter.js +54 -0
- package/dist/adapters/onepassword-secret-adapter.js +83 -0
- package/dist/adapters/reference-utils.js +44 -0
- package/dist/adapters/types.js +1 -0
- package/dist/adapters/vault-secret-adapter.js +103 -0
- package/dist/app.js +132 -0
- package/dist/cli/args.js +51 -0
- package/dist/cli/run.js +483 -0
- package/dist/cli.js +18 -0
- package/dist/config.js +295 -0
- package/dist/domain/types.js +967 -0
- package/dist/http/admin-ui.js +3010 -0
- package/dist/http/server.js +1210 -0
- package/dist/index.js +40 -0
- package/dist/mcp/create-server.js +388 -0
- package/dist/mcp/stdio.js +7 -0
- package/dist/repositories/credential-repository.js +109 -0
- package/dist/repositories/interfaces.js +1 -0
- package/dist/repositories/json-file.js +20 -0
- package/dist/repositories/pg-access-token-repository.js +118 -0
- package/dist/repositories/pg-approval-repository.js +157 -0
- package/dist/repositories/pg-audit-log.js +62 -0
- package/dist/repositories/pg-auth-client-repository.js +98 -0
- package/dist/repositories/pg-authorization-code-repository.js +95 -0
- package/dist/repositories/pg-break-glass-repository.js +174 -0
- package/dist/repositories/pg-credential-repository.js +163 -0
- package/dist/repositories/pg-oauth-client-assertion-repository.js +25 -0
- package/dist/repositories/pg-policy-repository.js +62 -0
- package/dist/repositories/pg-refresh-token-repository.js +125 -0
- package/dist/repositories/pg-rotation-run-repository.js +127 -0
- package/dist/repositories/pg-tenant-repository.js +56 -0
- package/dist/repositories/policy-repository.js +24 -0
- package/dist/runtime/sandbox-runner.js +114 -0
- package/dist/services/access-fingerprint.js +13 -0
- package/dist/services/approval-service.js +148 -0
- package/dist/services/audit-log.js +38 -0
- package/dist/services/auth-context.js +43 -0
- package/dist/services/auth-secrets.js +14 -0
- package/dist/services/auth-service.js +784 -0
- package/dist/services/backup-service.js +610 -0
- package/dist/services/break-glass-service.js +207 -0
- package/dist/services/broker-service.js +557 -0
- package/dist/services/core-mode-service.js +154 -0
- package/dist/services/egress-policy.js +119 -0
- package/dist/services/local-secret-store.js +119 -0
- package/dist/services/maintenance-service.js +99 -0
- package/dist/services/notification-service.js +83 -0
- package/dist/services/policy-engine.js +85 -0
- package/dist/services/rate-limit-service.js +80 -0
- package/dist/services/rotation-service.js +271 -0
- package/dist/services/telemetry.js +149 -0
- package/dist/services/tenant-service.js +127 -0
- package/dist/services/trace-export-service.js +126 -0
- package/dist/services/trace-service.js +87 -0
- package/dist/storage/bootstrap.js +68 -0
- package/dist/storage/database.js +39 -0
- package/dist/storage/in-memory-database.js +40 -0
- package/dist/storage/migrations.js +27 -0
- package/migrations/001_init.sql +49 -0
- package/migrations/002_phase2_auth.sql +53 -0
- package/migrations/003_v05_operations.sql +9 -0
- package/migrations/004_v07_security.sql +28 -0
- package/migrations/005_v08_reviews.sql +11 -0
- package/migrations/006_v09_auth_trace_rotation.sql +51 -0
- package/migrations/007_v010_multi_tenant.sql +32 -0
- package/migrations/008_v011_auth_tenant_ops.sql +95 -0
- 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
|
+
}
|