fix: enforce active organization boundary

This commit is contained in:
2026-07-10 18:42:12 +08:00
parent f07f280b8f
commit d730e51c3d
24 changed files with 1686 additions and 460 deletions
+32 -8
View File
@@ -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: [],
};
}
+87 -72
View File
@@ -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> {
+3
View File
@@ -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",