-
Notifications
You must be signed in to change notification settings - Fork 245
Add opt-in decryption for CLI and web observability #1000
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b9285f1
09b5354
87dbb9c
e041e20
61d0c47
5d733dd
31420f5
6de4489
4bde3e7
eb5d3a6
99b63da
eaeb70e
9828e58
217cafd
56eceb8
6d18ad1
65b06ba
7dfe3f5
1ec73ba
1eff2d5
0155949
02bfd98
b8afbd7
c68e1d9
d5e1039
9236bda
9ff06de
18e35f8
9ad205c
2b2d9b6
4fb407d
3157a45
e6a7388
6d8bc3e
a07fc21
f23c7cb
7374cbd
7120677
3bfe8ec
3f10420
4a0c137
fbf470e
288691d
7018c6c
6766332
9ef7740
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@workflow/core": patch | ||
| --- | ||
|
|
||
| Wire AES-GCM encryption into serialization layer with stream support |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| --- | ||
| "@workflow/cli": patch | ||
| "@workflow/core": patch | ||
| "@workflow/web": patch | ||
| "@workflow/web-shared": patch | ||
| --- | ||
|
|
||
| Add encryption-aware o11y for CLI and web UI |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@workflow/world-vercel": patch | ||
| --- | ||
|
|
||
| Implement `getEncryptionKeyForRun` with HKDF-SHA256 per-run key derivation and cross-deployment key resolution via `fetchRunKey` API |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@workflow/world": patch | ||
| --- | ||
|
|
||
| Overload `getEncryptionKeyForRun` interface: accept `WorkflowRun` (preferred) or `runId` string with optional opaque world-specific context for `start()` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@workflow/core": patch | ||
| --- | ||
|
|
||
| Add browser-compatible AES-256-GCM encryption module with `importKey`, `encrypt`, and `decrypt` functions; update all runtime callers to resolve `CryptoKey` once per run via `importKey()` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,21 +6,35 @@ | |
| */ | ||
|
|
||
| import { inspect } from 'node:util'; | ||
| import { maybeDecrypt } from '@workflow/core/serialization'; | ||
| import { | ||
| ClassInstanceRef, | ||
| extractClassName, | ||
| hydrateResourceIO as hydrateResourceIOGeneric, | ||
| isEncryptedData, | ||
| observabilityRevivers, | ||
| type Revivers, | ||
| } from '@workflow/core/serialization-format'; | ||
| import { parseClassName } from '@workflow/utils/parse-name'; | ||
| import chalk from 'chalk'; | ||
|
|
||
| /** | ||
| * A function that resolves an encryption key for a run, or null to skip | ||
| * decryption. Accepts a runId — the resolver is responsible for looking | ||
| * up the WorkflowRun internally (with caching) if the World needs it. | ||
| */ | ||
| export type EncryptionKeyResolver = | ||
| | ((runId: string) => Promise<Uint8Array | undefined>) | ||
| | null; | ||
|
|
||
| // Re-export types and utilities that consumers need | ||
| export { | ||
| CLASS_INSTANCE_REF_TYPE, | ||
| ClassInstanceRef, | ||
| ENCRYPTED_PLACEHOLDER, | ||
| extractStreamIds, | ||
| isClassInstanceRef, | ||
| isEncryptedData, | ||
| isStreamId, | ||
| isStreamRef, | ||
| type Revivers, | ||
|
|
@@ -53,6 +67,39 @@ class CLIClassInstanceRef extends ClassInstanceRef { | |
| } | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // CLI encrypted data placeholder with custom inspect | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /** | ||
| * Placeholder object for encrypted data fields in CLI output. | ||
| * | ||
| * Uses `util.inspect.custom` to render as a styled, unquoted string | ||
| * (e.g., dim yellow "🔒 Encrypted") instead of a plain quoted string. | ||
| * Also provides `toJSON()` for `--json` output. | ||
| */ | ||
| class EncryptedDataRef { | ||
| [inspect.custom](): string { | ||
| return chalk.dim.yellow('\u{1F512} Encrypted'); | ||
| } | ||
|
|
||
| toJSON(): string { | ||
| return '\u{1F512} Encrypted'; | ||
| } | ||
|
|
||
| toString(): string { | ||
| return '\u{1F512} Encrypted'; | ||
| } | ||
| } | ||
|
|
||
| /** Singleton encrypted data placeholder for CLI display */ | ||
| const ENCRYPTED_REF = new EncryptedDataRef(); | ||
|
|
||
| /** Check if a value is an EncryptedDataRef (for custom table formatting in CLI) */ | ||
| export function isEncryptedRef(value: unknown): value is EncryptedDataRef { | ||
| return value instanceof EncryptedDataRef; | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // CLI revivers (Node.js, uses Buffer) | ||
| // --------------------------------------------------------------------------- | ||
|
|
@@ -124,24 +171,108 @@ function getRevivers(): Revivers { | |
| return cachedRevivers; | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Decryption helpers | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /** | ||
| * Pre-process a resource's data fields: if the resolver is provided and | ||
| * the field is encrypted, decrypt it before generic hydration. | ||
| * | ||
| * Uses core's `maybeDecrypt()` which handles the 'encr' prefix stripping | ||
| * and AES-GCM decryption transparently. | ||
| * | ||
| * When the resolver is null (no --decrypt flag), encrypted fields pass | ||
| * through as Uint8Array and are replaced with EncryptedDataRef in post-processing. | ||
| */ | ||
| async function maybeDecryptFields< | ||
| T extends { | ||
| runId?: string; | ||
| input?: any; | ||
| output?: any; | ||
| metadata?: any; | ||
| eventData?: any; | ||
| }, | ||
| >(resource: T, resolver: EncryptionKeyResolver): Promise<T> { | ||
| if (!resolver) return resource; | ||
|
|
||
| const runId = (resource as any).runId as string | undefined; | ||
| if (!runId) return resource; | ||
|
|
||
| const result = { ...resource }; | ||
| const rawKey = await resolver(runId); | ||
| const { importKey } = await import('@workflow/core/encryption'); | ||
| const k = rawKey ? await importKey(rawKey) : undefined; | ||
|
|
||
| // Decrypt input/output fields (WorkflowRun, Step) | ||
| result.input = await maybeDecrypt(result.input, k); | ||
| result.output = await maybeDecrypt(result.output, k); | ||
|
|
||
| // Decrypt metadata field (Hook) | ||
| result.metadata = await maybeDecrypt(result.metadata, k); | ||
|
|
||
| // Decrypt eventData fields (Event) | ||
| if (result.eventData && typeof result.eventData === 'object') { | ||
| const eventData = { ...result.eventData }; | ||
| eventData.result = await maybeDecrypt(eventData.result, k); | ||
| eventData.input = await maybeDecrypt(eventData.input, k); | ||
| result.eventData = eventData; | ||
| } | ||
|
Comment on lines
+214
to
+220
|
||
|
|
||
| return result; | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Public API | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /** Resolver function that retrieves the encryption key for a given run ID. */ | ||
| export type EncryptionKeyResolver = | ||
| | ((runId: string) => Promise<Uint8Array | undefined>) | ||
| | null; | ||
| /** | ||
| * Replace encrypted Uint8Array values with EncryptedDataRef objects | ||
| * in known data fields so they render with custom inspect styling. | ||
| */ | ||
| function replaceEncryptedWithRef<T>(resource: T): T { | ||
| if (!resource || typeof resource !== 'object') return resource; | ||
| const r = resource as Record<string, unknown>; | ||
| const result = { ...r }; | ||
|
|
||
| for (const key of ['input', 'output', 'metadata']) { | ||
| if (isEncryptedData(result[key])) { | ||
| result[key] = ENCRYPTED_REF; | ||
| } | ||
| } | ||
|
|
||
| if (result.eventData && typeof result.eventData === 'object') { | ||
| const ed = { ...(result.eventData as Record<string, unknown>) }; | ||
| for (const key of ['result', 'input']) { | ||
| if (isEncryptedData(ed[key])) { | ||
| ed[key] = ENCRYPTED_REF; | ||
| } | ||
| } | ||
| result.eventData = ed; | ||
|
Comment on lines
+244
to
+251
|
||
| } | ||
|
|
||
| return result as T; | ||
| } | ||
|
|
||
| /** | ||
| * Hydrate the serialized data fields of a resource for CLI display. | ||
| * | ||
| * The optional `_encryptionKeyResolver` parameter is accepted for forward | ||
| * compatibility with encryption support but is not yet used. | ||
| * When `encryptorResolver` is null (default / no --decrypt flag), encrypted | ||
| * fields are shown as styled "🔒 Encrypted" placeholders via EncryptedDataRef. | ||
| * | ||
| * When `encryptorResolver` is provided (--decrypt flag), encrypted fields | ||
| * are decrypted before hydration so the actual user data is displayed. | ||
| */ | ||
| export function hydrateResourceIO<T>( | ||
| export async function hydrateResourceIO<T>( | ||
| resource: T, | ||
| _encryptionKeyResolver?: EncryptionKeyResolver | ||
| ): T { | ||
| return hydrateResourceIOGeneric(resource as any, getRevivers()) as T; | ||
| keyResolver?: EncryptionKeyResolver | ||
| ): Promise<T> { | ||
| // Pre-process: decrypt any encrypted fields when a resolver is provided | ||
| const preprocessed = await maybeDecryptFields( | ||
| resource as any, | ||
| keyResolver ?? null | ||
| ); | ||
| const hydrated = hydrateResourceIOGeneric(preprocessed, getRevivers()) as T; | ||
| // Post-process: swap encrypted Uint8Arrays for CLI-styled objects | ||
| return replaceEncryptedWithRef(hydrated); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,22 +17,39 @@ import type { | |
| } from '@workflow/world'; | ||
| import chalk from 'chalk'; | ||
|
|
||
| /** A function that resolves an encryption key for a given runId. */ | ||
| export type EncryptionKeyResolver = | ||
| | ((runId: string) => Promise<Uint8Array | undefined>) | ||
| | null; | ||
|
|
||
| /** Create an EncryptionKeyResolver from a World instance */ | ||
| function createResolver(world: World): EncryptionKeyResolver { | ||
| if (!world.getEncryptionKeyForRun) return null; | ||
| return (runId: string) => world.getEncryptionKeyForRun!(runId); | ||
| } | ||
|
|
||
| import { formatDistance } from 'date-fns'; | ||
| import Table from 'easy-table'; | ||
| import { logger } from '../config/log.js'; | ||
| import type { InspectCLIOptions } from '../config/types.js'; | ||
| import { hydrateResourceIO } from './hydration.js'; | ||
| import { | ||
| type EncryptionKeyResolver, | ||
| hydrateResourceIO, | ||
| isEncryptedRef, | ||
| } from './hydration.js'; | ||
|
|
||
| /** | ||
| * Create an EncryptionKeyResolver from a World instance. | ||
| * Returns null if decrypt is false — encrypted data will show as a placeholder. | ||
| * | ||
| * The resolver fetches the full WorkflowRun (cached per runId) so that the | ||
| * World can inspect deployment-specific fields for key resolution. | ||
| */ | ||
| function createResolver(world: World, decrypt: boolean): EncryptionKeyResolver { | ||
| if (!decrypt) return null; | ||
| if (!world.getEncryptionKeyForRun) return null; | ||
| const cache = new Map<string, Promise<Uint8Array | undefined>>(); | ||
| return (runId: string) => { | ||
| let cached = cache.get(runId); | ||
| if (!cached) { | ||
| cached = world.runs | ||
| .get(runId) | ||
| .then((run) => world.getEncryptionKeyForRun!(run)); | ||
| cache.set(runId, cached); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The cache never evicts on failure — if Consider evicting on rejection: cached = world.runs
.get(runId)
.then((run) => world.getEncryptionKeyForRun!(run))
.catch((err) => {
cache.delete(runId);
throw err;
}); |
||
| } | ||
| return cached; | ||
| }; | ||
| } | ||
|
|
||
| import { setupListPagination } from './pagination.js'; | ||
| import { streamToConsole } from './stream.js'; | ||
| import { | ||
|
|
@@ -485,6 +502,8 @@ const inlineFormatIO = <T>(io: T, topLevel: boolean = true): string => { | |
| value = '<empty>'; | ||
| } else if (io === null) { | ||
| value = '<null>'; | ||
| } else if (isEncryptedRef(io)) { | ||
| value = chalk.dim.yellow('\u{1F512} Encrypted'); | ||
| } else if (io && Array.isArray(io)) { | ||
| if (io.length === 0) { | ||
| value = '<empty>'; | ||
|
|
@@ -519,7 +538,8 @@ const inlineFormatIO = <T>(io: T, topLevel: boolean = true): string => { | |
| }; | ||
|
|
||
| export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => { | ||
| const resolveKey = createResolver(world); | ||
| const resolveKey = createResolver(world, opts?.decrypt ?? false); | ||
|
|
||
| if (opts.stepId || opts.runId) { | ||
| logger.warn( | ||
| 'Filtering by step-id or run-id is not supported in list calls, ignoring filter.' | ||
|
|
@@ -599,7 +619,8 @@ export const getRecentRun = async ( | |
| world: World, | ||
| opts: InspectCLIOptions = {} | ||
| ) => { | ||
| const resolveKey = createResolver(world); | ||
| const resolveKey = createResolver(world, opts?.decrypt ?? false); | ||
|
|
||
| logger.warn(`No runId provided, fetching data for latest run instead.`); | ||
| try { | ||
| const runs = await world.runs.list({ | ||
|
|
@@ -623,7 +644,8 @@ export const showRun = async ( | |
| runId: string, | ||
| opts: InspectCLIOptions = {} | ||
| ) => { | ||
| const resolveKey = createResolver(world); | ||
| const resolveKey = createResolver(world, opts?.decrypt ?? false); | ||
|
|
||
| if (opts.withData) { | ||
| logger.warn('`withData` flag is ignored when showing individual resources'); | ||
| } | ||
|
|
@@ -655,7 +677,8 @@ export const listSteps = async ( | |
| runId: undefined, | ||
| } | ||
| ) => { | ||
| const resolveKey = createResolver(world); | ||
| const resolveKey = createResolver(world, opts?.decrypt ?? false); | ||
|
|
||
| if (opts.stepId) { | ||
| logger.warn( | ||
| 'Filtering by step-id is not supported in list calls, ignoring filter.' | ||
|
|
@@ -747,7 +770,8 @@ export const showStep = async ( | |
| stepId: string, | ||
| opts: InspectCLIOptions = {} | ||
| ) => { | ||
| const resolveKey = createResolver(world); | ||
| const resolveKey = createResolver(world, opts?.decrypt ?? false); | ||
|
|
||
| if (opts.withData) { | ||
| logger.warn('`withData` flag is ignored when showing individual resources'); | ||
| } | ||
|
|
@@ -944,7 +968,8 @@ export const listEvents = async ( | |
| }; | ||
|
|
||
| export const listHooks = async (world: World, opts: InspectCLIOptions = {}) => { | ||
| const resolveKey = createResolver(world); | ||
| const resolveKey = createResolver(world, opts?.decrypt ?? false); | ||
|
|
||
| if (opts.workflowName) { | ||
| logger.warn( | ||
| 'Filtering by workflow-name is not supported for hooks, ignoring filter.' | ||
|
|
@@ -1036,7 +1061,8 @@ export const showHook = async ( | |
| hookId: string, | ||
| opts: InspectCLIOptions = {} | ||
| ) => { | ||
| const resolveKey = createResolver(world); | ||
| const resolveKey = createResolver(world, opts?.decrypt ?? false); | ||
|
|
||
| if (opts.withData) { | ||
| logger.warn('`withData` flag is ignored when showing individual resources'); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor:
(resource as any).runIdis used here but the genericTis already constrained to{ runId?: string; ... }— you can just useresource.runIddirectly without the cast.