feat: harden tenant access and production operations

This commit is contained in:
xz
2026-08-24 22:02:22 +08:00
parent ffb27d845b
commit 9811469a64
18 changed files with 709 additions and 36 deletions
+21 -4
View File
@@ -141,6 +141,7 @@ import { createSsoTicket, handleOidcCallback, redeemSsoTicket, startOidcLogin }
import { handleSamlCallback, isSamlProvider, samlServiceProviderMetadata, startSamlLogin } from "./saml.mjs";
import { listWorkItems } from "./work-items.mjs";
import { addTaskLink, createProjectTask, createTaskComment, getProjectTask, listProjectActivity, listProjectTasks, listTaskComments, removeTaskLink, updateProjectTask } from "./tasks.mjs";
import { searchPlatform } from "./search.mjs";
import { consumeRateLimit, rateLimitHeaders, rateLimitIdentity } from "./rate-limit.mjs";
import { createDeliveryAccessLink, listDeliveryAccessFeedback, listDeliveryAccessLinks, readPublicDeliveryFile, resolvePublicDeliveryPortal, revokeDeliveryAccessLink, submitPublicDeliveryFeedback } from "./delivery-portal.mjs";
@@ -953,12 +954,17 @@ function createInvitation(context, organizationId, body) {
throw httpError(409, "invitation_already_pending", "该邮箱已有待处理邀请,请重新发送或先撤销旧邀请", { email });
}
assertOrganizationSeatAvailable(organizationId, { email });
const roleKey = body.roleKey || "writer";
if (!dbGet("SELECT key FROM roles WHERE key = ?", [roleKey])) throw httpError(400, "role_invalid", "邀请角色不存在", { roleKey });
const workspaceId = body.workspaceId || null;
if (workspaceId && !dbGet("SELECT id FROM workspaces WHERE id = ? AND organization_id = ?", [workspaceId, organizationId])) throw httpError(400, "workspace_invalid", "邀请目标工作区不属于当前组织");
const projectId = body.projectId || null;
if (projectId && !workspaceId) throw httpError(400, "workspace_required_for_project_invitation", "项目级邀请必须同时指定所属工作区");
if (projectId && !dbGet("SELECT id FROM projects WHERE id = ? AND workspace_id = ?", [projectId, workspaceId])) throw httpError(400, "project_invalid", "邀请目标项目不属于当前工作区");
const invitationScope = projectId ? "project" : workspaceId ? "workspace" : "organization";
const defaultRole = invitationScope === "project" ? "project_editor" : invitationScope === "workspace" ? "writer" : "org_member";
const requestedRoleKey = String(body.roleKey || defaultRole).trim();
const role = roleForScope(requestedRoleKey, invitationScope);
if (invitationScope === "organization" && role.key === "org_owner") throw httpError(400, "owner_invitation_not_allowed", "组织所有者不能通过普通邀请授予,请在成员管理中完成所有者转移");
const roleKey = role.key;
const id = createId("invite");
const inviteToken = `invite-${randomBytes(24).toString("base64url")}`;
const tokenHash = createHash("sha256").update(inviteToken).digest("hex");
@@ -1019,12 +1025,14 @@ function updateWorkspaceMember(context, workspaceId, userId, body) {
const member = dbGet("SELECT * FROM workspace_members WHERE workspace_id = ? AND user_id = ?", [workspaceId, userId]);
if (!member) throw httpError(404, "member_not_found", "工作区成员不存在");
const roleKey = String(body.roleKey || member.role_key);
const accessMode = String(body.accessMode || member.access_mode || "all");
const status = String(body.status || member.status);
roleForScope(roleKey, "workspace");
if (!["all", "project-only"].includes(accessMode)) throw httpError(400, "workspace_access_mode_invalid", "工作区访问范围只能是 all 或 project-only");
if (!["active", "suspended"].includes(status)) throw httpError(400, "member_status_invalid", "成员状态无效");
const timestamp = new Date().toISOString();
dbRun("UPDATE workspace_members SET role_key = ?, status = ?, updated_at = ? WHERE workspace_id = ? AND user_id = ?", [roleKey, status, timestamp, workspaceId, userId]);
addAudit({ context, action: "workspace.member.updated", targetType: "workspace_member", targetId: `${workspaceId}:${userId}`, metadata: { previousRole: member.role_key, roleKey, previousStatus: member.status, status } });
dbRun("UPDATE workspace_members SET role_key = ?, access_mode = ?, status = ?, updated_at = ? WHERE workspace_id = ? AND user_id = ?", [roleKey, accessMode, status, timestamp, workspaceId, userId]);
addAudit({ context, action: "workspace.member.updated", targetType: "workspace_member", targetId: `${workspaceId}:${userId}`, metadata: { previousRole: member.role_key, roleKey, previousAccessMode: member.access_mode || "all", accessMode, previousStatus: member.status, status } });
return { members: workspaceMembers(workspaceId) };
}
@@ -1508,6 +1516,15 @@ createServer(async (req, res) => {
if (req.method === "GET" && pathname === "/api/context") return send(res, 200, platformPayload(resolveContext(req.headers, url.searchParams)));
if (req.method === "GET" && pathname === "/api/search") {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 200, searchPlatform(context, {
query: url.searchParams.get("q") || url.searchParams.get("query") || "",
scope: url.searchParams.get("scope") || "workspace",
limit: url.searchParams.get("limit") || 40
}));
}
if (req.method === "GET" && pathname === "/api/work-items") {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 200, listWorkItems(context, { limit: url.searchParams.get("limit") }));