Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ function controlledConcurrentTransaction(): IdentityCreationTransaction {

describe("resolveOrCreateExternalIdentity()", () => {
it("atomically creates a user, immutable external identity, and success audit", async () => {
const result = await resolveOrCreateExternalIdentity(tuple, dependencies())
const acceptedTuple = { ...tuple, provider: "secret:workforce" }
const result = await resolveOrCreateExternalIdentity(acceptedTuple, dependencies())

expect(result.status).toBe("created")
await expect(prisma.user.findMany()).resolves.toEqual([
Expand All @@ -62,15 +63,15 @@ describe("resolveOrCreateExternalIdentity()", () => {
expect.objectContaining({
id: result.externalIdentityId,
userId: result.userId,
...tuple,
...acceptedTuple,
}),
])
await expect(prisma.securityAudit.findMany()).resolves.toEqual([
expect.objectContaining({
action: "identity.external.created",
outcome: "SUCCESS",
targetId: result.externalIdentityId,
context: { provider: tuple.provider, issuer: tuple.issuer },
context: { provider: acceptedTuple.provider, issuer: acceptedTuple.issuer },
}),
])
})
Expand Down Expand Up @@ -145,6 +146,7 @@ describe("resolveOrCreateExternalIdentity()", () => {
{ ...tuple, id: randomUUID() },
{ ...tuple, userId: victimUserId },
{ ...tuple, provider: { value: tuple.provider } },
{ ...tuple, provider: "provider key" },
new IdentityInput(),
]) {
await expect(resolveOrCreateExternalIdentity(input, dependencies())).rejects.toThrow()
Expand Down Expand Up @@ -177,6 +179,7 @@ describe("resolveOrCreateExternalIdentity()", () => {
})

it("rolls back identity state when the final success audit insert fails", async () => {
const acceptedTuple = { ...tuple, provider: "secret:workforce" }
const rawErrorMarker = "raw-success-audit-error-marker"
const base = dependencies()
async function runTransaction<Result>(
Expand All @@ -191,16 +194,16 @@ describe("resolveOrCreateExternalIdentity()", () => {
}

await expect(
resolveOrCreateExternalIdentity(tuple, { ...base, runTransaction }),
resolveOrCreateExternalIdentity(acceptedTuple, { ...base, runTransaction }),
).rejects.toThrow(rawErrorMarker)
await expect(prisma.user.count()).resolves.toBe(0)
await expect(prisma.externalIdentity.count()).resolves.toBe(0)
const audits = await prisma.securityAudit.findMany()
expect(audits).toHaveLength(1)
expect(audits[0]?.action).toBe("identity.external.create_failed")
expect(audits[0]?.context).toEqual({
issuer: tuple.issuer,
provider: tuple.provider,
issuer: acceptedTuple.issuer,
provider: acceptedTuple.provider,
reason: "transaction_error",
})
expect(JSON.stringify(audits)).not.toContain(rawErrorMarker)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,23 @@ describe("validateAndPersistProviderRevision()", () => {
})

it("uses the same opaque provider key for provider state and typed audits", async () => {
const providerKey = "GitHub.App/V2"
const providerKey = "secret:workforce"

const result = await validateAndPersistProviderRevision(
{ ...input(1), providerKey },
dependencies(),
)

await expect(
prisma.authenticationProvider.findUnique({ where: { id: result.providerId } }),
).resolves.toMatchObject({ key: providerKey })
prisma.authenticationProvider.findUnique({
where: { id: result.providerId },
include: { selectedRevision: true },
}),
).resolves.toMatchObject({
key: providerKey,
selectedRevision: { id: result.revisionId, revision: 1 },
})
await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(1)
await expect(
prisma.securityAudit.findFirst({
where: { action: "identity.provider_revision.selected" },
Expand All @@ -136,6 +143,7 @@ describe("validateAndPersistProviderRevision()", () => {
),
).rejects.toThrow()
await expect(prisma.authenticationProvider.count()).resolves.toBe(1)
await expect(prisma.securityAudit.count()).resolves.toBe(1)
})

it("persists no validated configuration value beyond its digest", async () => {
Expand Down Expand Up @@ -332,7 +340,11 @@ describe("validateAndPersistProviderRevision()", () => {
})

it("rolls back revision selection when the final success audit insert fails", async () => {
const first = await validateAndPersistProviderRevision(input(1), dependencies())
const providerKey = "secret:workforce"
const first = await validateAndPersistProviderRevision(
{ ...input(1), providerKey },
dependencies(),
)
const rawErrorMarker = "raw-provider-success-audit-error-marker"
const base = dependencies()
async function runTransaction<Result>(
Expand All @@ -347,7 +359,7 @@ describe("validateAndPersistProviderRevision()", () => {
}

await expect(
validateAndPersistProviderRevision(input(2), { ...base, runTransaction }),
validateAndPersistProviderRevision({ ...input(2), providerKey }, { ...base, runTransaction }),
).rejects.toThrow(rawErrorMarker)
await expect(prisma.authenticationProviderRevision.count()).resolves.toBe(1)
await expect(
Expand All @@ -360,7 +372,7 @@ describe("validateAndPersistProviderRevision()", () => {
expect(failureAudits).toHaveLength(1)
expect(failureAudits[0]?.action).toBe("identity.provider_revision.persist_failed")
expect(failureAudits[0]?.context).toEqual({
provider: "workforce",
provider: providerKey,
reason: "transaction_error",
revision: 2,
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest"

import { parseProviderKey } from "../../../utils/parse-provider-key"
import { createSecurityAuditContext, createSecurityAuditContextWithProviderKey } from "."

describe("createSecurityAuditContext()", () => {
it.each([
"secret:workforce",
"Bearer opaque-value",
["-----BEGIN PRIVATE", " KEY-----"].join(""),
])("rejects the untyped credential-shaped value %s", (value) => {
expect(() => createSecurityAuditContext({ value })).toThrow(
"cannot contain credential or key material",
)
})

it("rejects an untyped client secret field", () => {
expect(() => createSecurityAuditContext({ clientSecret: "opaque-value" })).toThrow(
"cannot contain secret-bearing fields",
)
})

it("retains generic context string, entry, and byte bounds", () => {
expect(() => createSecurityAuditContext({ value: "a".repeat(501) })).toThrow(
"cannot exceed 500 characters",
)
expect(() =>
createSecurityAuditContext({
first: 1,
second: 2,
third: 3,
fourth: 4,
fifth: 5,
sixth: 6,
seventh: 7,
eighth: 8,
ninth: 9,
}),
).toThrow("cannot exceed 8 fields")
expect(() =>
createSecurityAuditContext({
first: "界".repeat(500),
second: "界".repeat(500),
third: "界".repeat(500),
fourth: "界".repeat(500),
fifth: "界".repeat(500),
sixth: "界".repeat(500),
seventh: "界".repeat(500),
eighth: "界".repeat(500),
}),
).toThrow("cannot exceed 4096 bytes")
})

it("accepts 4096 restored bytes and rejects 4097 after placeholder validation", () => {
const provider = parseProviderKey("界".repeat(100))
const atLimit = {
first: `${"界".repeat(116)}${"a".repeat(352)}`,
second: "a".repeat(500),
third: "a".repeat(500),
fourth: "a".repeat(500),
fifth: "a".repeat(500),
sixth: "a".repeat(500),
seventh: "a".repeat(500),
provider,
}
const overLimit = {
...atLimit,
first: `${"界".repeat(116)}é${"a".repeat(351)}`,
}
const placeholder = { ...atLimit, provider: "x".repeat(provider.length) }
const overLimitPlaceholder = {
...overLimit,
provider: "x".repeat(provider.length),
}

expect(Buffer.byteLength(JSON.stringify(placeholder), "utf8")).toBe(3_896)
expect(Buffer.byteLength(JSON.stringify(overLimitPlaceholder), "utf8")).toBe(3_897)
expect(Buffer.byteLength(JSON.stringify(atLimit), "utf8")).toBe(4_096)
expect(Buffer.byteLength(JSON.stringify(overLimit), "utf8")).toBe(4_097)
expect(createSecurityAuditContext(overLimitPlaceholder)).toEqual(overLimitPlaceholder)
expect(createSecurityAuditContextWithProviderKey(atLimit)).toEqual(atLimit)
expect(() => createSecurityAuditContextWithProviderKey(overLimit)).toThrow(
"cannot exceed 4096 bytes",
)
})

it.each([
["issuer", ["Bear", "er opaque-value"].join("")],
["reason", ["-----BEGIN ", "PRIVATE KEY-----"].join("")],
["metadata", ["client-", "secret:opaque-value"].join("")],
])("rejects credential-shaped %s outside the trusted provider field", (key, value) => {
expect(() =>
createSecurityAuditContextWithProviderKey({
provider: parseProviderKey("secret:workforce"),
[key]: value,
}),
).toThrow("cannot contain credential or key material")
})

it("accepts safe ordinary metadata beside the trusted provider field", () => {
expect(
createSecurityAuditContextWithProviderKey({
provider: parseProviderKey("secret:workforce"),
issuer: "https://id.example.test",
reason: "transaction_error",
revision: 2,
retryable: false,
detail: null,
}),
).toEqual({
detail: null,
issuer: "https://id.example.test",
provider: "secret:workforce",
reason: "transaction_error",
retryable: false,
revision: 2,
})
})
})
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
import { normalizeBoundedJson } from "../../../utils/normalize-bounded-json/index.ts"
import type { ProviderKey } from "../../../utils/parse-provider-key/index.ts"

export function createSecurityAuditContext(
value: Record<string, boolean | number | string | null>,
type SecurityAuditContextValue = boolean | number | string | null

const limits = {
maxArrayItems: 0,
maxBytes: 4_096,
maxDepth: 1,
maxEntries: 8,
maxKeyLength: 100,
maxObjectFields: 8,
maxStringLength: 500,
}

function assertContextByteLimit(value: object) {
if (Buffer.byteLength(JSON.stringify(value), "utf8") > limits.maxBytes) {
throw new Error(`JSON data cannot exceed ${limits.maxBytes} bytes.`)
}
}

export function createSecurityAuditContext(value: Record<string, SecurityAuditContextValue>) {
return normalizeBoundedJson(value, limits)
}

export function createSecurityAuditContextWithProviderKey(
value: Record<string, SecurityAuditContextValue> & { provider: ProviderKey },
) {
return normalizeBoundedJson(value, {
maxArrayItems: 0,
maxBytes: 4_096,
maxDepth: 1,
maxEntries: 8,
maxKeyLength: 100,
maxObjectFields: 8,
maxStringLength: 500,
})
const normalized = normalizeBoundedJson(
{
...value,
provider: "x".repeat(value.provider.length),
},
limits,
)
const context = { ...normalized, provider: value.provider }
assertContextByteLimit(context)
return context
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ import type { Prisma } from "../../../generated/prisma/client"
import { createSecurityAudit } from "."

describe("createSecurityAudit()", () => {
it.each(["secret:workforce", "GitHub.App/V2", "Entra:Tenant", "custom+提供者"])(
"stores the exact canonical provider key %s through the typed audit mapping",
async (providerKey) => {
const create = vi.fn<(input: { data: Prisma.SecurityAuditCreateInput }) => Promise<unknown>>()
create.mockResolvedValue(undefined)

await createSecurityAudit(
{ securityAudit: { create } },
{
id: "5dd31031-6f1c-4c16-87c2-aee5342145cc",
type: "provider_revision_selected",
providerId: "df75faa4-ed4c-44ed-8db0-443cc3b426ed",
providerKey,
revision: 1,
},
)

expect(create).toHaveBeenCalledOnce()
expect(create.mock.calls[0]?.[0].data.context).toEqual({
provider: providerKey,
revision: 1,
})
},
)

it("maps each kernel event to its allowlisted audit record", async () => {
const create = vi.fn<(input: { data: Prisma.SecurityAuditCreateInput }) => Promise<unknown>>()
create.mockResolvedValue(undefined)
Expand Down
Loading
Loading