Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/src/routes/v2/v2-test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ export const ConfigResourceServiceStubsLayer = Layer.mergeAll(
}),
Layer.succeed(OrgIngestKeysService, {
getOrCreate: die,
getSessionSalt: die,
rerollPublic: die,
rerollPrivate: die,
resolveIngestKey: die,
Expand Down
206 changes: 201 additions & 5 deletions apps/api/src/services/org/OrgIngestKeysService.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { afterEach, assert, describe, it } from "@effect/vitest"
import { Cause, ConfigProvider, Effect, Exit, Layer, Option, Schema } from "effect"
import { Cause, ConfigProvider, Effect, Exit, Layer, Option, Redacted, Schema } from "effect"
import { IngestKeyEncryptionError, IngestKeyPersistenceError, OrgId, UserId } from "@maple/domain/http"
import { hashIngestKey } from "@maple/db"
import { Database, DatabaseError } from "@/platform/DatabaseLive"
import { encryptAes256Gcm } from "@/platform/Crypto"
import { type DatabaseClient, Database, DatabaseError } from "@/platform/DatabaseLive"
import { Env } from "@/platform/Env"
import { OrgIngestKeysService } from "./OrgIngestKeysService"
import { cleanupTestDbs, createTestDb, queryFirstRow, type TestDb } from "@/platform/test-pglite"
import { cleanupTestDbs, createTestDb, executeSql, queryFirstRow, type TestDb } from "@/platform/test-pglite"

// A Database layer that builds successfully (so migrations are never attempted)
// but fails every query, exercising the service's `mapError(toPersistenceError)`
Expand Down Expand Up @@ -49,16 +50,75 @@ const makeConfig = (encryptionKey?: string) =>
}),
)

const makeLayer = (testDb: TestDb, encryptionKey = Buffer.alloc(32, 7).toString("base64")) =>
const makeLayerFrom = (
databaseLayer: Layer.Layer<Database>,
encryptionKey = Buffer.alloc(32, 7).toString("base64"),
) =>
OrgIngestKeysService.layer.pipe(
Layer.provide(testDb.layer),
Layer.provide(databaseLayer),
Layer.provide(Env.layer),
Layer.provide(makeConfig(encryptionKey)),
)

const makeLayer = (testDb: TestDb, encryptionKey = Buffer.alloc(32, 7).toString("base64")) =>
makeLayerFrom(testDb.layer, encryptionKey)

const asOrgId = Schema.decodeUnknownSync(OrgId)
const asUserId = Schema.decodeUnknownSync(UserId)

const TEST_ENCRYPTION_KEY = Buffer.alloc(32, 7)

interface SaltColumns {
session_salt_ciphertext: string | null
session_salt_iv: string | null
session_salt_tag: string | null
}

const readSaltColumns = (testDb: TestDb, orgId = "org_a") =>
Effect.promise(() =>
queryFirstRow<SaltColumns>(
testDb,
"SELECT session_salt_ciphertext, session_salt_iv, session_salt_tag FROM org_ingest_keys WHERE org_id = $1",
[orgId],
),
)

/** Puts an existing row back into the pre-migration shape the backfill targets. */
const clearSalt = (testDb: TestDb, orgId = "org_a") =>
Effect.promise(() =>
executeSql(
testDb,
"UPDATE org_ingest_keys SET session_salt_ciphertext = NULL, session_salt_iv = NULL, session_salt_tag = NULL WHERE org_id = $1",
[orgId],
),
)

/**
* Wraps the test Database so a competing writer deterministically WINS the
* backfill race. `getSessionSalt` over a saltless row issues exactly three
* statements — select, conditional update, re-read — so firing the interloper
* just before the second one guarantees the service's own
* `WHERE session_salt_ciphertext IS NULL` update matches zero rows. Racing two
* real fibers would only exercise this branch by scheduling luck.
*/
const racingDatabaseLayer = (testDb: TestDb, interlope: () => Promise<void>) =>
Layer.effect(
Database,
Effect.gen(function* () {
const base = yield* Database
let calls = 0

return Database.of({
execute: <T>(fn: (db: DatabaseClient) => Promise<T>) =>
Effect.gen(function* () {
calls += 1
if (calls === 2) yield* Effect.promise(interlope)
return yield* base.execute(fn)
}),
})
}),
).pipe(Layer.provide(testDb.layer))

describe("OrgIngestKeysService", () => {
it.effect("lazily creates keys for a new org", () => {
const testDb = createTestDb(trackedDbs)
Expand Down Expand Up @@ -266,6 +326,142 @@ describe("OrgIngestKeysService", () => {
}),
)

it.effect("provisions an encrypted session salt when creating keys", () => {
const testDb = createTestDb(trackedDbs)

return Effect.gen(function* () {
yield* OrgIngestKeysService.getOrCreate(asOrgId("org_a"), asUserId("user_a"))

const row = yield* readSaltColumns(testDb)
const salt = yield* OrgIngestKeysService.getSessionSalt(asOrgId("org_a"), asUserId("user_a"))

assert.isTrue(Boolean(row?.session_salt_ciphertext))
assert.isTrue(Boolean(row?.session_salt_iv))
assert.isTrue(Boolean(row?.session_salt_tag))
// 32 random bytes, base64url — and the plaintext is never what is stored.
assert.strictEqual(Buffer.from(Redacted.value(salt), "base64url").length, 32)
assert.notStrictEqual(Redacted.value(salt), row?.session_salt_ciphertext)
}).pipe(Effect.provide(makeLayer(testDb)))
})

it.effect("keeps session salts distinct per org and stable across reads", () => {
const testDb = createTestDb(trackedDbs)

return Effect.gen(function* () {
const first = yield* OrgIngestKeysService.getSessionSalt(asOrgId("org_a"), asUserId("user_a"))
const again = yield* OrgIngestKeysService.getSessionSalt(asOrgId("org_a"), asUserId("user_a"))
const other = yield* OrgIngestKeysService.getSessionSalt(asOrgId("org_b"), asUserId("user_b"))

assert.strictEqual(Redacted.value(again), Redacted.value(first))
assert.notStrictEqual(Redacted.value(other), Redacted.value(first))
}).pipe(Effect.provide(makeLayer(testDb)))
})

it.effect("survives a reroll of either key", () => {
const testDb = createTestDb(trackedDbs)

return Effect.gen(function* () {
const before = yield* OrgIngestKeysService.getSessionSalt(asOrgId("org_a"), asUserId("user_a"))
yield* OrgIngestKeysService.rerollPublic(asOrgId("org_a"), asUserId("user_a"))
yield* OrgIngestKeysService.rerollPrivate(asOrgId("org_a"), asUserId("user_a"))
const after = yield* OrgIngestKeysService.getSessionSalt(asOrgId("org_a"), asUserId("user_a"))

// Rerolling a key must not re-salt: that would break session-count
// continuity for an unrelated operation.
assert.strictEqual(Redacted.value(after), Redacted.value(before))
}).pipe(Effect.provide(makeLayer(testDb)))
})

it.effect("lazily backfills the salt for rows created before the column existed", () => {
const testDb = createTestDb(trackedDbs)

return Effect.gen(function* () {
yield* OrgIngestKeysService.getOrCreate(asOrgId("org_a"), asUserId("user_a")).pipe(
Effect.provide(makeLayer(testDb)),
)
yield* clearSalt(testDb)
const cleared = yield* readSaltColumns(testDb)

// Fresh instance: an empty memo is what a saltless row actually meets.
const salt = yield* OrgIngestKeysService.getSessionSalt(
asOrgId("org_a"),
asUserId("user_a"),
).pipe(Effect.provide(makeLayer(testDb)))
const filled = yield* readSaltColumns(testDb)

assert.isNull(cleared?.session_salt_ciphertext)
assert.isTrue(Boolean(filled?.session_salt_ciphertext))
assert.isTrue(Boolean(filled?.session_salt_iv))
assert.isTrue(Boolean(filled?.session_salt_tag))
assert.strictEqual(Buffer.from(Redacted.value(salt), "base64url").length, 32)
})
})

it.effect("hands concurrent readers the same salt when backfilling", () => {
const testDb = createTestDb(trackedDbs)

return Effect.gen(function* () {
yield* OrgIngestKeysService.getOrCreate(asOrgId("org_a"), asUserId("user_a")).pipe(
Effect.provide(makeLayer(testDb)),
)
yield* clearSalt(testDb)

// Two independent service instances (separate memos), as two isolates
// hitting the same row would be.
const [saltA, saltB] = yield* Effect.all(
[
OrgIngestKeysService.getSessionSalt(asOrgId("org_a"), asUserId("user_a")).pipe(
Effect.provide(makeLayer(testDb)),
),
OrgIngestKeysService.getSessionSalt(asOrgId("org_a"), asUserId("user_b")).pipe(
Effect.provide(makeLayer(testDb)),
),
],
{ concurrency: "unbounded" },
)
const stored = yield* OrgIngestKeysService.getSessionSalt(
asOrgId("org_a"),
asUserId("user_a"),
).pipe(Effect.provide(makeLayer(testDb)))

assert.strictEqual(Redacted.value(saltB), Redacted.value(saltA))
assert.strictEqual(Redacted.value(stored), Redacted.value(saltA))
})
})

it.effect("adopts the winner's salt when it loses the backfill race", () => {
const testDb = createTestDb(trackedDbs)
const winnerSalt = Buffer.alloc(32, 9).toString("base64url")
const encrypted = Effect.runSync(
encryptAes256Gcm(winnerSalt, TEST_ENCRYPTION_KEY, (message) => new Error(message)),
)

return Effect.gen(function* () {
yield* OrgIngestKeysService.getOrCreate(asOrgId("org_a"), asUserId("user_a")).pipe(
Effect.provide(makeLayer(testDb)),
)
yield* clearSalt(testDb)

const interlope = () =>
executeSql(
testDb,
"UPDATE org_ingest_keys SET session_salt_ciphertext = $1, session_salt_iv = $2, session_salt_tag = $3 WHERE org_id = $4",
[encrypted.ciphertext, encrypted.iv, encrypted.tag, "org_a"],
)

const salt = yield* OrgIngestKeysService.getSessionSalt(
asOrgId("org_a"),
asUserId("user_a"),
).pipe(Effect.provide(makeLayerFrom(racingDatabaseLayer(testDb, interlope))))
const stored = yield* readSaltColumns(testDb)

// The loser must discard its own generated salt entirely — both the value
// it returns and the value left in the row are the winner's.
assert.strictEqual(Redacted.value(salt), winnerSalt)
assert.strictEqual(stored?.session_salt_ciphertext, encrypted.ciphertext)
})
})

it.effect("maps database errors to IngestKeyPersistenceError", () =>
Effect.gen(function* () {
const layer = OrgIngestKeysService.layer.pipe(
Expand Down
Loading
Loading