Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e0a0c70
feat: add RBAC contracts and persistence
ColeMurray Aug 31, 2026
46b620d
feat: enforce workspace permissions at the HTTP boundary
ColeMurray Aug 31, 2026
68789f0
feat: enforce session authorization and revoke stale sockets
ColeMurray Aug 31, 2026
9c9403d
feat: enforce automation ownership and execution authority
ColeMurray Aug 31, 2026
4866d41
fix(rbac): address foundation review feedback
ColeMurray Aug 31, 2026
ab4dc1b
Merge branch 'rbac-foundation' into rbac-http-enforcement
ColeMurray Aug 31, 2026
34e6d03
Merge branch 'rbac-http-enforcement' into rbac-session-authorization
ColeMurray Aug 31, 2026
ba3774c
Merge branch 'rbac-session-authorization' into rbac-automation-author…
ColeMurray Aug 31, 2026
675a554
fix(rbac): preserve merge batch result contract
ColeMurray Aug 31, 2026
cf5e5e5
Merge branch 'rbac-foundation' into rbac-http-enforcement
ColeMurray Aug 31, 2026
0c33bb8
Merge branch 'rbac-http-enforcement' into rbac-session-authorization
ColeMurray Aug 31, 2026
2644c82
Merge branch 'rbac-session-authorization' into rbac-automation-author…
ColeMurray Aug 31, 2026
f799ffa
Merge main into rbac-session-authorization
ColeMurray Aug 31, 2026
08afe2b
fix: harden websocket authorization lifecycle
ColeMurray Aug 31, 2026
369fbbd
Merge branch 'rbac-session-authorization' into rbac-automation-author…
ColeMurray Aug 31, 2026
7792a9c
fix: address automation authorization review feedback
ColeMurray Aug 31, 2026
5ad2071
Merge remote-tracking branch 'origin/main' into HEAD
ColeMurray Aug 31, 2026
d55b160
test: add automation owner ids to web fixtures
ColeMurray Aug 31, 2026
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
70 changes: 70 additions & 0 deletions packages/control-plane/src/automation/authorization-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import type { SqlDatabase } from "../db/sql-database";
import { isAutomationExecutionAuthorized, isPrincipalAuthorized } from "./authorization-guard";

function recordingDb(): { db: SqlDatabase; bindings: unknown[][]; queries: string[] } {
const bindings: unknown[][] = [];
const queries: string[] = [];
const statement = {
bind(...values: unknown[]) {
bindings.push(values);
return statement;
},
first: async () => ({ authorized: 1 }),
};
return {
db: {
prepare: (query: string) => {
queries.push(query);
return statement;
},
} as unknown as SqlDatabase,
bindings,
queries,
};
}

describe("automation execution authorization", () => {
it("queries owner and target-use permissions with stable bindings", async () => {
const { db, bindings, queries } = recordingDb();

await expect(
isAutomationExecutionAuthorized(db, {
automationId: "automation-1",
requiresRepositoryUse: true,
requiresEnvironmentUse: true,
})
).resolves.toBe(true);

expect(bindings).toHaveLength(1);
expect(bindings[0]?.[0]).toBe("automation-1");
expect(queries[0]).toContain("a.id = ? AND a.deleted_at IS NULL");
expect(queries[0]).not.toContain("automation_repositories");
expect(queries[0]).not.toContain("automation_environments");
});

it("authorizes an explicit execution user instead of the stored owner", async () => {
const { db, bindings, queries } = recordingDb();

await expect(
isAutomationExecutionAuthorized(db, {
automationId: "automation-1",
executionUserId: "requester-1",
requiresRepositoryUse: false,
requiresEnvironmentUse: false,
})
).resolves.toBe(true);

expect(bindings[0]?.slice(0, 2)).toEqual(["requester-1", "automation-1"]);
expect(queries[0]).toContain("JOIN users u ON u.id = ?");
});

it("authorizes collaboration without requiring automation launch permissions", async () => {
const { db, bindings, queries } = recordingDb();

await expect(isPrincipalAuthorized(db, "actor-1", "sessions.collaborate")).resolves.toBe(true);

expect(bindings[0]?.[0]).toBe("actor-1");
expect(queries[0]).not.toContain("automations");
});
});
90 changes: 90 additions & 0 deletions packages/control-plane/src/automation/authorization-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { type PermissionId } from "@open-inspect/shared/rbac";
import { rolePermissionPredicate } from "../authorization/permission-sql";
import type { SqlDatabase } from "../db/sql-database";

interface SqlPredicate {
sql: string;
values: readonly unknown[];
}

/** Immutable execution requirements derived from the targets selected for one firing. */
export interface AutomationExecutionAuthorizationRequest {
automationId: string;
executionUserId?: string;
requiresRepositoryUse: boolean;
requiresEnvironmentUse: boolean;
}

function executionPredicate(request: AutomationExecutionAuthorizationRequest): SqlPredicate {
const createGuard = rolePermissionPredicate("sessions.create");
const repositoryGuard = rolePermissionPredicate("repositories.use");
const environmentGuard = rolePermissionPredicate("environments.use");
return {
sql: `EXISTS (
SELECT 1 FROM automations a
JOIN users u ON u.id = ${request.executionUserId ? "?" : "a.user_id"}
JOIN user_role_assignments ura ON ura.user_id = u.id
JOIN roles r ON r.id = ura.role_id
WHERE a.id = ? AND a.deleted_at IS NULL AND u.suspended_at IS NULL
AND ${createGuard.sql}
${request.requiresRepositoryUse ? `AND ${repositoryGuard.sql}` : ""}
${request.requiresEnvironmentUse ? `AND ${environmentGuard.sql}` : ""}
)`,
values: [
...(request.executionUserId ? [request.executionUserId] : []),
request.automationId,
...createGuard.values,
...(request.requiresRepositoryUse ? repositoryGuard.values : []),
...(request.requiresEnvironmentUse ? environmentGuard.values : []),
],
};
}

function principalPredicate(userId: string, permission: PermissionId): SqlPredicate {
const permissionGuard = rolePermissionPredicate(permission);
return {
sql: `EXISTS (
SELECT 1 FROM users u
JOIN user_role_assignments ura ON ura.user_id = u.id
JOIN roles r ON r.id = ura.role_id
WHERE u.id = ? AND u.suspended_at IS NULL AND ${permissionGuard.sql}
)`,
values: [userId, ...permissionGuard.values],
};
}

/**
* Revalidates that an automation's execution principal may create its session and use its targets.
*
* The caller derives repository/environment requirements from the immutable target selection that
* will execute, so a concurrent edit to the automation tables cannot weaken this decision. Missing
* users, roles, automations, or suspended users fail closed.
*
* This does not decide whether a caller may manage or manually trigger the automation. The route's
* ownership-scoped authorization performs that admission before execution begins.
*/
export async function isAutomationExecutionAuthorized(
db: SqlDatabase,
request: AutomationExecutionAuthorizationRequest
): Promise<boolean> {
const predicate = executionPredicate(request);
const row = await db
.prepare(`SELECT CASE WHEN (${predicate.sql}) THEN 1 ELSE 0 END AS authorized`)
.bind(...predicate.values)
.first<{ authorized: number }>();
return row?.authorized === 1;
}

/** Check one canonical principal for a permission without imposing automation-launch grants. */
export async function isPrincipalAuthorized(
db: SqlDatabase,
userId: string,
permission: PermissionId
): Promise<boolean> {
const predicate = principalPredicate(userId, permission);
const row = await db
.prepare(`SELECT CASE WHEN (${predicate.sql}) THEN 1 ELSE 0 END AS authorized`)
.bind(...predicate.values)
.first<{ authorized: number }>();
return row?.authorized === 1;
}
7 changes: 5 additions & 2 deletions packages/control-plane/src/db/automation-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ const sampleRow: AutomationRow = {
next_run_at: now + 86400000,
consecutive_failures: 0,
created_by: "user-1",
user_id: null,
user_id: "11111111111111111111111111111111",
created_at: now,
updated_at: now,
deleted_at: null,
Expand Down Expand Up @@ -152,6 +152,7 @@ describe("toAutomation", () => {
expect(automation.triggerConfig).toBeNull();
expect(automation.consecutiveFailures).toBe(0);
expect(automation.createdBy).toBe("user-1");
expect(automation.userId).toBe("11111111111111111111111111111111");
expect(automation.environmentIds).toEqual([]);
});

Expand Down Expand Up @@ -497,7 +498,9 @@ describe("AutomationStore", () => {
advanceSchedule: { fromSlot: now, nextRunAt: now + 60_000 },
});

const advance = statements.at(-1)!;
const advance = statements.find((statement) =>
statement.sql.includes("SET next_run_at = ?")
)!;
// Compare-and-set on the claimed slot, not a monotonic timestamp guard:
// "any later value wins" lets a loser advance again from the winner's
// successor and skip a slot entirely.
Expand Down
111 changes: 94 additions & 17 deletions packages/control-plane/src/db/automation-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from "./automation-model-provider-auth";
import type { SqlDatabase, SqlStatement } from "./sql-database";
import type { AutomationListCursor } from "./automation-list-cursor";
import { UserStore } from "./user-store";

function escapeLikePattern(value: string): string {
return value.replace(/[\\%_]/g, "\\$&");
Expand Down Expand Up @@ -206,6 +207,7 @@ export function toAutomation(
nextRunAt: row.next_run_at,
consecutiveFailures: row.consecutive_failures,
createdBy: row.created_by,
userId: row.user_id,
createdAt: row.created_at,
updatedAt: row.updated_at,
deletedAt: row.deleted_at,
Expand Down Expand Up @@ -314,6 +316,7 @@ function toAutomationInvocation(

// ─── Store ───────────────────────────────────────────────────────────────────

/** Persists automations, invocations, runs, and composable lifecycle mutations. */
export class AutomationStore {
constructor(private readonly db: SqlDatabase) {}

Expand Down Expand Up @@ -367,6 +370,29 @@ export class AutomationStore {
.first<AutomationRow>();
}

/**
* Repair a legacy SCM-only owner with a compare-and-set and return the canonical row.
* Every ownership admission path calls this before comparing `user_id`, so repair is
* a storage invariant rather than a side effect of starting an invocation.
*/
async resolveCanonicalOwner(automation: AutomationRow): Promise<AutomationRow> {
if (automation.user_id || !automation.created_by || automation.created_by === "anonymous") {
return automation;
}

const identity = await new UserStore(this.db).getIdentity("github", automation.created_by);
if (!identity) return automation;

const result = await this.db
.prepare("UPDATE automations SET user_id = ? WHERE id = ? AND user_id IS NULL")
.bind(identity.userId, automation.id)
.run();
if ((result.meta?.changes ?? 0) > 0) {
return { ...automation, user_id: identity.userId };
}
return (await this.getById(automation.id)) ?? automation;
}

async list(options: {
limit: number;
cursor?: AutomationListCursor | null;
Expand Down Expand Up @@ -512,36 +538,48 @@ export class AutomationStore {
return this.getById(id);
}

async softDelete(id: string): Promise<boolean> {
const now = Date.now();
const result = await this.db
/** Build a soft-delete statement for composition in an atomic batch. */
bindSoftDelete(id: string, now = Date.now()): SqlStatement {
return this.db
.prepare(
"UPDATE automations SET deleted_at = ?, next_run_at = NULL, updated_at = ? WHERE id = ? AND deleted_at IS NULL"
)
.bind(now, now, id)
.run();
.bind(now, now, id);
}

/** Soft-delete an automation and report whether a live row changed. */
async softDelete(id: string): Promise<boolean> {
const result = await this.bindSoftDelete(id).run();
return (result.meta?.changes ?? 0) > 0;
}

async pause(id: string): Promise<boolean> {
const now = Date.now();
const result = await this.db
/** Build a pause statement for composition in an atomic batch. */
bindPause(id: string, now = Date.now()): SqlStatement {
return this.db
.prepare(
"UPDATE automations SET enabled = 0, next_run_at = NULL, updated_at = ? WHERE id = ? AND deleted_at IS NULL"
)
.bind(now, id)
.run();
.bind(now, id);
}

/** Pause an automation and report whether a live row changed. */
async pause(id: string): Promise<boolean> {
const result = await this.bindPause(id).run();
return (result.meta?.changes ?? 0) > 0;
}

async resume(id: string, nextRunAt: number | null): Promise<boolean> {
const now = Date.now();
const result = await this.db
/** Build a resume statement for composition in an atomic batch. */
bindResume(id: string, nextRunAt: number | null, now = Date.now()): SqlStatement {
return this.db
.prepare(
"UPDATE automations SET enabled = 1, next_run_at = ?, consecutive_failures = 0, updated_at = ? WHERE id = ? AND deleted_at IS NULL"
)
.bind(nextRunAt, now, id)
.run();
.bind(nextRunAt, now, id);
}

/** Resume an automation and report whether a live row changed. */
async resume(id: string, nextRunAt: number | null): Promise<boolean> {
const result = await this.bindResume(id, nextRunAt).run();
return (result.meta?.changes ?? 0) > 0;
}

Expand Down Expand Up @@ -855,7 +893,7 @@ export class AutomationStore {

/**
* Per-source overlap predicate, used both as the cheap pre-check and inside
* the guarded insert (same SQL, one definition). Schedule/manual firings
* the conditional insert (same SQL, one definition). Schedule/manual firings
* block on ANY active run of the automation (main parity with
* getActiveRunForAutomation); event firings block per concurrency key only —
* an automation-wide guard would serialize unrelated events.
Expand Down Expand Up @@ -911,7 +949,6 @@ export class AutomationStore {
const invocation = params.invocation;
const overlap = this.overlapPredicate(invocation.automation_id, params.overlapScope);
const statements: SqlStatement[] = [];

statements.push(
this.db
.prepare(
Expand Down Expand Up @@ -1046,6 +1083,46 @@ export class AutomationStore {
return { inserted: (results[0]?.meta?.changes ?? 0) > 0 };
}

/** Atomically record a denied cron slot and pause it so overdue denial cannot starve the queue. */
async recordAuthorizationDenied(
invocation: AutomationInvocationRow,
fromSlot: number
): Promise<{ inserted: boolean; paused: boolean }> {
const results = await this.db.batch([
this.db
.prepare(
`INSERT OR IGNORE INTO automation_invocations
(id, automation_id, source, scheduled_at, trigger_key, concurrency_key,
trigger_metadata, skip_reason, failure_counted_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(
invocation.id,
invocation.automation_id,
invocation.source,
invocation.scheduled_at,
invocation.trigger_key,
invocation.concurrency_key,
invocation.trigger_metadata,
invocation.skip_reason,
invocation.failure_counted_at,
invocation.created_at,
invocation.updated_at
),
this.db
.prepare(
`UPDATE automations
SET enabled = 0, next_run_at = NULL, updated_at = ?
WHERE id = ? AND deleted_at IS NULL AND enabled = 1 AND next_run_at = ?`
)
.bind(Date.now(), invocation.automation_id, fromSlot),
]);
return {
inserted: (results[0]?.meta?.changes ?? 0) > 0,
paused: (results[1]?.meta?.changes ?? 0) > 0,
};
}

async getInvocationById(invocationId: string): Promise<AutomationInvocationRow | null> {
return this.db
.prepare(`SELECT * FROM automation_invocations WHERE id = ?`)
Expand Down
7 changes: 5 additions & 2 deletions packages/control-plane/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,8 +472,10 @@ async function enforceAutomationRequirement(
try {
const authorization = ctx.authorization;
if (!authorization) throw new Error("Missing request authorization");
const automation = await new AutomationStore(ctx.db).getById(automationId);
if (!automation) return error("Automation not found", 404);
const store = new AutomationStore(ctx.db);
const storedAutomation = await store.getById(automationId);
if (!storedAutomation) return error("Automation not found", 404);
const automation = await store.resolveCanonicalOwner(storedAutomation);

const permissionStem = `automations.${requirement.operation}` as const;
const permissionScope = resolveScopedPermission(permissionStem, authorization.permissions);
Expand All @@ -488,6 +490,7 @@ async function enforceAutomationRequirement(
);
}

ctx.automationAdmission = { automation };
return null;
} catch {
return json({ error: "Authorization unavailable", code: "authorization_unavailable" }, 503);
Expand Down
Loading
Loading