feat: ship enterprise control and desktop governance
This commit is contained in:
614
src/app/api/v1/admin/access/route.ts
Normal file
614
src/app/api/v1/admin/access/route.ts
Normal file
@@ -0,0 +1,614 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { buildRequestAuditMeta } from "@/lib/boss-audit";
|
||||
import { requireCsrfSafeMutation } from "@/lib/boss-csrf";
|
||||
import { BOSS_PERMISSION_TEMPLATES } from "@/lib/boss-access-templates";
|
||||
import {
|
||||
appendPermissionAuditLog,
|
||||
assignAccountCompanyByAdmin,
|
||||
assignDeviceCompanyByAdmin,
|
||||
bulkImportAuthAccountsByAdmin,
|
||||
previewBulkImportAuthAccountsByAdmin,
|
||||
readState,
|
||||
reclaimAuthAccountByAdmin,
|
||||
resetAuthAccountPasswordByAdmin,
|
||||
revokeAccessGrant,
|
||||
saveAccountDeviceGrant,
|
||||
saveAccountProjectGrant,
|
||||
saveAccountSkillGrant,
|
||||
setAdminCompanyStatusByAdmin,
|
||||
setAuthAccountMfaRequiredByAdmin,
|
||||
setAuthAccountStatusByAdmin,
|
||||
upsertAdminCompanyByAdmin,
|
||||
upsertAuthAccountByAdmin,
|
||||
} from "@/lib/boss-data";
|
||||
import type { AuthAccount, AuthAccountStatus, AuthRole, BossPermission, Device, Project } from "@/lib/boss-data";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
|
||||
const validRoles = new Set<AuthRole>(["member", "admin", "highest_admin"]);
|
||||
const validImportRoles = new Set<AuthRole>(["member", "admin"]);
|
||||
const validAccountStatuses = new Set<AuthAccountStatus>(["active", "disabled"]);
|
||||
const validCompanyStatuses = new Set(["active", "disabled"]);
|
||||
const validCompanyPlanTiers = new Set(["trial", "standard", "enterprise"]);
|
||||
const validPermissions = 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 publicAuthAccount(account: AuthAccount) {
|
||||
const { passwordHash, mfaSecret, ...safeAccount } = account;
|
||||
void passwordHash;
|
||||
void mfaSecret;
|
||||
return safeAccount;
|
||||
}
|
||||
|
||||
function publicAdminDevice(device: Device) {
|
||||
return {
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
avatar: device.avatar,
|
||||
account: device.account,
|
||||
status: device.status,
|
||||
companyId: device.companyId,
|
||||
source: device.source,
|
||||
lastSeenAt: device.lastSeenAt,
|
||||
preferredExecutionMode: device.preferredExecutionMode,
|
||||
capabilities: device.capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
function publicAdminProject(project: Project) {
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
deviceIds: project.deviceIds,
|
||||
threadMeta: project.threadMeta,
|
||||
isGroup: project.isGroup,
|
||||
updatedAt: project.updatedAt,
|
||||
lastMessageAt: project.lastMessageAt,
|
||||
collaborationMode: project.collaborationMode,
|
||||
approvalState: project.approvalState,
|
||||
riskLevel: project.riskLevel,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSkillCatalog(skills: Array<{
|
||||
skillId: string;
|
||||
deviceId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
invocation: string;
|
||||
category: string;
|
||||
updatedAt: string;
|
||||
}>) {
|
||||
const catalog = new Map<string, {
|
||||
name: string;
|
||||
invocation: string;
|
||||
description: string;
|
||||
deviceCount: number;
|
||||
devices: Array<{ skillId: string; deviceId: string; path: string; category: string; updatedAt: string }>;
|
||||
}>();
|
||||
|
||||
for (const skill of skills) {
|
||||
const key = skill.name.trim() || skill.skillId;
|
||||
const existing = catalog.get(key) ?? {
|
||||
name: key,
|
||||
invocation: skill.invocation,
|
||||
description: skill.description,
|
||||
deviceCount: 0,
|
||||
devices: [],
|
||||
};
|
||||
existing.devices.push({
|
||||
skillId: skill.skillId,
|
||||
deviceId: skill.deviceId,
|
||||
path: skill.path,
|
||||
category: skill.category,
|
||||
updatedAt: skill.updatedAt,
|
||||
});
|
||||
existing.deviceCount = existing.devices.length;
|
||||
catalog.set(key, existing);
|
||||
}
|
||||
|
||||
return [...catalog.values()].sort((left, right) => left.name.localeCompare(right.name, "zh-CN"));
|
||||
}
|
||||
|
||||
function forbidden() {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
async function requireHighestAdmin(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return { ok: false as const, response: jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }) };
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return { ok: false as const, response: forbidden() };
|
||||
}
|
||||
return { ok: true as const, session };
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function permissionValues(value: unknown): BossPermission[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((item): item is BossPermission =>
|
||||
typeof item === "string" && validPermissions.has(item as BossPermission),
|
||||
);
|
||||
}
|
||||
|
||||
function stringArray(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map(stringValue).filter(Boolean);
|
||||
}
|
||||
|
||||
function accountImportValues(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.filter((item): item is Record<string, unknown> => item !== null && typeof item === "object")
|
||||
.map((item) => ({
|
||||
account: stringValue(item.account),
|
||||
displayName: stringValue(item.displayName),
|
||||
role: validImportRoles.has(stringValue(item.role) as AuthRole) ? (stringValue(item.role) as AuthRole) : "member",
|
||||
password: stringValue(item.password),
|
||||
verificationEmail: stringValue(item.verificationEmail),
|
||||
}))
|
||||
.filter((item) => Boolean(item.account));
|
||||
}
|
||||
|
||||
function idSegment(value: string) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item";
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireHighestAdmin(request);
|
||||
if (!auth.ok) {
|
||||
return auth.response;
|
||||
}
|
||||
const state = await readState();
|
||||
return jsonNoStore({
|
||||
ok: true,
|
||||
companies: state.adminCompanies,
|
||||
accounts: state.authAccounts.map(publicAuthAccount),
|
||||
devices: state.devices.map(publicAdminDevice),
|
||||
projects: state.projects.map(publicAdminProject),
|
||||
skills: state.deviceSkills,
|
||||
skillCatalog: buildSkillCatalog(state.deviceSkills),
|
||||
permissionTemplates: BOSS_PERMISSION_TEMPLATES,
|
||||
grants: {
|
||||
devices: state.accountDeviceGrants,
|
||||
projects: state.accountProjectGrants,
|
||||
skills: state.accountSkillGrants,
|
||||
},
|
||||
auditLogs: state.permissionAuditLogs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const csrf = requireCsrfSafeMutation(request);
|
||||
if (csrf) return csrf;
|
||||
|
||||
const auth = await requireHighestAdmin(request);
|
||||
if (!auth.ok) {
|
||||
return auth.response;
|
||||
}
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const action = stringValue(body.action);
|
||||
const auditMeta = buildRequestAuditMeta(request);
|
||||
|
||||
if (action === "upsert_account") {
|
||||
const account = stringValue(body.account);
|
||||
const role = stringValue(body.role) as AuthRole;
|
||||
if (!account || !validRoles.has(role)) {
|
||||
return jsonNoStore({ ok: false, message: "ACCOUNT_OR_ROLE_INVALID" }, { status: 400 });
|
||||
}
|
||||
const saved = await upsertAuthAccountByAdmin({
|
||||
account,
|
||||
displayName: stringValue(body.displayName),
|
||||
role,
|
||||
password: stringValue(body.password),
|
||||
verificationEmail: stringValue(body.verificationEmail),
|
||||
companyId: stringValue(body.companyId) || undefined,
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, account: publicAuthAccount(saved) });
|
||||
}
|
||||
|
||||
if (action === "set_account_status") {
|
||||
const account = stringValue(body.account);
|
||||
const status = stringValue(body.status) as AuthAccountStatus;
|
||||
if (!account || !validAccountStatuses.has(status)) {
|
||||
return jsonNoStore({ ok: false, message: "ACCOUNT_STATUS_INVALID" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const saved = await setAuthAccountStatusByAdmin({
|
||||
account,
|
||||
status,
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, account: publicAuthAccount(saved) });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore(
|
||||
{ ok: false, message },
|
||||
{ status: message === "ACCOUNT_NOT_FOUND" ? 404 : 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "set_account_mfa_required") {
|
||||
const account = stringValue(body.account);
|
||||
if (!account) {
|
||||
return jsonNoStore({ ok: false, message: "ACCOUNT_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const saved = await setAuthAccountMfaRequiredByAdmin({
|
||||
account,
|
||||
required: body.required === true,
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({
|
||||
ok: true,
|
||||
account: publicAuthAccount(saved),
|
||||
mfaSetupSecret: body.required === true ? saved.mfaSecret : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore({ ok: false, message }, { status: message === "ACCOUNT_NOT_FOUND" ? 404 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "reset_account_password") {
|
||||
const account = stringValue(body.account);
|
||||
const password = stringValue(body.password);
|
||||
if (!account || !password) {
|
||||
return jsonNoStore({ ok: false, message: "ACCOUNT_OR_PASSWORD_INVALID" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const saved = await resetAuthAccountPasswordByAdmin({
|
||||
account,
|
||||
password,
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, account: publicAuthAccount(saved) });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore(
|
||||
{ ok: false, message },
|
||||
{ status: message === "ACCOUNT_NOT_FOUND" ? 404 : 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "upsert_company") {
|
||||
const companyId = stringValue(body.companyId);
|
||||
if (!companyId) {
|
||||
return jsonNoStore({ ok: false, message: "COMPANY_ID_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
const company = await upsertAdminCompanyByAdmin({
|
||||
companyId,
|
||||
name: stringValue(body.name),
|
||||
ownerAccount: stringValue(body.ownerAccount),
|
||||
successOwnerAccount: stringValue(body.successOwnerAccount),
|
||||
planTier: validCompanyPlanTiers.has(stringValue(body.planTier))
|
||||
? (stringValue(body.planTier) as "trial" | "standard" | "enterprise")
|
||||
: undefined,
|
||||
contractExpiresAt: stringValue(body.contractExpiresAt),
|
||||
note: stringValue(body.note),
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, company });
|
||||
}
|
||||
|
||||
if (action === "set_company_status") {
|
||||
const companyId = stringValue(body.companyId);
|
||||
const status = stringValue(body.status);
|
||||
if (!companyId || !validCompanyStatuses.has(status)) {
|
||||
return jsonNoStore({ ok: false, message: "COMPANY_STATUS_INVALID" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const result = await setAdminCompanyStatusByAdmin({
|
||||
companyId,
|
||||
status: status as "active" | "disabled",
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, ...result });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore({ ok: false, message }, { status: message === "COMPANY_NOT_FOUND" ? 404 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "assign_account_company") {
|
||||
const account = stringValue(body.account);
|
||||
const companyId = stringValue(body.companyId);
|
||||
if (!account || !companyId) {
|
||||
return jsonNoStore({ ok: false, message: "ACCOUNT_OR_COMPANY_INVALID" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const saved = await assignAccountCompanyByAdmin({
|
||||
account,
|
||||
companyId,
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, account: publicAuthAccount(saved) });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore({ ok: false, message }, { status: message.endsWith("_NOT_FOUND") ? 404 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "assign_device_company") {
|
||||
const deviceId = stringValue(body.deviceId);
|
||||
const companyId = stringValue(body.companyId);
|
||||
if (!deviceId || !companyId) {
|
||||
return jsonNoStore({ ok: false, message: "DEVICE_OR_COMPANY_INVALID" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const device = await assignDeviceCompanyByAdmin({
|
||||
deviceId,
|
||||
companyId,
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, device });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore({ ok: false, message }, { status: message.endsWith("_NOT_FOUND") ? 404 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "bulk_import_accounts") {
|
||||
const companyId = stringValue(body.companyId);
|
||||
const accounts = accountImportValues(body.accounts);
|
||||
if (!companyId || accounts.length === 0) {
|
||||
return jsonNoStore({ ok: false, message: "BULK_IMPORT_INVALID" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const imported = await bulkImportAuthAccountsByAdmin({
|
||||
companyId,
|
||||
accounts,
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, imported: imported.map(publicAuthAccount) });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore({ ok: false, message }, { status: message.endsWith("_NOT_FOUND") ? 404 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "preview_bulk_import_accounts") {
|
||||
const companyId = stringValue(body.companyId);
|
||||
const accounts = accountImportValues(body.accounts);
|
||||
if (!companyId || accounts.length === 0) {
|
||||
return jsonNoStore({ ok: false, message: "BULK_IMPORT_INVALID" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const preview = await previewBulkImportAuthAccountsByAdmin({
|
||||
companyId,
|
||||
accounts,
|
||||
});
|
||||
return jsonNoStore({ ok: true, preview });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore({ ok: false, message }, { status: message === "COMPANY_NOT_FOUND" ? 404 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "reclaim_account") {
|
||||
const account = stringValue(body.account);
|
||||
if (!account) {
|
||||
return jsonNoStore({ ok: false, message: "ACCOUNT_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const result = await reclaimAuthAccountByAdmin({
|
||||
account,
|
||||
reason: stringValue(body.reason),
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({
|
||||
ok: true,
|
||||
account: publicAuthAccount(result.account),
|
||||
removedGrants: result.removedGrants,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore({ ok: false, message }, { status: message === "ACCOUNT_NOT_FOUND" ? 404 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "grant_device") {
|
||||
const account = stringValue(body.account);
|
||||
const deviceId = stringValue(body.deviceId);
|
||||
const permissions = permissionValues(body.permissions);
|
||||
if (!account || !deviceId || permissions.length === 0) {
|
||||
return jsonNoStore({ ok: false, message: "DEVICE_GRANT_INVALID" }, { status: 400 });
|
||||
}
|
||||
const grant = await saveAccountDeviceGrant({
|
||||
grantId: stringValue(body.grantId) || undefined,
|
||||
account,
|
||||
deviceId,
|
||||
permissions,
|
||||
grantedBy: auth.session.account,
|
||||
expiresAt: stringValue(body.expiresAt) || undefined,
|
||||
note: stringValue(body.note) || undefined,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, grant });
|
||||
}
|
||||
|
||||
if (action === "grant_project") {
|
||||
const account = stringValue(body.account);
|
||||
const projectId = stringValue(body.projectId);
|
||||
const permissions = permissionValues(body.permissions);
|
||||
if (!account || !projectId || permissions.length === 0) {
|
||||
return jsonNoStore({ ok: false, message: "PROJECT_GRANT_INVALID" }, { status: 400 });
|
||||
}
|
||||
const grant = await saveAccountProjectGrant({
|
||||
grantId: stringValue(body.grantId) || undefined,
|
||||
account,
|
||||
projectId,
|
||||
deviceId: stringValue(body.deviceId) || undefined,
|
||||
permissions,
|
||||
inheritFromDeviceGrant: body.inheritFromDeviceGrant === true,
|
||||
grantedBy: auth.session.account,
|
||||
expiresAt: stringValue(body.expiresAt) || undefined,
|
||||
note: stringValue(body.note) || undefined,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, grant });
|
||||
}
|
||||
|
||||
if (action === "grant_skill") {
|
||||
const account = stringValue(body.account);
|
||||
const skillId = stringValue(body.skillId);
|
||||
const permissions = permissionValues(body.permissions);
|
||||
if (!account || !skillId || permissions.length === 0) {
|
||||
return jsonNoStore({ ok: false, message: "SKILL_GRANT_INVALID" }, { status: 400 });
|
||||
}
|
||||
const grant = await saveAccountSkillGrant({
|
||||
grantId: stringValue(body.grantId) || undefined,
|
||||
account,
|
||||
skillId,
|
||||
deviceId: stringValue(body.deviceId) || undefined,
|
||||
projectId: stringValue(body.projectId) || undefined,
|
||||
permissions,
|
||||
grantedBy: auth.session.account,
|
||||
expiresAt: stringValue(body.expiresAt) || undefined,
|
||||
note: stringValue(body.note) || undefined,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, grant });
|
||||
}
|
||||
|
||||
if (action === "apply_template") {
|
||||
const account = stringValue(body.account);
|
||||
const templateId = stringValue(body.templateId);
|
||||
const template = BOSS_PERMISSION_TEMPLATES.find((item) => item.templateId === templateId);
|
||||
const deviceIds = stringArray(body.deviceIds);
|
||||
const projectIds = stringArray(body.projectIds);
|
||||
const skillIds = stringArray(body.skillIds);
|
||||
if (!account || !template || deviceIds.length + projectIds.length + skillIds.length === 0) {
|
||||
return jsonNoStore({ ok: false, message: "ACCESS_TEMPLATE_INVALID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
if (!state.authAccounts.some((item) => item.account === account)) {
|
||||
return jsonNoStore({ ok: false, message: "ACCOUNT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
const missingDevice = deviceIds.find((deviceId) => !state.devices.some((device) => device.id === deviceId));
|
||||
const missingProject = projectIds.find((projectId) => !state.projects.some((project) => project.id === projectId));
|
||||
const missingSkill = skillIds.find((skillId) => !state.deviceSkills.some((skill) => skill.skillId === skillId));
|
||||
if (missingDevice || missingProject || missingSkill) {
|
||||
return jsonNoStore({
|
||||
ok: false,
|
||||
message: "ACCESS_TEMPLATE_TARGET_NOT_FOUND",
|
||||
missingDevice,
|
||||
missingProject,
|
||||
missingSkill,
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
const deviceGrants = [];
|
||||
for (const deviceId of deviceIds) {
|
||||
deviceGrants.push(await saveAccountDeviceGrant({
|
||||
grantId: `grant-template-${idSegment(templateId)}-device-${idSegment(account)}-${idSegment(deviceId)}`,
|
||||
account,
|
||||
deviceId,
|
||||
permissions: template.devicePermissions,
|
||||
grantedBy: auth.session.account,
|
||||
note: template.name,
|
||||
auditMeta,
|
||||
}));
|
||||
}
|
||||
|
||||
const projectGrants = [];
|
||||
for (const projectId of projectIds) {
|
||||
projectGrants.push(await saveAccountProjectGrant({
|
||||
grantId: `grant-template-${idSegment(templateId)}-project-${idSegment(account)}-${idSegment(projectId)}`,
|
||||
account,
|
||||
projectId,
|
||||
permissions: template.projectPermissions,
|
||||
grantedBy: auth.session.account,
|
||||
note: template.name,
|
||||
auditMeta,
|
||||
}));
|
||||
}
|
||||
|
||||
const skillGrants = [];
|
||||
for (const skillId of skillIds) {
|
||||
const skill = state.deviceSkills.find((item) => item.skillId === skillId);
|
||||
skillGrants.push(await saveAccountSkillGrant({
|
||||
grantId: `grant-template-${idSegment(templateId)}-skill-${idSegment(account)}-${idSegment(skillId)}`,
|
||||
account,
|
||||
skillId,
|
||||
deviceId: skill?.deviceId,
|
||||
permissions: template.skillPermissions,
|
||||
grantedBy: auth.session.account,
|
||||
note: template.name,
|
||||
auditMeta,
|
||||
}));
|
||||
}
|
||||
|
||||
await appendPermissionAuditLog({
|
||||
actorAccount: auth.session.account,
|
||||
action: "grant.updated",
|
||||
targetAccount: account,
|
||||
permissions: [
|
||||
...template.devicePermissions,
|
||||
...template.projectPermissions,
|
||||
...template.skillPermissions,
|
||||
],
|
||||
detail: `template:${template.templateId}`,
|
||||
...auditMeta,
|
||||
});
|
||||
|
||||
return jsonNoStore({
|
||||
ok: true,
|
||||
template,
|
||||
grants: {
|
||||
devices: deviceGrants,
|
||||
projects: projectGrants,
|
||||
skills: skillGrants,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "revoke_grant") {
|
||||
const grantId = stringValue(body.grantId);
|
||||
if (!grantId) {
|
||||
return jsonNoStore({ ok: false, message: "GRANT_ID_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
const grant = await revokeAccessGrant(grantId, auth.session.account, auditMeta);
|
||||
return jsonNoStore({ ok: true, grant });
|
||||
}
|
||||
|
||||
return jsonNoStore({ ok: false, message: "UNKNOWN_ACTION" }, { status: 400 });
|
||||
}
|
||||
219
src/app/api/v1/admin/backoffice/route.ts
Normal file
219
src/app/api/v1/admin/backoffice/route.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { BOSS_PERMISSION_TEMPLATES } from "@/lib/boss-access-templates";
|
||||
import { buildAdminOverview } from "@/lib/boss-admin-overview";
|
||||
import { readState, type BossState } from "@/lib/boss-data";
|
||||
|
||||
const MENU_TREE = [
|
||||
{ key: "workbench", label: "工作台" },
|
||||
{ key: "tenant", label: "租户管理" },
|
||||
{ key: "user", label: "账号管理" },
|
||||
{ key: "role", label: "角色权限" },
|
||||
{
|
||||
key: "resource",
|
||||
label: "资源授权",
|
||||
children: [
|
||||
{ key: "resource.devices", label: "设备资源" },
|
||||
{ key: "resource.projects", label: "项目线程" },
|
||||
{ key: "resource.skills", label: "Skill 资源" },
|
||||
],
|
||||
},
|
||||
{ key: "skills", label: "Skill 中心" },
|
||||
{ key: "risk", label: "风险告警" },
|
||||
{ key: "audit", label: "审计日志" },
|
||||
{ key: "system", label: "系统设置" },
|
||||
] as const;
|
||||
|
||||
function companyNameMap(state: BossState) {
|
||||
return new Map(state.adminCompanies.map((company) => [company.companyId, company.name]));
|
||||
}
|
||||
|
||||
function companyNameFor(state: BossState, companyId?: string) {
|
||||
if (!companyId || companyId === "default") return "默认公司";
|
||||
return companyNameMap(state).get(companyId) ?? companyId;
|
||||
}
|
||||
|
||||
function safeUsers(state: BossState) {
|
||||
return state.authAccounts.map((account) => ({
|
||||
id: account.id,
|
||||
account: account.account,
|
||||
displayName: account.displayName,
|
||||
role: account.role,
|
||||
status: account.status ?? "active",
|
||||
companyId: account.companyId ?? "default",
|
||||
companyName: companyNameFor(state, account.companyId),
|
||||
primaryDeviceId: account.primaryDeviceId,
|
||||
codexNodeId: account.codexNodeId,
|
||||
codexNodeLabel: account.codexNodeLabel,
|
||||
lastLoginAt: account.lastLoginAt,
|
||||
lastLoginMethod: account.lastLoginMethod,
|
||||
mfaRequired: Boolean(account.mfaRequired),
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
function skillResources(state: BossState) {
|
||||
const byName = new Map<
|
||||
string,
|
||||
{
|
||||
skillId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category?: string;
|
||||
invocation?: string;
|
||||
sourceType: "device" | "catalog";
|
||||
deviceCount: number;
|
||||
devices: Array<{ deviceId: string; updatedAt: string }>;
|
||||
updatedAt: string;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const skill of state.deviceSkills) {
|
||||
const existing = byName.get(skill.name);
|
||||
if (existing) {
|
||||
existing.deviceCount += existing.devices.some((device) => device.deviceId === skill.deviceId) ? 0 : 1;
|
||||
existing.devices.push({ deviceId: skill.deviceId, updatedAt: skill.updatedAt });
|
||||
if (skill.updatedAt.localeCompare(existing.updatedAt) > 0) {
|
||||
existing.updatedAt = skill.updatedAt;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
byName.set(skill.name, {
|
||||
skillId: skill.skillId,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
category: skill.category,
|
||||
invocation: skill.invocation,
|
||||
sourceType: "device",
|
||||
deviceCount: 1,
|
||||
devices: [{ deviceId: skill.deviceId, updatedAt: skill.updatedAt }],
|
||||
updatedAt: skill.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
for (const catalogItem of state.skillCatalog) {
|
||||
if (byName.has(catalogItem.name)) continue;
|
||||
byName.set(catalogItem.name, {
|
||||
skillId: catalogItem.skillId,
|
||||
name: catalogItem.name,
|
||||
description: catalogItem.description,
|
||||
category: catalogItem.category,
|
||||
sourceType: "catalog",
|
||||
deviceCount: 0,
|
||||
devices: [],
|
||||
updatedAt: catalogItem.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return [...byName.values()].sort(
|
||||
(left, right) => right.deviceCount - left.deviceCount || left.name.localeCompare(right.name, "zh-CN"),
|
||||
);
|
||||
}
|
||||
|
||||
function projectResources(state: BossState) {
|
||||
return state.projects.map((project) => ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
deviceIds: project.deviceIds,
|
||||
deviceCount: project.deviceIds.length,
|
||||
folderName: project.threadMeta.folderName,
|
||||
threadId: project.threadMeta.threadId,
|
||||
threadDisplayName: project.threadMeta.threadDisplayName,
|
||||
isGroup: project.isGroup,
|
||||
collaborationMode: project.collaborationMode,
|
||||
unreadCount: project.unreadCount,
|
||||
riskLevel: project.riskLevel,
|
||||
updatedAt: project.updatedAt,
|
||||
lastMessageAt: project.lastMessageAt,
|
||||
}));
|
||||
}
|
||||
|
||||
function rolesContract() {
|
||||
return {
|
||||
builtInRoles: [
|
||||
{
|
||||
role: "highest_admin",
|
||||
label: "超级管理员",
|
||||
description: "平台侧最高权限,可管理全部公司、账号、设备、项目、Skill、风险和审计。",
|
||||
},
|
||||
{
|
||||
role: "admin",
|
||||
label: "企业管理员",
|
||||
description: "企业内管理员,按授权范围管理本公司资源。",
|
||||
},
|
||||
{
|
||||
role: "member",
|
||||
label: "成员账号",
|
||||
description: "企业子账号,只能访问已分配的电脑、项目和 Skill。",
|
||||
},
|
||||
],
|
||||
permissionTemplates: BOSS_PERMISSION_TEMPLATES,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBackofficePayload(state: BossState) {
|
||||
const overview = buildAdminOverview(state);
|
||||
const skills = skillResources(state);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
menuTree: MENU_TREE,
|
||||
workbench: {
|
||||
summary: overview.summary,
|
||||
companies: overview.companies.slice(0, 10),
|
||||
devices: overview.devices.slice(0, 20),
|
||||
risks: overview.risks.slice(0, 20),
|
||||
notifications: overview.notifications,
|
||||
grantsSummary: overview.grantsSummary,
|
||||
},
|
||||
tenants: overview.companies.map((company) => ({
|
||||
...company,
|
||||
lifecycleStatus: company.status ?? "active",
|
||||
})),
|
||||
users: safeUsers(state),
|
||||
roles: rolesContract(),
|
||||
resourceGroups: {
|
||||
devices: overview.devices,
|
||||
projects: projectResources(state),
|
||||
skills,
|
||||
grants: {
|
||||
devices: state.accountDeviceGrants,
|
||||
projects: state.accountProjectGrants,
|
||||
skills: state.accountSkillGrants,
|
||||
},
|
||||
},
|
||||
audit: {
|
||||
risks: overview.risks,
|
||||
notifications: overview.notifications,
|
||||
riskTimeline: overview.riskTimeline,
|
||||
permissionLogs: state.permissionAuditLogs
|
||||
.slice()
|
||||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
||||
.slice(0, 100),
|
||||
},
|
||||
yudaoMapping: {
|
||||
tenant: "adminCompanies",
|
||||
user: "authAccounts",
|
||||
role: "BOSS_PERMISSION_TEMPLATES",
|
||||
menu: "menuTree",
|
||||
operateLog: "permissionAuditLogs",
|
||||
resource: "devices/projects/deviceSkills",
|
||||
risk: "opsFaults/threadContextAlerts/masterAgentTasks/adminNotifications",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
return jsonNoStore(buildBackofficePayload(state));
|
||||
}
|
||||
29
src/app/api/v1/admin/notifications/dispatch/route.ts
Normal file
29
src/app/api/v1/admin/notifications/dispatch/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { requireCsrfSafeMutation } from "@/lib/boss-csrf";
|
||||
import { dispatchAdminRiskNotifications } from "@/lib/boss-data";
|
||||
|
||||
function forbidden() {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const csrf = requireCsrfSafeMutation(request);
|
||||
if (csrf) return csrf;
|
||||
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { max?: number };
|
||||
const result = await dispatchAdminRiskNotifications({
|
||||
actorAccount: session.account,
|
||||
max: typeof body.max === "number" && Number.isFinite(body.max) ? body.max : undefined,
|
||||
});
|
||||
return jsonNoStore({ ok: true, ...result });
|
||||
}
|
||||
21
src/app/api/v1/admin/overview/route.ts
Normal file
21
src/app/api/v1/admin/overview/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { buildAdminOverview } from "@/lib/boss-admin-overview";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
return jsonNoStore({
|
||||
ok: true,
|
||||
...buildAdminOverview(state),
|
||||
});
|
||||
}
|
||||
67
src/app/api/v1/admin/risks/actions/route.ts
Normal file
67
src/app/api/v1/admin/risks/actions/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { requireCsrfSafeMutation } from "@/lib/boss-csrf";
|
||||
import { handleAdminRiskAction, type AdminRiskAction } from "@/lib/boss-data";
|
||||
|
||||
const validActions = new Set<AdminRiskAction>(["ack", "resolve", "create_repair_ticket", "assign_owner", "set_sla"]);
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function statusForError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
if (message === "RISK_TARGET_NOT_FOUND") return 404;
|
||||
if (
|
||||
message === "RISK_ACTION_UNSUPPORTED" ||
|
||||
message === "RISK_ID_INVALID" ||
|
||||
message === "RISK_OWNER_REQUIRED" ||
|
||||
message === "RISK_SLA_REQUIRED"
|
||||
) return 400;
|
||||
return 500;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const csrf = requireCsrfSafeMutation(request);
|
||||
if (csrf) return csrf;
|
||||
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const riskId = stringValue(body.riskId);
|
||||
const action = stringValue(body.action) as AdminRiskAction;
|
||||
if (!riskId || !validActions.has(action)) {
|
||||
return jsonNoStore({ ok: false, message: "RISK_ACTION_INVALID" }, { status: 400 });
|
||||
}
|
||||
const ownerAccount = stringValue(body.ownerAccount);
|
||||
const slaDueAt = stringValue(body.slaDueAt);
|
||||
const note = stringValue(body.note);
|
||||
if (action === "assign_owner" && !ownerAccount) {
|
||||
return jsonNoStore({ ok: false, message: "RISK_OWNER_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
if (action === "set_sla" && !slaDueAt) {
|
||||
return jsonNoStore({ ok: false, message: "RISK_SLA_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handleAdminRiskAction({
|
||||
riskId,
|
||||
action,
|
||||
actorAccount: session.account,
|
||||
ownerAccount,
|
||||
slaDueAt,
|
||||
note,
|
||||
});
|
||||
return jsonNoStore({ ok: true, riskId, action, ...result });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore({ ok: false, message }, { status: statusForError(error) });
|
||||
}
|
||||
}
|
||||
27
src/app/api/v1/admin/risks/scan/route.ts
Normal file
27
src/app/api/v1/admin/risks/scan/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { requireCsrfSafeMutation } from "@/lib/boss-csrf";
|
||||
import { scanAdminRiskNotifications } from "@/lib/boss-data";
|
||||
|
||||
function forbidden() {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const csrf = requireCsrfSafeMutation(request);
|
||||
if (csrf) return csrf;
|
||||
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const result = await scanAdminRiskNotifications({
|
||||
actorAccount: session.account,
|
||||
});
|
||||
return jsonNoStore({ ok: true, ...result });
|
||||
}
|
||||
110
src/app/api/v1/admin/skills/requests/route.ts
Normal file
110
src/app/api/v1/admin/skills/requests/route.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { buildRequestAuditMeta } from "@/lib/boss-audit";
|
||||
import { requireCsrfSafeMutation } from "@/lib/boss-csrf";
|
||||
import {
|
||||
createSkillLifecycleRequest,
|
||||
readState,
|
||||
type SkillLifecycleAction,
|
||||
} from "@/lib/boss-data";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
|
||||
const validActions = new Set<SkillLifecycleAction>([
|
||||
"install",
|
||||
"update",
|
||||
"uninstall",
|
||||
"rollback",
|
||||
"version_lock",
|
||||
]);
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function forbidden() {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
async function requireHighestAdmin(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return { ok: false as const, response: jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }) };
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return { ok: false as const, response: forbidden() };
|
||||
}
|
||||
return { ok: true as const, session };
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireHighestAdmin(request);
|
||||
if (!auth.ok) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
return jsonNoStore({
|
||||
ok: true,
|
||||
requests: [...state.skillLifecycleRequests].sort((left, right) =>
|
||||
right.requestedAt.localeCompare(left.requestedAt),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const csrf = requireCsrfSafeMutation(request);
|
||||
if (csrf) return csrf;
|
||||
|
||||
const auth = await requireHighestAdmin(request);
|
||||
if (!auth.ok) {
|
||||
return auth.response;
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const action = stringValue(body.action) as SkillLifecycleAction;
|
||||
const deviceId = stringValue(body.deviceId);
|
||||
const skillId = stringValue(body.skillId);
|
||||
const sourceUrl = stringValue(body.sourceUrl);
|
||||
const trustedSource = stringValue(body.trustedSource);
|
||||
const trustedSourceId = stringValue(body.trustedSourceId);
|
||||
|
||||
if (!validActions.has(action)) {
|
||||
return jsonNoStore({ ok: false, message: "SKILL_ACTION_INVALID" }, { status: 400 });
|
||||
}
|
||||
if (!deviceId) {
|
||||
return jsonNoStore({ ok: false, message: "DEVICE_ID_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
if (!skillId && !sourceUrl && !trustedSource && !trustedSourceId) {
|
||||
return jsonNoStore({ ok: false, message: "SKILL_ID_OR_SOURCE_URL_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
if (!state.devices.some((device) => device.id === deviceId)) {
|
||||
return jsonNoStore({ ok: false, message: "DEVICE_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
if (
|
||||
skillId &&
|
||||
!state.deviceSkills.some((skill) => skill.deviceId === deviceId && skill.skillId === skillId)
|
||||
) {
|
||||
return jsonNoStore({ ok: false, message: "SKILL_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
const requestRecord = await createSkillLifecycleRequest({
|
||||
action,
|
||||
deviceId,
|
||||
skillId: skillId || undefined,
|
||||
sourceUrl: sourceUrl || undefined,
|
||||
trustedSource: trustedSource || undefined,
|
||||
trustedSourceId: trustedSourceId || undefined,
|
||||
checksum: stringValue(body.checksum) || undefined,
|
||||
expectedChecksum: stringValue(body.expectedChecksum) || undefined,
|
||||
targetVersion: stringValue(body.targetVersion) || undefined,
|
||||
rollbackToVersion: stringValue(body.rollbackToVersion) || undefined,
|
||||
lockedVersion: stringValue(body.lockedVersion) || undefined,
|
||||
requestedBy: auth.session.account,
|
||||
note: stringValue(body.note) || undefined,
|
||||
auditMeta: buildRequestAuditMeta(request),
|
||||
});
|
||||
|
||||
return jsonNoStore({ ok: true, request: requestRecord });
|
||||
}
|
||||
Reference in New Issue
Block a user