Skip to content
58 changes: 33 additions & 25 deletions packages/control-plane/src/auth/identity-enforcement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,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 +98,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 +180,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 +225,30 @@ describe("resolveCanonicalUserId", () => {
);
});

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
20 changes: 18 additions & 2 deletions packages/control-plane/src/auth/identity-enforcement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,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 +235,7 @@ export async function resolveCanonicalUserId(
providerEmail: display.email,
avatarUrl: display.avatarUrl,
});
return { userId: user.id };
return requireActive(user.id);
} catch (e) {
logger.error("Failed to resolve verified actor identity", {
error: e instanceof Error ? e : String(e),
Expand Down
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