Add bot types with a permission matrix, and workspace admin bots

A bot's powers came from bots.kind alone, so only the coordinator could
hand work to other bots. bot_types now works like user_types: a row with
no workspace holds everywhere (the coordinator), one with a workspace
only there (a workspace admin), and no row is a worker. bots.kind stays
the legacy marker of the one coordinator for who sees it.

The matrices in src/dict/bot-types-dict.ts say what each type may do,
whom it may direct and who grants it, read by one generic interpreter
(rbac/type-assignment.ts) that userTypeAssignment now wraps too. Every
run reads its powers once (grabBotRunPowers) and the chat tools, task
runs and task tools gate on them instead of kind; a run's powers are its
type's and its user's together.

In the workspace it leads, an admin hands tasks to the workers there
(results relayed back to it), sets their instructions, adds tasks to
their lists and keeps the workspace's context when its user may. Super
admins and admins grant it from the workspace's settings or the chat
header's menu, and the coordinator with set_bot_type. Admins are marked
with a filled green shield beside their name across the workspace, kept
live by pushBotUpdated's admin_workspace_ids.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 09:04:14 +01:00
co-authored by Claude Opus 5.5
parent 4161863587
commit ea62dd11f0
65 changed files with 1970 additions and 239 deletions
+33
View File
@@ -0,0 +1,33 @@
import { BotTypeTargetMatrix } from "@/src/dict/bot-types-dict";
import botTypesIn, { type BotTypeRow } from "./bot-types-in";
import typeAssignment from "./type-assignment";
type Params = {
actor_bot_types?: BotTypeRow[] | null;
target_bot_types?: BotTypeRow[] | null;
/**
* Where they're working: types count there.
*/
workspace_id?: number | string | null;
};
/**
* Whether a bot may act on another (hand it a task, set its instructions)
* where they're working, through `BotTypeTargetMatrix`: the coordinator
* acts on anyone, a workspace admin on the workers there.
*/
export default function botCanTarget({ actor_bot_types, target_bot_types, workspace_id }: Params): boolean {
const actor_types = botTypesIn({ bot_types: actor_bot_types, workspace_id });
const target_types = botTypesIn({ bot_types: target_bot_types, workspace_id });
return typeAssignment({
actor_types,
pass_through: actor_types.includes("coordinator") && !target_types.includes("coordinator"),
target_type: target_types[0] || "worker",
target_types,
action: "assign",
assignment_matrix: BotTypeTargetMatrix,
deletion_matrix: BotTypeTargetMatrix,
label: "Bot RBAC",
});
}
+24
View File
@@ -0,0 +1,24 @@
import { BotTypePermissionMatrix } from "@/src/dict/bot-types-dict";
import type { BotPermission } from "@/src/types";
import botTypesIn, { type BotTypeRow } from "./bot-types-in";
type Params = {
bot_types?: BotTypeRow[] | null;
permission: BotPermission;
/**
* Where the bot is working: its own chat (null) or a workspace. A
* workspace type only counts there.
*/
workspace_id?: number | string | null;
};
/**
* Whether a bot's types let it do `permission` where it's working
* (`BotTypePermissionMatrix`). This is the bot's side only: the user the
* run works for must still be allowed to do it.
*/
export default function botCan({ bot_types, permission, workspace_id }: Params): boolean {
return botTypesIn({ bot_types, workspace_id }).some((bt) =>
BotTypePermissionMatrix[bt].includes(permission),
);
}
+47
View File
@@ -0,0 +1,47 @@
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import { BotTypeAssignmentMatrix, BotTypeDeletionMatrix } from "@/src/dict/bot-types-dict";
import type { BotType } from "@/src/types";
import botTypesIn, { type BotTypeRow } from "./bot-types-in";
import isPassThroughUser from "./is-pass-through-user";
import typeAssignment from "./type-assignment";
type Params = {
/**
* A user granting it: their user types (the super admin passes
* through).
*/
actor_user_types?: Pick<BUN_SQLITE_COBBLE_USER_TYPES, "user_type">[] | null;
/**
* A bot granting it from chat: its types. Only install-wide ones count,
* so a workspace admin never grants types.
*/
actor_bot_types?: BotTypeRow[] | null;
target_bot_type: BotType;
action: "assign" | "delete";
};
/**
* Whether a user, or a bot, may grant or remove `target_bot_type`
* (`BotTypeAssignmentMatrix`, `BotTypeDeletionMatrix`). A bot acting for a
* user needs both: call it once for the bot and once for the user.
*/
export default function botTypeAssignment({
actor_user_types,
actor_bot_types,
target_bot_type,
action,
}: Params): boolean {
const actor_types = actor_bot_types
? botTypesIn({ bot_types: actor_bot_types })
: (actor_user_types || []).map((ut) => ut.user_type).filter((ut): ut is string => Boolean(ut));
return typeAssignment({
actor_types,
pass_through: actor_bot_types ? false : isPassThroughUser({ user_types: actor_user_types as never }),
target_type: target_bot_type,
action,
assignment_matrix: BotTypeAssignmentMatrix,
deletion_matrix: BotTypeDeletionMatrix,
label: "Bot RBAC",
});
}
+44
View File
@@ -0,0 +1,44 @@
import type { BUN_SQLITE_COBBLE_BOT_TYPES } from "@/db/types/db";
import type { BotType } from "@/src/types";
export type BotTypeRow = Pick<BUN_SQLITE_COBBLE_BOT_TYPES, "bot_type"> & {
/**
* Null (or empty) for an install-wide type.
*/
workspace_id?: number | "" | null;
};
type Params = {
bot_types?: BotTypeRow[] | null;
/**
* Where the bot is working: its own chat (null) or a workspace.
*/
workspace_id?: number | string | null;
};
/**
* Strongest first, so a bot holding several is judged by the one that
* reaches furthest.
*/
const BotTypeRank: BotType[] = ["coordinator", "admin", "worker"];
/**
* The bot types that hold where the bot is working: install-wide rows
* everywhere, workspace rows only in their workspace. Strongest first,
* `worker` when nothing else holds.
*/
export default function botTypesIn({ bot_types, workspace_id }: Params): BotType[] {
const held = new Set<BotType>();
for (const row of bot_types || []) {
if (!row.bot_type) continue;
const applies = !row.workspace_id || (workspace_id && row.workspace_id == Number(workspace_id));
if (applies) held.add(row.bot_type);
}
if (!held.size) held.add("worker");
return BotTypeRank.filter((bt) => held.has(bt));
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, test } from "bun:test";
import botCan from "./bot-can";
import botCanTarget from "./bot-can-target";
import botTypeAssignment from "./bot-type-assignment";
import botTypesIn from "./bot-types-in";
const coordinator = [{ bot_type: "coordinator" as const, workspace_id: null }];
const admin_of_3 = [{ bot_type: "admin" as const, workspace_id: 3 }];
describe("botTypesIn", () => {
test("a bot with no rows is a worker", () => {
expect(botTypesIn({ bot_types: [] })).toEqual(["worker"]);
});
test("a workspace type only holds in its workspace", () => {
expect(botTypesIn({ bot_types: admin_of_3, workspace_id: 3 })).toEqual(["admin"]);
expect(botTypesIn({ bot_types: admin_of_3, workspace_id: 4 })).toEqual(["worker"]);
expect(botTypesIn({ bot_types: admin_of_3 })).toEqual(["worker"]);
});
test("an install-wide type holds everywhere", () => {
expect(botTypesIn({ bot_types: coordinator, workspace_id: 9 })).toEqual(["coordinator"]);
});
});
describe("botCan", () => {
test("a workspace admin delegates in its workspace only", () => {
expect(botCan({ bot_types: admin_of_3, permission: "delegate_task", workspace_id: 3 })).toBe(true);
expect(botCan({ bot_types: admin_of_3, permission: "delegate_task", workspace_id: 5 })).toBe(false);
expect(botCan({ bot_types: admin_of_3, permission: "delegate_task" })).toBe(false);
});
test("a workspace admin doesn't manage the crew", () => {
expect(botCan({ bot_types: admin_of_3, permission: "manage_crew", workspace_id: 3 })).toBe(false);
});
test("the coordinator can do everything", () => {
expect(botCan({ bot_types: coordinator, permission: "manage_extensions" })).toBe(true);
expect(botCan({ bot_types: coordinator, permission: "delegate_task", workspace_id: 3 })).toBe(true);
});
test("a worker can't delegate", () => {
expect(botCan({ bot_types: [], permission: "delegate_task", workspace_id: 3 })).toBe(false);
});
});
describe("botCanTarget", () => {
test("a workspace admin directs the workers there", () => {
expect(botCanTarget({ actor_bot_types: admin_of_3, target_bot_types: [], workspace_id: 3 })).toBe(true);
});
test("a workspace admin doesn't direct another admin or the coordinator", () => {
expect(botCanTarget({ actor_bot_types: admin_of_3, target_bot_types: admin_of_3, workspace_id: 3 })).toBe(false);
expect(botCanTarget({ actor_bot_types: admin_of_3, target_bot_types: coordinator, workspace_id: 3 })).toBe(false);
});
test("an admin elsewhere is a worker here", () => {
expect(botCanTarget({ actor_bot_types: admin_of_3, target_bot_types: [], workspace_id: 4 })).toBe(false);
expect(botCanTarget({
actor_bot_types: admin_of_3,
target_bot_types: [{ bot_type: "admin", workspace_id: 8 }],
workspace_id: 3,
})).toBe(true);
});
test("the coordinator directs workers and admins, never itself", () => {
expect(botCanTarget({ actor_bot_types: coordinator, target_bot_types: [], workspace_id: 3 })).toBe(true);
expect(botCanTarget({ actor_bot_types: coordinator, target_bot_types: admin_of_3, workspace_id: 3 })).toBe(true);
expect(botCanTarget({ actor_bot_types: coordinator, target_bot_types: coordinator })).toBe(false);
});
test("a worker directs no one", () => {
expect(botCanTarget({ actor_bot_types: [], target_bot_types: [], workspace_id: 3 })).toBe(false);
});
});
describe("botTypeAssignment", () => {
const user = (...types: string[]) => types.map((user_type) => ({ user_type }));
test("super admins and admins grant and remove workspace admin", () => {
for (const action of ["assign", "delete"] as const) {
expect(botTypeAssignment({ actor_user_types: user("super_admin"), target_bot_type: "admin", action })).toBe(true);
expect(botTypeAssignment({ actor_user_types: user("admin"), target_bot_type: "admin", action })).toBe(true);
expect(botTypeAssignment({ actor_user_types: user("member"), target_bot_type: "admin", action })).toBe(false);
}
});
test("only the super admin passes for the coordinator type", () => {
expect(botTypeAssignment({ actor_user_types: user("admin"), target_bot_type: "coordinator", action: "assign" })).toBe(false);
});
test("the coordinator bot grants workspace admin, a workspace admin doesn't", () => {
expect(botTypeAssignment({ actor_bot_types: coordinator, target_bot_type: "admin", action: "assign" })).toBe(true);
expect(botTypeAssignment({ actor_bot_types: admin_of_3, target_bot_type: "admin", action: "assign" })).toBe(false);
expect(botTypeAssignment({ actor_bot_types: coordinator, target_bot_type: "coordinator", action: "assign" })).toBe(false);
});
});
+152
View File
@@ -0,0 +1,152 @@
import type { TypeAssignmentMatrixType, TypeAssignmentRules } from "@/src/types";
type Params = {
/**
* The actor's types: user types, bot types, or both.
*/
actor_types: string[];
/**
* Whether the actor bypasses the matrix (the super admin).
*/
pass_through?: boolean;
target_type: string;
/**
* The types the target already holds.
*/
target_types?: string[];
action: "assign" | "delete";
assignment_matrix: TypeAssignmentMatrixType<string>;
deletion_matrix: TypeAssignmentMatrixType<string>;
/**
* Prefix for debug lines.
*/
label?: string;
debug?: boolean;
};
/**
* The type-matrix interpreter, for any set of types (user types through
* `userTypeAssignment`, bot types through `botTypeAssignment` and
* `botCanTarget`). Whether an actor holding `actor_types` may assign or
* remove `target_type` on a target holding `target_types`.
*
* Matrix forms:
* - `allowed_actors` as an array lists actor types allowed to act.
* - `allowed_actors` as a per-actor matrix keys actors to rules:
* - `allowed_targets`: the target must hold at least one of these types.
* - `excluded_targets`: the target must not hold any of these types.
* - `all`: anything goes for that actor.
* - `same_as`: clones the rules of another actor type.
* - Entry-level `excluded_targets` blocks the action when the target holds
* any listed type.
* - Empty/missing `allowed_actors` means only pass-through actors may act.
*/
export default function typeAssignment({
actor_types,
pass_through,
target_type,
target_types,
action,
assignment_matrix,
deletion_matrix,
label = "RBAC",
debug,
}: Params): boolean {
const log = (text: string) => {
if (debug) console.log(`[${label}] ${text}`);
};
if (pass_through) {
log(`Pass-through actor detected for [${target_type}], access granted.`);
return true;
}
const matrix = action == "assign" ? assignment_matrix : deletion_matrix;
const assignable_by = matrix[target_type];
if (!assignable_by?.allowed_actors) {
log(`No allowed actors for [${target_type}] in the ${action} matrix.`);
return false;
}
const is_target_type_excluded =
target_types && assignable_by.excluded_targets
? target_types.find((tt) =>
assignable_by.excluded_targets?.find((ex) => ex == tt),
)
: false;
if (is_target_type_excluded) {
log(`Target types [${target_types?.join(",")}] excluded for [${target_type}].`);
return false;
}
const actor_type_set = new Set<string>(
actor_types.filter((at): at is string => Boolean(at)),
);
if (Array.isArray(assignable_by.allowed_actors)) {
if (!assignable_by.allowed_actors.length) {
log(`Actor not allowed for [${target_type}], allowed actors list is empty.`);
return false;
}
return assignable_by.allowed_actors.some((actor_type) =>
actor_type_set.has(actor_type),
);
}
const allowed_actors_matrix = assignable_by.allowed_actors as Record<
string,
TypeAssignmentRules | undefined
>;
if (!Object.keys(allowed_actors_matrix).length) {
log(`No allowed actors matrix for [${target_type}].`);
return false;
}
return Array.from(actor_type_set).some((actor_type) => {
let actor_rules = allowed_actors_matrix[actor_type];
if (
actor_rules?.same_as &&
allowed_actors_matrix[actor_rules.same_as]
) {
actor_rules = {
...actor_rules,
...allowed_actors_matrix[actor_rules.same_as],
};
}
if (actor_rules?.all) {
log(`Actor [${actor_type}] has all access for [${target_type}].`);
return true;
}
if (!actor_rules?.allowed_targets && !actor_rules?.excluded_targets) {
log(`No actor rules for [${actor_type}] on [${target_type}].`);
return false;
}
if (
actor_rules.allowed_targets &&
!actor_rules.allowed_targets.find((t) => target_types?.includes(t))
) {
log(`Target types [${target_types?.join(",")}] not included for [${actor_type}] -> [${target_type}].`);
return false;
}
if (
actor_rules.excluded_targets &&
actor_rules.excluded_targets.find((t) => target_types?.includes(t))
) {
log(`Target types [${target_types?.join(",")}] excluded for [${actor_type}] -> [${target_type}].`);
return false;
}
log(`Actor [${actor_type}] granted [${action}] of [${target_type}].`);
return true;
});
}
+14 -143
View File
@@ -1,18 +1,16 @@
import type {
UserAssignmentFnParams,
UserTypeAssignmentRules,
} from "@/src/types";
import type { UserAssignmentFnParams } from "@/src/types";
import {
UserTypeAssignmentMatrix,
UserTypeDeletionMatrix,
} from "@/src/dict/user-types-dict";
import isPassThroughUser from "./is-pass-through-user";
import typeAssignment from "./type-assignment";
/**
* Whether the actor may assign or remove `target_user_type` on a user.
* Governed by `UserTypeAssignmentMatrix` and `UserTypeDeletionMatrix` and
* applied symmetrically to granting and removing. Pass-through actors
* (`super_admin`) can always assign.
* (`super_admin`) can always assign. The interpreter is `typeAssignment`.
*/
export default function userTypeAssignment({
actor_user_types,
@@ -23,143 +21,16 @@ export default function userTypeAssignment({
deletion_matrix = UserTypeDeletionMatrix,
debug,
}: UserAssignmentFnParams): boolean {
if (isPassThroughUser({ user_types: actor_user_types })) {
if (debug) {
console.log(
`[RBAC] Pass-through actor detected for [${target_user_type}], access granted.`,
);
}
return true;
}
const matrix = action == "assign" ? assignment_matrix : deletion_matrix;
const assignable_by = matrix[target_user_type];
if (!assignable_by?.allowed_actors) {
if (debug) {
console.log(
`[RBAC] No allowed actors for [${target_user_type}] in the ${action} matrix.`,
);
}
return false;
}
const is_target_user_type_excluded =
target_user_types && assignable_by.excluded_targets
? target_user_types.find((tut) =>
assignable_by.excluded_targets?.find((ex) => ex == tut),
)
: false;
if (is_target_user_type_excluded) {
if (debug) {
console.log(
`[RBAC] Target user type [${target_user_types?.join(",")}] excluded for [${target_user_type}].`,
);
}
return false;
}
const actor_type_set = new Set<string>(
actor_user_types
?.map((ut) => ut.user_type)
return typeAssignment({
actor_types: (actor_user_types || [])
.map((ut) => ut.user_type)
.filter((ut): ut is string => Boolean(ut)),
);
if (Array.isArray(assignable_by.allowed_actors)) {
if (!assignable_by.allowed_actors.length) {
if (debug) {
console.log(
`[RBAC] Actor not allowed for [${target_user_type}], allowed actors list is empty.`,
);
}
return false;
}
return assignable_by.allowed_actors.some((actor_type) =>
actor_type_set.has(actor_type),
);
}
const allowed_actors_matrix = assignable_by.allowed_actors as Record<
string,
UserTypeAssignmentRules | undefined
>;
if (!Object.keys(allowed_actors_matrix).length) {
if (debug) {
console.log(
`[RBAC] No allowed actors matrix for [${target_user_type}].`,
);
}
return false;
}
return Array.from(actor_type_set).some((actor_type) => {
let actor_rules = allowed_actors_matrix[actor_type];
if (
actor_rules?.same_as &&
allowed_actors_matrix[actor_rules.same_as]
) {
actor_rules = {
...actor_rules,
...allowed_actors_matrix[actor_rules.same_as],
};
}
if (actor_rules?.all) {
if (debug) {
console.log(
`[RBAC] Actor [${actor_type}] has all access for [${target_user_type}].`,
);
}
return true;
}
if (!actor_rules?.allowed_targets && !actor_rules?.excluded_targets) {
if (debug) {
console.log(
`[RBAC] No actor rules for [${actor_type}] on [${target_user_type}].`,
);
}
return false;
}
if (
actor_rules.allowed_targets &&
!actor_rules.allowed_targets.find((t) =>
target_user_types?.includes(t),
)
) {
if (debug) {
console.log(
`[RBAC] Target user types [${target_user_types?.join(",")}] not included for [${actor_type}] -> [${target_user_type}].`,
);
}
return false;
}
if (
actor_rules.excluded_targets &&
actor_rules.excluded_targets.find((t) =>
target_user_types?.includes(t),
)
) {
if (debug) {
console.log(
`[RBAC] Target user types [${target_user_types?.join(",")}] excluded for [${actor_type}] -> [${target_user_type}].`,
);
}
return false;
}
if (debug) {
console.log(
`[RBAC] Actor [${actor_type}] granted [${action}] of [${target_user_type}].`,
);
}
return true;
pass_through: isPassThroughUser({ user_types: actor_user_types }),
target_type: target_user_type,
target_types: target_user_types,
action,
assignment_matrix,
deletion_matrix,
debug,
});
}
}
+28 -1
View File
@@ -7,6 +7,11 @@ type Params = {
* bot's own and every workspace's).
*/
is_coordinator: boolean;
/**
* The workspaces the bot is admin of: it also reaches the tasks it added
* to their members' own lists.
*/
admin_workspace_ids?: number[];
/**
* The table alias the condition reads.
*/
@@ -16,15 +21,37 @@ type Params = {
/**
* The tasks a bot's run reaches: its own, plus every workspace it works in
* (a workspace's tasks are shared by its bots). The coordinator reaches
* every task in the install: every bot's own and every workspace's.
* every task in the install: every bot's own and every workspace's. A
* workspace admin also reaches the tasks it put on that workspace's
* members' own lists.
*/
export default function botTaskScopesSql({
bot_id,
is_coordinator,
admin_workspace_ids = [],
alias = "t",
}: Params): SqlCondition {
if (is_coordinator) return { sql: `1`, values: [] };
const admin_ids = admin_workspace_ids.map(Number).filter(Boolean);
if (admin_ids.length) {
const marks = admin_ids.map(() => "?").join(", ");
return {
sql: `(
${alias}.bot_id = ?
OR (${alias}.workspace_id IS NOT NULL AND ${alias}.workspace_id IN (
SELECT workspace_id FROM workspace_bots WHERE bot_id = ?
))
OR (${alias}.created_by_bot_id = ? AND ${alias}.bot_id IN (
SELECT bot_id FROM workspace_bots WHERE workspace_id IN (${marks})
))
)`,
values: [Number(bot_id), Number(bot_id), Number(bot_id), ...admin_ids],
};
}
return {
sql: `(
${alias}.bot_id = ?
+16 -3
View File
@@ -18,10 +18,10 @@ beforeAll(() => {
db.run(`INSERT INTO workspace_users VALUES (11, 7)`);
db.run(`INSERT INTO workspace_bots VALUES (11, 3), (10, 3)`);
db.run(`CREATE TABLE tasks (id INTEGER PRIMARY KEY, bot_id INTEGER, workspace_id INTEGER)`);
db.run(`CREATE TABLE tasks (id INTEGER PRIMARY KEY, bot_id INTEGER, workspace_id INTEGER, created_by_bot_id INTEGER)`);
db.run(`INSERT INTO tasks VALUES
(100, 1, NULL), (101, 2, NULL), (102, 3, NULL),
(110, NULL, 10), (111, NULL, 11)`);
(100, 1, NULL, NULL), (101, 2, NULL, NULL), (102, 3, NULL, NULL),
(110, NULL, 10, NULL), (111, NULL, 11, NULL)`);
});
function ids({ condition }: { condition: SqlCondition }) {
@@ -61,3 +61,16 @@ describe("botTaskScopesSql", () => {
]);
});
});
describe("botTaskScopesSql for a workspace admin", () => {
test("it also reaches what it put on its workspace members' own lists", () => {
db.run(`INSERT INTO workspace_bots VALUES (11, 4)`);
db.run(`INSERT INTO tasks VALUES (103, 3, NULL, 4), (104, 2, NULL, 4)`);
expect(ids({ condition: botTaskScopesSql({ bot_id: 4, is_coordinator: false, admin_workspace_ids: [11] }) })).toEqual([
103, 111,
]);
expect(ids({ condition: botTaskScopesSql({ bot_id: 4, is_coordinator: false }) })).toEqual([111]);
db.run(`DELETE FROM workspace_bots WHERE bot_id = 4`);
db.run(`DELETE FROM tasks WHERE id IN (103, 104)`);
});
});