@cosmicdrift/kumiko-bundled-features 0.210.0 → 0.212.0
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/package.json +8 -8
- package/src/admin-shell/feature.ts +2 -0
- package/src/admin-shell/web/platform-overview-screen.tsx +1 -0
- package/src/admin-shell/web/tenant-overview-screen.tsx +1 -0
- package/src/audit/__tests__/audit-log-screen.test.tsx +228 -0
- package/src/audit/constants.ts +3 -0
- package/src/audit/feature.ts +2 -0
- package/src/audit/i18n.ts +1 -0
- package/src/audit/web/audit-log-detail-screen.tsx +28 -6
- package/src/audit/web/audit-log-screen.tsx +51 -23
- package/src/auth-email-password/feature.ts +1 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +2 -0
- package/src/auth-email-password/web/auth-gate.tsx +1 -0
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +1 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +3 -0
- package/src/auth-email-password/web/session.tsx +1 -0
- package/src/auth-email-password/web/verify-email-screen.tsx +1 -0
- package/src/auth-mfa/feature.ts +1 -0
- package/src/auth-mfa/web/mfa-disable-dialog.tsx +1 -0
- package/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +1 -0
- package/src/compliance-profiles/feature.ts +1 -5
- package/src/crypto-shredding/changes.json +2 -1
- package/src/custom-fields/__tests__/feature.test.ts +42 -0
- package/src/custom-fields/__tests__/field-definition-role-config.integration.test.ts +139 -0
- package/src/custom-fields/constants.ts +6 -0
- package/src/custom-fields/feature.ts +56 -10
- package/src/custom-fields/handlers/define-tenant-field.write.ts +7 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +54 -33
- package/src/custom-fields/handlers/update-tenant-field.write.ts +58 -35
- package/src/delivery/feature.ts +1 -3
- package/src/feature-toggles/feature.ts +1 -0
- package/src/feature-toggles/web/toggle-admin-screen.tsx +1 -0
- package/src/file-derivatives/changes.json +9 -1
- package/src/folders/web/folder-manager.tsx +1 -0
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +174 -0
- package/src/jobs/__tests__/jobs-stale-run-sweep.integration.test.ts +148 -0
- package/src/jobs/db/queries/stale-run-sweep.ts +51 -0
- package/src/jobs/feature.ts +25 -1
- package/src/jobs/handlers/detail.query.ts +13 -1
- package/src/jobs/handlers/stale-run-sweep.job.ts +28 -0
- package/src/jobs/job-run-logger.ts +68 -18
- package/src/jobs/web/job-run-detail-screen.tsx +1 -0
- package/src/page-render/web.ts +1 -0
- package/src/personal-access-tokens/changes.json +7 -0
- package/src/personal-access-tokens/feature.ts +1 -0
- package/src/secrets/__tests__/access-options.integration.test.ts +114 -0
- package/src/secrets/constants.ts +12 -0
- package/src/secrets/feature.ts +35 -9
- package/src/secrets/handlers/delete.write.ts +23 -16
- package/src/secrets/handlers/list.query.ts +37 -30
- package/src/secrets/handlers/set.write.ts +43 -36
- package/src/secrets/index.ts +5 -0
- package/src/seo/feature.ts +1 -0
- package/src/tags/feature.ts +1 -0
- package/src/tags/web/tag-chip.tsx +2 -0
- package/src/tags/web/tag-manager.tsx +2 -1
- package/src/tags/web/tag-picker.tsx +1 -0
- package/src/template-resolver/changes.json +9 -1
- package/src/template-resolver/web/client-plugin.tsx +2 -0
- package/src/tenant/__tests__/members-screens.boot.test.ts +79 -4
- package/src/tenant/__tests__/tenant-security.integration.test.ts +260 -3
- package/src/tenant/changes.json +6 -0
- package/src/tenant/constants.ts +17 -4
- package/src/tenant/feature.ts +28 -11
- package/src/tenant/handlers/team-list.query.ts +250 -0
- package/src/tenant/i18n.ts +20 -0
- package/src/tenant/screens.ts +111 -3
- package/src/tenant/web/client-plugin.tsx +11 -6
- package/src/tenant/web/index.ts +2 -2
- package/src/tenant/web/member-roles-cell.tsx +12 -0
- package/src/tenant/web/member-status-cell.tsx +23 -0
- package/src/tier-engine/feature.ts +2 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +5 -5
- package/src/user-data-rights/feature.ts +2 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
- package/src/user-profile/i18n.ts +1 -0
- package/src/user-profile/web/profile-screen.tsx +1 -1
- package/src/tenant/web/i18n.ts +0 -24
- package/src/tenant/web/members-screen.tsx +0 -270
|
@@ -67,6 +67,35 @@ async function encryptStartedPayload(
|
|
|
67
67
|
);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// Same per-subject encryption as encryptStartedPayload, applied to each
|
|
71
|
+
// batched log line's `message` (#2247) before insertMany into
|
|
72
|
+
// jobRunLogsTable — that table is unmanaged/direct-write, so there is no
|
|
73
|
+
// event-piiFields catalog to lean on here either. "message" is the AAD
|
|
74
|
+
// field name and must match the field passed to decryptStoredPii on read
|
|
75
|
+
// (detail.query.ts) or decrypt fails loud. Same skip rules as the payload:
|
|
76
|
+
// null subject (system/cron runs) and absent KMS (rollout mode) both stay
|
|
77
|
+
// plaintext.
|
|
78
|
+
async function encryptLogMessages<T extends { readonly message: string }>(
|
|
79
|
+
logs: readonly T[],
|
|
80
|
+
triggeredById: string | null,
|
|
81
|
+
): Promise<T[]> {
|
|
82
|
+
if (triggeredById === null) return [...logs];
|
|
83
|
+
const kms = configuredPiiSubjectKms();
|
|
84
|
+
if (!kms) return [...logs];
|
|
85
|
+
return Promise.all(
|
|
86
|
+
logs.map(async (log) => ({
|
|
87
|
+
...log,
|
|
88
|
+
message: await encryptPiiValueForSubject(
|
|
89
|
+
kms,
|
|
90
|
+
{ kind: "user", userId: triggeredById },
|
|
91
|
+
log.message,
|
|
92
|
+
{ requestId: "jobs:job-run-logger" },
|
|
93
|
+
"message",
|
|
94
|
+
),
|
|
95
|
+
})),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
70
99
|
export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallbacks {
|
|
71
100
|
const { db } = opts;
|
|
72
101
|
|
|
@@ -78,10 +107,17 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
78
107
|
// Bounded cache (LRU-ish with TTL) — worker-crash between start and
|
|
79
108
|
// complete would otherwise leak entries. DB-lookup recovers evicted
|
|
80
109
|
// entries via bull_job_id on jobRunsTable.
|
|
81
|
-
|
|
110
|
+
// triggeredById rides along with runId (#2247) — resolved once at start
|
|
111
|
+
// (or on cache-miss DB fallback) so onJobComplete/-Failed know the log
|
|
112
|
+
// subject without an extra always-on DB round trip.
|
|
113
|
+
type CacheEntry = {
|
|
114
|
+
readonly runId: string;
|
|
115
|
+
readonly triggeredById: string | null;
|
|
116
|
+
readonly expiresAt: number;
|
|
117
|
+
};
|
|
82
118
|
const runIdByBullJobId = new Map<string, CacheEntry>();
|
|
83
119
|
|
|
84
|
-
function cachePut(bullJobId: string, runId: string): void {
|
|
120
|
+
function cachePut(bullJobId: string, runId: string, triggeredById: string | null): void {
|
|
85
121
|
// Enforce max-size BEFORE insert. Map iteration returns insertion
|
|
86
122
|
// order, so dropping the first entry is the oldest.
|
|
87
123
|
if (runIdByBullJobId.size >= DEFAULT_CACHE_MAX_ENTRIES) {
|
|
@@ -90,24 +126,32 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
90
126
|
}
|
|
91
127
|
runIdByBullJobId.set(bullJobId, {
|
|
92
128
|
runId,
|
|
129
|
+
triggeredById,
|
|
93
130
|
expiresAt: Date.now() + DEFAULT_CACHE_TTL_MS,
|
|
94
131
|
});
|
|
95
132
|
}
|
|
96
133
|
|
|
97
|
-
function cacheGet(bullJobId: string):
|
|
134
|
+
function cacheGet(bullJobId: string): CacheEntry | undefined {
|
|
98
135
|
const entry = runIdByBullJobId.get(bullJobId);
|
|
99
136
|
if (!entry) return undefined;
|
|
100
137
|
if (Date.now() >= entry.expiresAt) {
|
|
101
138
|
runIdByBullJobId.delete(bullJobId); // immediate cleanup on terminal callback
|
|
102
139
|
return undefined;
|
|
103
140
|
}
|
|
104
|
-
return entry
|
|
141
|
+
return entry;
|
|
105
142
|
}
|
|
106
143
|
|
|
107
|
-
async function
|
|
144
|
+
async function resolveRun(
|
|
145
|
+
bullJobId: string,
|
|
146
|
+
): Promise<{ readonly runId: string; readonly triggeredById: string | null } | undefined> {
|
|
108
147
|
const cached = cacheGet(bullJobId);
|
|
109
|
-
if (cached) return cached;
|
|
110
|
-
const row = await fetchOne<{ id: string | number
|
|
148
|
+
if (cached) return { runId: cached.runId, triggeredById: cached.triggeredById };
|
|
149
|
+
const row = await fetchOne<{ id: string | number; triggeredById: string | null }>(
|
|
150
|
+
db,
|
|
151
|
+
jobRunsTable,
|
|
152
|
+
{ bullJobId },
|
|
153
|
+
);
|
|
154
|
+
if (!row) return undefined;
|
|
111
155
|
// buildBaseColumns's signature types `id` as `string | number` because
|
|
112
156
|
// it returns both branches of the idType union. We know this table
|
|
113
157
|
// was built with idType: "uuid" (see job-run-table.ts), so narrowing
|
|
@@ -115,15 +159,17 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
115
159
|
// would overload buildBaseColumns per idType — scoped out of this
|
|
116
160
|
// follow-up as its return type has four branches (with/without
|
|
117
161
|
// softDelete × serial/uuid).
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
162
|
+
const runId = String(row.id);
|
|
163
|
+
const triggeredById = row.triggeredById ?? null;
|
|
164
|
+
cachePut(bullJobId, runId, triggeredById);
|
|
165
|
+
return { runId, triggeredById };
|
|
121
166
|
}
|
|
122
167
|
|
|
123
168
|
return {
|
|
124
169
|
onJobStart: async (jobName: string, bullJobId: string, meta: JobMeta) => {
|
|
125
170
|
const runId = generateId();
|
|
126
|
-
|
|
171
|
+
const triggeredById = meta.triggeredById ?? null;
|
|
172
|
+
cachePut(bullJobId, runId, triggeredById);
|
|
127
173
|
// Parse against the registered schema so out-of-dispatcher writes
|
|
128
174
|
// get the same validation guarantee as ctx.appendEvent. A shape
|
|
129
175
|
// drift between feature + logger fails loudly at the source
|
|
@@ -133,7 +179,7 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
133
179
|
bullJobId,
|
|
134
180
|
status: "running",
|
|
135
181
|
payload: meta.payload ?? null,
|
|
136
|
-
triggeredById
|
|
182
|
+
triggeredById,
|
|
137
183
|
startedAt: Temporal.Now.instant().toString(),
|
|
138
184
|
attempt: meta.attempt ?? 1,
|
|
139
185
|
});
|
|
@@ -158,12 +204,13 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
158
204
|
duration: number,
|
|
159
205
|
logs: JobLogEntry[],
|
|
160
206
|
) => {
|
|
161
|
-
const
|
|
207
|
+
const resolved = await resolveRun(bullJobId);
|
|
162
208
|
// skip: state loss between start + complete (worker restart, cache
|
|
163
209
|
// evicted AND DB has no matching bull_job_id). Rare edge case; we
|
|
164
210
|
// drop the completion write rather than forging a run row from
|
|
165
211
|
// scratch — forensics still has the original BullMQ lifecycle.
|
|
166
|
-
if (!
|
|
212
|
+
if (!resolved) return;
|
|
213
|
+
const { runId, triggeredById } = resolved;
|
|
167
214
|
const payload = runCompletedSchema.parse({
|
|
168
215
|
duration,
|
|
169
216
|
finishedAt: Temporal.Now.instant().toString(),
|
|
@@ -188,10 +235,11 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
188
235
|
// skip: empty log batch — the worker ran silent. No child rows to
|
|
189
236
|
// insert; the status update above already recorded completion.
|
|
190
237
|
if (payload.logs.length > 0) {
|
|
238
|
+
const encryptedLogs = await encryptLogMessages(payload.logs, triggeredById);
|
|
191
239
|
await insertMany(
|
|
192
240
|
db,
|
|
193
241
|
jobRunLogsTable,
|
|
194
|
-
|
|
242
|
+
encryptedLogs.map((log) => ({
|
|
195
243
|
runId,
|
|
196
244
|
level: log.level,
|
|
197
245
|
message: log.message,
|
|
@@ -208,10 +256,11 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
208
256
|
error: string,
|
|
209
257
|
logs: JobLogEntry[],
|
|
210
258
|
) => {
|
|
211
|
-
const
|
|
259
|
+
const resolved = await resolveRun(bullJobId);
|
|
212
260
|
// skip: same rare state-loss case as in onJobComplete — drop the
|
|
213
261
|
// failure write rather than forge a run row from scratch.
|
|
214
|
-
if (!
|
|
262
|
+
if (!resolved) return;
|
|
263
|
+
const { runId, triggeredById } = resolved;
|
|
215
264
|
// Read started_at off the row so we can compute duration
|
|
216
265
|
// symmetrically to onJobComplete (which gets duration from the
|
|
217
266
|
// worker). The row already has started_at from onJobStart.
|
|
@@ -243,10 +292,11 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
243
292
|
);
|
|
244
293
|
// skip: empty log batch — mirror of onJobComplete
|
|
245
294
|
if (payload.logs.length > 0) {
|
|
295
|
+
const encryptedLogs = await encryptLogMessages(payload.logs, triggeredById);
|
|
246
296
|
await insertMany(
|
|
247
297
|
db,
|
|
248
298
|
jobRunLogsTable,
|
|
249
|
-
|
|
299
|
+
encryptedLogs.map((log) => ({
|
|
250
300
|
runId,
|
|
251
301
|
level: log.level,
|
|
252
302
|
message: log.message,
|
package/src/page-render/web.ts
CHANGED
|
@@ -4,5 +4,12 @@
|
|
|
4
4
|
"type": "improvement",
|
|
5
5
|
"title": "personal-access-tokens: add `toggleable` option so the whole feature can be tier-gated vi…",
|
|
6
6
|
"detail": "personal-access-tokens: add `toggleable` option so the whole feature can be tier-gated via the tier-engine (mirrors ledger/tags). Pass `{ toggleable: { default: false } }` for fail-closed gating — PAT is then off until a tier lists `\"personal-access-tokens\"` in its features. Omitting the option keeps PAT always-on (no behaviour change for existing consumers)."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.202.0",
|
|
10
|
+
"type": "breaking",
|
|
11
|
+
"title": "personal-access-tokens: `write:create` now requires `currentPassword` (+ `mfaCode` if MFA enrolled) (e91d4cb).",
|
|
12
|
+
"detail": "`personal-access-tokens:write:create` now requires `currentPassword` (verified against the caller's password hash) before minting a token, and rejects when the caller has MFA enrolled and `mfaCode` is missing or wrong — a session cookie alone is no longer enough to stand up a durable API credential. `expiresInDays` now defaults to 90 days instead of never-expiring when omitted (the existing 3650-day cap is unchanged, so a genuinely long-lived token is still possible if requested explicitly). Changing a user's password, or enabling/disabling MFA, now revokes all of that user's live PAT tokens — mirrors the existing session auto-revoke-on-password-change behavior. `run-prod-app`/`run-dev-app` wire the new MFA↔PAT revoke callback automatically when both `auth-mfa` and `personal-access-tokens` are mounted; no app-level change needed for that part.",
|
|
13
|
+
"migration": "Apps that already mint PATs (their own client code, scripts, or tests) need to add `currentPassword` to the `create` request payload — this is a breaking change to the `create` request shape despite the minor bump (bundled-features doesn't follow strict semver across its handler schemas yet). If the caller has MFA enrolled, also include a valid `mfaCode`."
|
|
7
14
|
}
|
|
8
15
|
]
|
|
@@ -149,6 +149,7 @@ export function createPersonalAccessTokensFeature(
|
|
|
149
149
|
// the app places it via r.nav in its logged-in settings area. dormant:
|
|
150
150
|
// true skips createKumikoApp's missing-client-plugin boot diagnostic
|
|
151
151
|
// (#2025) for apps that don't nav this screen (#2034).
|
|
152
|
+
// kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
|
|
152
153
|
r.screen({
|
|
153
154
|
id: PAT_SCREEN_ID,
|
|
154
155
|
type: "custom",
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// #2296 — createSecretsFeature() took no options at all: set/delete/list were
|
|
2
|
+
// hard-wired to ["TenantAdmin"], so an app with its own role vocabulary
|
|
3
|
+
// (e.g. ["Admin","Editor"]) could not let its own admins manage secrets
|
|
4
|
+
// without also granting the framework "TenantAdmin" role — the same
|
|
5
|
+
// Role-Naming-Drift documented on custom-fields' valueWriteRoles. Verifies
|
|
6
|
+
// the new `roles`/`access` options actually gate set/delete/list, that the
|
|
7
|
+
// framework-default role loses access once overridden, and that `openToAll`
|
|
8
|
+
// really does open the surface to any authenticated tenant user.
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
13
|
+
import {
|
|
14
|
+
createEnvMasterKeyProvider,
|
|
15
|
+
type MasterKeyProvider,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/secrets";
|
|
17
|
+
import {
|
|
18
|
+
createTestUser,
|
|
19
|
+
setupTestStack,
|
|
20
|
+
type TestStack,
|
|
21
|
+
unsafePushTables,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
|
+
import { createSecretsFeature } from "../feature";
|
|
24
|
+
import { createSecretsContext } from "../secrets-context";
|
|
25
|
+
import { tenantSecretsTable } from "../table";
|
|
26
|
+
|
|
27
|
+
function masterKeyProvider(): MasterKeyProvider {
|
|
28
|
+
return createEnvMasterKeyProvider({
|
|
29
|
+
env: {
|
|
30
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
|
|
31
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function buildStack(feature: ReturnType<typeof createSecretsFeature>): Promise<TestStack> {
|
|
37
|
+
const provider = masterKeyProvider();
|
|
38
|
+
const stack = await setupTestStack({
|
|
39
|
+
features: [feature],
|
|
40
|
+
extraContext: ({ db }) => ({
|
|
41
|
+
secrets: createSecretsContext({ db, masterKeyProvider: provider }),
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
await unsafePushTables(stack.db, { tenant_secrets: tenantSecretsTable });
|
|
45
|
+
await createEventsTable(stack.db);
|
|
46
|
+
return stack;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
50
|
+
const customRoleUser = createTestUser({ roles: ["Admin"] });
|
|
51
|
+
const unprivileged = createTestUser({ roles: ["Viewer"] });
|
|
52
|
+
|
|
53
|
+
describe("secrets — roles option (#2296)", () => {
|
|
54
|
+
let stack: TestStack;
|
|
55
|
+
|
|
56
|
+
beforeAll(async () => {
|
|
57
|
+
stack = await buildStack(createSecretsFeature({ roles: ["Admin"] }));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterAll(async () => {
|
|
61
|
+
await stack.cleanup();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a custom-role user can set, list and delete", async () => {
|
|
65
|
+
await stack.http.writeOk(
|
|
66
|
+
"secrets:write:set",
|
|
67
|
+
{ key: "api.key.role-opt", value: "shh" },
|
|
68
|
+
customRoleUser,
|
|
69
|
+
);
|
|
70
|
+
const list = await stack.http.queryOk<Array<{ key: string }>>(
|
|
71
|
+
"secrets:query:list",
|
|
72
|
+
{},
|
|
73
|
+
customRoleUser,
|
|
74
|
+
);
|
|
75
|
+
expect(list.some((r) => r.key === "api.key.role-opt")).toBe(true);
|
|
76
|
+
await stack.http.writeOk("secrets:write:delete", { key: "api.key.role-opt" }, customRoleUser);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("the framework-default TenantAdmin role is denied once `roles` overrides it", async () => {
|
|
80
|
+
const denied = await stack.http.writeErr(
|
|
81
|
+
"secrets:write:set",
|
|
82
|
+
{ key: "api.key.denied", value: "x" },
|
|
83
|
+
admin,
|
|
84
|
+
);
|
|
85
|
+
expect(denied.code).toBe("access_denied");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("secrets — access: { openToAll: true } (#2296)", () => {
|
|
90
|
+
let stack: TestStack;
|
|
91
|
+
|
|
92
|
+
beforeAll(async () => {
|
|
93
|
+
stack = await buildStack(createSecretsFeature({ access: { openToAll: true } }));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterAll(async () => {
|
|
97
|
+
await stack.cleanup();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("any authenticated tenant user can set, list and delete", async () => {
|
|
101
|
+
await stack.http.writeOk(
|
|
102
|
+
"secrets:write:set",
|
|
103
|
+
{ key: "api.key.open", value: "shh" },
|
|
104
|
+
unprivileged,
|
|
105
|
+
);
|
|
106
|
+
const list = await stack.http.queryOk<Array<{ key: string }>>(
|
|
107
|
+
"secrets:query:list",
|
|
108
|
+
{},
|
|
109
|
+
unprivileged,
|
|
110
|
+
);
|
|
111
|
+
expect(list.some((r) => r.key === "api.key.open")).toBe(true);
|
|
112
|
+
await stack.http.writeOk("secrets:write:delete", { key: "api.key.open" }, unprivileged);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// secrets bundle constants — default RBAC for set/delete/list (#2296).
|
|
3
|
+
|
|
4
|
+
import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
|
|
6
|
+
// Default-RBAC of the set/delete/list handlers, hard-wired to ["TenantAdmin"]
|
|
7
|
+
// before #2296. Apps with their own role vocabulary override via
|
|
8
|
+
// createSecretsFeature({ roles }) or createSecretsFeature({ access }) — the
|
|
9
|
+
// rotate job stays untouched, it's a manual/ops path across all tenants by
|
|
10
|
+
// design, not a per-tenant RBAC surface.
|
|
11
|
+
export const DEFAULT_SECRETS_ROLES = ["TenantAdmin"] as const;
|
|
12
|
+
export const DEFAULT_SECRETS_ACCESS: AccessRule = { roles: DEFAULT_SECRETS_ROLES };
|
package/src/secrets/feature.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type AccessRule,
|
|
3
|
+
defineFeature,
|
|
4
|
+
type FeatureDefinition,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
2
6
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
7
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
4
8
|
import { z } from "zod";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
9
|
+
import { DEFAULT_SECRETS_ACCESS } from "./constants";
|
|
10
|
+
import { createDeleteHandler } from "./handlers/delete.write";
|
|
11
|
+
import { createListHandler } from "./handlers/list.query";
|
|
7
12
|
import { rotateJob } from "./handlers/rotate.job";
|
|
8
|
-
import {
|
|
13
|
+
import { createSetHandler } from "./handlers/set.write";
|
|
9
14
|
import { secretReadSchema } from "./secrets-context";
|
|
10
15
|
import { tenantSecretEntity, tenantSecretsTable } from "./table";
|
|
11
16
|
|
|
@@ -84,10 +89,31 @@ export function requireSecretsContext(
|
|
|
84
89
|
};
|
|
85
90
|
}
|
|
86
91
|
|
|
87
|
-
export
|
|
92
|
+
export type SecretsFeatureOptions = {
|
|
93
|
+
/** Access rule for the `set`, `delete` and `list` handlers. Default
|
|
94
|
+
* { roles: ["TenantAdmin"] }. Adopt the host's role vocabulary with
|
|
95
|
+
* { roles: [...] }, or { openToAll: true } to let any authenticated tenant
|
|
96
|
+
* user manage secrets — note this is a much larger blast radius than the
|
|
97
|
+
* default: tenant credentials (API keys, tokens) become writable/deletable,
|
|
98
|
+
* and their previews/hints listable, by every tenant member, not just
|
|
99
|
+
* admins. Takes precedence over `roles`. The `rotate` job is unaffected —
|
|
100
|
+
* it's a manual, cross-tenant ops path, not per-tenant RBAC. */
|
|
101
|
+
readonly access?: AccessRule;
|
|
102
|
+
/** Shorthand for { access: { roles } }. Ignored when `access` is set. */
|
|
103
|
+
readonly roles?: readonly string[];
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
function resolveSecretsAccess(opts: SecretsFeatureOptions): AccessRule {
|
|
107
|
+
if (opts.access !== undefined) return opts.access;
|
|
108
|
+
if (opts.roles !== undefined) return { roles: opts.roles };
|
|
109
|
+
return DEFAULT_SECRETS_ACCESS;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createSecretsFeature(opts: SecretsFeatureOptions = {}): FeatureDefinition {
|
|
113
|
+
const access = resolveSecretsAccess(opts);
|
|
88
114
|
return defineFeature(SECRETS_FEATURE_NAME, (r) => {
|
|
89
115
|
r.describe(
|
|
90
|
-
|
|
116
|
+
'Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump. The `set`/`delete`/`list` handlers share one access rule — default { roles: ["TenantAdmin"] }; adopt the host\'s role vocabulary with createSecretsFeature({ roles }) or open it to every authenticated tenant user with { access: { openToAll: true } } (larger blast radius: any tenant member can then read secret previews and write/delete secrets, not just admins).',
|
|
91
117
|
);
|
|
92
118
|
r.uiHints({
|
|
93
119
|
displayLabel: "Tenant Secrets",
|
|
@@ -117,9 +143,9 @@ export function createSecretsFeature(): FeatureDefinition {
|
|
|
117
143
|
// The rotation job deliberately reaches for ctx.db as DbConnection
|
|
118
144
|
// (raw, cross-tenant) because rotation is a deployment-wide operation —
|
|
119
145
|
// no feature-wide r.systemScope() needed.
|
|
120
|
-
r.writeHandler(
|
|
121
|
-
r.writeHandler(
|
|
122
|
-
r.queryHandler(
|
|
146
|
+
r.writeHandler(createSetHandler(access));
|
|
147
|
+
r.writeHandler(createDeleteHandler(access));
|
|
148
|
+
r.queryHandler(createListHandler(access));
|
|
123
149
|
// Manual-only by design: ops triggers rotation after a KEK version flip.
|
|
124
150
|
// BullMQ delivers to exactly one worker, so running it against a busy
|
|
125
151
|
// table on multiple instances is still safe.
|
|
@@ -1,20 +1,27 @@
|
|
|
1
|
-
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { type AccessRule, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { DEFAULT_SECRETS_ACCESS } from "../constants";
|
|
4
5
|
import { requireSecretsContext } from "../feature";
|
|
5
6
|
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
7
|
+
export function createDeleteHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
|
|
8
|
+
return defineWriteHandler({
|
|
9
|
+
name: "delete",
|
|
10
|
+
schema: z.object({
|
|
11
|
+
key: z.string().min(1).max(100),
|
|
12
|
+
}),
|
|
13
|
+
access,
|
|
14
|
+
handler: async (event, ctx) => {
|
|
15
|
+
const secrets = requireSecretsContext(ctx, "secrets:write:delete");
|
|
16
|
+
const removed = await secrets.delete(event.user.tenantId, event.payload.key, {
|
|
17
|
+
deletedBy: event.user.id,
|
|
18
|
+
});
|
|
19
|
+
if (!removed) return failNotFound("tenant-secret", event.payload.key);
|
|
20
|
+
return { isSuccess: true, data: { key: event.payload.key } };
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Backwards-compat: existing imports of `deleteWrite` keep working —
|
|
26
|
+
// identical to createDeleteHandler() with the default access.
|
|
27
|
+
export const deleteWrite = createDeleteHandler();
|
|
@@ -1,37 +1,44 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { type AccessRule, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { DEFAULT_SECRETS_ACCESS } from "../constants";
|
|
4
5
|
import { tenantSecretsTable } from "../table";
|
|
5
6
|
|
|
6
7
|
// Lists all secrets for the current tenant. Returns redactedPreview, never
|
|
7
8
|
// the plaintext. Decryption would be pointless here anyway — this is the
|
|
8
9
|
// TenantAdmin UI, not feature code that needs the value.
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
10
|
+
export function createListHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
|
|
11
|
+
return defineQueryHandler({
|
|
12
|
+
name: "list",
|
|
13
|
+
schema: z.object({}),
|
|
14
|
+
access,
|
|
15
|
+
handler: async (event, ctx) => {
|
|
16
|
+
const rows = await selectMany<{
|
|
17
|
+
key: string;
|
|
18
|
+
kekVersion: number;
|
|
19
|
+
metadata: { redactedPreview?: string; hint?: string };
|
|
20
|
+
lastRotatedAt: unknown;
|
|
21
|
+
insertedAt: unknown;
|
|
22
|
+
}>(
|
|
23
|
+
ctx.db.raw,
|
|
24
|
+
tenantSecretsTable,
|
|
25
|
+
{ tenantId: event.user.tenantId },
|
|
26
|
+
{
|
|
27
|
+
orderBy: { col: "key", direction: "asc" },
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
return rows.map((r) => ({
|
|
31
|
+
key: r.key,
|
|
32
|
+
redactedPreview: r.metadata.redactedPreview ?? null,
|
|
33
|
+
hint: r.metadata.hint ?? null,
|
|
34
|
+
kekVersion: r.kekVersion,
|
|
35
|
+
lastRotatedAt: r.lastRotatedAt,
|
|
36
|
+
createdAt: r.insertedAt,
|
|
37
|
+
}));
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Backwards-compat: existing imports of `listQuery` keep working — identical
|
|
43
|
+
// to createListHandler() with the default access.
|
|
44
|
+
export const listQuery = createListHandler();
|
|
@@ -1,45 +1,52 @@
|
|
|
1
|
-
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { type AccessRule, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { DEFAULT_SECRETS_ACCESS } from "../constants";
|
|
3
4
|
import { requireSecretsContext } from "../feature";
|
|
4
5
|
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
6
|
+
export function createSetHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
|
|
7
|
+
return defineWriteHandler({
|
|
8
|
+
name: "set",
|
|
9
|
+
schema: z.object({
|
|
10
|
+
key: z.string().min(1).max(100),
|
|
11
|
+
value: z.string(),
|
|
12
|
+
// Optional fixed-length preview — if the caller's UI wants a domain-
|
|
13
|
+
// specific redaction ("sk_live_abc…xyz") it can send it here; else the
|
|
14
|
+
// handler derives a generic one (first-3-chars + bullets).
|
|
15
|
+
redactedPreview: z.string().max(50).optional(),
|
|
16
|
+
hint: z.string().max(200).optional(),
|
|
17
|
+
}),
|
|
18
|
+
access,
|
|
19
|
+
handler: async (event, ctx) => {
|
|
20
|
+
const secrets = requireSecretsContext(ctx, "secrets:write:set");
|
|
21
|
+
const { key, value, redactedPreview, hint } = event.payload;
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
23
|
+
// Preview-priority: explicit payload param > feature-declared redact
|
|
24
|
+
// (via r.secret()) > generic default. A feature that declared a
|
|
25
|
+
// domain-aware redact (Stripe keys: "sk_test...2345") wins over the
|
|
26
|
+
// framework default unless the caller sent a specific preview.
|
|
27
|
+
const keyDef = ctx.registry.getSecretKey(key);
|
|
28
|
+
const featureRedact = keyDef?.redact;
|
|
29
|
+
const redactFn: (v: string) => string = redactedPreview
|
|
30
|
+
? () => redactedPreview
|
|
31
|
+
: (featureRedact ?? defaultRedact);
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
await secrets.set(event.user.tenantId, key, value, {
|
|
34
|
+
redact: redactFn,
|
|
35
|
+
...(hint ? { hint } : {}),
|
|
36
|
+
updatedBy: event.user.id,
|
|
37
|
+
});
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
});
|
|
39
|
+
return {
|
|
40
|
+
isSuccess: true,
|
|
41
|
+
data: { key, redactedPreview: redactedPreview ?? redactFn(value) },
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Backwards-compat: existing imports of `setWrite` keep working — identical
|
|
48
|
+
// to createSetHandler() with the default access.
|
|
49
|
+
export const setWrite = createSetHandler();
|
|
43
50
|
|
|
44
51
|
// Fallback redaction: show at most the first 3 chars + trailing bullets.
|
|
45
52
|
// Deliberately conservative — a too-generous preview defeats the point.
|
package/src/secrets/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { DEFAULT_SECRETS_ACCESS, DEFAULT_SECRETS_ROLES } from "./constants";
|
|
1
2
|
export {
|
|
2
3
|
createSecretsContext,
|
|
3
4
|
createSecretsFeature,
|
|
@@ -5,14 +6,18 @@ export {
|
|
|
5
6
|
SECRETS_FEATURE_NAME,
|
|
6
7
|
type SecretsContext,
|
|
7
8
|
type SecretsContextOptions,
|
|
9
|
+
type SecretsFeatureOptions,
|
|
8
10
|
type StoredEnvelope,
|
|
9
11
|
type StoredMetadata,
|
|
10
12
|
secretsEnvSchema,
|
|
11
13
|
TENANT_SECRET_READ_EVENT,
|
|
12
14
|
tenantSecretsTable,
|
|
13
15
|
} from "./feature";
|
|
16
|
+
export { createDeleteHandler, deleteWrite } from "./handlers/delete.write";
|
|
17
|
+
export { createListHandler, listQuery } from "./handlers/list.query";
|
|
14
18
|
export {
|
|
15
19
|
type RotateJobPayload,
|
|
16
20
|
type RotateJobResult,
|
|
17
21
|
rotateJob,
|
|
18
22
|
} from "./handlers/rotate.job";
|
|
23
|
+
export { createSetHandler, setWrite } from "./handlers/set.write";
|