fix(security): encrypt sensitive note metadata
This commit is contained in:
parent
7eda8dda00
commit
6b37b728aa
25
backend/src/lib/encrypted-fields.ts
Normal file
25
backend/src/lib/encrypted-fields.ts
Normal 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 });
|
||||
}
|
||||
134
backend/src/lib/encryption-coverage.test.ts
Normal file
134
backend/src/lib/encryption-coverage.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
26
docs/FIELD_ENCRYPTION_COVERAGE.md
Normal file
26
docs/FIELD_ENCRYPTION_COVERAGE.md
Normal 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.
|
||||
Loading…
Reference in New Issue
Block a user