@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,163 @@
|
|
|
1
|
+
import { createCredentialInputSchema, credentialRecordSchema, updateCredentialInputSchema, } from "../domain/types.js";
|
|
2
|
+
function mapRow(row) {
|
|
3
|
+
return credentialRecordSchema.parse({
|
|
4
|
+
id: row.id,
|
|
5
|
+
tenantId: row.tenant_id,
|
|
6
|
+
displayName: row.display_name,
|
|
7
|
+
service: row.service,
|
|
8
|
+
owner: row.owner,
|
|
9
|
+
scopeTier: row.scope_tier,
|
|
10
|
+
sensitivity: row.sensitivity,
|
|
11
|
+
allowedDomains: row.allowed_domains,
|
|
12
|
+
permittedOperations: row.permitted_operations,
|
|
13
|
+
expiresAt: row.expires_at instanceof Date ? row.expires_at.toISOString() : row.expires_at,
|
|
14
|
+
rotationPolicy: row.rotation_policy,
|
|
15
|
+
lastValidatedAt: row.last_validated_at instanceof Date
|
|
16
|
+
? row.last_validated_at.toISOString()
|
|
17
|
+
: row.last_validated_at,
|
|
18
|
+
selectionNotes: row.selection_notes,
|
|
19
|
+
binding: row.binding,
|
|
20
|
+
tags: row.tags,
|
|
21
|
+
status: row.status,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function makeSearchClauses(input) {
|
|
25
|
+
const conditions = [];
|
|
26
|
+
const params = [];
|
|
27
|
+
const push = (sql, value) => {
|
|
28
|
+
params.push(value);
|
|
29
|
+
conditions.push(sql.replace("$?", `$${params.length}`));
|
|
30
|
+
};
|
|
31
|
+
if (input.query) {
|
|
32
|
+
params.push(input.query);
|
|
33
|
+
const placeholder = `$${params.length}`;
|
|
34
|
+
conditions.push(`(id ILIKE '%' || ${placeholder} || '%' OR display_name ILIKE '%' || ${placeholder} || '%' OR service ILIKE '%' || ${placeholder} || '%' OR owner ILIKE '%' || ${placeholder} || '%' OR selection_notes ILIKE '%' || ${placeholder} || '%')`);
|
|
35
|
+
}
|
|
36
|
+
if (input.service) {
|
|
37
|
+
push("service = $?", input.service);
|
|
38
|
+
}
|
|
39
|
+
if (input.owner) {
|
|
40
|
+
push("owner = $?", input.owner);
|
|
41
|
+
}
|
|
42
|
+
if (input.scopeTier) {
|
|
43
|
+
push("scope_tier = $?", input.scopeTier);
|
|
44
|
+
}
|
|
45
|
+
if (input.sensitivity) {
|
|
46
|
+
push("sensitivity = $?", input.sensitivity);
|
|
47
|
+
}
|
|
48
|
+
if (input.status) {
|
|
49
|
+
push("status = $?", input.status);
|
|
50
|
+
}
|
|
51
|
+
if (input.tag) {
|
|
52
|
+
push("$? = ANY(tags)", input.tag);
|
|
53
|
+
}
|
|
54
|
+
const clause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
55
|
+
return { clause, params };
|
|
56
|
+
}
|
|
57
|
+
export class PgCredentialRepository {
|
|
58
|
+
database;
|
|
59
|
+
constructor(database) {
|
|
60
|
+
this.database = database;
|
|
61
|
+
}
|
|
62
|
+
async ensureInitialized() {
|
|
63
|
+
await this.database.healthcheck();
|
|
64
|
+
}
|
|
65
|
+
async list() {
|
|
66
|
+
const result = await this.database.query(`SELECT * FROM credentials ORDER BY id ASC`);
|
|
67
|
+
return result.rows.map(mapRow);
|
|
68
|
+
}
|
|
69
|
+
async count() {
|
|
70
|
+
const result = await this.database.query("SELECT COUNT(*)::text AS count FROM credentials");
|
|
71
|
+
return Number.parseInt(result.rows[0]?.count ?? "0", 10);
|
|
72
|
+
}
|
|
73
|
+
async getById(id) {
|
|
74
|
+
const result = await this.database.query("SELECT * FROM credentials WHERE id = $1", [id]);
|
|
75
|
+
return result.rows[0] ? mapRow(result.rows[0]) : undefined;
|
|
76
|
+
}
|
|
77
|
+
async search(input) {
|
|
78
|
+
const { clause, params } = makeSearchClauses(input);
|
|
79
|
+
const limitPlaceholder = `$${params.length + 1}`;
|
|
80
|
+
const result = await this.database.query(`SELECT * FROM credentials ${clause} ORDER BY id ASC LIMIT ${limitPlaceholder}`, [...params, input.limit]);
|
|
81
|
+
return result.rows.map(mapRow);
|
|
82
|
+
}
|
|
83
|
+
async create(record) {
|
|
84
|
+
const parsed = createCredentialInputSchema.parse(record);
|
|
85
|
+
await this.database.query(`INSERT INTO credentials (
|
|
86
|
+
id, tenant_id, display_name, service, owner, scope_tier, sensitivity,
|
|
87
|
+
allowed_domains, permitted_operations, expires_at, rotation_policy,
|
|
88
|
+
last_validated_at, selection_notes, binding, tags, status
|
|
89
|
+
) VALUES (
|
|
90
|
+
$1, $2, $3, $4, $5, $6, $7,
|
|
91
|
+
$8, $9, $10, $11,
|
|
92
|
+
$12, $13, $14::jsonb, $15, $16
|
|
93
|
+
)`, [
|
|
94
|
+
parsed.id,
|
|
95
|
+
parsed.tenantId,
|
|
96
|
+
parsed.displayName,
|
|
97
|
+
parsed.service,
|
|
98
|
+
parsed.owner,
|
|
99
|
+
parsed.scopeTier,
|
|
100
|
+
parsed.sensitivity,
|
|
101
|
+
parsed.allowedDomains,
|
|
102
|
+
parsed.permittedOperations,
|
|
103
|
+
parsed.expiresAt,
|
|
104
|
+
parsed.rotationPolicy,
|
|
105
|
+
parsed.lastValidatedAt,
|
|
106
|
+
parsed.selectionNotes,
|
|
107
|
+
JSON.stringify(parsed.binding),
|
|
108
|
+
parsed.tags,
|
|
109
|
+
parsed.status,
|
|
110
|
+
]);
|
|
111
|
+
return parsed;
|
|
112
|
+
}
|
|
113
|
+
async update(id, patch) {
|
|
114
|
+
const parsedPatch = updateCredentialInputSchema.parse(patch);
|
|
115
|
+
const current = await this.getById(id);
|
|
116
|
+
if (!current) {
|
|
117
|
+
throw new Error(`Credential ${id} was not found.`);
|
|
118
|
+
}
|
|
119
|
+
const merged = createCredentialInputSchema.parse({
|
|
120
|
+
...current,
|
|
121
|
+
...parsedPatch,
|
|
122
|
+
id,
|
|
123
|
+
});
|
|
124
|
+
await this.database.query(`UPDATE credentials SET
|
|
125
|
+
display_name = $2,
|
|
126
|
+
service = $3,
|
|
127
|
+
owner = $4,
|
|
128
|
+
scope_tier = $5,
|
|
129
|
+
sensitivity = $6,
|
|
130
|
+
allowed_domains = $7,
|
|
131
|
+
permitted_operations = $8,
|
|
132
|
+
expires_at = $9,
|
|
133
|
+
rotation_policy = $10,
|
|
134
|
+
last_validated_at = $11,
|
|
135
|
+
selection_notes = $12,
|
|
136
|
+
binding = $13::jsonb,
|
|
137
|
+
tags = $14,
|
|
138
|
+
status = $15,
|
|
139
|
+
updated_at = NOW()
|
|
140
|
+
WHERE id = $1`, [
|
|
141
|
+
merged.id,
|
|
142
|
+
merged.displayName,
|
|
143
|
+
merged.service,
|
|
144
|
+
merged.owner,
|
|
145
|
+
merged.scopeTier,
|
|
146
|
+
merged.sensitivity,
|
|
147
|
+
merged.allowedDomains,
|
|
148
|
+
merged.permittedOperations,
|
|
149
|
+
merged.expiresAt,
|
|
150
|
+
merged.rotationPolicy,
|
|
151
|
+
merged.lastValidatedAt,
|
|
152
|
+
merged.selectionNotes,
|
|
153
|
+
JSON.stringify(merged.binding),
|
|
154
|
+
merged.tags,
|
|
155
|
+
merged.status,
|
|
156
|
+
]);
|
|
157
|
+
return merged;
|
|
158
|
+
}
|
|
159
|
+
async delete(id) {
|
|
160
|
+
const result = await this.database.query("DELETE FROM credentials WHERE id = $1", [id]);
|
|
161
|
+
return (result.rowCount ?? 0) > 0;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export class PgOAuthClientAssertionRepository {
|
|
2
|
+
database;
|
|
3
|
+
constructor(database) {
|
|
4
|
+
this.database = database;
|
|
5
|
+
}
|
|
6
|
+
async register(clientId, jti, expiresAt) {
|
|
7
|
+
try {
|
|
8
|
+
await this.database.query(`INSERT INTO oauth_client_assertion_jtis (client_id, jti, expires_at)
|
|
9
|
+
VALUES ($1, $2, $3)`, [clientId, jti, expiresAt]);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
async cleanup() {
|
|
17
|
+
const result = await this.database.query(`WITH deleted AS (
|
|
18
|
+
DELETE FROM oauth_client_assertion_jtis
|
|
19
|
+
WHERE expires_at <= NOW()
|
|
20
|
+
RETURNING 1
|
|
21
|
+
)
|
|
22
|
+
SELECT COUNT(*)::text AS count FROM deleted`);
|
|
23
|
+
return Number.parseInt(result.rows[0]?.count ?? "0", 10);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { policyFileSchema } from "../domain/types.js";
|
|
2
|
+
function mapRow(row) {
|
|
3
|
+
return {
|
|
4
|
+
id: row.id,
|
|
5
|
+
tenantId: row.tenant_id,
|
|
6
|
+
effect: row.effect,
|
|
7
|
+
description: row.description,
|
|
8
|
+
principals: row.principals,
|
|
9
|
+
principalRoles: row.principal_roles ?? undefined,
|
|
10
|
+
credentialIds: row.credential_ids ?? undefined,
|
|
11
|
+
services: row.services ?? undefined,
|
|
12
|
+
operations: row.operations,
|
|
13
|
+
domainPatterns: row.domain_patterns,
|
|
14
|
+
environments: row.environments ?? undefined,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export class PgPolicyRepository {
|
|
18
|
+
database;
|
|
19
|
+
constructor(database) {
|
|
20
|
+
this.database = database;
|
|
21
|
+
}
|
|
22
|
+
async ensureInitialized() {
|
|
23
|
+
await this.database.healthcheck();
|
|
24
|
+
}
|
|
25
|
+
async read() {
|
|
26
|
+
const result = await this.database.query("SELECT * FROM policy_rules ORDER BY id ASC");
|
|
27
|
+
return policyFileSchema.parse({
|
|
28
|
+
version: 1,
|
|
29
|
+
rules: result.rows.map(mapRow),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async replaceAll(file) {
|
|
33
|
+
const parsed = policyFileSchema.parse(file);
|
|
34
|
+
await this.database.withTransaction(async (client) => {
|
|
35
|
+
await client.query("DELETE FROM policy_rules");
|
|
36
|
+
for (const rule of parsed.rules) {
|
|
37
|
+
await client.query(`INSERT INTO policy_rules (
|
|
38
|
+
id, tenant_id, effect, description, principals, principal_roles, credential_ids, services,
|
|
39
|
+
operations, domain_patterns, environments
|
|
40
|
+
) VALUES (
|
|
41
|
+
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11
|
|
42
|
+
)`, [
|
|
43
|
+
rule.id,
|
|
44
|
+
rule.tenantId,
|
|
45
|
+
rule.effect,
|
|
46
|
+
rule.description,
|
|
47
|
+
rule.principals,
|
|
48
|
+
rule.principalRoles ?? null,
|
|
49
|
+
rule.credentialIds ?? null,
|
|
50
|
+
rule.services ?? null,
|
|
51
|
+
rule.operations,
|
|
52
|
+
rule.domainPatterns,
|
|
53
|
+
rule.environments ?? null,
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async count() {
|
|
59
|
+
const result = await this.database.query("SELECT COUNT(*)::text AS count FROM policy_rules");
|
|
60
|
+
return Number.parseInt(result.rows[0]?.count ?? "0", 10);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { refreshTokenRecordSchema, } 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
|
+
const record = refreshTokenRecordSchema.parse({
|
|
11
|
+
refreshTokenId: row.refresh_token_id,
|
|
12
|
+
clientId: row.client_id,
|
|
13
|
+
tenantId: row.tenant_id,
|
|
14
|
+
subject: row.subject,
|
|
15
|
+
scopes: row.scopes,
|
|
16
|
+
roles: row.roles,
|
|
17
|
+
resource: row.resource ?? undefined,
|
|
18
|
+
expiresAt: toIso(row.expires_at),
|
|
19
|
+
status: row.status,
|
|
20
|
+
createdAt: toIso(row.created_at),
|
|
21
|
+
lastUsedAt: toIso(row.last_used_at),
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
...record,
|
|
25
|
+
tokenHash: row.token_hash,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export class PgRefreshTokenRepository {
|
|
29
|
+
database;
|
|
30
|
+
constructor(database) {
|
|
31
|
+
this.database = database;
|
|
32
|
+
}
|
|
33
|
+
async issue(input) {
|
|
34
|
+
await this.database.query(`INSERT INTO refresh_tokens (
|
|
35
|
+
refresh_token_id, token_hash, client_id, tenant_id, subject, scopes, roles, resource, expires_at, status
|
|
36
|
+
) VALUES (
|
|
37
|
+
$1, $2, $3, $4, $5, $6, $7, $8, $9, 'active'
|
|
38
|
+
)`, [
|
|
39
|
+
randomUUID(),
|
|
40
|
+
input.tokenHash,
|
|
41
|
+
input.clientId,
|
|
42
|
+
input.tenantId,
|
|
43
|
+
input.subject,
|
|
44
|
+
input.scopes,
|
|
45
|
+
input.roles,
|
|
46
|
+
input.resource ?? null,
|
|
47
|
+
input.expiresAt,
|
|
48
|
+
]);
|
|
49
|
+
}
|
|
50
|
+
async getByHash(tokenHash) {
|
|
51
|
+
const result = await this.database.query("SELECT * FROM refresh_tokens WHERE token_hash = $1", [tokenHash]);
|
|
52
|
+
return result.rows[0] ? mapRow(result.rows[0]) : undefined;
|
|
53
|
+
}
|
|
54
|
+
async getById(refreshTokenId) {
|
|
55
|
+
const result = await this.database.query("SELECT * FROM refresh_tokens WHERE refresh_token_id = $1", [refreshTokenId]);
|
|
56
|
+
if (!result.rows[0]) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const { tokenHash: _tokenHash, ...record } = mapRow(result.rows[0]);
|
|
60
|
+
return record;
|
|
61
|
+
}
|
|
62
|
+
async touch(tokenHash) {
|
|
63
|
+
await this.database.query("UPDATE refresh_tokens SET last_used_at = NOW() WHERE token_hash = $1", [tokenHash]);
|
|
64
|
+
}
|
|
65
|
+
async list(filter) {
|
|
66
|
+
const clauses = [];
|
|
67
|
+
const values = [];
|
|
68
|
+
if (filter?.clientId) {
|
|
69
|
+
values.push(filter.clientId);
|
|
70
|
+
clauses.push(`client_id = $${values.length}`);
|
|
71
|
+
}
|
|
72
|
+
if (filter?.status) {
|
|
73
|
+
values.push(filter.status);
|
|
74
|
+
clauses.push(`status = $${values.length}`);
|
|
75
|
+
}
|
|
76
|
+
if (filter?.tenantId) {
|
|
77
|
+
values.push(filter.tenantId);
|
|
78
|
+
clauses.push(`tenant_id = $${values.length}`);
|
|
79
|
+
}
|
|
80
|
+
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
81
|
+
const result = await this.database.query(`SELECT * FROM refresh_tokens ${where} ORDER BY created_at DESC`, values);
|
|
82
|
+
return result.rows.map((row) => {
|
|
83
|
+
const { tokenHash: _tokenHash, ...record } = mapRow(row);
|
|
84
|
+
return record;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async expireStale() {
|
|
88
|
+
const result = await this.database.query(`WITH expired AS (
|
|
89
|
+
UPDATE refresh_tokens
|
|
90
|
+
SET status = 'revoked'
|
|
91
|
+
WHERE status = 'active' AND expires_at <= NOW()
|
|
92
|
+
RETURNING 1
|
|
93
|
+
)
|
|
94
|
+
SELECT COUNT(*)::text AS count FROM expired`);
|
|
95
|
+
return Number.parseInt(result.rows[0]?.count ?? "0", 10);
|
|
96
|
+
}
|
|
97
|
+
async revokeById(refreshTokenId) {
|
|
98
|
+
const result = await this.database.query(`UPDATE refresh_tokens
|
|
99
|
+
SET status = 'revoked'
|
|
100
|
+
WHERE refresh_token_id = $1 AND status = 'active'
|
|
101
|
+
RETURNING *`, [refreshTokenId]);
|
|
102
|
+
if (!result.rows[0]) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
const { tokenHash: _tokenHash, ...record } = mapRow(result.rows[0]);
|
|
106
|
+
return record;
|
|
107
|
+
}
|
|
108
|
+
async revokeByClientId(clientId) {
|
|
109
|
+
const result = await this.database.query(`WITH revoked AS (
|
|
110
|
+
UPDATE refresh_tokens
|
|
111
|
+
SET status = 'revoked'
|
|
112
|
+
WHERE client_id = $1 AND status = 'active'
|
|
113
|
+
RETURNING 1
|
|
114
|
+
)
|
|
115
|
+
SELECT COUNT(*)::text AS count FROM revoked`, [clientId]);
|
|
116
|
+
return Number.parseInt(result.rows[0]?.count ?? "0", 10);
|
|
117
|
+
}
|
|
118
|
+
async replace(tokenHash, replacedByTokenId) {
|
|
119
|
+
const result = await this.database.query(`UPDATE refresh_tokens
|
|
120
|
+
SET status = 'revoked', replaced_by_token_id = $2, last_used_at = NOW()
|
|
121
|
+
WHERE token_hash = $1 AND status = 'active'
|
|
122
|
+
RETURNING *`, [tokenHash, replacedByTokenId]);
|
|
123
|
+
return result.rows[0] ? mapRow(result.rows[0]) : undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { rotationRunSchema } from "../domain/types.js";
|
|
2
|
+
function toIso(value) {
|
|
3
|
+
if (value === null) {
|
|
4
|
+
return undefined;
|
|
5
|
+
}
|
|
6
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
7
|
+
}
|
|
8
|
+
function mapRow(row) {
|
|
9
|
+
return rotationRunSchema.parse({
|
|
10
|
+
id: row.id,
|
|
11
|
+
tenantId: row.tenant_id,
|
|
12
|
+
credentialId: row.credential_id,
|
|
13
|
+
status: row.status,
|
|
14
|
+
source: row.source,
|
|
15
|
+
reason: row.reason,
|
|
16
|
+
dueAt: toIso(row.due_at),
|
|
17
|
+
plannedAt: toIso(row.planned_at),
|
|
18
|
+
startedAt: toIso(row.started_at),
|
|
19
|
+
completedAt: toIso(row.completed_at),
|
|
20
|
+
plannedBy: row.planned_by,
|
|
21
|
+
updatedBy: row.updated_by,
|
|
22
|
+
note: row.note ?? undefined,
|
|
23
|
+
targetRef: row.target_ref ?? undefined,
|
|
24
|
+
resultNote: row.result_note ?? undefined,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export class PgRotationRunRepository {
|
|
28
|
+
database;
|
|
29
|
+
constructor(database) {
|
|
30
|
+
this.database = database;
|
|
31
|
+
}
|
|
32
|
+
async create(input) {
|
|
33
|
+
const parsed = rotationRunSchema.parse(input);
|
|
34
|
+
const result = await this.database.query(`INSERT INTO rotation_runs (
|
|
35
|
+
id, tenant_id, credential_id, status, source, reason, due_at, planned_at, started_at,
|
|
36
|
+
completed_at, planned_by, updated_by, note, target_ref, result_note
|
|
37
|
+
) VALUES (
|
|
38
|
+
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
|
39
|
+
$10, $11, $12, $13, $14, $15
|
|
40
|
+
)
|
|
41
|
+
RETURNING *`, [
|
|
42
|
+
parsed.id,
|
|
43
|
+
parsed.tenantId,
|
|
44
|
+
parsed.credentialId,
|
|
45
|
+
parsed.status,
|
|
46
|
+
parsed.source,
|
|
47
|
+
parsed.reason,
|
|
48
|
+
parsed.dueAt ?? null,
|
|
49
|
+
parsed.plannedAt,
|
|
50
|
+
parsed.startedAt ?? null,
|
|
51
|
+
parsed.completedAt ?? null,
|
|
52
|
+
parsed.plannedBy,
|
|
53
|
+
parsed.updatedBy,
|
|
54
|
+
parsed.note ?? null,
|
|
55
|
+
parsed.targetRef ?? null,
|
|
56
|
+
parsed.resultNote ?? null,
|
|
57
|
+
]);
|
|
58
|
+
return mapRow(result.rows[0]);
|
|
59
|
+
}
|
|
60
|
+
async getById(id) {
|
|
61
|
+
const result = await this.database.query("SELECT * FROM rotation_runs WHERE id = $1", [id]);
|
|
62
|
+
return result.rows[0] ? mapRow(result.rows[0]) : undefined;
|
|
63
|
+
}
|
|
64
|
+
async list(filter) {
|
|
65
|
+
const clauses = [];
|
|
66
|
+
const params = [];
|
|
67
|
+
if (filter?.status) {
|
|
68
|
+
params.push(filter.status);
|
|
69
|
+
clauses.push(`status = $${params.length}`);
|
|
70
|
+
}
|
|
71
|
+
if (filter?.credentialId) {
|
|
72
|
+
params.push(filter.credentialId);
|
|
73
|
+
clauses.push(`credential_id = $${params.length}`);
|
|
74
|
+
}
|
|
75
|
+
if (filter?.tenantId) {
|
|
76
|
+
params.push(filter.tenantId);
|
|
77
|
+
clauses.push(`tenant_id = $${params.length}`);
|
|
78
|
+
}
|
|
79
|
+
const whereClause = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
80
|
+
const result = await this.database.query(`SELECT * FROM rotation_runs ${whereClause} ORDER BY planned_at DESC, id DESC`, params);
|
|
81
|
+
return result.rows.map((row) => mapRow(row));
|
|
82
|
+
}
|
|
83
|
+
async findOpenByCredentialId(credentialId) {
|
|
84
|
+
const result = await this.database.query(`SELECT * FROM rotation_runs
|
|
85
|
+
WHERE credential_id = $1
|
|
86
|
+
AND status IN ('pending', 'in_progress')
|
|
87
|
+
ORDER BY planned_at DESC
|
|
88
|
+
LIMIT 1`, [credentialId]);
|
|
89
|
+
return result.rows[0] ? mapRow(result.rows[0]) : undefined;
|
|
90
|
+
}
|
|
91
|
+
async transition(id, update) {
|
|
92
|
+
return this.database.withTransaction(async (client) => {
|
|
93
|
+
const existingResult = await client.query("SELECT * FROM rotation_runs WHERE id = $1 FOR UPDATE", [id]);
|
|
94
|
+
const existing = existingResult.rows[0];
|
|
95
|
+
if (!existing || !update.fromStatuses.includes(existing.status)) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const startedAt = update.status === "in_progress"
|
|
99
|
+
? new Date().toISOString()
|
|
100
|
+
: toIso(existing.started_at) ?? null;
|
|
101
|
+
const completedAt = update.status === "completed" || update.status === "failed" || update.status === "cancelled"
|
|
102
|
+
? new Date().toISOString()
|
|
103
|
+
: null;
|
|
104
|
+
const result = await client.query(`UPDATE rotation_runs
|
|
105
|
+
SET status = $2,
|
|
106
|
+
started_at = $3,
|
|
107
|
+
completed_at = $4,
|
|
108
|
+
updated_by = $5,
|
|
109
|
+
note = COALESCE($6, note),
|
|
110
|
+
target_ref = COALESCE($7, target_ref),
|
|
111
|
+
result_note = COALESCE($8, result_note),
|
|
112
|
+
updated_at = NOW()
|
|
113
|
+
WHERE id = $1
|
|
114
|
+
RETURNING *`, [
|
|
115
|
+
id,
|
|
116
|
+
update.status,
|
|
117
|
+
startedAt,
|
|
118
|
+
completedAt,
|
|
119
|
+
update.updatedBy,
|
|
120
|
+
update.note ?? null,
|
|
121
|
+
update.targetRef ?? null,
|
|
122
|
+
update.resultNote ?? null,
|
|
123
|
+
]);
|
|
124
|
+
return result.rows[0] ? mapRow(result.rows[0]) : undefined;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { tenantRecordSchema } from "../domain/types.js";
|
|
2
|
+
function toIso(value) {
|
|
3
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
4
|
+
}
|
|
5
|
+
function mapRow(row) {
|
|
6
|
+
return tenantRecordSchema.parse({
|
|
7
|
+
tenantId: row.tenant_id,
|
|
8
|
+
displayName: row.display_name,
|
|
9
|
+
description: row.description ?? undefined,
|
|
10
|
+
status: row.status,
|
|
11
|
+
createdAt: toIso(row.created_at),
|
|
12
|
+
updatedAt: toIso(row.updated_at),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export class PgTenantRepository {
|
|
16
|
+
database;
|
|
17
|
+
constructor(database) {
|
|
18
|
+
this.database = database;
|
|
19
|
+
}
|
|
20
|
+
async ensureInitialized() {
|
|
21
|
+
await this.database.healthcheck();
|
|
22
|
+
}
|
|
23
|
+
async list() {
|
|
24
|
+
const result = await this.database.query("SELECT * FROM tenants ORDER BY tenant_id ASC");
|
|
25
|
+
return result.rows.map(mapRow);
|
|
26
|
+
}
|
|
27
|
+
async getById(tenantId) {
|
|
28
|
+
const result = await this.database.query("SELECT * FROM tenants WHERE tenant_id = $1", [tenantId]);
|
|
29
|
+
return result.rows[0] ? mapRow(result.rows[0]) : undefined;
|
|
30
|
+
}
|
|
31
|
+
async create(input) {
|
|
32
|
+
const result = await this.database.query(`INSERT INTO tenants (tenant_id, display_name, description, status)
|
|
33
|
+
VALUES ($1, $2, $3, $4)
|
|
34
|
+
RETURNING *`, [input.tenantId, input.displayName, input.description ?? null, input.status]);
|
|
35
|
+
return mapRow(result.rows[0]);
|
|
36
|
+
}
|
|
37
|
+
async update(tenantId, patch) {
|
|
38
|
+
const existing = await this.getById(tenantId);
|
|
39
|
+
if (!existing) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const result = await this.database.query(`UPDATE tenants
|
|
43
|
+
SET display_name = $2,
|
|
44
|
+
description = $3,
|
|
45
|
+
status = $4,
|
|
46
|
+
updated_at = NOW()
|
|
47
|
+
WHERE tenant_id = $1
|
|
48
|
+
RETURNING *`, [
|
|
49
|
+
tenantId,
|
|
50
|
+
patch.displayName ?? existing.displayName,
|
|
51
|
+
patch.description ?? existing.description ?? null,
|
|
52
|
+
patch.status ?? existing.status,
|
|
53
|
+
]);
|
|
54
|
+
return result.rows[0] ? mapRow(result.rows[0]) : undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { policyFileSchema } from "../domain/types.js";
|
|
2
|
+
import { readTextFile, writeTextFile } from "./json-file.js";
|
|
3
|
+
export class JsonPolicyRepository {
|
|
4
|
+
filePath;
|
|
5
|
+
constructor(filePath) {
|
|
6
|
+
this.filePath = filePath;
|
|
7
|
+
}
|
|
8
|
+
async ensureInitialized() {
|
|
9
|
+
const text = await readTextFile(this.filePath);
|
|
10
|
+
if (text) {
|
|
11
|
+
policyFileSchema.parse(JSON.parse(text));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const emptyPolicy = { version: 1, rules: [] };
|
|
15
|
+
await writeTextFile(this.filePath, `${JSON.stringify(emptyPolicy, null, 2)}\n`);
|
|
16
|
+
}
|
|
17
|
+
async read() {
|
|
18
|
+
const text = await readTextFile(this.filePath);
|
|
19
|
+
if (!text) {
|
|
20
|
+
return { version: 1, rules: [] };
|
|
21
|
+
}
|
|
22
|
+
return policyFileSchema.parse(JSON.parse(text));
|
|
23
|
+
}
|
|
24
|
+
}
|