forked from EduCraft/curriculum-project-hub
fix: enforce active organization boundary
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { PermissionResourceType, PermissionRole, PrismaClient, PrincipalType } from "@prisma/client";
|
||||
import type { OrganizationStatus, PermissionResourceType, PermissionRole, PrismaClient, PrincipalType } from "@prisma/client";
|
||||
import { PrismaPrincipalResolver, type ActorInput, type PrincipalRef, type PrincipalResolution, type PrincipalResolver } from "./principals.js";
|
||||
import { inactiveOrganizationReason } from "../org/status.js";
|
||||
|
||||
export type AuthorizationAction =
|
||||
| "project.read"
|
||||
@@ -61,8 +62,16 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
|
||||
if (req.action === "role.trigger" && (req.roleId === undefined || req.roleId === "")) {
|
||||
throw new Error("role.trigger authorization requires roleId");
|
||||
}
|
||||
const organizationId = await this.organizationForResource(req.resource);
|
||||
const resolution = await this.resolver.resolveActor(req.actor, { organizationId });
|
||||
const organization = await this.organizationForResource(req.resource);
|
||||
const inactiveReason = inactiveOrganizationReason(organization.id, organization.status);
|
||||
if (inactiveReason !== undefined) {
|
||||
return this.decision(req, inactivePrincipalResolution(req.actor, organization.id), {
|
||||
allowed: false,
|
||||
reason: inactiveReason,
|
||||
requiredRole: defaultRequiredRole(req.action),
|
||||
});
|
||||
}
|
||||
const resolution = await this.resolver.resolveActor(req.actor, { organizationId: organization.id });
|
||||
const threshold = await this.requiredRole(req);
|
||||
if (threshold === "DISABLED") {
|
||||
return this.decision(req, resolution, {
|
||||
@@ -221,27 +230,29 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
|
||||
};
|
||||
}
|
||||
|
||||
private async organizationForResource(resource: AuthorizationResource): Promise<string> {
|
||||
private async organizationForResource(
|
||||
resource: AuthorizationResource,
|
||||
): Promise<{ readonly id: string; readonly status: OrganizationStatus }> {
|
||||
switch (resource.type) {
|
||||
case "PROJECT": {
|
||||
const project = await this.prisma.project.findUnique({
|
||||
where: { id: resource.id },
|
||||
select: { organizationId: true },
|
||||
select: { organization: { select: { id: true, status: true } } },
|
||||
});
|
||||
if (project === null) {
|
||||
throw new Error(`authorization resource missing: PROJECT ${resource.id}`);
|
||||
}
|
||||
return project.organizationId;
|
||||
return project.organization;
|
||||
}
|
||||
case "PROJECT_GROUP": {
|
||||
const binding = await this.prisma.projectGroupBinding.findFirst({
|
||||
where: { chatId: resource.id, archivedAt: null },
|
||||
select: { project: { select: { organizationId: true } } },
|
||||
select: { project: { select: { organization: { select: { id: true, status: true } } } } },
|
||||
});
|
||||
if (binding === null) {
|
||||
throw new Error(`authorization resource missing: PROJECT_GROUP ${resource.id}`);
|
||||
}
|
||||
return binding.project.organizationId;
|
||||
return binding.project.organization;
|
||||
}
|
||||
case "ARTIFACT":
|
||||
throw new Error("authorization for ARTIFACT resources requires an organization resolver");
|
||||
@@ -293,3 +304,16 @@ function principalWhere(principals: readonly PrincipalRef[]): Array<{ principalT
|
||||
principalId: principal.id,
|
||||
}));
|
||||
}
|
||||
|
||||
function inactivePrincipalResolution(actor: ActorInput, organizationId: string): PrincipalResolution {
|
||||
const principals: PrincipalRef[] = [{ type: "USER", id: actor.feishuOpenId }];
|
||||
if (actor.chatId !== undefined && actor.chatId !== "") {
|
||||
principals.push({ type: "FEISHU_CHAT", id: actor.chatId });
|
||||
}
|
||||
return {
|
||||
actor,
|
||||
organizationId,
|
||||
principals,
|
||||
resolved: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PrismaClient, PrincipalType } from "@prisma/client";
|
||||
import type { Prisma, PrismaClient, PrincipalType } from "@prisma/client";
|
||||
import { lockActiveOrganization } from "../org/status.js";
|
||||
|
||||
export type ExternalPrincipalType = Extract<
|
||||
PrincipalType,
|
||||
@@ -33,98 +34,112 @@ export async function syncExternalPrincipalMemberships(
|
||||
const syncedAt = new Date();
|
||||
const incomingKeys = new Set<string>();
|
||||
let synced = 0;
|
||||
const connection = await prisma.externalDirectoryConnection.upsert({
|
||||
where: {
|
||||
organizationId_provider_source: {
|
||||
const connection = await prisma.$transaction(async (tx) => {
|
||||
await lockActiveOrganization(tx, input.organizationId);
|
||||
return tx.externalDirectoryConnection.upsert({
|
||||
where: {
|
||||
organizationId_provider_source: {
|
||||
organizationId: input.organizationId,
|
||||
provider: input.provider ?? "FEISHU",
|
||||
source: input.source,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
revokedAt: null,
|
||||
...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}),
|
||||
},
|
||||
create: {
|
||||
organizationId: input.organizationId,
|
||||
provider: input.provider ?? "FEISHU",
|
||||
...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}),
|
||||
source: input.source,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
revokedAt: null,
|
||||
...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}),
|
||||
},
|
||||
create: {
|
||||
organizationId: input.organizationId,
|
||||
provider: input.provider ?? "FEISHU",
|
||||
...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}),
|
||||
source: input.source,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
for (const item of input.memberships) {
|
||||
assertExternalPrincipalType(item.principalType);
|
||||
const user = await prisma.user.upsert({
|
||||
where: { feishuOpenId: item.feishuOpenId },
|
||||
update: { displayName: item.displayName },
|
||||
create: {
|
||||
feishuOpenId: item.feishuOpenId,
|
||||
displayName: item.displayName,
|
||||
platformRoles: { create: { role: "TEACHER" } },
|
||||
organizationMemberships: { create: { organizationId: input.organizationId, role: "MEMBER" } },
|
||||
},
|
||||
});
|
||||
await ensureOrganizationMembership(prisma, input.organizationId, user.id);
|
||||
incomingKeys.add(externalMembershipKey(user.id, item.principalType, item.principalId, connection.id));
|
||||
const existing = await prisma.externalPrincipalMembership.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
principalType: item.principalType,
|
||||
principalId: item.principalId,
|
||||
connectionId: connection.id,
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing === null) {
|
||||
await prisma.externalPrincipalMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
connectionId: connection.id,
|
||||
principalType: item.principalType,
|
||||
principalId: item.principalId,
|
||||
syncedAt,
|
||||
});
|
||||
|
||||
// Each membership is a short lifecycle-serialized unit. A large directory
|
||||
// must not hold the Organization row lock (or one interactive transaction)
|
||||
// for the duration of the entire external sync.
|
||||
for (const item of input.memberships) {
|
||||
assertExternalPrincipalType(item.principalType);
|
||||
const userId = await prisma.$transaction(async (tx) => {
|
||||
await lockActiveOrganization(tx, input.organizationId);
|
||||
const user = await tx.user.upsert({
|
||||
where: { feishuOpenId: item.feishuOpenId },
|
||||
update: { displayName: item.displayName },
|
||||
create: {
|
||||
feishuOpenId: item.feishuOpenId,
|
||||
displayName: item.displayName,
|
||||
platformRoles: { create: { role: "TEACHER" } },
|
||||
organizationMemberships: { create: { organizationId: input.organizationId, role: "MEMBER" } },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.externalPrincipalMembership.update({
|
||||
where: { id: existing.id },
|
||||
data: { syncedAt },
|
||||
await ensureOrganizationMembership(tx, input.organizationId, user.id);
|
||||
const existing = await tx.externalPrincipalMembership.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
principalType: item.principalType,
|
||||
principalId: item.principalId,
|
||||
connectionId: connection.id,
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
if (existing === null) {
|
||||
await tx.externalPrincipalMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
connectionId: connection.id,
|
||||
principalType: item.principalType,
|
||||
principalId: item.principalId,
|
||||
syncedAt,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await tx.externalPrincipalMembership.update({
|
||||
where: { id: existing.id },
|
||||
data: { syncedAt },
|
||||
});
|
||||
}
|
||||
return user.id;
|
||||
});
|
||||
incomingKeys.add(externalMembershipKey(userId, item.principalType, item.principalId, connection.id));
|
||||
synced++;
|
||||
}
|
||||
|
||||
let revoked = 0;
|
||||
if (input.replaceSource === true) {
|
||||
const active = await prisma.externalPrincipalMembership.findMany({
|
||||
where: { connectionId: connection.id, revokedAt: null },
|
||||
select: { id: true, userId: true, principalType: true, principalId: true, connectionId: true },
|
||||
});
|
||||
const revokeIds = active
|
||||
.filter((membership) => !incomingKeys.has(externalMembershipKey(
|
||||
membership.userId,
|
||||
membership.principalType,
|
||||
membership.principalId,
|
||||
membership.connectionId,
|
||||
)))
|
||||
.map((membership) => membership.id);
|
||||
if (revokeIds.length > 0) {
|
||||
const result = await prisma.externalPrincipalMembership.updateMany({
|
||||
where: { id: { in: revokeIds } },
|
||||
data: { revokedAt: syncedAt },
|
||||
revoked = await prisma.$transaction(async (tx) => {
|
||||
await lockActiveOrganization(tx, input.organizationId);
|
||||
const active = await tx.externalPrincipalMembership.findMany({
|
||||
where: { connectionId: connection.id, revokedAt: null },
|
||||
select: { id: true, userId: true, principalType: true, principalId: true, connectionId: true },
|
||||
});
|
||||
revoked = result.count;
|
||||
}
|
||||
const revokeIds = active
|
||||
.filter((membership) => !incomingKeys.has(externalMembershipKey(
|
||||
membership.userId,
|
||||
membership.principalType,
|
||||
membership.principalId,
|
||||
membership.connectionId,
|
||||
)))
|
||||
.map((membership) => membership.id);
|
||||
if (revokeIds.length > 0) {
|
||||
const result = await tx.externalPrincipalMembership.updateMany({
|
||||
where: { id: { in: revokeIds } },
|
||||
data: { revokedAt: syncedAt },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return { synced, revoked };
|
||||
}
|
||||
|
||||
async function ensureOrganizationMembership(
|
||||
prisma: PrismaClient,
|
||||
prisma: PrismaClient | Prisma.TransactionClient,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PermissionRole, Prisma, PrismaClient } from "@prisma/client";
|
||||
import { lockActiveOrganization } from "../org/status.js";
|
||||
|
||||
export interface GrantTeamProjectAccessInput {
|
||||
readonly projectId: string;
|
||||
@@ -30,6 +31,7 @@ export async function grantTeamProjectAccess(
|
||||
): Promise<ProjectTeamAccessEntry> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const { project, team } = await resolveProjectAndTeam(tx, input);
|
||||
await lockActiveOrganization(tx, project.organizationId);
|
||||
const now = new Date();
|
||||
const existing = await tx.permissionGrant.findFirst({
|
||||
where: {
|
||||
@@ -87,6 +89,7 @@ export async function revokeTeamProjectAccess(
|
||||
): Promise<number> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const { project, team } = await resolveProjectAndTeam(tx, input);
|
||||
await lockActiveOrganization(tx, project.organizationId);
|
||||
const result = await tx.permissionGrant.updateMany({
|
||||
where: {
|
||||
resourceType: "PROJECT",
|
||||
|
||||
Reference in New Issue
Block a user