feat(workspaces): encrypted write-only credential storage
Workspaces Payload collection with the PAT as a write-only field: access.read () => false strips it from every access-controlled read path, and a field-level beforeChange hook encrypts on write with AES-256-GCM (scrypt key from VEECT_SECRET, random per-value salt + IV, v1 storage format) via node:crypto only. The real repository replaces the phase-1 stub with payload create/findByID; toDomain never maps the credential, and getDecryptedCredential(id) is the single server-side decrypt path for the runner handoff (story 07). Contract suite now covers create, write-only behaviour, and the decrypt path against both the mock and the Payload impl (stub runs the real collection hooks). Missing VEECT_SECRET fails production bind/boot with an actionable message; dev-seed boots without it. Env declared in turbo.json globalEnv + .env.example. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
@@ -11,6 +11,12 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5433/template
|
|||||||
# Payload CMS encryption key. Any random 32+ char string in dev.
|
# Payload CMS encryption key. Any random 32+ char string in dev.
|
||||||
PAYLOAD_SECRET=replace-with-a-random-32-char-string
|
PAYLOAD_SECRET=replace-with-a-random-32-char-string
|
||||||
|
|
||||||
|
# Workspace credential encryption key (AES-256-GCM, scrypt-derived).
|
||||||
|
# Required in production mode - boot fails without it. Dev-seed mode
|
||||||
|
# (USE_DEV_SEED=true / plain `pnpm dev`) boots fine without it.
|
||||||
|
# Generate via `openssl rand -hex 32`.
|
||||||
|
VEECT_SECRET=replace-with-a-random-64-char-hex-string
|
||||||
|
|
||||||
# --- Optional: app URLs (defaults work in dev) ---
|
# --- Optional: app URLs (defaults work in dev) ---
|
||||||
|
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
|||||||
@@ -21,3 +21,7 @@ collections:
|
|||||||
restrictable: true
|
restrictable: true
|
||||||
source: auth-default
|
source: auth-default
|
||||||
slug: users
|
slug: users
|
||||||
|
workspaces:
|
||||||
|
auth: false
|
||||||
|
piiFields: []
|
||||||
|
slug: workspaces
|
||||||
|
|||||||
@@ -9,3 +9,10 @@ collections:
|
|||||||
trigger: after-deletion
|
trigger: after-deletion
|
||||||
purgeSchedule: daily
|
purgeSchedule: daily
|
||||||
slug: users
|
slug: users
|
||||||
|
workspaces:
|
||||||
|
postDeletion:
|
||||||
|
action: hard-delete
|
||||||
|
duration: P30D
|
||||||
|
trigger: after-deletion
|
||||||
|
purgeSchedule: daily
|
||||||
|
slug: workspaces
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@payloadcms/db-postgres": "^3.14.0",
|
"@payloadcms/db-postgres": "^3.14.0",
|
||||||
"@payloadcms/richtext-lexical": "^3.14.0",
|
"@payloadcms/richtext-lexical": "^3.14.0",
|
||||||
"@repo/auth": "workspace:*",
|
"@repo/auth": "workspace:*",
|
||||||
|
"@repo/workspaces": "workspace:*",
|
||||||
"payload": "^3.14.0"
|
"payload": "^3.14.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ describe("payloadConfig composition", () => {
|
|||||||
it("registers all feature collections", async () => {
|
it("registers all feature collections", async () => {
|
||||||
const resolved = await config;
|
const resolved = await config;
|
||||||
const slugs = resolved.collections?.map((c) => c.slug) ?? [];
|
const slugs = resolved.collections?.map((c) => c.slug) ?? [];
|
||||||
expect(slugs).toEqual(expect.arrayContaining(["users"]));
|
expect(slugs).toEqual(expect.arrayContaining(["users", "workspaces"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registers no feature globals (none remain)", async () => {
|
it("registers no feature globals (none remain)", async () => {
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import path from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { users } from "@repo/auth/cms";
|
import { users } from "@repo/auth/cms";
|
||||||
|
import { workspaces } from "@repo/workspaces/cms";
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url);
|
const filename = fileURLToPath(import.meta.url);
|
||||||
const dirname = path.dirname(filename);
|
const dirname = path.dirname(filename);
|
||||||
|
|
||||||
export default buildConfig({
|
export default buildConfig({
|
||||||
editor: lexicalEditor(),
|
editor: lexicalEditor(),
|
||||||
collections: [users],
|
collections: [users, workspaces],
|
||||||
globals: [],
|
globals: [],
|
||||||
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
|
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
|
||||||
db: postgresAdapter({
|
db: postgresAdapter({
|
||||||
|
|||||||
@@ -31,11 +31,21 @@ export const CONTRACT_WORKSPACE_SEED: ReadonlyArray<
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Fixture PAT used by the write-only credential contract tests. */
|
||||||
|
export const CONTRACT_WORKSPACE_PAT = "ghp_contract-secret-token";
|
||||||
|
|
||||||
|
const CREATE_DATA = {
|
||||||
|
name: "Contract Workspace",
|
||||||
|
gitUrl: "https://github.com/acme/contract.git",
|
||||||
|
credential: CONTRACT_WORKSPACE_PAT,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contract for IWorkspaceRepository.
|
* Contract for IWorkspaceRepository — runs against both the mock and the
|
||||||
*
|
* Payload implementation. Covers read/create behaviour, span emission, and
|
||||||
* The interface exposes only `getWorkspace(id)`. The contract verifies
|
* the write-only credential guarantee: no read path returns the PAT; the
|
||||||
* found vs missing behaviour and span emission.
|
* narrow `getDecryptedCredential` path returns the plaintext for the runner
|
||||||
|
* handoff (story 07).
|
||||||
*/
|
*/
|
||||||
export const workspaceRepositoryContract =
|
export const workspaceRepositoryContract =
|
||||||
defineContractSuite<IWorkspaceRepository>(
|
defineContractSuite<IWorkspaceRepository>(
|
||||||
@@ -59,6 +69,47 @@ export const workspaceRepositoryContract =
|
|||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("createWorkspace", () => {
|
||||||
|
it("creates a workspace with initial status 'created'", async () => {
|
||||||
|
const created = await repo.createWorkspace(CREATE_DATA);
|
||||||
|
expect(created.id).not.toBe("");
|
||||||
|
expect(created.name).toBe(CREATE_DATA.name);
|
||||||
|
expect(created.gitUrl).toBe(CREATE_DATA.gitUrl);
|
||||||
|
expect(created.status).toBe("created");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists the workspace — readable back via getWorkspace", async () => {
|
||||||
|
const created = await repo.createWorkspace(CREATE_DATA);
|
||||||
|
const found = await repo.getWorkspace(created.id);
|
||||||
|
expect(found).toEqual(created);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("write-only credential behaviour", () => {
|
||||||
|
it("create → read back: the credential is absent from every read path", async () => {
|
||||||
|
const created = await repo.createWorkspace(CREATE_DATA);
|
||||||
|
const found = await repo.getWorkspace(created.id);
|
||||||
|
|
||||||
|
expect(created).not.toHaveProperty("credential");
|
||||||
|
expect(found).not.toHaveProperty("credential");
|
||||||
|
expect(JSON.stringify(created)).not.toContain(CONTRACT_WORKSPACE_PAT);
|
||||||
|
expect(JSON.stringify(found)).not.toContain(CONTRACT_WORKSPACE_PAT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getDecryptedCredential returns the plaintext for the runner handoff", async () => {
|
||||||
|
const created = await repo.createWorkspace(CREATE_DATA);
|
||||||
|
await expect(repo.getDecryptedCredential(created.id)).resolves.toBe(
|
||||||
|
CONTRACT_WORKSPACE_PAT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getDecryptedCredential returns null for an unknown id", async () => {
|
||||||
|
await expect(
|
||||||
|
repo.getDecryptedCredential("does-not-exist"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("span emission", () => {
|
describe("span emission", () => {
|
||||||
it("getWorkspace emits span 'workspace.getWorkspace' with op=repository", async () => {
|
it("getWorkspace emits span 'workspace.getWorkspace' with op=repository", async () => {
|
||||||
if (!getTracer) return;
|
if (!getTracer) return;
|
||||||
@@ -69,6 +120,16 @@ export const workspaceRepositoryContract =
|
|||||||
expect(span).toBeDefined();
|
expect(span).toBeDefined();
|
||||||
expect(span!.op).toBe("repository");
|
expect(span!.op).toBe("repository");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("createWorkspace emits span 'workspace.createWorkspace' with op=repository", async () => {
|
||||||
|
if (!getTracer) return;
|
||||||
|
const tracer = getTracer();
|
||||||
|
tracer.reset();
|
||||||
|
await repo.createWorkspace(CREATE_DATA);
|
||||||
|
const span = tracer.findSpan("workspace.createWorkspace");
|
||||||
|
expect(span).toBeDefined();
|
||||||
|
expect(span!.op).toBe("repository");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,4 +16,11 @@ export interface IWorkspaceRepository {
|
|||||||
getWorkspace(id: string): Promise<Workspace | null>;
|
getWorkspace(id: string): Promise<Workspace | null>;
|
||||||
/** Persists a new workspace with initial status "created". */
|
/** Persists a new workspace with initial status "created". */
|
||||||
createWorkspace(data: CreateWorkspaceData): Promise<Workspace>;
|
createWorkspace(data: CreateWorkspaceData): Promise<Workspace>;
|
||||||
|
/**
|
||||||
|
* INTERNAL server-side decrypt path for the runner handoff (story 07):
|
||||||
|
* returns the plaintext PAT, or null when the workspace does not exist.
|
||||||
|
* MUST never be surfaced through a use-case output — the credential is
|
||||||
|
* write-only on every API read path.
|
||||||
|
*/
|
||||||
|
getDecryptedCredential(id: string): Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ describe("connectWorkspaceUseCase", () => {
|
|||||||
it("strips a credential leaked by a misbehaving repository", async () => {
|
it("strips a credential leaked by a misbehaving repository", async () => {
|
||||||
const leakyRepo = {
|
const leakyRepo = {
|
||||||
getWorkspace: async () => null,
|
getWorkspace: async () => null,
|
||||||
|
getDecryptedCredential: async () => null,
|
||||||
createWorkspace: async () => ({
|
createWorkspace: async () => ({
|
||||||
id: "ws-1",
|
id: "ws-1",
|
||||||
name: "Acme Web",
|
name: "Acme Web",
|
||||||
@@ -99,6 +100,7 @@ describe("connectWorkspaceUseCase", () => {
|
|||||||
it("does not audit when the repository write fails", async () => {
|
it("does not audit when the repository write fails", async () => {
|
||||||
const failingRepo = {
|
const failingRepo = {
|
||||||
getWorkspace: async () => null,
|
getWorkspace: async () => null,
|
||||||
|
getDecryptedCredential: async () => null,
|
||||||
createWorkspace: async () => {
|
createWorkspace: async () => {
|
||||||
throw new Error("boom");
|
throw new Error("boom");
|
||||||
},
|
},
|
||||||
@@ -120,6 +122,7 @@ describe("connectWorkspaceUseCase", () => {
|
|||||||
it("throws ZodError when the repository returns malformed data", async () => {
|
it("throws ZodError when the repository returns malformed data", async () => {
|
||||||
const malformedRepo = {
|
const malformedRepo = {
|
||||||
getWorkspace: async () => null,
|
getWorkspace: async () => null,
|
||||||
|
getDecryptedCredential: async () => null,
|
||||||
createWorkspace: async () => ({ id: "", name: "x" }) as never,
|
createWorkspace: async () => ({ id: "", name: "x" }) as never,
|
||||||
};
|
};
|
||||||
const useCase = connectWorkspaceUseCase(malformedRepo);
|
const useCase = connectWorkspaceUseCase(malformedRepo);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ describe("getWorkspaceUseCase", () => {
|
|||||||
const malformedRepo = {
|
const malformedRepo = {
|
||||||
getWorkspace: async () => ({ id: "", name: "x" }) as never,
|
getWorkspace: async () => ({ id: "", name: "x" }) as never,
|
||||||
createWorkspace: async () => ({ id: "", name: "x" }) as never,
|
createWorkspace: async () => ({ id: "", name: "x" }) as never,
|
||||||
|
getDecryptedCredential: async () => null,
|
||||||
};
|
};
|
||||||
const useCase = getWorkspaceUseCase(malformedRepo);
|
const useCase = getWorkspaceUseCase(malformedRepo);
|
||||||
await expect(useCase({ id: "anything" })).rejects.toBeInstanceOf(ZodError);
|
await expect(useCase({ id: "anything" })).rejects.toBeInstanceOf(ZodError);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { workspacesContainer } from "./container";
|
|||||||
import { WORKSPACES_SYMBOLS } from "./symbols";
|
import { WORKSPACES_SYMBOLS } from "./symbols";
|
||||||
import { workspacesManifest } from "../feature.manifest";
|
import { workspacesManifest } from "../feature.manifest";
|
||||||
import { WorkspaceRepository } from "../infrastructure/repositories/workspace.repository";
|
import { WorkspaceRepository } from "../infrastructure/repositories/workspace.repository";
|
||||||
|
import { requireVeectSecret } from "../infrastructure/crypto/credential-cipher";
|
||||||
import { connectWorkspaceUseCase } from "../application/use-cases/connect-workspace.use-case";
|
import { connectWorkspaceUseCase } from "../application/use-cases/connect-workspace.use-case";
|
||||||
import { getWorkspaceUseCase } from "../application/use-cases/get-workspace.use-case";
|
import { getWorkspaceUseCase } from "../application/use-cases/get-workspace.use-case";
|
||||||
import { connectWorkspaceController } from "../interface-adapters/controllers/connect-workspace.controller";
|
import { connectWorkspaceController } from "../interface-adapters/controllers/connect-workspace.controller";
|
||||||
@@ -45,11 +46,19 @@ export function bindProductionWorkspaces(ctx: BindProductionContext): void {
|
|||||||
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
|
||||||
.toConstantValue(logger);
|
.toConstantValue(logger);
|
||||||
|
|
||||||
// Real repository
|
// Real repository. `requireVeectSecret()` fails the boot fast (clear
|
||||||
|
// message) when VEECT_SECRET is missing — production cannot encrypt or
|
||||||
|
// decrypt workspace credentials without it. Dev-seed mode never runs this.
|
||||||
|
const credentialSecret = requireVeectSecret();
|
||||||
if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IWorkspaceRepository)) {
|
if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IWorkspaceRepository)) {
|
||||||
workspacesContainer.unbind(WORKSPACES_SYMBOLS.IWorkspaceRepository);
|
workspacesContainer.unbind(WORKSPACES_SYMBOLS.IWorkspaceRepository);
|
||||||
}
|
}
|
||||||
const repo = new WorkspaceRepository(config, tracer, logger);
|
const repo = new WorkspaceRepository(
|
||||||
|
config,
|
||||||
|
credentialSecret,
|
||||||
|
tracer,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
workspacesContainer
|
workspacesContainer
|
||||||
.bind(WORKSPACES_SYMBOLS.IWorkspaceRepository)
|
.bind(WORKSPACES_SYMBOLS.IWorkspaceRepository)
|
||||||
.toConstantValue(repo);
|
.toConstantValue(repo);
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
decryptCredential,
|
||||||
|
encryptCredential,
|
||||||
|
isEncryptedCredential,
|
||||||
|
requireVeectSecret,
|
||||||
|
} from "@/infrastructure/crypto/credential-cipher";
|
||||||
|
|
||||||
|
const SECRET = "test-secret-0123456789abcdef0123456789abcdef";
|
||||||
|
const PAT = "ghp_super-secret-token";
|
||||||
|
|
||||||
|
describe("encryptCredential / decryptCredential", () => {
|
||||||
|
it("round-trips a credential", () => {
|
||||||
|
const stored = encryptCredential(SECRET, PAT);
|
||||||
|
expect(decryptCredential(SECRET, stored)).toBe(PAT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the v1:<salt>:<iv>:<authTag>:<ciphertext> format", () => {
|
||||||
|
const stored = encryptCredential(SECRET, PAT);
|
||||||
|
const parts = stored.split(":");
|
||||||
|
expect(parts).toHaveLength(5);
|
||||||
|
expect(parts[0]).toBe("v1");
|
||||||
|
// salt 16 bytes, iv 12 bytes, GCM tag 16 bytes
|
||||||
|
expect(Buffer.from(parts[1]!, "base64")).toHaveLength(16);
|
||||||
|
expect(Buffer.from(parts[2]!, "base64")).toHaveLength(12);
|
||||||
|
expect(Buffer.from(parts[3]!, "base64")).toHaveLength(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never stores the plaintext", () => {
|
||||||
|
const stored = encryptCredential(SECRET, PAT);
|
||||||
|
expect(stored).not.toContain(PAT);
|
||||||
|
expect(
|
||||||
|
Buffer.from(stored.split(":")[4]!, "base64").toString("utf8"),
|
||||||
|
).not.toBe(PAT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a fresh salt + iv per call — two encryptions of the same value differ", () => {
|
||||||
|
expect(encryptCredential(SECRET, PAT)).not.toBe(
|
||||||
|
encryptCredential(SECRET, PAT),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects decryption with the wrong secret", () => {
|
||||||
|
const stored = encryptCredential(SECRET, PAT);
|
||||||
|
expect(() => decryptCredential("wrong-secret", stored)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects tampered ciphertext (GCM auth tag)", () => {
|
||||||
|
const stored = encryptCredential(SECRET, PAT);
|
||||||
|
const parts = stored.split(":");
|
||||||
|
const tampered = Buffer.from(parts[4]!, "base64");
|
||||||
|
tampered[0] = tampered[0]! ^ 0xff;
|
||||||
|
parts[4] = tampered.toString("base64");
|
||||||
|
expect(() => decryptCredential(SECRET, parts.join(":"))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unrecognized storage format", () => {
|
||||||
|
expect(() => decryptCredential(SECRET, "plaintext-not-encrypted")).toThrow(
|
||||||
|
/Unrecognized encrypted credential format/,
|
||||||
|
);
|
||||||
|
expect(() => decryptCredential(SECRET, "v2:a:b:c:d")).toThrow(
|
||||||
|
/Unrecognized encrypted credential format/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isEncryptedCredential", () => {
|
||||||
|
it("recognizes encrypted values", () => {
|
||||||
|
expect(isEncryptedCredential(encryptCredential(SECRET, PAT))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects plaintext values", () => {
|
||||||
|
expect(isEncryptedCredential(PAT)).toBe(false);
|
||||||
|
expect(isEncryptedCredential("v1-but-not-really")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requireVeectSecret", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the secret when set", () => {
|
||||||
|
vi.stubEnv("VEECT_SECRET", SECRET);
|
||||||
|
expect(requireVeectSecret()).toBe(SECRET);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a clear, actionable error when unset", () => {
|
||||||
|
vi.stubEnv("VEECT_SECRET", undefined);
|
||||||
|
expect(() => requireVeectSecret()).toThrow(
|
||||||
|
/VEECT_SECRET environment variable is required/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when set to an empty string", () => {
|
||||||
|
vi.stubEnv("VEECT_SECRET", "");
|
||||||
|
expect(() => requireVeectSecret()).toThrow(/VEECT_SECRET/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
createCipheriv,
|
||||||
|
createDecipheriv,
|
||||||
|
randomBytes,
|
||||||
|
scryptSync,
|
||||||
|
} from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-256-GCM credential encryption for workspace PATs (tech spec §13).
|
||||||
|
*
|
||||||
|
* - Key derived from `VEECT_SECRET` via scrypt with a random per-value salt.
|
||||||
|
* - Random 12-byte IV per encryption; GCM auth tag guards integrity.
|
||||||
|
* - Storage format: `v1:<salt>:<iv>:<authTag>:<ciphertext>` (base64 parts).
|
||||||
|
*
|
||||||
|
* node:crypto only — no runtime dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ALGORITHM = "aes-256-gcm";
|
||||||
|
const KEY_LENGTH = 32;
|
||||||
|
const IV_LENGTH = 12;
|
||||||
|
const SALT_LENGTH = 16;
|
||||||
|
const FORMAT_VERSION = "v1";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads `VEECT_SECRET`, failing fast with an actionable message. Called at
|
||||||
|
* production bind time (boot-time error when missing) and by the collection's
|
||||||
|
* encrypt-on-write hook. Dev-seed mode never calls this — it must boot
|
||||||
|
* without the secret.
|
||||||
|
*/
|
||||||
|
export function requireVeectSecret(): string {
|
||||||
|
const secret = process.env.VEECT_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error(
|
||||||
|
"VEECT_SECRET environment variable is required to encrypt/decrypt workspace credentials. " +
|
||||||
|
"Generate one via `openssl rand -hex 32` and set it in .env (development) " +
|
||||||
|
"or your secrets manager (production).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `value` is already in this module's encrypted storage format. */
|
||||||
|
export function isEncryptedCredential(value: string): boolean {
|
||||||
|
return (
|
||||||
|
value.startsWith(`${FORMAT_VERSION}:`) && value.split(":").length === 5
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptCredential(secret: string, plaintext: string): string {
|
||||||
|
const salt = randomBytes(SALT_LENGTH);
|
||||||
|
const key = scryptSync(secret, salt, KEY_LENGTH);
|
||||||
|
const iv = randomBytes(IV_LENGTH);
|
||||||
|
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||||
|
const ciphertext = Buffer.concat([
|
||||||
|
cipher.update(plaintext, "utf8"),
|
||||||
|
cipher.final(),
|
||||||
|
]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
return [
|
||||||
|
FORMAT_VERSION,
|
||||||
|
salt.toString("base64"),
|
||||||
|
iv.toString("base64"),
|
||||||
|
authTag.toString("base64"),
|
||||||
|
ciphertext.toString("base64"),
|
||||||
|
].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptCredential(secret: string, stored: string): string {
|
||||||
|
const [version, saltB64, ivB64, tagB64, dataB64] = stored.split(":");
|
||||||
|
if (version !== FORMAT_VERSION || !saltB64 || !ivB64 || !tagB64 || !dataB64) {
|
||||||
|
throw new Error(
|
||||||
|
"Unrecognized encrypted credential format — expected v1:<salt>:<iv>:<authTag>:<ciphertext>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const key = scryptSync(secret, Buffer.from(saltB64, "base64"), KEY_LENGTH);
|
||||||
|
const decipher = createDecipheriv(
|
||||||
|
ALGORITHM,
|
||||||
|
key,
|
||||||
|
Buffer.from(ivB64, "base64"),
|
||||||
|
);
|
||||||
|
decipher.setAuthTag(Buffer.from(tagB64, "base64"));
|
||||||
|
return Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(dataB64, "base64")),
|
||||||
|
decipher.final(),
|
||||||
|
]).toString("utf8");
|
||||||
|
}
|
||||||
@@ -86,4 +86,19 @@ export class MockWorkspaceRepository implements IWorkspaceRepository {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDecryptedCredential(id: string): Promise<string | null> {
|
||||||
|
return this.tracer.startSpan(
|
||||||
|
{
|
||||||
|
name: "workspace.getDecryptedCredential",
|
||||||
|
op: "repository",
|
||||||
|
attributes: { id },
|
||||||
|
},
|
||||||
|
async (span) => {
|
||||||
|
const found = this.credentials.get(id) ?? null;
|
||||||
|
span.setAttribute("found", found !== null);
|
||||||
|
return found;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterAll } from "vitest";
|
||||||
|
import type { FieldHook } from "payload";
|
||||||
import type { ITracer } from "@repo/core-shared/instrumentation";
|
import type { ITracer } from "@repo/core-shared/instrumentation";
|
||||||
import {
|
import {
|
||||||
RecordingTracer,
|
RecordingTracer,
|
||||||
@@ -6,76 +7,201 @@ import {
|
|||||||
} from "@repo/core-testing/instrumentation";
|
} from "@repo/core-testing/instrumentation";
|
||||||
import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config";
|
import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config";
|
||||||
import { WorkspaceRepository } from "@/infrastructure/repositories/workspace.repository";
|
import { WorkspaceRepository } from "@/infrastructure/repositories/workspace.repository";
|
||||||
|
import { workspaces } from "@/integrations/cms/collections/workspaces";
|
||||||
|
import { isEncryptedCredential } from "@/infrastructure/crypto/credential-cipher";
|
||||||
|
import {
|
||||||
|
workspaceRepositoryContract,
|
||||||
|
CONTRACT_WORKSPACE_SEED,
|
||||||
|
CONTRACT_WORKSPACE_PAT,
|
||||||
|
} from "@/__contracts__/workspace-repository.contract";
|
||||||
|
|
||||||
// Phase-1 scaffold: the real repository returns null until the Payload
|
vi.mock("payload", () => ({ getPayload: vi.fn() }));
|
||||||
// collection is wired. These tests pin the span shape and the stub return
|
|
||||||
// value so that callers (use case + DI tests) keep working when the body is
|
|
||||||
// later replaced with a real `payload.find()` call.
|
|
||||||
|
|
||||||
describe("WorkspaceRepository (Phase-1 stub)", () => {
|
const SECRET = "test-secret-0123456789abcdef0123456789abcdef";
|
||||||
it("returns null and emits a span with op='repository'", async () => {
|
vi.stubEnv("VEECT_SECRET", SECRET);
|
||||||
const tracer = new RecordingTracer();
|
afterAll(() => {
|
||||||
const logger = new RecordingLogger();
|
vi.unstubAllEnvs();
|
||||||
const repo = new WorkspaceRepository(stubPayloadConfig, tracer, logger);
|
|
||||||
|
|
||||||
const result = await repo.getWorkspace("anything");
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(tracer.spans).toHaveLength(1);
|
|
||||||
expect(tracer.spans[0]).toMatchObject({
|
|
||||||
name: "workspace.getWorkspace",
|
|
||||||
op: "repository",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("records the requested id as a span attribute", async () => {
|
/**
|
||||||
const tracer = new RecordingTracer();
|
* In-memory Payload stub that honours the REAL workspaces collection config:
|
||||||
const repo = new WorkspaceRepository(stubPayloadConfig, tracer);
|
* `create` runs each field's `beforeChange` hooks (so the credential is
|
||||||
await repo.getWorkspace("custom-id");
|
* encrypted exactly as Payload would at runtime) and applies select
|
||||||
expect(tracer.spans[0]!.attributes.id).toBe("custom-id");
|
* defaults. Reads return the stored doc — the repository always reads with
|
||||||
expect(tracer.spans[0]!.attributes.found).toBe(false);
|
* `overrideAccess: true`, and `toDomain` owns credential stripping.
|
||||||
});
|
*/
|
||||||
|
function buildPayloadStub(
|
||||||
|
seed: ReadonlyArray<readonly [string, Record<string, unknown>]> = [],
|
||||||
|
) {
|
||||||
|
const store = new Map<string, Record<string, unknown>>(
|
||||||
|
seed.map(([id, doc]) => [id, { ...doc }]),
|
||||||
|
);
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
it("getWorkspace captures and rethrows infra errors (capture-at-throw-site)", async () => {
|
async function applyBeforeChangeHooks(
|
||||||
const logger = new RecordingLogger();
|
data: Record<string, unknown>,
|
||||||
// Span whose setAttribute throws — drives the repository's catch path
|
): Promise<Record<string, unknown>> {
|
||||||
// that the real payload-backed body will rely on.
|
const doc: Record<string, unknown> = { ...data };
|
||||||
const throwingTracer: ITracer = {
|
for (const field of workspaces.fields) {
|
||||||
startSpan: (_opts, fn) =>
|
if (!("name" in field)) continue;
|
||||||
fn({
|
const hooks = ("hooks" in field && field.hooks?.beforeChange) || [];
|
||||||
setAttribute: () => {
|
for (const hook of hooks as FieldHook[]) {
|
||||||
throw new Error("span boom");
|
doc[field.name] = await hook({
|
||||||
|
value: doc[field.name],
|
||||||
|
data: doc,
|
||||||
|
} as Parameters<FieldHook>[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
create: vi.fn(
|
||||||
|
async ({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
collection: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}) => {
|
||||||
|
const doc = await applyBeforeChangeHooks(data);
|
||||||
|
doc.id = `pw-${nextId++}`;
|
||||||
|
store.set(String(doc.id), doc);
|
||||||
|
return doc;
|
||||||
},
|
},
|
||||||
setStatus: () => {},
|
),
|
||||||
|
findByID: vi.fn(
|
||||||
|
async ({ id }: { collection: string; id: string }) =>
|
||||||
|
store.get(String(id)) ?? null,
|
||||||
|
),
|
||||||
|
__store: store,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildRepo(tracer?: ITracer) {
|
||||||
|
const stub = buildPayloadStub(CONTRACT_WORKSPACE_SEED);
|
||||||
|
const { getPayload } = await import("payload");
|
||||||
|
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
|
||||||
|
return {
|
||||||
|
stub,
|
||||||
|
repo: new WorkspaceRepository(stubPayloadConfig, SECRET, tracer),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WorkspaceRepository", () => {
|
||||||
|
describe("contract", () => {
|
||||||
|
const tracer = new RecordingTracer();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
workspaceRepositoryContract.run(
|
||||||
|
async () => (await buildRepo(tracer)).repo,
|
||||||
|
{ tracer: () => tracer },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("encryption at rest", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the credential encrypted — the plaintext never reaches the database", async () => {
|
||||||
|
const { stub, repo } = await buildRepo();
|
||||||
|
const created = await repo.createWorkspace({
|
||||||
|
name: "Enc",
|
||||||
|
gitUrl: "https://github.com/acme/enc.git",
|
||||||
|
credential: CONTRACT_WORKSPACE_PAT,
|
||||||
|
});
|
||||||
|
|
||||||
|
const storedDoc = stub.__store.get(created.id)!;
|
||||||
|
expect(typeof storedDoc.credential).toBe("string");
|
||||||
|
expect(storedDoc.credential).not.toBe(CONTRACT_WORKSPACE_PAT);
|
||||||
|
expect(isEncryptedCredential(storedDoc.credential as string)).toBe(true);
|
||||||
|
expect(JSON.stringify(storedDoc)).not.toContain(CONTRACT_WORKSPACE_PAT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getDecryptedCredential decrypts the stored value back to the plaintext", async () => {
|
||||||
|
const { repo } = await buildRepo();
|
||||||
|
const created = await repo.createWorkspace({
|
||||||
|
name: "Enc",
|
||||||
|
gitUrl: "https://github.com/acme/enc.git",
|
||||||
|
credential: CONTRACT_WORKSPACE_PAT,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(repo.getDecryptedCredential(created.id)).resolves.toBe(
|
||||||
|
CONTRACT_WORKSPACE_PAT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("error paths (capture-at-throw-site)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getWorkspace treats a Payload 404 error as null", async () => {
|
||||||
|
const stub = {
|
||||||
|
findByID: vi.fn(async () => {
|
||||||
|
throw Object.assign(new Error("Not Found"), { status: 404 });
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
const { getPayload } = await import("payload");
|
||||||
|
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
|
||||||
|
const repo = new WorkspaceRepository(stubPayloadConfig, SECRET);
|
||||||
|
|
||||||
|
await expect(repo.getWorkspace("missing")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getDecryptedCredential treats a Payload 404 error as null", async () => {
|
||||||
|
const stub = {
|
||||||
|
findByID: vi.fn(async () => {
|
||||||
|
throw Object.assign(new Error("Not Found"), { status: 404 });
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { getPayload } = await import("payload");
|
||||||
|
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
|
||||||
|
const repo = new WorkspaceRepository(stubPayloadConfig, SECRET);
|
||||||
|
|
||||||
|
await expect(repo.getDecryptedCredential("missing")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["getWorkspace", (r: WorkspaceRepository) => r.getWorkspace("x")],
|
||||||
|
[
|
||||||
|
"createWorkspace",
|
||||||
|
(r: WorkspaceRepository) =>
|
||||||
|
r.createWorkspace({
|
||||||
|
name: "X",
|
||||||
|
gitUrl: "https://github.com/acme/x.git",
|
||||||
|
credential: "pat",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"getDecryptedCredential",
|
||||||
|
(r: WorkspaceRepository) => r.getDecryptedCredential("x"),
|
||||||
|
],
|
||||||
|
])("%s captures and rethrows infra errors", async (_name, call) => {
|
||||||
|
const stub = {
|
||||||
|
findByID: vi.fn(async () => {
|
||||||
|
throw new Error("infra boom");
|
||||||
|
}),
|
||||||
|
create: vi.fn(async () => {
|
||||||
|
throw new Error("infra boom");
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { getPayload } = await import("payload");
|
||||||
|
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
|
||||||
|
const logger = new RecordingLogger();
|
||||||
const repo = new WorkspaceRepository(
|
const repo = new WorkspaceRepository(
|
||||||
stubPayloadConfig,
|
stubPayloadConfig,
|
||||||
throwingTracer,
|
SECRET,
|
||||||
|
undefined,
|
||||||
logger,
|
logger,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(repo.getWorkspace("x")).rejects.toThrow("span boom");
|
await expect(call(repo)).rejects.toThrow("infra boom");
|
||||||
expect(logger.captures).toHaveLength(1);
|
expect(logger.captures).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("createWorkspace fails loudly until the Payload collection is registered", async () => {
|
|
||||||
const tracer = new RecordingTracer();
|
|
||||||
const logger = new RecordingLogger();
|
|
||||||
const repo = new WorkspaceRepository(stubPayloadConfig, tracer, logger);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
repo.createWorkspace({
|
|
||||||
name: "Acme Web",
|
|
||||||
gitUrl: "https://github.com/acme/web.git",
|
|
||||||
credential: "ghp_token",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/not registered yet/);
|
|
||||||
|
|
||||||
expect(logger.captures).toHaveLength(1);
|
|
||||||
expect(tracer.spans[0]).toMatchObject({
|
|
||||||
name: "workspace.createWorkspace",
|
|
||||||
op: "repository",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
import { injectable } from "inversify";
|
import { injectable } from "inversify";
|
||||||
|
import { getPayload } from "payload";
|
||||||
import type { SanitizedConfig } from "payload";
|
import type { SanitizedConfig } from "payload";
|
||||||
import {
|
import {
|
||||||
NoopTracer,
|
NoopTracer,
|
||||||
@@ -13,46 +14,56 @@ import type {
|
|||||||
IWorkspaceRepository,
|
IWorkspaceRepository,
|
||||||
} from "../../application/repositories/workspace.repository.interface";
|
} from "../../application/repositories/workspace.repository.interface";
|
||||||
import type { Workspace } from "../../entities/models/workspace";
|
import type { Workspace } from "../../entities/models/workspace";
|
||||||
|
import { decryptCredential } from "../crypto/credential-cipher";
|
||||||
|
|
||||||
const FEATURE = "workspaces" as const;
|
const FEATURE = "workspaces" as const;
|
||||||
const REPO = "workspace" as const;
|
const REPO = "workspace" as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase-1 scaffold — the Payload collection has not been added yet, so this
|
* Payload-backed workspace repository. The `credential` column is encrypted
|
||||||
* repository emits a span but always resolves to `null`. Once you add the
|
* at rest by the collection's field-level `beforeChange` hook and is
|
||||||
* collection at `integrations/cms/collections/workspace.ts` and
|
* write-only through every access-controlled API path; this repository's
|
||||||
* register it with Payload, replace the stub below with a real `payload.find`
|
* `getDecryptedCredential` is the single server-side decrypt path (runner
|
||||||
* call (see `packages/blog/src/infrastructure/repositories/articles.repository.ts`
|
* handoff, story 07). `toDomain` never maps the credential, so ordinary
|
||||||
* for the canonical pattern).
|
* reads are credential-free by construction.
|
||||||
*/
|
*/
|
||||||
@injectable()
|
@injectable()
|
||||||
export class WorkspaceRepository implements IWorkspaceRepository {
|
export class WorkspaceRepository implements IWorkspaceRepository {
|
||||||
private config: SanitizedConfig;
|
private config: SanitizedConfig;
|
||||||
|
private credentialSecret: string;
|
||||||
private tracer: ITracer;
|
private tracer: ITracer;
|
||||||
private logger: ILogger;
|
private logger: ILogger;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: SanitizedConfig,
|
config: SanitizedConfig,
|
||||||
|
credentialSecret: string,
|
||||||
tracer: ITracer = new NoopTracer(),
|
tracer: ITracer = new NoopTracer(),
|
||||||
logger: ILogger = new NoopLogger(),
|
logger: ILogger = new NoopLogger(),
|
||||||
) {
|
) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this.credentialSecret = credentialSecret;
|
||||||
this.tracer = tracer;
|
this.tracer = tracer;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
void this.config;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getWorkspace(id: string): Promise<Workspace | null> {
|
async getWorkspace(id: string): Promise<Workspace | null> {
|
||||||
return this.tracer.startSpan(
|
return this.tracer.startSpan(
|
||||||
{ name: "workspace.getWorkspace", op: "repository", attributes: {} },
|
{ name: "workspace.getWorkspace", op: "repository", attributes: { id } },
|
||||||
async (span) => {
|
async (span) => {
|
||||||
try {
|
try {
|
||||||
// TODO: replace with `payload.find({ collection: "workspaces", where: { id: { equals: id } } })`
|
const payload = await getPayload({ config: this.config });
|
||||||
// once the Payload collection is registered.
|
const doc = await payload.findByID({
|
||||||
span.setAttribute("id", id);
|
collection: "workspaces",
|
||||||
|
id,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
span.setAttribute("found", Boolean(doc));
|
||||||
|
return doc ? this.toDomain(doc as Record<string, unknown>) : null;
|
||||||
|
} catch (err) {
|
||||||
|
if (isNotFound(err)) {
|
||||||
span.setAttribute("found", false);
|
span.setAttribute("found", false);
|
||||||
return null;
|
return null;
|
||||||
} catch (err) {
|
}
|
||||||
this.logger.captureException(err, {
|
this.logger.captureException(err, {
|
||||||
tags: { feature: FEATURE, repo: REPO, method: "getWorkspace" },
|
tags: { feature: FEATURE, repo: REPO, method: "getWorkspace" },
|
||||||
});
|
});
|
||||||
@@ -70,20 +81,95 @@ export class WorkspaceRepository implements IWorkspaceRepository {
|
|||||||
return this.tracer.startSpan(
|
return this.tracer.startSpan(
|
||||||
{ name: "workspace.createWorkspace", op: "repository", attributes: {} },
|
{ name: "workspace.createWorkspace", op: "repository", attributes: {} },
|
||||||
async (span) => {
|
async (span) => {
|
||||||
// Placeholder until the encrypted-credential storage slice registers
|
try {
|
||||||
// the Payload collection: fail loudly rather than silently dropping
|
const payload = await getPayload({ config: this.config });
|
||||||
// the write. Replaced by a real `payload.create` with AES-256-GCM
|
// The collection's beforeChange field hook encrypts `credential`
|
||||||
// credential encryption in the same story.
|
// before it reaches the database.
|
||||||
void data;
|
const created = await payload.create({
|
||||||
const err = new Error(
|
collection: "workspaces",
|
||||||
"Workspaces Payload collection is not registered yet — createWorkspace is unavailable in production mode.",
|
data: {
|
||||||
);
|
name: data.name,
|
||||||
|
gitUrl: data.gitUrl,
|
||||||
|
status: "created",
|
||||||
|
credential: data.credential,
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
span.setAttribute("created", true);
|
||||||
|
return this.toDomain(created as Record<string, unknown>);
|
||||||
|
} catch (err) {
|
||||||
this.logger.captureException(err, {
|
this.logger.captureException(err, {
|
||||||
tags: { feature: FEATURE, repo: REPO, method: "createWorkspace" },
|
tags: { feature: FEATURE, repo: REPO, method: "createWorkspace" },
|
||||||
});
|
});
|
||||||
span.setStatus("error", err.message);
|
span.setStatus(
|
||||||
|
"error",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
throw err;
|
throw err;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDecryptedCredential(id: string): Promise<string | null> {
|
||||||
|
return this.tracer.startSpan(
|
||||||
|
{
|
||||||
|
name: "workspace.getDecryptedCredential",
|
||||||
|
op: "repository",
|
||||||
|
attributes: { id },
|
||||||
|
},
|
||||||
|
async (span) => {
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config: this.config });
|
||||||
|
// overrideAccess bypasses the field's read: () => false — this is
|
||||||
|
// the single sanctioned decrypt path (server-side runner handoff).
|
||||||
|
const doc = await payload.findByID({
|
||||||
|
collection: "workspaces",
|
||||||
|
id,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const stored = (doc as Record<string, unknown> | null)?.credential;
|
||||||
|
span.setAttribute("found", typeof stored === "string");
|
||||||
|
if (typeof stored !== "string") return null;
|
||||||
|
return decryptCredential(this.credentialSecret, stored);
|
||||||
|
} catch (err) {
|
||||||
|
if (isNotFound(err)) {
|
||||||
|
span.setAttribute("found", false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
this.logger.captureException(err, {
|
||||||
|
tags: {
|
||||||
|
feature: FEATURE,
|
||||||
|
repo: REPO,
|
||||||
|
method: "getDecryptedCredential",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
span.setStatus(
|
||||||
|
"error",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(doc: Record<string, unknown>): Workspace {
|
||||||
|
// Deliberately never maps `credential`.
|
||||||
|
return {
|
||||||
|
id: String(doc.id),
|
||||||
|
name: doc.name as string,
|
||||||
|
gitUrl: doc.gitUrl as string,
|
||||||
|
status: doc.status as Workspace["status"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNotFound(err: unknown): boolean {
|
||||||
|
return (
|
||||||
|
err !== null &&
|
||||||
|
typeof err === "object" &&
|
||||||
|
"status" in err &&
|
||||||
|
(err as { status: unknown }).status === 404
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { FieldHook, TextField } from "payload";
|
||||||
|
import { workspaces } from "@/integrations/cms/collections/workspaces";
|
||||||
|
import {
|
||||||
|
decryptCredential,
|
||||||
|
isEncryptedCredential,
|
||||||
|
} from "@/infrastructure/crypto/credential-cipher";
|
||||||
|
|
||||||
|
const SECRET = "test-secret-0123456789abcdef0123456789abcdef";
|
||||||
|
const PAT = "ghp_super-secret-token";
|
||||||
|
|
||||||
|
function credentialField(): TextField {
|
||||||
|
const field = workspaces.fields.find(
|
||||||
|
(f) => "name" in f && f.name === "credential",
|
||||||
|
);
|
||||||
|
if (!field) throw new Error("credential field missing");
|
||||||
|
return field as TextField;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encryptHook(): FieldHook {
|
||||||
|
const hook = credentialField().hooks?.beforeChange?.[0];
|
||||||
|
if (!hook) throw new Error("credential beforeChange hook missing");
|
||||||
|
return hook;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HookArgs = Parameters<FieldHook>[0];
|
||||||
|
|
||||||
|
describe("workspaces collection", () => {
|
||||||
|
it("uses the workspaces slug with name/gitUrl/status/credential fields", () => {
|
||||||
|
expect(workspaces.slug).toBe("workspaces");
|
||||||
|
const names = workspaces.fields.map((f) => ("name" in f ? f.name : ""));
|
||||||
|
expect(names).toEqual(["name", "gitUrl", "status", "credential"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares a retention purge schedule (compliance requirement)", () => {
|
||||||
|
expect(
|
||||||
|
(workspaces.custom as { retention: { purgeSchedule: string } }).retention
|
||||||
|
.purgeSchedule,
|
||||||
|
).toBe("daily");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("status options match the persisted status enum", () => {
|
||||||
|
const status = workspaces.fields.find(
|
||||||
|
(f) => "name" in f && f.name === "status",
|
||||||
|
) as { options: { value: string }[]; defaultValue: string };
|
||||||
|
expect(status.options.map((o) => o.value)).toEqual([
|
||||||
|
"created",
|
||||||
|
"connecting",
|
||||||
|
"ready",
|
||||||
|
"error",
|
||||||
|
]);
|
||||||
|
expect(status.defaultValue).toBe("created");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("credential field (write-only, encrypted at rest)", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies read access unconditionally", () => {
|
||||||
|
const read = credentialField().access?.read;
|
||||||
|
expect(read).toBeDefined();
|
||||||
|
expect(read!({} as Parameters<NonNullable<typeof read>>[0])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encrypts the plaintext PAT on write", async () => {
|
||||||
|
vi.stubEnv("VEECT_SECRET", SECRET);
|
||||||
|
const stored = await encryptHook()({ value: PAT } as HookArgs);
|
||||||
|
|
||||||
|
expect(stored).not.toBe(PAT);
|
||||||
|
expect(isEncryptedCredential(stored as string)).toBe(true);
|
||||||
|
expect(decryptCredential(SECRET, stored as string)).toBe(PAT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes an already-encrypted value through untouched (update flows)", async () => {
|
||||||
|
vi.stubEnv("VEECT_SECRET", SECRET);
|
||||||
|
const first = await encryptHook()({ value: PAT } as HookArgs);
|
||||||
|
const second = await encryptHook()({ value: first } as HookArgs);
|
||||||
|
|
||||||
|
expect(second).toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes undefined through (partial updates without the field)", async () => {
|
||||||
|
const result = await encryptHook()({ value: undefined } as HookArgs);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails loudly when VEECT_SECRET is missing at write time", async () => {
|
||||||
|
vi.stubEnv("VEECT_SECRET", undefined);
|
||||||
|
await expect(async () =>
|
||||||
|
encryptHook()({ value: PAT } as HookArgs),
|
||||||
|
).rejects.toThrow(/VEECT_SECRET/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { CollectionConfig } from "payload";
|
||||||
|
import {
|
||||||
|
encryptCredential,
|
||||||
|
isEncryptedCredential,
|
||||||
|
requireVeectSecret,
|
||||||
|
} from "../../../infrastructure/crypto/credential-cipher";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workspaces collection — the root entity of the control plane.
|
||||||
|
*
|
||||||
|
* The `credential` field (repository PAT) is write-only and encrypted at
|
||||||
|
* rest:
|
||||||
|
* - `access.read: () => false` strips it from every access-controlled
|
||||||
|
* read path (REST, GraphQL, admin UI). Only local-API calls that pass
|
||||||
|
* `overrideAccess: true` — the repository's internal decrypt path —
|
||||||
|
* can see the (still encrypted) stored value.
|
||||||
|
* - The field-level `beforeChange` hook encrypts on write with
|
||||||
|
* AES-256-GCM (scrypt key from `VEECT_SECRET`), so the plaintext never
|
||||||
|
* reaches the database. Already-encrypted values pass through untouched
|
||||||
|
* (Payload feeds existing values back through update flows).
|
||||||
|
* - There is deliberately NO `afterRead` decrypt hook — decryption happens
|
||||||
|
* only in `WorkspaceRepository.getDecryptedCredential`, the narrow
|
||||||
|
* server-side path for the runner handoff.
|
||||||
|
*/
|
||||||
|
export const workspaces: CollectionConfig = {
|
||||||
|
slug: "workspaces",
|
||||||
|
admin: {
|
||||||
|
useAsTitle: "name",
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
retention: {
|
||||||
|
purgeSchedule: "daily",
|
||||||
|
postDeletion: {
|
||||||
|
duration: "P30D",
|
||||||
|
trigger: "after-deletion",
|
||||||
|
action: "hard-delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gitUrl",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "Created", value: "created" },
|
||||||
|
{ label: "Connecting", value: "connecting" },
|
||||||
|
{ label: "Ready", value: "ready" },
|
||||||
|
{ label: "Error", value: "error" },
|
||||||
|
],
|
||||||
|
defaultValue: "created",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "credential",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
access: {
|
||||||
|
// Write-only: no API read path ever returns this field.
|
||||||
|
read: () => false,
|
||||||
|
},
|
||||||
|
hooks: {
|
||||||
|
beforeChange: [
|
||||||
|
({ value }) => {
|
||||||
|
if (typeof value !== "string" || value === "") return value;
|
||||||
|
if (isEncryptedCredential(value)) return value;
|
||||||
|
return encryptCredential(requireVeectSecret(), value);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
// Payload CMS integration barrel for @repo/workspaces.
|
// Payload CMS integration barrel for @repo/workspaces.
|
||||||
// Re-export this feature's collections and globals here as you add them
|
// The `<gen:job-tasks>` anchor below is required by `pnpm turbo gen job` and
|
||||||
// under ./collections and ./globals. See packages/auth/src/integrations/cms
|
// `pnpm turbo gen event` — do not remove it.
|
||||||
// for the canonical shape. The `<gen:job-tasks>` anchor below is required by
|
export { workspaces } from "./collections/workspaces";
|
||||||
// `pnpm turbo gen job` and `pnpm turbo gen event` — do not remove it.
|
|
||||||
export {};
|
|
||||||
// <gen:job-tasks>
|
// <gen:job-tasks>
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -454,6 +454,9 @@ importers:
|
|||||||
"@repo/auth":
|
"@repo/auth":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../auth
|
version: link:../auth
|
||||||
|
"@repo/workspaces":
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../workspaces
|
||||||
payload:
|
payload:
|
||||||
specifier: ^3.14.0
|
specifier: ^3.14.0
|
||||||
version: 3.81.0(graphql@16.13.2)(typescript@5.9.3)
|
version: 3.81.0(graphql@16.13.2)(typescript@5.9.3)
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
"VERCEL_GIT_COMMIT_SHA",
|
"VERCEL_GIT_COMMIT_SHA",
|
||||||
"NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA",
|
"NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA",
|
||||||
"VERCEL_ENV",
|
"VERCEL_ENV",
|
||||||
"AUDIT_PSEUDONYM_SALT"
|
"AUDIT_PSEUDONYM_SALT",
|
||||||
|
"VEECT_SECRET"
|
||||||
],
|
],
|
||||||
"boundaries": {
|
"boundaries": {
|
||||||
"tags": {
|
"tags": {
|
||||||
|
|||||||
Reference in New Issue
Block a user