50 KiB
Multi-User RBAC Foundation Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the first usable multi-user RBAC foundation so Boss filters devices, conversations, projects, messages, Skill visibility, and main Agent context by the logged-in account's permissions.
Architecture: Add explicit grant arrays to BossState, centralize authorization in src/lib/boss-permissions.ts, then route all API projections and task entrypoints through this module. Highest admin remains globally privileged, while non-admin users get device-derived read visibility plus explicit grants for chat, takeover, computer control, and Skill usage.
Tech Stack: Next.js App Router route handlers, TypeScript, node:test + tsx, Android Java unit tests, existing file-backed data/boss-state.json.
File Structure
Create:
src/lib/boss-permissions.ts- central permission predicates, filtering helpers, and audit helper builders.tests/boss-permissions.test.ts- pure unit tests for grants, inheritance, expiration, and legacy fallback.tests/rbac-route-filtering.test.ts- API-level tests for devices, conversations, project detail, messages, and Skill filtering.tests/rbac-master-agent-scope.test.ts- tests proving main Agent context excludes unauthorized state.android/app/src/test/java/com/hyzq/boss/BossRbacVisibilityTest.java- Android-side tests for role-gated access entry and unauthorized messaging.
Modify:
src/lib/boss-data.ts- add grant/audit types toBossState, normalize persisted grants, and expose grant mutation helpers.src/lib/boss-projections.ts- add session-aware projections for conversations, project detail, device workspace, and Skill inventory.src/lib/boss-device-auth.ts- replace rawdevice.account === session.accountchecks with RBAC helpers while keeping legacy fallback.src/lib/boss-attachment-access.ts- align attachment access with project permissions.src/app/api/v1/devices/route.ts- filter devices and workspace by session permissions.src/app/api/v1/conversations/route.ts- filter conversation list by session permissions.src/app/api/v1/conversations/home/route.ts- filter Android home list by session permissions.src/app/api/v1/conversation-folders/[folderKey]/route.ts- filter folder contents by session permissions.src/app/api/v1/projects/[projectId]/route.ts- requireproject.view.src/app/api/v1/projects/[projectId]/messages/route.ts- requireproject.viewfor read andthread.chatormaster_agent.askfor write.src/app/api/v1/devices/[deviceId]/skills/route.ts- requireskill.viewfor reads and keep device-token writes for local-agent.src/lib/boss-master-agent.ts- build main Agent runtime context from authorized scope.src/app/api/state/route.ts- avoid returning all devices/projects to non-admin users.android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java- expose account/permission menu only for eligible roles.android/app/src/main/java/com/hyzq/boss/MainActivity.java- route the new account access entry and preserve unauthorized toasts.README.mdanddocs/architecture/current_runtime_and_deploy_status_cn.md- document first-stage RBAC runtime behavior.
Task 1: Add State Types, Normalization, and Grant Mutators
Files:
-
Modify:
src/lib/boss-data.ts -
Test:
tests/boss-permissions.test.ts -
Step 1: Write failing tests for persisted grants and legacy fallback
Create tests/boss-permissions.test.ts with this initial content:
import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
let runtimeRoot = "";
let data: typeof import("../src/lib/boss-data");
let permissions: typeof import("../src/lib/boss-permissions");
async function setup() {
if (runtimeRoot) return;
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-rbac-permissions-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
data = await import("../src/lib/boss-data.ts");
permissions = await import("../src/lib/boss-permissions.ts");
}
test.after(async () => {
if (runtimeRoot) {
await rm(runtimeRoot, { recursive: true, force: true });
}
});
test.beforeEach(async () => {
await setup();
const state = await data.readState();
await data.writeState({
...state,
accountDeviceGrants: [],
accountProjectGrants: [],
accountSkillGrants: [],
skillCatalog: [],
permissionAuditLogs: [],
});
});
test("highest admin can access every device and project without explicit grants", async () => {
const state = await data.readState();
const session = {
account: "17600003315",
role: "highest_admin" as const,
displayName: "Boss 超级管理员",
};
assert.equal(permissions.canAccessDevice(state, session, "mac-studio", "device.view"), true);
assert.equal(permissions.canAccessProject(state, session, "audit-collab", "project.view"), true);
});
test("device.view grant gives project read visibility but not thread chat", async () => {
const state = await data.readState();
state.accountDeviceGrants = [
{
grantId: "grant-device-view",
account: "worker@example.com",
deviceId: "mac-studio",
permissions: ["device.view"],
grantedBy: "17600003315",
grantedAt: "2026-04-26T12:00:00+08:00",
},
];
await data.writeState(state);
const next = await data.readState();
const session = {
account: "worker@example.com",
role: "member" as const,
displayName: "Worker",
};
assert.equal(permissions.canAccessDevice(next, session, "mac-studio", "device.view"), true);
assert.equal(permissions.canAccessProject(next, session, "master-agent", "project.view"), true);
assert.equal(permissions.canAccessProject(next, session, "master-agent", "thread.chat"), false);
});
test("explicit project thread.chat grant allows posting to that project", async () => {
const state = await data.readState();
state.accountProjectGrants = [
{
grantId: "grant-thread-chat",
account: "worker@example.com",
projectId: "master-agent",
permissions: ["project.view", "thread.chat", "master_agent.ask"],
grantedBy: "17600003315",
grantedAt: "2026-04-26T12:00:00+08:00",
},
];
await data.writeState(state);
const next = await data.readState();
const session = {
account: "worker@example.com",
role: "member" as const,
displayName: "Worker",
};
assert.equal(permissions.canAccessProject(next, session, "master-agent", "project.view"), true);
assert.equal(permissions.canAccessProject(next, session, "master-agent", "thread.chat"), true);
assert.equal(permissions.canAccessProject(next, session, "master-agent", "computer.control"), false);
});
test("expired grants are ignored", async () => {
const state = await data.readState();
state.accountDeviceGrants = [
{
grantId: "expired-device-grant",
account: "worker@example.com",
deviceId: "mac-studio",
permissions: ["device.view"],
grantedBy: "17600003315",
grantedAt: "2026-04-25T12:00:00+08:00",
expiresAt: "2000-01-01T00:00:00.000Z",
},
];
await data.writeState(state);
const next = await data.readState();
const session = {
account: "worker@example.com",
role: "member" as const,
displayName: "Worker",
};
assert.equal(permissions.canAccessDevice(next, session, "mac-studio", "device.view"), false);
});
test("legacy device account ownership remains a compatibility fallback", async () => {
const state = await data.readState();
const session = {
account: "kris.plus.gpu",
role: "member" as const,
displayName: "GPU User",
};
assert.equal(permissions.canAccessDevice(state, session, "win-gpu-01", "device.view"), true);
assert.equal(permissions.canAccessProject(state, session, "audit-collab", "project.view"), true);
});
- Step 2: Run test to verify it fails
Run:
npx tsx --test tests/boss-permissions.test.ts
Expected: fail because src/lib/boss-permissions.ts and RBAC state fields do not exist.
- Step 3: Add RBAC types to
boss-data.ts
In src/lib/boss-data.ts, add these exported types near AuthRole:
export type BossPermission =
| "device.view"
| "device.manage"
| "project.view"
| "thread.chat"
| "master_agent.ask"
| "master_agent.takeover"
| "computer.control"
| "skill.view"
| "skill.use"
| "skill.manage"
| "account.manage"
| "audit.view";
export interface AccountDeviceGrant {
grantId: string;
account: string;
deviceId: string;
permissions: BossPermission[];
grantedBy: string;
grantedAt: string;
expiresAt?: string;
note?: string;
}
export interface AccountProjectGrant {
grantId: string;
account: string;
projectId: string;
deviceId?: string;
permissions: BossPermission[];
inheritFromDeviceGrant?: boolean;
grantedBy: string;
grantedAt: string;
expiresAt?: string;
note?: string;
}
export interface AccountSkillGrant {
grantId: string;
account: string;
skillId: string;
deviceId?: string;
projectId?: string;
permissions: BossPermission[];
grantedBy: string;
grantedAt: string;
expiresAt?: string;
note?: string;
}
export interface SkillCatalogEntry {
skillId: string;
name: string;
description: string;
sourceType: "gitea" | "skillhub" | "local";
sourceUrl?: string;
version?: string;
checksum?: string;
category?: string;
updatedAt: string;
}
export interface PermissionAuditLog {
auditId: string;
actorAccount: string;
action:
| "grant.created"
| "grant.updated"
| "grant.revoked"
| "skill.assigned"
| "skill.revoked"
| "task.authorized"
| "task.denied";
targetAccount?: string;
deviceId?: string;
projectId?: string;
skillId?: string;
permissions?: BossPermission[];
detail?: string;
createdAt: string;
}
Add these fields to BossState:
accountDeviceGrants: AccountDeviceGrant[];
accountProjectGrants: AccountProjectGrant[];
accountSkillGrants: AccountSkillGrant[];
skillCatalog: SkillCatalogEntry[];
permissionAuditLogs: PermissionAuditLog[];
Add empty arrays in initialState.
- Step 4: Normalize persisted RBAC arrays
In the state normalization function that builds normalized BossState, add:
accountDeviceGrants: ensureArray(raw.accountDeviceGrants, []).map(normalizeAccountDeviceGrant),
accountProjectGrants: ensureArray(raw.accountProjectGrants, []).map(normalizeAccountProjectGrant),
accountSkillGrants: ensureArray(raw.accountSkillGrants, []).map(normalizeAccountSkillGrant),
skillCatalog: ensureArray(raw.skillCatalog, []).map(normalizeSkillCatalogEntry),
permissionAuditLogs: ensureArray(raw.permissionAuditLogs, []).map(normalizePermissionAuditLog),
Add local normalizers that preserve only valid permission strings:
const validBossPermissions = new Set<BossPermission>([
"device.view",
"device.manage",
"project.view",
"thread.chat",
"master_agent.ask",
"master_agent.takeover",
"computer.control",
"skill.view",
"skill.use",
"skill.manage",
"account.manage",
"audit.view",
]);
function normalizeBossPermissions(value: unknown): BossPermission[] {
return ensureArray(value, [])
.filter((item): item is BossPermission => typeof item === "string" && validBossPermissions.has(item as BossPermission));
}
Each grant normalizer must keep the original grantId if present, otherwise generate a stable fallback from account, target, and permissions.
- Step 5: Add grant mutation helpers
Add these exports to src/lib/boss-data.ts:
export async function saveAccountDeviceGrant(input: Omit<AccountDeviceGrant, "grantId" | "grantedAt"> & { grantId?: string }) {
return mutateState((state) => {
const grantedAt = nowIso();
const grant: AccountDeviceGrant = {
...input,
grantId: input.grantId ?? randomToken("grant-device"),
permissions: dedupeStrings(input.permissions).filter((item): item is BossPermission => validBossPermissions.has(item as BossPermission)),
grantedAt,
};
state.accountDeviceGrants = [
grant,
...state.accountDeviceGrants.filter((item) => item.grantId !== grant.grantId),
];
state.permissionAuditLogs.unshift({
auditId: randomToken("audit"),
actorAccount: input.grantedBy,
action: "grant.created",
targetAccount: input.account,
deviceId: input.deviceId,
permissions: grant.permissions,
createdAt: grantedAt,
});
return grant;
});
}
Repeat the same pattern for saveAccountProjectGrant, saveAccountSkillGrant, and revokeAccessGrant(grantId, actorAccount).
- Step 6: Run permission tests
Run:
npx tsx --test tests/boss-permissions.test.ts
Expected: still fail until Task 2 creates boss-permissions.ts.
Task 2: Implement Central Permission Module
Files:
-
Create:
src/lib/boss-permissions.ts -
Test:
tests/boss-permissions.test.ts -
Step 1: Create the permission module
Create src/lib/boss-permissions.ts:
import type {
AuthSession,
BossPermission,
BossState,
Device,
Project,
} from "@/lib/boss-data";
export type PermissionSession = Pick<AuthSession, "account" | "role" | "displayName">;
function isExpired(expiresAt?: string) {
return Boolean(expiresAt && new Date(expiresAt).getTime() <= Date.now());
}
export function isHighestAdmin(session: Pick<PermissionSession, "role">) {
return session.role === "highest_admin";
}
function permissionSetIncludes(permissions: BossPermission[], required: BossPermission) {
return permissions.includes(required);
}
function projectUsesDevice(project: Project, deviceId: string) {
if (project.deviceIds.includes(deviceId)) return true;
return project.groupMembers.some((member) => member.deviceId === deviceId);
}
function accountOwnsDevice(device: Device | undefined, account: string) {
return Boolean(device && device.account === account);
}
export function canAccessDevice(
state: BossState,
session: PermissionSession,
deviceId: string,
permission: BossPermission = "device.view",
) {
if (isHighestAdmin(session)) return true;
const device = state.devices.find((item) => item.id === deviceId);
if (!device) return false;
if (permission === "device.view" && accountOwnsDevice(device, session.account)) return true;
return state.accountDeviceGrants.some(
(grant) =>
grant.account === session.account &&
grant.deviceId === deviceId &&
!isExpired(grant.expiresAt) &&
permissionSetIncludes(grant.permissions, permission),
);
}
export function canAccessProject(
state: BossState,
session: PermissionSession,
projectId: string,
permission: BossPermission = "project.view",
) {
if (isHighestAdmin(session)) return true;
const project = state.projects.find((item) => item.id === projectId);
if (!project) return false;
const directProjectGrant = state.accountProjectGrants.some(
(grant) =>
grant.account === session.account &&
grant.projectId === projectId &&
!isExpired(grant.expiresAt) &&
permissionSetIncludes(grant.permissions, permission),
);
if (directProjectGrant) return true;
if (permission === "project.view") {
return state.devices.some(
(device) =>
projectUsesDevice(project, device.id) &&
(accountOwnsDevice(device, session.account) ||
canAccessDevice(state, session, device.id, "device.view")),
);
}
return state.accountDeviceGrants.some(
(grant) =>
grant.account === session.account &&
!isExpired(grant.expiresAt) &&
projectUsesDevice(project, grant.deviceId) &&
permissionSetIncludes(grant.permissions, permission),
);
}
export function canUseSkill(
state: BossState,
session: PermissionSession,
skillId: string,
scope: { deviceId?: string; projectId?: string } = {},
) {
if (isHighestAdmin(session)) return true;
return state.accountSkillGrants.some((grant) => {
if (grant.account !== session.account || isExpired(grant.expiresAt)) return false;
if (grant.skillId !== skillId) return false;
if (!permissionSetIncludes(grant.permissions, "skill.use")) return false;
if (grant.deviceId && scope.deviceId && grant.deviceId !== scope.deviceId) return false;
if (grant.projectId && scope.projectId && grant.projectId !== scope.projectId) return false;
return true;
});
}
export function canViewSkill(
state: BossState,
session: PermissionSession,
skillId: string,
scope: { deviceId?: string; projectId?: string } = {},
) {
if (isHighestAdmin(session)) return true;
return state.accountSkillGrants.some((grant) => {
if (grant.account !== session.account || isExpired(grant.expiresAt)) return false;
if (grant.skillId !== skillId) return false;
if (!grant.permissions.includes("skill.view") && !grant.permissions.includes("skill.use")) return false;
if (grant.deviceId && scope.deviceId && grant.deviceId !== scope.deviceId) return false;
if (grant.projectId && scope.projectId && grant.projectId !== scope.projectId) return false;
return true;
});
}
export function filterDevicesForSession(state: BossState, session: PermissionSession) {
if (isHighestAdmin(session)) return state.devices;
return state.devices.filter((device) => canAccessDevice(state, session, device.id, "device.view"));
}
export function filterProjectsForSession(state: BossState, session: PermissionSession) {
if (isHighestAdmin(session)) return state.projects;
return state.projects.filter((project) => canAccessProject(state, session, project.id, "project.view"));
}
export function filterProjectDevicesForSession(
state: BossState,
session: PermissionSession,
project: Project,
) {
if (isHighestAdmin(session)) {
return state.devices.filter((device) => project.deviceIds.includes(device.id));
}
return state.devices.filter(
(device) =>
project.deviceIds.includes(device.id) &&
canAccessDevice(state, session, device.id, "device.view"),
);
}
export function assertProjectPermission(
state: BossState,
session: PermissionSession,
projectId: string,
permission: BossPermission,
) {
if (!canAccessProject(state, session, projectId, permission)) {
return { ok: false as const, status: 403 as const, message: "FORBIDDEN" };
}
return { ok: true as const };
}
- Step 2: Run permission tests
Run:
npx tsx --test tests/boss-permissions.test.ts
Expected: all tests pass.
- Step 3: Commit
Run:
git add src/lib/boss-data.ts src/lib/boss-permissions.ts tests/boss-permissions.test.ts
git commit -m "feat: add boss rbac permission foundation"
Task 3: Filter Devices, Conversations, Project Detail, and Messages
Files:
-
Modify:
src/lib/boss-projections.ts -
Modify:
src/app/api/v1/devices/route.ts -
Modify:
src/app/api/v1/conversations/route.ts -
Modify:
src/app/api/v1/conversations/home/route.ts -
Modify:
src/app/api/v1/conversation-folders/[folderKey]/route.ts -
Modify:
src/app/api/v1/projects/[projectId]/route.ts -
Modify:
src/app/api/v1/projects/[projectId]/messages/route.ts -
Test:
tests/rbac-route-filtering.test.ts -
Step 1: Write failing API filtering tests
Create tests/rbac-route-filtering.test.ts:
import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { NextRequest } from "next/server";
let runtimeRoot = "";
let data: typeof import("../src/lib/boss-data");
let authCookie = "";
let getDevices: typeof import("../src/app/api/v1/devices/route")["GET"];
let getConversations: typeof import("../src/app/api/v1/conversations/route")["GET"];
let getProject: typeof import("../src/app/api/v1/projects/[projectId]/route")["GET"];
let getMessages: typeof import("../src/app/api/v1/projects/[projectId]/messages/route")["GET"];
let postMessages: typeof import("../src/app/api/v1/projects/[projectId]/messages/route")["POST"];
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
async function setup() {
if (runtimeRoot) return;
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-rbac-route-filtering-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const [dataModule, authModule, devicesRoute, conversationsRoute, projectRoute, messagesRoute] =
await Promise.all([
import("../src/lib/boss-data.ts"),
import("../src/lib/boss-auth.ts"),
import("../src/app/api/v1/devices/route.ts"),
import("../src/app/api/v1/conversations/route.ts"),
import("../src/app/api/v1/projects/[projectId]/route.ts"),
import("../src/app/api/v1/projects/[projectId]/messages/route.ts"),
]);
data = dataModule;
authCookie = authModule.AUTH_SESSION_COOKIE;
getDevices = devicesRoute.GET;
getConversations = conversationsRoute.GET;
getProject = projectRoute.GET;
getMessages = messagesRoute.GET;
postMessages = messagesRoute.POST;
baseState = structuredClone(await data.readState());
}
test.after(async () => {
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
});
test.beforeEach(async () => {
await setup();
const state = structuredClone(baseState);
const existingProjectIds = new Set(state.projects.map((project) => project.id));
if (!existingProjectIds.has("cloud-only-project")) {
state.projects.push({
id: "cloud-only-project",
name: "Cloud Only Project",
pinned: false,
systemPinned: false,
deviceIds: ["cloud-backup"],
preview: "只绑定未授权设备的项目。",
updatedAt: "2026-04-26T12:00:00+08:00",
lastMessageAt: "2026-04-26T12:00:00+08:00",
isGroup: false,
threadMeta: {
projectId: "cloud-only-project",
threadId: "thread-cloud-only",
threadDisplayName: "Cloud Only",
folderName: "Cloud",
activityIconCount: 0,
updatedAt: "2026-04-26T12:00:00+08:00",
codexThreadRef: "thread-cloud-only",
codexFolderRef: "cloud",
},
groupMembers: [],
createdByAgent: true,
collaborationMode: "development",
approvalState: "not_required",
unreadCount: 0,
riskLevel: "low",
messages: [
{
id: "cloud-only-message",
sender: "assistant",
senderLabel: "Codex",
body: "这个项目不应该被 worker 看到。",
sentAt: "2026-04-26T12:00:00+08:00",
kind: "text",
},
],
goals: [],
versions: [],
});
}
await data.writeState({
...state,
accountDeviceGrants: [
{
grantId: "grant-worker-mac-view",
account: "worker@example.com",
deviceId: "mac-studio",
permissions: ["device.view"],
grantedBy: "17600003315",
grantedAt: "2026-04-26T12:00:00+08:00",
},
],
accountProjectGrants: [],
accountSkillGrants: [],
skillCatalog: [],
permissionAuditLogs: [],
});
});
async function authedRequest(account: string, role: "member" | "admin" | "highest_admin", url: string, init: RequestInit = {}) {
const session = await data.createAuthSession({
account,
role,
displayName: account,
loginMethod: "password",
});
return new NextRequest(url, {
...init,
headers: {
...(init.headers ?? {}),
cookie: `${authCookie}=${session.sessionToken}`,
},
});
}
test("device list only includes devices visible to member", async () => {
const response = await getDevices(await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/devices"));
assert.equal(response.status, 200);
const body = await response.json();
assert.deepEqual(body.devices.map((device: { id: string }) => device.id), ["mac-studio"]);
});
test("conversation list only includes projects visible to member", async () => {
const response = await getConversations(await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/conversations"));
assert.equal(response.status, 200);
const body = await response.json();
assert.ok(body.conversations.some((item: { projectId: string }) => item.projectId === "master-agent"));
assert.equal(body.conversations.some((item: { projectId: string }) => item.projectId === "cloud-backup"), false);
assert.equal(body.conversations.some((item: { projectId: string }) => item.projectId === "cloud-only-project"), false);
});
test("project detail returns 403 when member lacks project view", async () => {
const response = await getProject(
await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/projects/cloud-only-project"),
{ params: Promise.resolve({ projectId: "cloud-only-project" }) },
);
assert.equal(response.status, 403);
});
test("messages GET requires project view", async () => {
const response = await getMessages(
await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/projects/audit-collab/messages"),
{ params: Promise.resolve({ projectId: "audit-collab" }) },
);
assert.equal(response.status, 200);
});
test("messages POST requires explicit thread.chat or master_agent.ask", async () => {
const denied = await postMessages(
await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/projects/master-agent/messages", {
method: "POST",
body: JSON.stringify({ body: "总结一下当前进度" }),
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(denied.status, 403);
const state = await data.readState();
state.accountProjectGrants.push({
grantId: "grant-master-chat",
account: "worker@example.com",
projectId: "master-agent",
permissions: ["project.view", "thread.chat", "master_agent.ask"],
grantedBy: "17600003315",
grantedAt: "2026-04-26T12:30:00+08:00",
});
await data.writeState(state);
const allowed = await postMessages(
await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/projects/master-agent/messages", {
method: "POST",
body: JSON.stringify({ body: "总结一下当前进度" }),
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.notEqual(allowed.status, 403);
});
- Step 2: Run route filtering tests and verify failure
Run:
npx tsx --test tests/rbac-route-filtering.test.ts
Expected: fail because API routes do not call permission filters.
- Step 3: Add session-aware projection helpers
In src/lib/boss-projections.ts, import:
import type { AuthSession } from "@/lib/boss-data";
import {
canAccessProject,
filterDevicesForSession,
filterProjectDevicesForSession,
filterProjectsForSession,
} from "@/lib/boss-permissions";
Add:
export function getConversationItemsForSession(state: BossState, session: Pick<AuthSession, "account" | "role" | "displayName">): ConversationItem[] {
const visibleProjectIds = new Set(filterProjectsForSession(state, session).map((project) => project.id));
return getConversationItems({
...state,
projects: state.projects.filter((project) => visibleProjectIds.has(project.id)),
});
}
export function getConversationHomeItemsForSession(state: BossState, session: Pick<AuthSession, "account" | "role" | "displayName">): ConversationItem[] {
const visibleProjectIds = new Set(filterProjectsForSession(state, session).map((project) => project.id));
return getConversationHomeItems({
...state,
projects: state.projects.filter((project) => visibleProjectIds.has(project.id)),
});
}
export function getProjectDetailViewForSession(
state: BossState,
projectId: string,
session: Pick<AuthSession, "account" | "role" | "displayName">,
): ProjectDetailView | null {
if (!canAccessProject(state, session, projectId, "project.view")) return null;
const detail = getProjectDetailView(state, projectId, session.account);
if (!detail) return null;
return {
...detail,
devices: filterProjectDevicesForSession(state, session, detail.project),
};
}
- Step 4: Update device and conversation routes
In src/app/api/v1/devices/route.ts, replace raw state return with:
const visibleDevices = filterDevicesForSession(state, session);
return jsonNoStore({
ok: true,
devices: visibleDevices,
enrollments: session.role === "highest_admin"
? state.deviceEnrollments
: state.deviceEnrollments.filter((item) => visibleDevices.some((device) => device.id === item.deviceId)),
workspace: getDeviceWorkspaceViewForSession(state, session, deviceId ?? undefined),
});
In src/app/api/v1/conversations/route.ts, return:
conversations: getConversationItemsForSession(state, session),
Apply the same pattern to src/app/api/v1/conversations/home/route.ts and src/app/api/v1/conversation-folders/[folderKey]/route.ts.
In src/app/api/state/route.ts, return a session-filtered state snapshot instead of raw state:
const visibleDevices = filterDevicesForSession(state, session);
const visibleProjects = filterProjectsForSession(state, session);
const visibleDeviceIds = new Set(visibleDevices.map((device) => device.id));
const visibleProjectIds = new Set(visibleProjects.map((project) => project.id));
return NextResponse.json({
...state,
devices: visibleDevices,
projects: visibleProjects,
deviceSkills: state.deviceSkills.filter((skill) => visibleDeviceIds.has(skill.deviceId)),
threadStatusDocuments: state.threadStatusDocuments.filter((doc) => visibleProjectIds.has(doc.projectId)),
threadProgressEvents: state.threadProgressEvents.filter((event) => visibleProjectIds.has(event.projectId)),
aiAccounts: state.aiAccounts.map(({ apiKey, ...account }) => account),
});
- Step 5: Update project detail and messages routes
In src/app/api/v1/projects/[projectId]/route.ts, replace getProjectDetailView with getProjectDetailViewForSession. Return 403 when project exists but is unauthorized:
const projectExists = state.projects.some((project) => project.id === projectId);
const detail = getProjectDetailViewForSession(state, projectId, session);
if (!detail) {
return jsonNoStore(
{ ok: false, message: projectExists ? "FORBIDDEN" : "PROJECT_NOT_FOUND" },
{ status: projectExists ? 403 : 404 },
);
}
In src/app/api/v1/projects/[projectId]/messages/route.ts:
GETrequiresproject.view.POSTrequiresthread.chatfor regular thread messages.POSTwith main Agent mention requiresmaster_agent.ask.- takeover enable/disable requires
master_agent.takeover. - dispatch or computer-control paths require
computer.controlwhen they create execution tasks.
Add a helper inside the route:
function forbiddenResponse(message = "FORBIDDEN") {
return NextResponse.json({ ok: false, message }, { status: 403 });
}
Then guard before appending user messages:
if (!canAccessProject(state, session, projectId, "project.view")) {
return forbiddenResponse();
}
if (masterAgentMention) {
if (!canAccessProject(state, session, projectId, "master_agent.ask")) {
return forbiddenResponse("MASTER_AGENT_FORBIDDEN");
}
} else if (!canAccessProject(state, session, projectId, "thread.chat")) {
return forbiddenResponse("THREAD_CHAT_FORBIDDEN");
}
- Step 6: Run route filtering tests
Run:
npx tsx --test tests/rbac-route-filtering.test.ts
Expected: all tests pass.
- Step 7: Run existing route tests touched by the change
Run:
npx tsx --test tests/project-messages-route.test.ts tests/project-message-delete.test.ts tests/conversation-home-items.test.ts
Expected: all tests pass.
- Step 8: Commit
Run:
git add src/lib/boss-projections.ts src/app/api/state/route.ts src/app/api/v1/devices/route.ts src/app/api/v1/conversations/route.ts src/app/api/v1/conversations/home/route.ts 'src/app/api/v1/conversation-folders/[folderKey]/route.ts' 'src/app/api/v1/projects/[projectId]/route.ts' 'src/app/api/v1/projects/[projectId]/messages/route.ts' tests/rbac-route-filtering.test.ts
git commit -m "feat: filter boss api responses by rbac grants"
Task 4: Filter Skill Visibility and Device Write Authorization
Files:
-
Modify:
src/app/api/v1/devices/[deviceId]/skills/route.ts -
Modify:
src/lib/boss-device-auth.ts -
Modify:
src/lib/boss-attachment-access.ts -
Modify:
src/lib/boss-projections.ts -
Test:
tests/rbac-route-filtering.test.ts -
Step 1: Add failing Skill visibility tests
Append to tests/rbac-route-filtering.test.ts:
test("device skills route only returns skills granted to member", async () => {
const state = await data.readState();
state.deviceSkills = [
{
skillId: "mac-studio:boss-server-debug",
deviceId: "mac-studio",
name: "boss-server-debug",
description: "服务器调试",
path: "/Users/kris/.codex/skills/boss-server-debug/SKILL.md",
invocation: "$boss-server-debug",
category: "Mac Studio",
updatedAt: "2026-04-26T12:00:00+08:00",
},
{
skillId: "mac-studio:gitea-version-upload",
deviceId: "mac-studio",
name: "gitea-version-upload",
description: "Gitea 上传",
path: "/Users/kris/.codex/skills/gitea-version-upload/SKILL.md",
invocation: "$gitea-version-upload",
category: "Mac Studio",
updatedAt: "2026-04-26T12:00:00+08:00",
},
];
state.accountSkillGrants = [
{
grantId: "grant-skill-view",
account: "worker@example.com",
skillId: "mac-studio:boss-server-debug",
deviceId: "mac-studio",
permissions: ["skill.view", "skill.use"],
grantedBy: "17600003315",
grantedAt: "2026-04-26T12:30:00+08:00",
},
];
await data.writeState(state);
const route = await import("../src/app/api/v1/devices/[deviceId]/skills/route.ts");
const response = await route.GET(
await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/devices/mac-studio/skills"),
{ params: Promise.resolve({ deviceId: "mac-studio" }) },
);
assert.equal(response.status, 200);
const body = await response.json();
assert.deepEqual(body.skills.map((skill: { skillId: string }) => skill.skillId), ["mac-studio:boss-server-debug"]);
});
- Step 2: Run the route filtering test and verify failure
Run:
npx tsx --test tests/rbac-route-filtering.test.ts
Expected: fail because the Skill route currently returns all device skills.
- Step 3: Filter
GET /devices/[deviceId]/skills
In src/app/api/v1/devices/[deviceId]/skills/route.ts, import:
import { canAccessDevice, canViewSkill } from "@/lib/boss-permissions";
Then guard:
if (!canAccessDevice(state, session, deviceId, "skill.view") && !canAccessDevice(state, session, deviceId, "device.view")) {
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
}
const allDeviceSkills = state.deviceSkills.filter((item) => item.deviceId === deviceId);
const visibleSkills = session.role === "highest_admin"
? allDeviceSkills
: allDeviceSkills.filter((skill) =>
canViewSkill(state, session, skill.skillId, { deviceId }),
);
Return visibleSkills.
- Step 4: Update device write auth
In src/lib/boss-device-auth.ts, replace session allow logic with:
import { canAccessDevice } from "@/lib/boss-permissions";
const state = await readState();
if (device && session && canAccessDevice(state, session, deviceId, "device.manage")) {
return { ok: true as const, device, principal: "session" as const };
}
Keep device-token authorization unchanged so local-agent can still heartbeat and upload skills.
- Step 5: Update attachment project access
In src/lib/boss-attachment-access.ts, replace owned-device logic with:
import { canAccessProject } from "@/lib/boss-permissions";
export function canSessionAccessAttachmentProject(
state: BossState,
session: Pick<AuthSession, "account" | "role" | "displayName">,
project: Pick<Project, "id" | "deviceIds" | "groupMembers">,
) {
return canAccessProject(state, session, project.id, "project.view");
}
If callers pass a Project type that does not include id, update the caller to pass the full project object.
- Step 6: Run tests
Run:
npx tsx --test tests/rbac-route-filtering.test.ts tests/project-messages-route.test.ts
Expected: all tests pass.
- Step 7: Commit
Run:
git add 'src/app/api/v1/devices/[deviceId]/skills/route.ts' src/lib/boss-device-auth.ts src/lib/boss-attachment-access.ts src/lib/boss-projections.ts tests/rbac-route-filtering.test.ts
git commit -m "feat: enforce rbac for device skills and writes"
Task 5: Scope Main Agent Context and Task Authorization
Files:
-
Modify:
src/lib/boss-master-agent.ts -
Modify:
src/lib/boss-data.ts -
Modify:
src/app/api/v1/master-agent/tasks/claim/route.ts -
Modify:
src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts -
Test:
tests/rbac-master-agent-scope.test.ts -
Step 1: Write failing main Agent scope tests
Create tests/rbac-master-agent-scope.test.ts:
import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
let runtimeRoot = "";
let data: typeof import("../src/lib/boss-data");
let master: typeof import("../src/lib/boss-master-agent");
async function setup() {
if (runtimeRoot) return;
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-rbac-master-agent-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
data = await import("../src/lib/boss-data.ts");
master = await import("../src/lib/boss-master-agent.ts");
}
test.after(async () => {
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
});
test.beforeEach(async () => {
await setup();
const state = await data.readState();
state.accountDeviceGrants = [
{
grantId: "grant-worker-mac-view",
account: "worker@example.com",
deviceId: "mac-studio",
permissions: ["device.view"],
grantedBy: "17600003315",
grantedAt: "2026-04-26T12:00:00+08:00",
},
];
await data.writeState(state);
});
test("authorized master agent scope excludes unauthorized devices and projects", async () => {
const state = await data.readState();
const scope = master.buildAuthorizedMasterAgentScopeForTest(state, {
account: "worker@example.com",
role: "member",
displayName: "Worker",
});
assert.equal(scope.devices.some((device: { id: string }) => device.id === "mac-studio"), true);
assert.equal(scope.devices.some((device: { id: string }) => device.id === "cloud-backup"), false);
assert.equal(scope.projects.every((project: { deviceIds: string[] }) => project.deviceIds.includes("mac-studio")), true);
});
- Step 2: Run test and verify failure
Run:
npx tsx --test tests/rbac-master-agent-scope.test.ts
Expected: fail because buildAuthorizedMasterAgentScopeForTest does not exist.
- Step 3: Add authorized scope builder
In src/lib/boss-master-agent.ts, import:
import type { AuthSession } from "@/lib/boss-data";
import { filterDevicesForSession, filterProjectsForSession } from "@/lib/boss-permissions";
Add:
function buildAuthorizedMasterAgentScope(
state: BossState,
session: Pick<AuthSession, "account" | "role" | "displayName">,
) {
const devices = filterDevicesForSession(state, session);
const deviceIds = new Set(devices.map((device) => device.id));
const projects = filterProjectsForSession(state, session).filter(
(project) =>
project.id === "master-agent" ||
project.deviceIds.some((deviceId) => deviceIds.has(deviceId)) ||
project.groupMembers.some((member) => deviceIds.has(member.deviceId)),
);
const projectIds = new Set(projects.map((project) => project.id));
return {
devices,
projects,
threadStatusDocuments: state.threadStatusDocuments.filter((doc) => projectIds.has(doc.projectId)),
threadProgressEvents: state.threadProgressEvents.filter((event) => projectIds.has(event.projectId)),
deviceSkills: state.deviceSkills.filter((skill) => deviceIds.has(skill.deviceId)),
};
}
export const buildAuthorizedMasterAgentScopeForTest =
process.env.NODE_ENV === "test" ? buildAuthorizedMasterAgentScope : buildAuthorizedMasterAgentScope;
Use this scope anywhere main Agent prompt context currently reads raw state.devices, state.projects, state.threadStatusDocuments, state.threadProgressEvents, or state.deviceSkills.
- Step 4: Persist requester identity into tasks
In src/lib/boss-data.ts, extend MasterAgentTask with:
requestedByAccount?: string;
authorizedDeviceIds?: string[];
authorizedProjectIds?: string[];
authorizedSkillIds?: string[];
requiredPermissions?: BossPermission[];
When queueing tasks from user-originated messages, set:
requestedByAccount: session.account,
authorizedDeviceIds: scope.devices.map((device) => device.id),
authorizedProjectIds: scope.projects.map((project) => project.id),
authorizedSkillIds: scope.deviceSkills.map((skill) => skill.skillId),
requiredPermissions: ["thread.chat"],
- Step 5: Add task claim authorization guard
In src/app/api/v1/master-agent/tasks/claim/route.ts, after a task is selected:
if (task.authorizedDeviceIds?.length && !task.authorizedDeviceIds.includes(deviceId)) {
await appendPermissionAuditLog({
actorAccount: task.requestedByAccount ?? "unknown",
action: "task.denied",
deviceId,
projectId: task.projectId,
detail: "Device attempted to claim a task outside authorizedDeviceIds.",
});
return NextResponse.json({ ok: false, message: "TASK_DEVICE_FORBIDDEN" }, { status: 403 });
}
If existing claim code picks tasks before knowing deviceId, move the filter into task selection so unauthorized tasks are not claimed.
- Step 6: Run main Agent scope test
Run:
npx tsx --test tests/rbac-master-agent-scope.test.ts
Expected: pass.
- Step 7: Run main Agent related tests
Run:
npx tsx --test tests/master-agent-chat-controls.test.ts tests/master-agent-thread-status-prompt.test.ts tests/master-agent-message-queue.test.ts tests/rbac-master-agent-scope.test.ts
Expected: all tests pass.
- Step 8: Commit
Run:
git add src/lib/boss-master-agent.ts src/lib/boss-data.ts src/app/api/v1/master-agent/tasks/claim/route.ts src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts tests/rbac-master-agent-scope.test.ts
git commit -m "feat: scope master agent context by rbac grants"
Task 6: Add Minimal Account and Permission Visibility in Android
Files:
-
Modify:
android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java -
Modify:
android/app/src/main/java/com/hyzq/boss/MainActivity.java -
Test:
android/app/src/test/java/com/hyzq/boss/BossRbacVisibilityTest.java -
Step 1: Write failing Android UI visibility tests
Create android/app/src/test/java/com/hyzq/boss/BossRbacVisibilityTest.java:
package com.hyzq.boss;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.json.JSONObject;
import org.junit.Test;
public class BossRbacVisibilityTest {
@Test
public void meMenuShowsAccessManagementForHighestAdminOnly() throws Exception {
JSONObject highestAdmin = new JSONObject()
.put("account", "17600003315")
.put("role", "highest_admin");
JSONObject member = new JSONObject()
.put("account", "worker@example.com")
.put("role", "member");
assertTrue(WechatSurfaceMapper.meMenuContainsAccessManagement(highestAdmin));
assertFalse(WechatSurfaceMapper.meMenuContainsAccessManagement(member));
}
@Test
public void unauthorizedMessageLabelIsReadable() {
assertTrue(WechatSurfaceMapper.permissionDeniedMessage("THREAD_CHAT_FORBIDDEN").contains("没有权限"));
assertTrue(WechatSurfaceMapper.permissionDeniedMessage("MASTER_AGENT_FORBIDDEN").contains("主 Agent"));
}
}
- Step 2: Run Android test and verify failure
Run:
cd android && ./gradlew testDebugUnitTest --tests "com.hyzq.boss.BossRbacVisibilityTest"
Expected: fail because the helper methods do not exist.
- Step 3: Add mapper helpers
In WechatSurfaceMapper.java, add:
public static boolean meMenuContainsAccessManagement(JSONObject session) {
return session != null && "highest_admin".equals(session.optString("role", ""));
}
public static String permissionDeniedMessage(String code) {
if ("THREAD_CHAT_FORBIDDEN".equals(code)) {
return "当前账号没有权限和这个线程对话。";
}
if ("MASTER_AGENT_FORBIDDEN".equals(code)) {
return "当前账号没有权限在这个会话里使用主 Agent。";
}
if ("COMPUTER_CONTROL_FORBIDDEN".equals(code)) {
return "当前账号没有权限控制这台电脑。";
}
return "当前账号没有权限执行这个操作。";
}
When building the Me root menu, add 账号与权限 only if meMenuContainsAccessManagement(sessionJson) is true.
- Step 4: Route access management entry
In MainActivity.java, handle the new menu key:
case "access_management":
Toast.makeText(this, "账号与权限管理正在接入 Web 管理端", Toast.LENGTH_SHORT).show();
break;
This first-stage Android entry is intentionally shallow; full native management UI is a later phase.
- Step 5: Run Android test
Run:
cd android && ./gradlew testDebugUnitTest --tests "com.hyzq.boss.BossRbacVisibilityTest"
Expected: pass.
- Step 6: Commit
Run:
git add android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java android/app/src/main/java/com/hyzq/boss/MainActivity.java android/app/src/test/java/com/hyzq/boss/BossRbacVisibilityTest.java
git commit -m "feat: add android rbac visibility affordances"
Task 7: Documentation and Full Regression
Files:
-
Modify:
README.md -
Modify:
docs/architecture/current_runtime_and_deploy_status_cn.md -
Modify:
docs/architecture/api_and_service_inventory_cn.md -
Step 1: Update docs
Add a short runtime note to README.md:
### 多用户权限第一阶段
- Boss 当前已经引入第一阶段 RBAC:设备、会话、项目、消息和 Skill 读取会按登录账号授权范围过滤。
- `highest_admin` 保持全局可见;子账号默认只能看到自己拥有或被授权的设备。
- 设备授权默认继承项目只读可见;线程聊天、主 Agent 接管、电脑控制和 Skill 使用必须显式授权。
- SkillHub 暂不作为权限源,Boss 自己负责最终授权,Gitea/SkillHub 只作为 Skill 来源。
Add the same operational facts to docs/architecture/current_runtime_and_deploy_status_cn.md and endpoint inventory to docs/architecture/api_and_service_inventory_cn.md.
- Step 2: Run focused TypeScript tests
Run:
npx tsx --test tests/boss-permissions.test.ts tests/rbac-route-filtering.test.ts tests/rbac-master-agent-scope.test.ts
Expected: all tests pass.
- Step 3: Run existing impacted tests
Run:
npx tsx --test tests/project-messages-route.test.ts tests/project-message-delete.test.ts tests/conversation-home-items.test.ts tests/master-agent-message-queue.test.ts
Expected: all tests pass.
- Step 4: Run lint and build
Run:
npm run lint
npm run build
Expected: both commands exit 0. If npm run build prints the known Turbopack warning for src/lib/boss-mail.ts, keep it as a known warning only if the build exits 0.
- Step 5: Run Android targeted and full unit tests
Run:
cd android && ./gradlew testDebugUnitTest --tests "com.hyzq.boss.BossRbacVisibilityTest"
cd android && ./gradlew testDebugUnitTest
Expected: targeted test passes, then full Android unit tests pass.
- Step 6: Build debug APK
Run:
npm run apk:debug
Expected: publishes public/downloads/boss-android-latest.apk and updates public/downloads/boss-android-latest.json.
- Step 7: Deploy server
Run:
./scripts/deploy-server.sh
curl -fsS https://boss.hyzq.net/api/health
Expected: deploy exits 0 and health returns {"ok":true,"service":"boss-web",...}.
- Step 8: Install to connected test phone if available
Run:
adb devices -l
device_serial="$(adb devices | awk 'NR>1 && $2=="device"{print $1; exit}')"
test -n "$device_serial"
adb -s "$device_serial" install -r public/downloads/boss-android-latest.apk
adb -s "$device_serial" shell monkey -p com.hyzq.boss -c android.intent.category.LAUNCHER 1
adb -s "$device_serial" logcat -d -t 500 | rg -i "FATAL EXCEPTION|AndroidRuntime|com\\.hyzq\\.boss" || true
Expected: install succeeds, app starts, and no Boss crash appears in the recent logcat scan.
- Step 9: Commit docs and release metadata
Run:
git add README.md docs/architecture/current_runtime_and_deploy_status_cn.md docs/architecture/api_and_service_inventory_cn.md public/downloads/boss-android-latest.apk public/downloads/boss-android-latest.json public/downloads/boss-android-v2.5.11-debug.apk
git commit -m "docs: record boss rbac foundation runtime"
Self-Review Checklist
- Spec coverage: This plan covers phase one only: permission data, central checks, API filtering, main Agent scope, minimal Android affordance, docs, build, deploy, and APK install.
- Deferred scope: account management UI, full Skill assignment UI, Gitea Skill sync, and SkillHub adapter are intentionally deferred to later phases.
- Permission boundary:
device.viewgives read visibility to projects;thread.chat,master_agent.takeover,computer.control, andskill.useremain explicit grants. - Compatibility:
device.account === session.accountremains a read-only fallback for existing users until explicit grants are stable. - No placeholders: Every task names exact files, commands, expected results, and concrete code shape.