fix(subscriptions): avoid credential-derived redaction metadata

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO authored and Devin Foley committed 2026-06-24 14:49:52 -07:00
1 parent c29c5e6158
commit 1d136fea5e
5 files changed
+71 -37

No files matched your search

@@ -29,7 +29,7 @@ export const userSubscriptionCredentials = pgTable(
material: jsonb("material").$type<Record<string, unknown>>().notNull(),
// SHA-256 of the plaintext credential, for change detection / audit only.
valueSha256: text("value_sha256").notNull(),
// Non-sensitive redacted preview metadata (e.g. token length, suffix hint).
// Safe metadata derived from credential kind only; never from plaintext.
redactedMetadata: jsonb("redacted_metadata").$type<Record<string, unknown>>(),
status: text("status").notNull().default("active"),
lastTestedAt: timestamp("last_tested_at", { withTimezone: true }),
@@ -71,7 +71,7 @@ export const subscriptionCredentialReadModelSchema = z.object({
credentialKind: subscriptionCredentialKindSchema,
status: subscriptionCredentialStatusSchema,
testStatus: subscriptionCredentialTestStatusSchema,
// Non-sensitive hints only (e.g. material length, last 4 chars of a token).
// Safe metadata only; must not be derived from plaintext credential material.
redactedMetadata: z.record(z.string(), z.unknown()).nullable(),
lastTestedAt: z.string().datetime().nullable(),
lastResolvedAt: z.string().datetime().nullable(),
@@ -55,7 +55,7 @@ const REDACTED_CREDENTIAL = {
credentialKind: "claude_oauth_token",
status: "active",
testStatus: "untested",
redactedMetadata: { kind: "claude_oauth_token", materialLength: 40, tokenSuffix: "abcd" },
redactedMetadata: { kind: "claude_oauth_token", materialFormat: "token" },
lastTestedAt: null,
lastResolvedAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
@@ -81,6 +81,7 @@ describe("subscription credential routes", () => {
expect(res.status).toBe(200);
expect(res.body).toEqual([REDACTED_CREDENTIAL]);
expect(Object.keys(res.body[0].redactedMetadata).sort()).toEqual(["kind", "materialFormat"]);
expect(mockSvc.list).toHaveBeenCalledWith("company-1", "user-1");
});
@@ -100,20 +100,20 @@ function makeFakeDb(options: {
describe("subscriptionCredentialService", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSecretProvider.createSecret.mockResolvedValue({
mockSecretProvider.createSecret.mockImplementation(async ({ value }: { value: string }) => ({
material: ENCRYPTED_MATERIAL,
valueSha256: sha256Hex("plain-token"),
});
valueSha256: sha256Hex(value),
}));
mockSecretProvider.resolveVersion.mockResolvedValue("plain-token");
});
it("encrypts credential material before persistence and returns only a redacted read model", async () => {
const credentialMaterial = "plain-secret-1234";
const returnedRow = makeCredentialRow({
material: ENCRYPTED_MATERIAL,
redactedMetadata: {
kind: "claude_oauth_token",
materialLength: "plain-token".length,
tokenSuffix: "oken",
materialFormat: "token",
},
});
const { db, state } = makeFakeDb({ insertReturningRow: returnedRow });
@@ -124,23 +124,71 @@ describe("subscriptionCredentialService", () => {
userId: returnedRow.userId,
provider: returnedRow.provider as SubscriptionCredentialProvider,
credentialKind: "claude_oauth_token",
material: "plain-token",
material: credentialMaterial,
});
expect(getSecretProvider).toHaveBeenCalledWith("local_encrypted");
expect(mockSecretProvider.createSecret).toHaveBeenCalledWith({ value: "plain-token" });
expect(mockSecretProvider.createSecret).toHaveBeenCalledWith({ value: credentialMaterial });
expect(state.insertedValues).toMatchObject({
material: ENCRYPTED_MATERIAL,
valueSha256: sha256Hex("plain-token"),
valueSha256: sha256Hex(credentialMaterial),
});
expect(state.insertedValues).not.toMatchObject({ material: "plain-token" });
expect(state.insertedValues).not.toMatchObject({ material: credentialMaterial });
expect(result).toMatchObject({
id: returnedRow.id,
provider: "claude",
credentialKind: "claude_oauth_token",
redactedMetadata: expect.objectContaining({ tokenSuffix: "oken" }),
redactedMetadata: { kind: "claude_oauth_token", materialFormat: "token" },
});
expect(result).not.toHaveProperty("material");
expect(JSON.stringify(result)).not.toContain(credentialMaterial);
expect(JSON.stringify(result)).not.toContain("1234");
expect(state.insertedValues?.redactedMetadata).toEqual({
kind: "claude_oauth_token",
materialFormat: "token",
});
expect(JSON.stringify(state.insertedValues?.redactedMetadata)).not.toContain("1234");
});
it("does not derive JSON key names into redacted metadata", async () => {
const codexJson = JSON.stringify({
accessToken: "access-secret",
refreshToken: "refresh-secret",
accountId: "acct-1",
});
const returnedRow = makeCredentialRow({
provider: "codex",
credentialKind: "codex_auth_json",
redactedMetadata: {
kind: "codex_auth_json",
materialFormat: "json",
},
});
const { db, state } = makeFakeDb({ insertReturningRow: returnedRow });
const svc = subscriptionCredentialService(db);
const result = await svc.upsert({
companyId: returnedRow.companyId,
userId: returnedRow.userId,
provider: "codex",
credentialKind: "codex_auth_json",
material: codexJson,
});
expect(result.redactedMetadata).toEqual({
kind: "codex_auth_json",
materialFormat: "json",
});
expect(state.insertedValues?.redactedMetadata).toEqual({
kind: "codex_auth_json",
materialFormat: "json",
});
const metadataJson = JSON.stringify(state.insertedValues?.redactedMetadata);
expect(metadataJson).not.toContain("accessToken");
expect(metadataJson).not.toContain("refreshToken");
expect(metadataJson).not.toContain("accountId");
expect(metadataJson).not.toContain("access-secret");
expect(metadataJson).not.toContain("refresh-secret");
});
it("resolves decrypted material through the secret provider and records a redacted resolution timestamp", async () => {
@@ -56,29 +56,14 @@ function assertKindMatchesProvider(
}
}
// Build non-sensitive redacted metadata for read models. Only safe hints (no
// secret material) are stored or returned.
function buildRedactedMetadata(
kind: SubscriptionCredentialKind,
material: string,
): Record<string, unknown> {
const length = material.length;
if (kind === "claude_oauth_token") {
const suffix = material.length >= 4 ? material.slice(-4) : null;
return { kind, materialLength: length, tokenSuffix: suffix };
}
// JSON credential documents: surface only the top-level key names so the UI
// can hint at shape without exposing values.
let jsonKeys: string[] | null = null;
try {
const parsed = JSON.parse(material) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
jsonKeys = Object.keys(parsed as Record<string, unknown>).sort();
}
} catch {
jsonKeys = null;
}
return { kind, materialLength: length, jsonKeys };
// Build redacted metadata from the declared credential kind only. Do not derive
// this record from plaintext credential material; even suffixes, lengths, or
// JSON key names are treated as credential-derived data.
function buildRedactedMetadata(kind: SubscriptionCredentialKind): Record<string, unknown> {
return {
kind,
materialFormat: kind === "claude_oauth_token" ? "token" : "json",
};
}
function toReadModel(row: CredentialRow): SubscriptionCredentialReadModel {
@@ -156,7 +141,7 @@ export function subscriptionCredentialService(db: Db) {
const provider = getSecretProvider(ENCRYPTION_PROVIDER);
const prepared = await provider.createSecret({ value: input.material });
const now = new Date();
const redactedMetadata = buildRedactedMetadata(input.credentialKind, input.material);
const redactedMetadata = buildRedactedMetadata(input.credentialKind);
const [row] = await db
.insert(userSubscriptionCredentials)