forked from EduCraft/curriculum-project-hub
fix: enforce active organization boundary
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { relative, resolve } from "node:path";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { dirname, relative, resolve } from "node:path";
|
||||
import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClient } from "@prisma/client";
|
||||
import { createPermissionAuthorizer } from "./permission.js";
|
||||
import { writeAudit } from "./audit.js";
|
||||
import { lockActiveOrganization, requireActiveOrganizationStatus } from "./org/status.js";
|
||||
|
||||
export interface OrganizationProjectPolicy {
|
||||
readonly organizationId: string;
|
||||
@@ -72,23 +73,24 @@ export async function setMembersCanCreateProjects(
|
||||
prisma: PrismaClient,
|
||||
input: { readonly organizationId: string; readonly enabled: boolean },
|
||||
): Promise<OrganizationProjectPolicy> {
|
||||
await ensureOrganizationExists(prisma, input.organizationId);
|
||||
const settings = await prisma.organizationProjectSettings.upsert({
|
||||
where: { organizationId: input.organizationId },
|
||||
update: { membersCanCreateProjects: input.enabled },
|
||||
create: {
|
||||
organizationId: input.organizationId,
|
||||
membersCanCreateProjects: input.enabled,
|
||||
},
|
||||
select: { organizationId: true, membersCanCreateProjects: true },
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await requireActiveOrganizationTx(tx, input.organizationId);
|
||||
return tx.organizationProjectSettings.upsert({
|
||||
where: { organizationId: input.organizationId },
|
||||
update: { membersCanCreateProjects: input.enabled },
|
||||
create: {
|
||||
organizationId: input.organizationId,
|
||||
membersCanCreateProjects: input.enabled,
|
||||
},
|
||||
select: { organizationId: true, membersCanCreateProjects: true },
|
||||
});
|
||||
});
|
||||
return settings;
|
||||
}
|
||||
|
||||
export async function createFolder(prisma: PrismaClient, input: CreateFolderInput): Promise<Folder> {
|
||||
const name = requireNonEmpty(input.name, "folder name");
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await ensureOrganizationExistsTx(tx, input.organizationId);
|
||||
await requireActiveOrganizationTx(tx, input.organizationId);
|
||||
if (input.parentId !== undefined) {
|
||||
await assertFolderInOrganization(tx, input.parentId, input.organizationId);
|
||||
}
|
||||
@@ -113,6 +115,7 @@ export async function moveProjectToFolder(
|
||||
select: { id: true, organizationId: true },
|
||||
});
|
||||
if (project === null) throw new Error(`project not found: ${input.projectId}`);
|
||||
await requireActiveOrganizationTx(tx, project.organizationId);
|
||||
if (input.folderId !== null) {
|
||||
await assertFolderInOrganization(tx, input.folderId, project.organizationId);
|
||||
}
|
||||
@@ -188,6 +191,7 @@ export async function bindFeishuChatToProject(
|
||||
if (project === null) throw new Error(`project not found: ${input.projectId}`);
|
||||
const actor = await requireProjectManager(prisma, project.id, project.organizationId, input.actorFeishuOpenId);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await requireActiveOrganizationTx(tx, project.organizationId);
|
||||
const activeProjectBinding = await tx.projectGroupBinding.findFirst({
|
||||
where: { projectId: project.id, archivedAt: null },
|
||||
select: { chatId: true },
|
||||
@@ -248,6 +252,7 @@ export async function archiveFeishuChatBinding(
|
||||
if (binding === null) return { archived: false };
|
||||
const actor = await requireProjectManager(prisma, binding.projectId, binding.project.organizationId, input.actorFeishuOpenId);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await requireActiveOrganizationTx(tx, binding.project.organizationId);
|
||||
await tx.projectGroupBinding.update({
|
||||
where: { id: binding.id },
|
||||
data: { archivedAt: new Date() },
|
||||
@@ -287,9 +292,10 @@ async function createManagedProject(
|
||||
): Promise<ProjectOnboardingResult> {
|
||||
const organization = await prisma.organization.findUnique({
|
||||
where: { id: input.organizationId },
|
||||
select: { id: true, slug: true },
|
||||
select: { id: true, slug: true, status: true },
|
||||
});
|
||||
if (organization === null) throw new Error(`organization not found: ${input.organizationId}`);
|
||||
requireActiveOrganizationStatus(organization.id, organization.status);
|
||||
|
||||
const projectId = createProjectId();
|
||||
const workspaceDir = projectWorkspaceDir({
|
||||
@@ -297,48 +303,66 @@ async function createManagedProject(
|
||||
organizationSlug: organization.slug,
|
||||
projectId,
|
||||
});
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
let workspaceCreated = false;
|
||||
let project: { readonly id: string; readonly organizationId: string; readonly folderId: string | null; readonly workspaceDir: string };
|
||||
try {
|
||||
project = await prisma.$transaction(async (tx) => {
|
||||
await ensureOrganizationProjectSettingsTx(tx, organization.id);
|
||||
const folderId = input.folderId ?? (await ensureInboxFolder(tx, organization.id)).id;
|
||||
await assertFolderInOrganization(tx, folderId, organization.id);
|
||||
|
||||
const project = await prisma.$transaction(async (tx) => {
|
||||
await ensureOrganizationProjectSettingsTx(tx, organization.id);
|
||||
const folderId = input.folderId ?? (await ensureInboxFolder(tx, organization.id)).id;
|
||||
await assertFolderInOrganization(tx, folderId, organization.id);
|
||||
await mkdir(dirname(workspaceDir), { recursive: true });
|
||||
await mkdir(workspaceDir);
|
||||
workspaceCreated = true;
|
||||
|
||||
const created = await tx.project.create({
|
||||
data: {
|
||||
id: projectId,
|
||||
organizationId: organization.id,
|
||||
folderId,
|
||||
name: input.name,
|
||||
workspaceDir,
|
||||
createdByUserId: input.actorUserId,
|
||||
},
|
||||
select: { id: true, organizationId: true, folderId: true, workspaceDir: true },
|
||||
});
|
||||
await tx.permissionSettings.create({
|
||||
data: defaultProjectPermissionSettings(created.id),
|
||||
});
|
||||
await replaceProjectGrant(tx, {
|
||||
projectId: created.id,
|
||||
principalType: "USER",
|
||||
principalId: input.actorFeishuOpenId,
|
||||
role: "MANAGE",
|
||||
createdByUserId: input.actorUserId,
|
||||
});
|
||||
if (input.chatId !== undefined) {
|
||||
await tx.projectGroupBinding.create({
|
||||
data: { projectId: created.id, chatId: input.chatId, createdByUserId: input.actorUserId },
|
||||
const created = await tx.project.create({
|
||||
data: {
|
||||
id: projectId,
|
||||
organizationId: organization.id,
|
||||
folderId,
|
||||
name: input.name,
|
||||
workspaceDir,
|
||||
createdByUserId: input.actorUserId,
|
||||
},
|
||||
select: { id: true, organizationId: true, folderId: true, workspaceDir: true },
|
||||
});
|
||||
await tx.permissionSettings.create({
|
||||
data: defaultProjectPermissionSettings(created.id),
|
||||
});
|
||||
await replaceProjectGrant(tx, {
|
||||
projectId: created.id,
|
||||
principalType: "FEISHU_CHAT",
|
||||
principalId: input.chatId,
|
||||
role: "EDIT",
|
||||
principalType: "USER",
|
||||
principalId: input.actorFeishuOpenId,
|
||||
role: "MANAGE",
|
||||
createdByUserId: input.actorUserId,
|
||||
});
|
||||
if (input.chatId !== undefined) {
|
||||
await tx.projectGroupBinding.create({
|
||||
data: { projectId: created.id, chatId: input.chatId, createdByUserId: input.actorUserId },
|
||||
});
|
||||
await replaceProjectGrant(tx, {
|
||||
projectId: created.id,
|
||||
principalType: "FEISHU_CHAT",
|
||||
principalId: input.chatId,
|
||||
role: "EDIT",
|
||||
createdByUserId: input.actorUserId,
|
||||
});
|
||||
}
|
||||
return created;
|
||||
});
|
||||
} catch (error) {
|
||||
if (workspaceCreated) {
|
||||
try {
|
||||
await rm(workspaceDir, { recursive: true });
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError(
|
||||
[error, cleanupError],
|
||||
`project creation failed and workspace cleanup also failed: ${workspaceDir}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return created;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
await writeAudit(prisma, {
|
||||
projectId: project.id,
|
||||
@@ -382,6 +406,7 @@ async function requireActiveOrgMember(
|
||||
organizationId: string,
|
||||
feishuOpenId: string,
|
||||
): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole }> {
|
||||
await requireActiveOrganization(prisma, organizationId);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { feishuOpenId },
|
||||
select: {
|
||||
@@ -407,7 +432,7 @@ async function ensureOrganizationProjectSettingsTx(
|
||||
prisma: Prisma.TransactionClient,
|
||||
organizationId: string,
|
||||
): Promise<OrganizationProjectPolicy> {
|
||||
await ensureOrganizationExistsTx(prisma, organizationId);
|
||||
await requireActiveOrganizationTx(prisma, organizationId);
|
||||
return prisma.organizationProjectSettings.upsert({
|
||||
where: { organizationId },
|
||||
update: {},
|
||||
@@ -445,14 +470,17 @@ async function assertFolderInOrganization(
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureOrganizationExists(prisma: PrismaClient, organizationId: string): Promise<void> {
|
||||
const organization = await prisma.organization.findUnique({ where: { id: organizationId }, select: { id: true } });
|
||||
async function requireActiveOrganization(prisma: PrismaClient, organizationId: string): Promise<void> {
|
||||
const organization = await prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (organization === null) throw new Error(`organization not found: ${organizationId}`);
|
||||
requireActiveOrganizationStatus(organization.id, organization.status);
|
||||
}
|
||||
|
||||
async function ensureOrganizationExistsTx(prisma: Prisma.TransactionClient, organizationId: string): Promise<void> {
|
||||
const organization = await prisma.organization.findUnique({ where: { id: organizationId }, select: { id: true } });
|
||||
if (organization === null) throw new Error(`organization not found: ${organizationId}`);
|
||||
async function requireActiveOrganizationTx(prisma: Prisma.TransactionClient, organizationId: string): Promise<void> {
|
||||
await lockActiveOrganization(prisma, organizationId);
|
||||
}
|
||||
|
||||
async function replaceProjectGrant(
|
||||
|
||||
Reference in New Issue
Block a user