Skip to content
131 changes: 70 additions & 61 deletions packages/control-plane/src/auth/identity-enforcement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
applyIdentityEnforcement,
deriveIdentity,
mayAttachCallbackContext,
requireEventPoster,
resolveCanonicalUserId,
} from "./identity-enforcement";
import type { Principal, ResolvedIdentity } from "./principal";
Expand All @@ -31,12 +30,17 @@ const SLACK_BOT_PRINCIPAL: Principal = {
};

function createCtx(principal?: Principal): RequestContext {
const statement = {
bind: vi.fn(() => statement),
first: vi.fn(async () => ({ active: 1 })),
};
return {
trace_id: "trace-test",
request_id: "req-test",
principal,
db: { prepare: vi.fn(() => statement) },
executionCtx: TEST_BACKGROUND_TASK_CONTEXT,
} as RequestContext;
} as unknown as RequestContext;
}

function loggedEvents(spy: { mock: { calls: unknown[][] } }): Array<Record<string, unknown>> {
Expand Down Expand Up @@ -93,26 +97,7 @@ describe("applyIdentityEnforcement — identityless principals", () => {
});

describe("applyIdentityEnforcement — forbidden-field rejection", () => {
it("rejects forbidden keys with a 400 naming the field", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const { rejection } = applyIdentityEnforcement(createCtx(USER_PRINCIPAL), "session-lifecycle", {
userId: "someone",
title: "ok",
});
expect(rejection).toBeDefined();
expect(rejection!.status).toBe(400);
expect(((await rejection!.clone().json()) as { error: string }).error).toBe(
"Field 'userId' is not accepted from verified callers"
);
const logged = loggedEvents(warn).find((e) => e.event === "identity.forbidden_field_rejected");
expect(logged).toMatchObject({ route: "session-lifecycle", field: "userId" });
});

it("accepts bodies carrying only permitted fields", () => {
expect(
applyIdentityEnforcement(createCtx(USER_PRINCIPAL), "session-lifecycle", { title: "ok" })
.rejection
).toBeUndefined();
expect(
applyIdentityEnforcement(createCtx(USER_PRINCIPAL), "session-create", {
scmLogin: "ada",
Expand Down Expand Up @@ -194,11 +179,9 @@ describe("applyIdentityEnforcement — requires-user rejection", () => {
});

it("does not gate routes that accept participantless principals", () => {
for (const route of ["prompt", "session-lifecycle"] as const) {
const result = applyIdentityEnforcement(createCtx(ACTORLESS_BOT), route, {});
expect(result.rejection).toBeUndefined();
expect(result.enforced).toMatchObject({ participantUserId: null });
}
const result = applyIdentityEnforcement(createCtx(ACTORLESS_BOT), "prompt", {});
expect(result.rejection).toBeUndefined();
expect(result.enforced).toMatchObject({ participantUserId: null });
});
});

Expand Down Expand Up @@ -241,6 +224,67 @@ describe("resolveCanonicalUserId", () => {
);
});

it("rejects when actor enrichment relinks to a different authorized user", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const ctx = createCtx(SLACK_BOT_PRINCIPAL);
ctx.authorization = {
userId: "canon-provisional",
suspendedAt: null,
permissions: ["sessions.create"],
role: { id: "role-member", key: "member", name: "Member" },
};
const result = await resolveCanonicalUserId(
{
resolveOrCreateUser: vi.fn(async () => ({ id: "canon-existing" })),
} as unknown as UserStore,
ctx,
{
participantUserId: "slack:U0123",
canonicalUserId: null,
actor: SLACK_ACTOR,
spawnSource: "slack-bot",
},
display
);

expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(409);
await expect((result as Response).json()).resolves.toMatchObject({
code: "actor_identity_changed",
});
expect(loggedEvents(warn)).toContainEqual(
expect.objectContaining({
event: "identity.mismatch_rejected",
expected: "canon-provisional",
actual: "canon-existing",
})
);
});

it("rejects a canonical identity whose workspace access is suspended", async () => {
const ctx = createCtx(USER_PRINCIPAL);
const statement = {
bind: vi.fn(() => statement),
first: vi.fn(async () => null),
};
ctx.db = { prepare: vi.fn(() => statement) } as never;

const result = await resolveCanonicalUserId(
{ resolveOrCreateUser: vi.fn() } as unknown as UserStore,
ctx,
{
participantUserId: "canon-1",
canonicalUserId: "canon-1",
actor: null,
spawnSource: "user",
},
display
);

expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(403);
});

it("fails closed with a 500 if a participant ever lacks both a canonical user and an actor", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const userStore = { resolveOrCreateUser: vi.fn() } as unknown as UserStore;
Expand Down Expand Up @@ -296,38 +340,3 @@ describe("mayAttachCallbackContext", () => {
expect(mayAttachCallbackContext(createCtx(undefined))).toBe(false);
});
});

describe("requireEventPoster", () => {
const GITHUB_BOT: Principal = {
kind: "service",
service: "github-bot",
actor: null,
};

it("logs and 401s a mismatched poster", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const rejection = requireEventPoster(createCtx(GITHUB_BOT), "slack");
expect(rejection?.status).toBe(401);
const mismatch = loggedEvents(warn).find((e) => e.event === "identity.mismatch_rejected");
expect(mismatch).toMatchObject({
route: "internal-slack-event",
field: "service",
expected: "slack-bot",
actual: "github-bot",
});
});

it("401s non-service principals — the gate never falls open", () => {
expect(requireEventPoster(createCtx(USER_PRINCIPAL), "slack")?.status).toBe(401);
expect(requireEventPoster(createCtx(undefined), "slack")?.status).toBe(401);
expect(
requireEventPoster(createCtx({ kind: "sandbox", sessionId: "s1" }), "sentry")?.status
).toBe(401);
});

it("passes the matching bot and exempt sources", () => {
expect(requireEventPoster(createCtx(SLACK_BOT_PRINCIPAL), "slack")).toBeNull();
// Sentry events are not bot-posted: explicit exemption for any service.
expect(requireEventPoster(createCtx(GITHUB_BOT), "sentry")).toBeNull();
});
});
73 changes: 35 additions & 38 deletions packages/control-plane/src/auth/identity-enforcement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,13 @@
* can run the steps out of order or skip one.
*/

import type { AutomationEventSource } from "@open-inspect/shared/triggers";
import type { SpawnSource } from "@open-inspect/shared/types/sessions";
import type { ServiceName } from "@open-inspect/shared/service-auth";
import { createLogger } from "./../logger";
import { CALLBACK_DESTINATIONS } from "./service/callback-signing";
import type { Principal, ResolvedIdentity } from "./principal";
import type { UserStore } from "../db/user-store";
import { error, type RequestContext } from "../routes/shared";
import { error, json, type RequestContext } from "../routes/shared";

const logger = createLogger("identity-enforcement");

Expand Down Expand Up @@ -198,7 +197,23 @@ export async function resolveCanonicalUserId(
enforced: DerivedIdentity & { participantUserId: string },
display: { displayName?: string; email?: string; avatarUrl?: string }
): Promise<{ userId: string } | Response> {
if (enforced.canonicalUserId) return { userId: enforced.canonicalUserId };
const requireActive = async (userId: string): Promise<{ userId: string } | Response> => {
try {
const active = await ctx.db
.prepare("SELECT 1 AS active FROM users WHERE id = ? AND suspended_at IS NULL")
.bind(userId)
.first<{ active: number }>();
return active ? { userId } : error("Workspace access is disabled", 403);
} catch (cause) {
logger.error("Failed to verify workspace access", {
error: cause instanceof Error ? cause : String(cause),
request_id: ctx.request_id,
trace_id: ctx.trace_id,
});
return error("Authorization unavailable", 503);
}
};
if (enforced.canonicalUserId) return requireActive(enforced.canonicalUserId);
const actor = enforced.actor;
if (!actor) {
// Unreachable while deriveIdentity holds its invariant (a participant
Expand All @@ -219,7 +234,23 @@ export async function resolveCanonicalUserId(
providerEmail: display.email,
avatarUrl: display.avatarUrl,
});
return { userId: user.id };
if (ctx.authorization && user.id !== ctx.authorization.userId) {
logMismatchRejected(
"actor-resolution",
"canonicalUserId",
ctx.authorization.userId,
user.id,
ctx
);
return json(
{
error: "Actor identity changed; retry the request",
code: "actor_identity_changed",
},
409
);
}
return requireActive(user.id);
} catch (e) {
logger.error("Failed to resolve verified actor identity", {
error: e instanceof Error ? e : String(e),
Expand Down Expand Up @@ -261,37 +292,3 @@ function logMismatchRejected(
trace_id: ctx.trace_id,
});
}

/**
* The bot service allowed to post each normalized automation event source.
* `null` marks sources that are not bot-posted (sentry/webhook arrive on the
* CP's own public webhook surface; linear posts no normalized events today)
* — an explicit exemption, not a missing row.
*/
const EVENT_SOURCE_SERVICE: Record<AutomationEventSource, ServiceName | null> = {
slack: "slack-bot",
github: "github-bot",
linear: null,
sentry: null,
webhook: null,
};

/**
* Gate for the internal normalized automation-event endpoints: the poster
* must be a service principal (401 otherwise), and per-service sources
* accept only the source's own bot. Sources with a null row arrive via the
* CP's own public webhook surface, so any service may forward them.
*/
export function requireEventPoster(
ctx: RequestContext,
source: AutomationEventSource
): Response | null {
const principal = ctx.principal;
if (principal?.kind !== "service") {
return error("Unauthorized", 401);
}
const expected = EVENT_SOURCE_SERVICE[source];
if (expected === null || principal.service === expected) return null;
logMismatchRejected(`internal-${source}-event`, "service", expected, principal.service, ctx);
return error("Unauthorized", 401);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { serviceAllowsPermission } from "./service-permissions";

describe("serviceAllowsPermission", () => {
it("allows launch capabilities but denies management capabilities", () => {
expect(serviceAllowsPermission("slack-bot", "sessions.create")).toBe(true);
expect(serviceAllowsPermission("slack-bot", "global_secrets.manage")).toBe(false);
expect(serviceAllowsPermission("github-bot", "sessions.sandbox_access")).toBe(false);
});
});
45 changes: 45 additions & 0 deletions packages/control-plane/src/authorization/service-permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { PermissionId } from "@open-inspect/shared/rbac";
import type { ServiceName } from "@open-inspect/shared/service-auth";

const SERVICE_PERMISSION_CEILINGS: Record<ServiceName, readonly PermissionId[]> = {
web: [],
"github-bot": [
"repositories.read",
"repositories.use",
"environments.read",
"environments.use",
"sessions.create",
"sessions.read",
"sessions.collaborate",
"sessions.lifecycle",
"skills.read",
],
"slack-bot": [
"repositories.read",
"repositories.use",
"environments.read",
"environments.use",
"sessions.create",
"sessions.read",
"sessions.collaborate",
"sessions.lifecycle",
"sessions.sandbox_access",
"skills.read",
],
"linear-bot": [
"repositories.read",
"repositories.use",
"environments.read",
"environments.use",
"sessions.create",
"sessions.read",
"sessions.collaborate",
"sessions.lifecycle",
"skills.read",
],
};

/** Checks the hard permission ceiling for a trusted service, independent of user grants. */
export function serviceAllowsPermission(service: ServiceName, permission: PermissionId): boolean {
return SERVICE_PERMISSION_CEILINGS[service].includes(permission);
}
21 changes: 4 additions & 17 deletions packages/control-plane/src/router.analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe("analytics router integration", () => {
vi.clearAllMocks();
});

it("serves analytics routes even when the SCM provider is not github", async () => {
it("does not let an actorless service read analytics", async () => {
mockStore.getSummary.mockResolvedValue({
totalSessions: 1,
activeUsers: 1,
Expand Down Expand Up @@ -63,21 +63,8 @@ describe("analytics router integration", () => {
TEST_BACKGROUND_TASK_CONTEXT
);

expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
totalSessions: 1,
activeUsers: 1,
totalCost: 0,
avgCost: 0,
totalPrs: 0,
statusBreakdown: {
created: 1,
active: 0,
completed: 0,
failed: 0,
archived: 0,
cancelled: 0,
},
});
expect(response.status).toBe(403);
await expect(response.json()).resolves.toMatchObject({ code: "service_actor_required" });
expect(mockStore.getSummary).not.toHaveBeenCalled();
});
});
Loading
Loading