@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,80 @@
1
+ function toDate(value) {
2
+ return value instanceof Date ? value : new Date(value);
3
+ }
4
+ export class PgRateLimitService {
5
+ database;
6
+ windowMs;
7
+ maxRequests;
8
+ telemetry;
9
+ constructor(database, windowMs, maxRequests, telemetry) {
10
+ this.database = database;
11
+ this.windowMs = windowMs;
12
+ this.maxRequests = maxRequests;
13
+ this.telemetry = telemetry;
14
+ }
15
+ async check(bucketKey) {
16
+ const now = Date.now();
17
+ let row;
18
+ for (let attempt = 0; attempt < 2; attempt += 1) {
19
+ try {
20
+ row = await this.database.withTransaction(async (client) => {
21
+ const existing = await client.query("SELECT request_count, window_started_at FROM request_rate_limits WHERE bucket_key = $1", [bucketKey]);
22
+ if (!existing.rows[0]) {
23
+ const inserted = await client.query(`INSERT INTO request_rate_limits (bucket_key, window_started_at, request_count, updated_at)
24
+ VALUES ($1, NOW(), 1, NOW())
25
+ RETURNING request_count, window_started_at`, [bucketKey]);
26
+ return inserted.rows[0];
27
+ }
28
+ const current = existing.rows[0];
29
+ const windowStartedAt = toDate(current.window_started_at).getTime();
30
+ const expired = now - windowStartedAt >= this.windowMs;
31
+ const updated = await client.query(`UPDATE request_rate_limits
32
+ SET request_count = $2,
33
+ window_started_at = $3,
34
+ updated_at = NOW()
35
+ WHERE bucket_key = $1
36
+ RETURNING request_count, window_started_at`, [
37
+ bucketKey,
38
+ expired ? 1 : Number(current.request_count) + 1,
39
+ expired ? new Date(now).toISOString() : new Date(windowStartedAt).toISOString(),
40
+ ]);
41
+ return updated.rows[0];
42
+ });
43
+ break;
44
+ }
45
+ catch (error) {
46
+ const code = typeof error === "object" && error !== null && "code" in error
47
+ ? String(error.code)
48
+ : undefined;
49
+ if (code === "23505" && attempt === 0) {
50
+ continue;
51
+ }
52
+ throw error;
53
+ }
54
+ }
55
+ if (!row) {
56
+ throw new Error("Failed to update rate limit state.");
57
+ }
58
+ const requestCount = Number(row.request_count);
59
+ const resetAt = toDate(row.window_started_at).getTime() + this.windowMs;
60
+ const limited = requestCount > this.maxRequests;
61
+ if (limited) {
62
+ this.telemetry.recordRateLimitBlock("http");
63
+ }
64
+ return {
65
+ limited,
66
+ retryAfterSeconds: limited ? Math.max(1, Math.ceil((resetAt - now) / 1000)) : undefined,
67
+ remaining: Math.max(0, this.maxRequests - requestCount),
68
+ };
69
+ }
70
+ async cleanup() {
71
+ const cutoff = new Date(Date.now() - this.windowMs * 4).toISOString();
72
+ const result = await this.database.query(`WITH deleted AS (
73
+ DELETE FROM request_rate_limits
74
+ WHERE window_started_at < $1
75
+ RETURNING 1
76
+ )
77
+ SELECT COUNT(*)::text AS count FROM deleted`, [cutoff]);
78
+ return Number.parseInt(result.rows[0]?.count ?? "0", 10);
79
+ }
80
+ }
@@ -0,0 +1,271 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { rotationCreateInputSchema, rotationPlanInputSchema, rotationRunSchema, } from "../domain/types.js";
3
+ function earliestDueDate(values) {
4
+ const parsed = values
5
+ .filter((value) => Boolean(value))
6
+ .map((value) => new Date(value))
7
+ .filter((value) => !Number.isNaN(value.getTime()))
8
+ .sort((left, right) => left.getTime() - right.getTime());
9
+ return parsed[0]?.toISOString();
10
+ }
11
+ function dueSource(credential, inspection) {
12
+ if (inspection.nextRotationAt) {
13
+ return "secret_rotation_window";
14
+ }
15
+ if (inspection.expiresAt) {
16
+ return "secret_expiry";
17
+ }
18
+ if (credential.expiresAt) {
19
+ return "catalog_expiry";
20
+ }
21
+ return undefined;
22
+ }
23
+ function tenantAllowed(context, tenantId) {
24
+ return !context.tenantId || context.tenantId === tenantId;
25
+ }
26
+ export class RotationService {
27
+ runs;
28
+ credentials;
29
+ adapters;
30
+ audit;
31
+ notifications;
32
+ traces;
33
+ defaultHorizonDays;
34
+ constructor(runs, credentials, adapters, audit, notifications, traces, defaultHorizonDays) {
35
+ this.runs = runs;
36
+ this.credentials = credentials;
37
+ this.adapters = adapters;
38
+ this.audit = audit;
39
+ this.notifications = notifications;
40
+ this.traces = traces;
41
+ this.defaultHorizonDays = defaultHorizonDays;
42
+ }
43
+ async createRun(input, actor) {
44
+ const created = await this.runs.create(rotationRunSchema.parse(input));
45
+ await this.audit.record({
46
+ type: "rotation.run",
47
+ action: "rotation.create",
48
+ outcome: "success",
49
+ tenantId: created.tenantId,
50
+ principal: actor.principal,
51
+ metadata: {
52
+ rotationId: created.id,
53
+ tenantId: created.tenantId,
54
+ credentialId: created.credentialId,
55
+ source: created.source,
56
+ dueAt: created.dueAt ?? null,
57
+ },
58
+ });
59
+ await this.notifications.send("rotation.created", {
60
+ rotationId: created.id,
61
+ credentialId: created.credentialId,
62
+ source: created.source,
63
+ dueAt: created.dueAt ?? null,
64
+ });
65
+ return created;
66
+ }
67
+ async list(filter) {
68
+ return this.runs.list(filter);
69
+ }
70
+ async createManual(context, input) {
71
+ const parsed = rotationCreateInputSchema.parse(input);
72
+ return this.traces.withSpan("rotation.create_manual", { credentialId: parsed.credentialId }, async () => {
73
+ const credential = await this.credentials.getById(parsed.credentialId);
74
+ if (!credential) {
75
+ throw new Error("Credential not found.");
76
+ }
77
+ if (!tenantAllowed(context, credential.tenantId)) {
78
+ throw new Error("Tenant access denied.");
79
+ }
80
+ const existing = await this.runs.findOpenByCredentialId(parsed.credentialId);
81
+ if (existing) {
82
+ throw new Error("An open rotation already exists for this credential.");
83
+ }
84
+ return this.createRun({
85
+ id: randomUUID(),
86
+ tenantId: credential.tenantId,
87
+ credentialId: parsed.credentialId,
88
+ status: "pending",
89
+ source: "manual",
90
+ reason: parsed.reason,
91
+ dueAt: parsed.dueAt,
92
+ plannedAt: new Date().toISOString(),
93
+ plannedBy: context.principal,
94
+ updatedBy: context.principal,
95
+ note: parsed.note,
96
+ }, context);
97
+ });
98
+ }
99
+ async planDue(context, input) {
100
+ const parsed = rotationPlanInputSchema.parse(input ?? { horizonDays: this.defaultHorizonDays });
101
+ return this.traces.withSpan("rotation.plan_due", { horizonDays: parsed.horizonDays }, async () => {
102
+ const credentials = await this.credentials.list();
103
+ const cutoff = Date.now() + parsed.horizonDays * 24 * 60 * 60 * 1000;
104
+ const created = [];
105
+ for (const credential of credentials) {
106
+ if (credential.status !== "active") {
107
+ continue;
108
+ }
109
+ if (!tenantAllowed(context, credential.tenantId)) {
110
+ continue;
111
+ }
112
+ if (parsed.credentialIds?.length && !parsed.credentialIds.includes(credential.id)) {
113
+ continue;
114
+ }
115
+ if (await this.runs.findOpenByCredentialId(credential.id)) {
116
+ continue;
117
+ }
118
+ const inspection = await this.adapters.inspectCredential(credential);
119
+ const dueAt = earliestDueDate([
120
+ credential.expiresAt,
121
+ inspection.expiresAt,
122
+ inspection.nextRotationAt,
123
+ ]);
124
+ if (!dueAt || new Date(dueAt).getTime() > cutoff) {
125
+ continue;
126
+ }
127
+ const source = dueSource(credential, inspection);
128
+ if (!source) {
129
+ continue;
130
+ }
131
+ created.push(await this.createRun({
132
+ id: randomUUID(),
133
+ tenantId: credential.tenantId,
134
+ credentialId: credential.id,
135
+ status: "pending",
136
+ source,
137
+ reason: source === "catalog_expiry"
138
+ ? "Credential catalogue expiry is approaching."
139
+ : source === "secret_expiry"
140
+ ? "Secret backend reports upcoming expiry."
141
+ : "Secret backend reports upcoming rotation window.",
142
+ dueAt,
143
+ plannedAt: new Date().toISOString(),
144
+ plannedBy: context.principal,
145
+ updatedBy: context.principal,
146
+ }, context));
147
+ }
148
+ return created;
149
+ });
150
+ }
151
+ async start(id, context, note) {
152
+ return this.traces.withSpan("rotation.start", { rotationId: id }, async () => {
153
+ const existing = await this.runs.getById(id);
154
+ if (existing && !tenantAllowed(context, existing.tenantId)) {
155
+ throw new Error("Tenant access denied.");
156
+ }
157
+ const updated = await this.runs.transition(id, {
158
+ fromStatuses: ["pending"],
159
+ status: "in_progress",
160
+ updatedBy: context.principal,
161
+ note,
162
+ });
163
+ if (updated) {
164
+ await this.audit.record({
165
+ type: "rotation.run",
166
+ action: "rotation.start",
167
+ outcome: "success",
168
+ tenantId: updated.tenantId,
169
+ principal: context.principal,
170
+ metadata: {
171
+ rotationId: updated.id,
172
+ tenantId: updated.tenantId,
173
+ credentialId: updated.credentialId,
174
+ },
175
+ });
176
+ await this.notifications.send("rotation.started", {
177
+ rotationId: updated.id,
178
+ credentialId: updated.credentialId,
179
+ });
180
+ }
181
+ return updated;
182
+ });
183
+ }
184
+ async complete(id, context, input) {
185
+ return this.traces.withSpan("rotation.complete", { rotationId: id }, async () => {
186
+ const run = await this.runs.getById(id);
187
+ if (!run) {
188
+ return undefined;
189
+ }
190
+ if (!tenantAllowed(context, run.tenantId)) {
191
+ throw new Error("Tenant access denied.");
192
+ }
193
+ const credential = await this.credentials.getById(run.credentialId);
194
+ if (!credential) {
195
+ throw new Error("Credential not found.");
196
+ }
197
+ const nextLastValidatedAt = input.lastValidatedAt ?? new Date().toISOString();
198
+ const nextBinding = input.targetRef && input.targetRef !== credential.binding.ref
199
+ ? { ...credential.binding, ref: input.targetRef }
200
+ : undefined;
201
+ await this.credentials.update(credential.id, {
202
+ lastValidatedAt: nextLastValidatedAt,
203
+ expiresAt: input.expiresAt ?? credential.expiresAt,
204
+ ...(nextBinding ? { binding: nextBinding } : {}),
205
+ });
206
+ const updated = await this.runs.transition(id, {
207
+ fromStatuses: ["pending", "in_progress"],
208
+ status: "completed",
209
+ updatedBy: context.principal,
210
+ note: input.note,
211
+ targetRef: input.targetRef,
212
+ resultNote: input.note,
213
+ });
214
+ if (updated) {
215
+ await this.audit.record({
216
+ type: "rotation.run",
217
+ action: "rotation.complete",
218
+ outcome: "success",
219
+ tenantId: updated.tenantId,
220
+ principal: context.principal,
221
+ metadata: {
222
+ rotationId: updated.id,
223
+ tenantId: updated.tenantId,
224
+ credentialId: updated.credentialId,
225
+ targetRef: updated.targetRef ?? null,
226
+ },
227
+ });
228
+ await this.notifications.send("rotation.completed", {
229
+ rotationId: updated.id,
230
+ credentialId: updated.credentialId,
231
+ targetRef: updated.targetRef ?? null,
232
+ });
233
+ }
234
+ return updated;
235
+ });
236
+ }
237
+ async fail(id, context, note) {
238
+ return this.traces.withSpan("rotation.fail", { rotationId: id }, async () => {
239
+ const existing = await this.runs.getById(id);
240
+ if (existing && !tenantAllowed(context, existing.tenantId)) {
241
+ throw new Error("Tenant access denied.");
242
+ }
243
+ const updated = await this.runs.transition(id, {
244
+ fromStatuses: ["pending", "in_progress"],
245
+ status: "failed",
246
+ updatedBy: context.principal,
247
+ note,
248
+ resultNote: note,
249
+ });
250
+ if (updated) {
251
+ await this.audit.record({
252
+ type: "rotation.run",
253
+ action: "rotation.fail",
254
+ outcome: "error",
255
+ tenantId: updated.tenantId,
256
+ principal: context.principal,
257
+ metadata: {
258
+ rotationId: updated.id,
259
+ tenantId: updated.tenantId,
260
+ credentialId: updated.credentialId,
261
+ },
262
+ });
263
+ await this.notifications.send("rotation.failed", {
264
+ rotationId: updated.id,
265
+ credentialId: updated.credentialId,
266
+ });
267
+ }
268
+ return updated;
269
+ });
270
+ }
271
+ }
@@ -0,0 +1,149 @@
1
+ function labelKey(labels) {
2
+ return Object.entries(labels)
3
+ .sort(([left], [right]) => left.localeCompare(right))
4
+ .map(([key, value]) => `${key}=${value}`)
5
+ .join(",");
6
+ }
7
+ function renderLabels(labels) {
8
+ const entries = Object.entries(labels).sort(([left], [right]) => left.localeCompare(right));
9
+ if (entries.length === 0) {
10
+ return "";
11
+ }
12
+ const body = entries
13
+ .map(([key, value]) => `${key}="${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`)
14
+ .join(",");
15
+ return `{${body}}`;
16
+ }
17
+ export class TelemetryService {
18
+ startedAt = Date.now();
19
+ counters = new Map();
20
+ gauges = new Map();
21
+ summaries = new Map();
22
+ getCounter(name) {
23
+ let metric = this.counters.get(name);
24
+ if (!metric) {
25
+ metric = new Map();
26
+ this.counters.set(name, metric);
27
+ }
28
+ return metric;
29
+ }
30
+ getGauge(name) {
31
+ let metric = this.gauges.get(name);
32
+ if (!metric) {
33
+ metric = new Map();
34
+ this.gauges.set(name, metric);
35
+ }
36
+ return metric;
37
+ }
38
+ getSummary(name) {
39
+ let metric = this.summaries.get(name);
40
+ if (!metric) {
41
+ metric = new Map();
42
+ this.summaries.set(name, metric);
43
+ }
44
+ return metric;
45
+ }
46
+ incrementCounter(name, labels = {}, value = 1) {
47
+ const metric = this.getCounter(name);
48
+ const key = labelKey(labels);
49
+ const current = metric.get(key);
50
+ metric.set(key, {
51
+ labels,
52
+ value: (current?.value ?? 0) + value,
53
+ });
54
+ }
55
+ setGauge(name, labels = {}, value) {
56
+ this.getGauge(name).set(labelKey(labels), {
57
+ labels,
58
+ value,
59
+ });
60
+ }
61
+ adjustGauge(name, labels = {}, delta) {
62
+ const metric = this.getGauge(name);
63
+ const key = labelKey(labels);
64
+ const current = metric.get(key);
65
+ metric.set(key, {
66
+ labels,
67
+ value: (current?.value ?? 0) + delta,
68
+ });
69
+ }
70
+ observeSummary(name, labels = {}, value) {
71
+ const metric = this.getSummary(name);
72
+ const key = labelKey(labels);
73
+ const current = metric.get(key);
74
+ metric.set(key, {
75
+ labels,
76
+ count: (current?.count ?? 0) + 1,
77
+ sum: (current?.sum ?? 0) + value,
78
+ max: Math.max(current?.max ?? value, value),
79
+ });
80
+ }
81
+ recordHttpRequest(route, method, statusCode, durationMs) {
82
+ const labels = {
83
+ route,
84
+ method,
85
+ status_class: `${Math.floor(statusCode / 100)}xx`,
86
+ };
87
+ this.incrementCounter("keylore_http_requests_total", labels);
88
+ this.observeSummary("keylore_http_request_duration_ms", labels, durationMs);
89
+ }
90
+ recordRateLimitBlock(scope) {
91
+ this.incrementCounter("keylore_rate_limit_blocks_total", { scope });
92
+ }
93
+ recordAuthTokenIssued(outcome) {
94
+ this.incrementCounter("keylore_auth_token_issuance_total", { outcome });
95
+ }
96
+ recordAuthTokenValidation(outcome) {
97
+ this.incrementCounter("keylore_auth_token_validation_total", { outcome });
98
+ }
99
+ recordAdapterOperation(adapter, operation, outcome) {
100
+ this.incrementCounter("keylore_adapter_operations_total", {
101
+ adapter,
102
+ operation,
103
+ outcome,
104
+ });
105
+ }
106
+ recordMaintenanceRun(task, outcome, durationMs) {
107
+ this.incrementCounter("keylore_maintenance_runs_total", { task, outcome });
108
+ this.observeSummary("keylore_maintenance_duration_ms", { task, outcome }, durationMs);
109
+ this.setGauge("keylore_maintenance_last_run_timestamp_seconds", { task }, Math.floor(Date.now() / 1000));
110
+ }
111
+ recordNotificationDelivery(eventType, outcome) {
112
+ this.incrementCounter("keylore_notification_deliveries_total", { event_type: eventType, outcome });
113
+ }
114
+ recordTraceExport(outcome, batchSize) {
115
+ this.incrementCounter("keylore_trace_exports_total", { outcome });
116
+ this.observeSummary("keylore_trace_export_batch_size", { outcome }, batchSize);
117
+ }
118
+ recordRotationRun(action, outcome) {
119
+ this.incrementCounter("keylore_rotation_runs_total", { action, outcome });
120
+ }
121
+ renderPrometheus() {
122
+ const lines = [
123
+ "# HELP keylore_process_start_time_seconds Process start time in Unix seconds.",
124
+ "# TYPE keylore_process_start_time_seconds gauge",
125
+ `keylore_process_start_time_seconds ${Math.floor(this.startedAt / 1000)}`,
126
+ ];
127
+ for (const [name, metric] of this.counters.entries()) {
128
+ lines.push(`# TYPE ${name} counter`);
129
+ for (const entry of metric.values()) {
130
+ lines.push(`${name}${renderLabels(entry.labels)} ${entry.value}`);
131
+ }
132
+ }
133
+ for (const [name, metric] of this.gauges.entries()) {
134
+ lines.push(`# TYPE ${name} gauge`);
135
+ for (const entry of metric.values()) {
136
+ lines.push(`${name}${renderLabels(entry.labels)} ${entry.value}`);
137
+ }
138
+ }
139
+ for (const [name, metric] of this.summaries.entries()) {
140
+ lines.push(`# TYPE ${name} summary`);
141
+ for (const entry of metric.values()) {
142
+ lines.push(`${name}_count${renderLabels(entry.labels)} ${entry.count}`);
143
+ lines.push(`${name}_sum${renderLabels(entry.labels)} ${entry.sum}`);
144
+ lines.push(`${name}_max${renderLabels(entry.labels)} ${entry.max}`);
145
+ }
146
+ }
147
+ return `${lines.join("\n")}\n`;
148
+ }
149
+ }
@@ -0,0 +1,127 @@
1
+ import { authClientSecretOutputSchema, tenantSummarySchema, } from "../domain/types.js";
2
+ function requireTenantAdmin(actor, tenantId) {
3
+ if (actor.tenantId && tenantId && actor.tenantId !== tenantId) {
4
+ throw new Error("Tenant access denied.");
5
+ }
6
+ }
7
+ export class TenantService {
8
+ tenants;
9
+ database;
10
+ audit;
11
+ auth;
12
+ constructor(tenants, database, audit, auth) {
13
+ this.tenants = tenants;
14
+ this.database = database;
15
+ this.audit = audit;
16
+ this.auth = auth;
17
+ }
18
+ async list(actor) {
19
+ const tenants = await this.tenants.list();
20
+ const visibleTenants = actor.tenantId
21
+ ? tenants.filter((tenant) => tenant.tenantId === actor.tenantId)
22
+ : tenants;
23
+ const summaries = await Promise.all(visibleTenants.map((tenant) => this.summaryFor(tenant)));
24
+ return summaries.map((summary) => tenantSummarySchema.parse(summary));
25
+ }
26
+ async get(actor, tenantId) {
27
+ requireTenantAdmin(actor, tenantId);
28
+ const tenant = await this.tenants.getById(tenantId);
29
+ if (!tenant) {
30
+ return undefined;
31
+ }
32
+ return tenantSummarySchema.parse(await this.summaryFor(tenant));
33
+ }
34
+ async requireActiveTenant(tenantId) {
35
+ const tenant = await this.tenants.getById(tenantId);
36
+ if (!tenant) {
37
+ throw new Error(`Unknown tenant: ${tenantId}`);
38
+ }
39
+ if (tenant.status !== "active") {
40
+ throw new Error(`Tenant is disabled: ${tenantId}`);
41
+ }
42
+ return tenant;
43
+ }
44
+ async create(actor, input) {
45
+ requireTenantAdmin(actor, input.tenantId);
46
+ const existing = await this.tenants.getById(input.tenantId);
47
+ if (existing) {
48
+ throw new Error(`Tenant already exists: ${input.tenantId}`);
49
+ }
50
+ const tenant = await this.tenants.create(input);
51
+ await this.audit.record({
52
+ type: "auth.client",
53
+ action: "tenant.create",
54
+ outcome: "success",
55
+ tenantId: tenant.tenantId,
56
+ principal: actor.principal,
57
+ metadata: {
58
+ tenantId: tenant.tenantId,
59
+ status: tenant.status,
60
+ },
61
+ });
62
+ return tenantSummarySchema.parse(await this.summaryFor(tenant));
63
+ }
64
+ async update(actor, tenantId, patch) {
65
+ requireTenantAdmin(actor, tenantId);
66
+ const tenant = await this.tenants.update(tenantId, patch);
67
+ if (!tenant) {
68
+ return undefined;
69
+ }
70
+ await this.audit.record({
71
+ type: "auth.client",
72
+ action: "tenant.update",
73
+ outcome: "success",
74
+ tenantId: tenant.tenantId,
75
+ principal: actor.principal,
76
+ metadata: {
77
+ tenantId: tenant.tenantId,
78
+ fields: Object.keys(patch),
79
+ status: tenant.status,
80
+ },
81
+ });
82
+ return tenantSummarySchema.parse(await this.summaryFor(tenant));
83
+ }
84
+ async bootstrap(actor, input) {
85
+ const tenant = await this.create(actor, input.tenant);
86
+ const clients = [];
87
+ for (const client of input.authClients) {
88
+ clients.push(authClientSecretOutputSchema.parse(await this.auth.createClient(actor, {
89
+ ...client,
90
+ tenantId: tenant.tenantId,
91
+ })));
92
+ }
93
+ const refreshedTenant = tenantSummarySchema.parse(await this.summaryFor({
94
+ tenantId: tenant.tenantId,
95
+ displayName: tenant.displayName,
96
+ description: tenant.description,
97
+ status: tenant.status,
98
+ createdAt: tenant.createdAt,
99
+ updatedAt: tenant.updatedAt,
100
+ }));
101
+ await this.audit.record({
102
+ type: "auth.client",
103
+ action: "tenant.bootstrap",
104
+ outcome: "success",
105
+ tenantId: refreshedTenant.tenantId,
106
+ principal: actor.principal,
107
+ metadata: {
108
+ tenantId: refreshedTenant.tenantId,
109
+ authClientCount: clients.length,
110
+ },
111
+ });
112
+ return { tenant: refreshedTenant, clients };
113
+ }
114
+ async summaryFor(tenant) {
115
+ const result = await this.database.query(`SELECT
116
+ (SELECT COUNT(*)::text FROM credentials WHERE tenant_id = $1) AS credential_count,
117
+ (SELECT COUNT(*)::text FROM oauth_clients WHERE tenant_id = $1) AS auth_client_count,
118
+ (SELECT COUNT(*)::text FROM access_tokens WHERE tenant_id = $1 AND status = 'active') AS active_token_count`, [tenant.tenantId]);
119
+ const row = result.rows[0];
120
+ return {
121
+ ...tenant,
122
+ credentialCount: Number.parseInt(row?.credential_count ?? "0", 10),
123
+ authClientCount: Number.parseInt(row?.auth_client_count ?? "0", 10),
124
+ activeTokenCount: Number.parseInt(row?.active_token_count ?? "0", 10),
125
+ };
126
+ }
127
+ }