fix(security): encrypt sensitive note metadata

This commit is contained in:
Saravana Achu Mac 2026-05-05 10:12:40 -07:00
parent 7eda8dda00
commit 6b37b728aa
6 changed files with 289 additions and 19 deletions

View File

@ -0,0 +1,25 @@
import { isEncryptedField, type EncryptedField } from '@bytelyst/field-encrypt';
import { getEncryptor } from './field-encrypt.js';
export async function encryptTextField(
value: string | undefined,
userId: string,
context: string,
): Promise<string | undefined> {
if (value === undefined) return undefined;
if (isEncryptedField(value)) return value as unknown as string;
const encrypted = await getEncryptor().encrypt(value, { userId, context });
return encrypted as unknown as string;
}
export async function decryptTextField(
value: string | undefined,
userId: string,
context: string,
): Promise<string | undefined> {
if (value === undefined) return undefined;
if (!isEncryptedField(value)) return value;
return getEncryptor().decrypt(value as unknown as EncryptedField, { userId, context });
}

View File

@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { isEncryptedField } from '@bytelyst/field-encrypt';
import { getCollection } from './datastore.js';
import { _resetEncryptor } from './field-encrypt.js';
import { resetMemoryDatastore } from '../test-helpers.js';
import { createNoteArtifact, getNoteArtifact } from '../modules/note-artifacts/repository.js';
import type { NoteArtifactDoc } from '../modules/note-artifacts/types.js';
import { createPromptTemplate, getPromptTemplate } from '../modules/note-prompts/repository.js';
import type { PromptTemplateDoc } from '../modules/note-prompts/types.js';
import {
createNoteAgentAction,
getNoteAgentAction,
updateNoteAgentAction,
} from '../modules/note-agent-actions/repository.js';
import type { NoteAgentActionDoc } from '../modules/note-agent-actions/types.js';
describe('field encryption coverage', () => {
beforeEach(() => {
resetMemoryDatastore();
_resetEncryptor();
});
it('encrypts sensitive artifact metadata while returning plaintext to callers', async () => {
const created = await createNoteArtifact({
id: 'artifact-1',
productId: 'notelett',
workspaceId: 'ws-1',
userId: 'user-1',
noteId: 'note-1',
artifactType: 'summary',
title: 'Private summary',
description: 'Sensitive extracted summary',
blobPath: 'notelett/user-1/private-summary.md',
contentType: 'text/markdown',
createdAt: '2026-05-05T00:00:00.000Z',
createdBy: 'user-1',
updatedAt: '2026-05-05T00:00:00.000Z',
updatedBy: 'user-1',
});
expect(created.title).toBe('Private summary');
expect(created.description).toBe('Sensitive extracted summary');
const raw = await getCollection<NoteArtifactDoc>('note_artifacts', '/workspaceId').findById('artifact-1', 'ws-1');
expect(isEncryptedField(raw?.title)).toBe(true);
expect(isEncryptedField(raw?.description)).toBe(true);
expect(isEncryptedField(raw?.blobPath)).toBe(true);
const fetched = await getNoteArtifact('artifact-1', 'ws-1');
expect(fetched).toMatchObject({
title: 'Private summary',
description: 'Sensitive extracted summary',
blobPath: 'notelett/user-1/private-summary.md',
});
});
it('encrypts custom prompt content while preserving slug lookup', async () => {
const created = await createPromptTemplate('user-1', {
slug: 'private-template',
name: 'Private Template',
description: 'Internal reasoning instructions',
systemPrompt: 'Use confidential house style.',
userPromptTemplate: 'Rewrite {{note}} for leadership.',
inputType: 'text',
outputType: 'new_note',
category: 'transform',
requiresApproval: true,
});
expect(created.systemPrompt).toBe('Use confidential house style.');
expect(created.userPromptTemplate).toBe('Rewrite {{note}} for leadership.');
const raw = await getCollection<PromptTemplateDoc>('note_prompts', '/userId').findById(created.id, 'user-1');
expect(raw?.slug).toBe('private-template');
expect(isEncryptedField(raw?.description)).toBe(true);
expect(isEncryptedField(raw?.systemPrompt)).toBe(true);
expect(isEncryptedField(raw?.userPromptTemplate)).toBe(true);
const fetched = await getPromptTemplate(created.id, 'user-1');
expect(fetched).toMatchObject({
slug: 'private-template',
description: 'Internal reasoning instructions',
systemPrompt: 'Use confidential house style.',
userPromptTemplate: 'Rewrite {{note}} for leadership.',
});
});
it('encrypts agent action details and review notes while preserving query metadata', async () => {
await createNoteAgentAction({
id: 'action-1',
productId: 'notelett',
workspaceId: 'ws-1',
userId: 'user-1',
noteId: 'note-1',
actorId: 'agent-1',
actorType: 'agent',
toolName: 'notes.notes.update',
actionType: 'update',
state: 'proposed',
reason: 'Agent proposed a sensitive rewrite',
beforeSummary: 'Original private note',
afterSummary: 'Rewritten private note',
idempotencyKey: 'idem-1',
correlationId: 'corr-1',
createdAt: '2026-05-05T00:00:00.000Z',
createdBy: 'user-1',
updatedAt: '2026-05-05T00:00:00.000Z',
updatedBy: 'user-1',
});
await updateNoteAgentAction('action-1', 'ws-1', {
state: 'approved',
reviewNote: 'Approved after private review',
updatedAt: '2026-05-05T01:00:00.000Z',
updatedBy: 'user-1',
});
const raw = await getCollection<NoteAgentActionDoc>('note_agent_actions', '/workspaceId').findById('action-1', 'ws-1');
expect(raw?.toolName).toBe('notes.notes.update');
expect(raw?.idempotencyKey).toBe('idem-1');
expect(isEncryptedField(raw?.reason)).toBe(true);
expect(isEncryptedField(raw?.beforeSummary)).toBe(true);
expect(isEncryptedField(raw?.afterSummary)).toBe(true);
expect(isEncryptedField(raw?.reviewNote)).toBe(true);
const fetched = await getNoteAgentAction('action-1', 'ws-1');
expect(fetched).toMatchObject({
reason: 'Agent proposed a sensitive rewrite',
beforeSummary: 'Original private note',
afterSummary: 'Rewritten private note',
reviewNote: 'Approved after private review',
});
});
});

View File

@ -1,11 +1,38 @@
import { getCollection } from '../../lib/datastore.js';
import { decryptTextField, encryptTextField } from '../../lib/encrypted-fields.js';
import type { NoteAgentActionDoc, ListNoteAgentActionsQuery } from './types.js';
import type { FilterMap } from '@bytelyst/datastore';
const ENCRYPT_CONTEXT = 'note_agent_actions';
function collection() {
return getCollection<NoteAgentActionDoc>('note_agent_actions', '/workspaceId');
}
async function encryptFields(doc: NoteAgentActionDoc): Promise<NoteAgentActionDoc> {
return {
...doc,
reason: await encryptTextField(doc.reason, doc.userId, ENCRYPT_CONTEXT),
beforeSummary: await encryptTextField(doc.beforeSummary, doc.userId, ENCRYPT_CONTEXT),
afterSummary: await encryptTextField(doc.afterSummary, doc.userId, ENCRYPT_CONTEXT),
reviewNote: await encryptTextField(doc.reviewNote, doc.userId, ENCRYPT_CONTEXT),
};
}
async function decryptFields(doc: NoteAgentActionDoc): Promise<NoteAgentActionDoc> {
return {
...doc,
reason: await decryptTextField(doc.reason, doc.userId, ENCRYPT_CONTEXT),
beforeSummary: await decryptTextField(doc.beforeSummary, doc.userId, ENCRYPT_CONTEXT),
afterSummary: await decryptTextField(doc.afterSummary, doc.userId, ENCRYPT_CONTEXT),
reviewNote: await decryptTextField(doc.reviewNote, doc.userId, ENCRYPT_CONTEXT),
};
}
async function decryptBatch(docs: NoteAgentActionDoc[]): Promise<NoteAgentActionDoc[]> {
return Promise.all(docs.map(decryptFields));
}
export async function listNoteAgentActions(
userId: string,
productId: string,
@ -27,18 +54,20 @@ export async function listNoteAgentActions(
limit: query.limit,
});
return { items, total };
return { items: await decryptBatch(items), total };
}
export async function getNoteAgentAction(
id: string,
workspaceId: string
): Promise<NoteAgentActionDoc | null> {
return collection().findById(id, workspaceId);
const doc = await collection().findById(id, workspaceId);
return doc ? decryptFields(doc) : null;
}
export async function createNoteAgentAction(doc: NoteAgentActionDoc): Promise<NoteAgentActionDoc> {
return collection().create(doc);
const created = await collection().create(await encryptFields(doc));
return decryptFields(created);
}
export async function listPendingActions(
@ -62,9 +91,9 @@ export async function listPendingActions(
collection().findMany({ filter: proposedFilter, sort: { updatedAt: -1 }, offset: 0, limit: limit + offset }),
]);
const merged = [...draftItems, ...proposedItems]
const merged = await decryptBatch([...draftItems, ...proposedItems]
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(offset, offset + limit);
.slice(offset, offset + limit));
return { items: merged, total };
}
@ -80,7 +109,7 @@ export async function updateNoteAgentAction(
}
const merged: NoteAgentActionDoc = {
...existing,
...(await decryptFields(existing)),
...updates,
id: existing.id,
workspaceId: existing.workspaceId,
@ -93,5 +122,6 @@ export async function updateNoteAgentAction(
actionType: existing.actionType,
};
return collection().upsert(merged);
const updated = await collection().upsert(await encryptFields(merged));
return decryptFields(updated);
}

View File

@ -1,11 +1,36 @@
import { getCollection } from '../../lib/datastore.js';
import { decryptTextField, encryptTextField } from '../../lib/encrypted-fields.js';
import type { NoteArtifactDoc, ListNoteArtifactsQuery } from './types.js';
import type { FilterMap } from '@bytelyst/datastore';
const ENCRYPT_CONTEXT = 'note_artifacts';
function collection() {
return getCollection<NoteArtifactDoc>('note_artifacts', '/workspaceId');
}
async function encryptFields(doc: NoteArtifactDoc): Promise<NoteArtifactDoc> {
return {
...doc,
title: (await encryptTextField(doc.title, doc.userId, ENCRYPT_CONTEXT)) ?? doc.title,
description: await encryptTextField(doc.description, doc.userId, ENCRYPT_CONTEXT),
blobPath: await encryptTextField(doc.blobPath, doc.userId, ENCRYPT_CONTEXT),
};
}
async function decryptFields(doc: NoteArtifactDoc): Promise<NoteArtifactDoc> {
return {
...doc,
title: (await decryptTextField(doc.title, doc.userId, ENCRYPT_CONTEXT)) ?? doc.title,
description: await decryptTextField(doc.description, doc.userId, ENCRYPT_CONTEXT),
blobPath: await decryptTextField(doc.blobPath, doc.userId, ENCRYPT_CONTEXT),
};
}
async function decryptBatch(docs: NoteArtifactDoc[]): Promise<NoteArtifactDoc[]> {
return Promise.all(docs.map(decryptFields));
}
export async function listNoteArtifacts(
userId: string,
productId: string,
@ -29,15 +54,17 @@ export async function listNoteArtifacts(
limit: query.limit,
});
return { items, total };
return { items: await decryptBatch(items), total };
}
export async function getNoteArtifact(id: string, workspaceId: string): Promise<NoteArtifactDoc | null> {
return collection().findById(id, workspaceId);
const doc = await collection().findById(id, workspaceId);
return doc ? decryptFields(doc) : null;
}
export async function createNoteArtifact(doc: NoteArtifactDoc): Promise<NoteArtifactDoc> {
return collection().create(doc);
const created = await collection().create(await encryptFields(doc));
return decryptFields(created);
}
export async function deleteNoteArtifact(id: string, workspaceId: string): Promise<boolean> {
@ -58,7 +85,7 @@ export async function updateNoteArtifact(
}
const merged: NoteArtifactDoc = {
...existing,
...(await decryptFields(existing)),
...updates,
id: existing.id,
workspaceId: existing.workspaceId,
@ -68,5 +95,6 @@ export async function updateNoteArtifact(
artifactType: existing.artifactType,
};
return collection().upsert(merged);
const updated = await collection().upsert(await encryptFields(merged));
return decryptFields(updated);
}

View File

@ -6,6 +6,7 @@
*/
import { getCollection } from '../../lib/datastore.js';
import { decryptTextField, encryptTextField } from '../../lib/encrypted-fields.js';
import { PRODUCT_ID } from '../../lib/product-config.js';
import type { FilterMap } from '@bytelyst/datastore';
import type {
@ -17,11 +18,34 @@ import type {
const COLLECTION = 'note_prompts';
const PARTITION_KEY = '/userId';
const ENCRYPT_CONTEXT = 'note_prompts';
function col() {
return getCollection<PromptTemplateDoc>(COLLECTION, PARTITION_KEY);
}
async function encryptFields(doc: PromptTemplateDoc): Promise<PromptTemplateDoc> {
return {
...doc,
description: (await encryptTextField(doc.description, doc.userId, ENCRYPT_CONTEXT)) ?? '',
systemPrompt: (await encryptTextField(doc.systemPrompt, doc.userId, ENCRYPT_CONTEXT)) ?? doc.systemPrompt,
userPromptTemplate: (await encryptTextField(doc.userPromptTemplate, doc.userId, ENCRYPT_CONTEXT)) ?? doc.userPromptTemplate,
};
}
async function decryptFields(doc: PromptTemplateDoc): Promise<PromptTemplateDoc> {
return {
...doc,
description: (await decryptTextField(doc.description, doc.userId, ENCRYPT_CONTEXT)) ?? '',
systemPrompt: (await decryptTextField(doc.systemPrompt, doc.userId, ENCRYPT_CONTEXT)) ?? doc.systemPrompt,
userPromptTemplate: (await decryptTextField(doc.userPromptTemplate, doc.userId, ENCRYPT_CONTEXT)) ?? doc.userPromptTemplate,
};
}
async function decryptBatch(docs: PromptTemplateDoc[]): Promise<PromptTemplateDoc[]> {
return Promise.all(docs.map(decryptFields));
}
export async function createPromptTemplate(
userId: string,
input: CreatePromptTemplateInput,
@ -47,14 +71,16 @@ export async function createPromptTemplate(
createdAt: now,
updatedAt: now,
};
return col().create(doc);
const created = await col().create(await encryptFields(doc));
return decryptFields(created);
}
export async function getPromptTemplate(
id: string,
userId: string,
): Promise<PromptTemplateDoc | null> {
return col().findById(id, userId);
const doc = await col().findById(id, userId);
return doc ? decryptFields(doc) : null;
}
export async function getPromptTemplateBySlug(
@ -66,14 +92,14 @@ export async function getPromptTemplateBySlug(
filter: { slug, userId, productId: PRODUCT_ID } as FilterMap,
limit: 1,
});
if (userResults.length > 0) return userResults[0];
if (userResults.length > 0) return decryptFields(userResults[0]);
// Check builtins (userId = '__builtin__')
const builtinResults = await col().findMany({
filter: { slug, userId: '__builtin__', productId: PRODUCT_ID, isBuiltin: true } as FilterMap,
limit: 1,
});
return builtinResults[0] ?? null;
return builtinResults[0] ? decryptFields(builtinResults[0]) : null;
}
export async function updatePromptTemplate(
@ -85,11 +111,12 @@ export async function updatePromptTemplate(
if (!existing || existing.isBuiltin) return null;
const updated: PromptTemplateDoc = {
...existing,
...(await decryptFields(existing)),
...input,
updatedAt: new Date().toISOString(),
};
return col().upsert(updated);
const saved = await col().upsert(await encryptFields(updated));
return decryptFields(saved);
}
export async function deletePromptTemplate(
@ -134,7 +161,7 @@ export async function listPromptTemplates(
}
// Merge: builtins first, then user
const all = [...builtinItems, ...userItems];
const all = await decryptBatch([...builtinItems, ...userItems]);
return {
items: all.slice(query.offset, query.offset + query.limit),
total: all.length,

View File

@ -0,0 +1,26 @@
# NoteLett Field Encryption Coverage
Date: May 5, 2026
## Encrypted Fields
NoteLett uses `@bytelyst/field-encrypt` through `backend/src/lib/field-encrypt.ts`. Repositories decrypt fields before returning documents and accept plaintext legacy fields during reads.
| Container | Encrypted fields | Query-safe plaintext fields |
| --- | --- | --- |
| `notes` | `body` | `id`, `productId`, `workspaceId`, `userId`, `title`, `status`, `tags`, timestamps |
| `palace_memories` | `content` | wing/room ids, kinds, tags, embeddings, timestamps |
| `note_artifacts` | `title`, `description`, `blobPath` | `id`, `productId`, `workspaceId`, `userId`, `noteId`, `artifactType`, `contentType`, `sizeBytes`, timestamps |
| `note_prompts` | `description`, `systemPrompt`, `userPromptTemplate` | `id`, `productId`, `userId`, `slug`, `name`, prompt routing/config metadata, timestamps |
| `note_agent_actions` | `reason`, `beforeSummary`, `afterSummary`, `reviewNote` | `id`, `productId`, `workspaceId`, `userId`, `noteId`, actor/tool/action/state metadata, idempotency/correlation/workflow ids, review identity/timestamp |
## Migration Notes
This change is backward-compatible for existing plaintext documents: repository reads decrypt only values that match the shared `EncryptedField` envelope and pass plaintext through unchanged. Newly created and updated documents write encrypted values for the fields above.
Recommended production migration before launch:
1. Run a one-time backfill using `@bytelyst/field-encrypt` `migrateDocuments` or an equivalent repository-level script for the fields listed above.
2. Process one container at a time, partition-aware: `notes` and artifact/action containers by `workspaceId`, prompt templates by `userId`.
3. Verify by sampling raw Cosmos documents for `__encrypted: true` on sensitive fields and by reading the same records through the NoteLett API to confirm plaintext responses.
4. Keep query filters on the query-safe plaintext fields listed above; do not add server-side filters against encrypted text fields.