feat: add organization tenant model

This commit is contained in:
2026-07-09 23:37:20 +08:00
parent 5d315fff21
commit 2b7aa3294f
20 changed files with 900 additions and 116 deletions
+32 -2
View File
@@ -38,6 +38,7 @@ export interface AuthorizationDecision {
readonly action: AuthorizationAction;
readonly resource: AuthorizationResource;
readonly actor: ActorInput;
readonly organizationId: string;
readonly actorUserId?: string | undefined;
readonly principals: readonly PrincipalRef[];
readonly requiredRole: PermissionRole;
@@ -60,7 +61,8 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
if (req.action === "role.trigger" && (req.roleId === undefined || req.roleId === "")) {
throw new Error("role.trigger authorization requires roleId");
}
const resolution = await this.resolver.resolveActor(req.actor);
const organizationId = await this.organizationForResource(req.resource);
const resolution = await this.resolver.resolveActor(req.actor, { organizationId });
const threshold = await this.requiredRole(req);
if (threshold === "DISABLED") {
return this.decision(req, resolution, {
@@ -206,17 +208,45 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
private decision(
req: AuthorizationRequest,
resolution: PrincipalResolution,
result: Omit<AuthorizationDecision, "action" | "resource" | "actor" | "actorUserId" | "principals">,
result: Omit<AuthorizationDecision, "action" | "resource" | "actor" | "organizationId" | "actorUserId" | "principals">,
): AuthorizationDecision {
return {
action: req.action,
resource: req.resource,
actor: req.actor,
organizationId: resolution.organizationId,
...(resolution.userId !== undefined ? { actorUserId: resolution.userId } : {}),
principals: resolution.principals,
...result,
};
}
private async organizationForResource(resource: AuthorizationResource): Promise<string> {
switch (resource.type) {
case "PROJECT": {
const project = await this.prisma.project.findUnique({
where: { id: resource.id },
select: { organizationId: true },
});
if (project === null) {
throw new Error(`authorization resource missing: PROJECT ${resource.id}`);
}
return project.organizationId;
}
case "PROJECT_GROUP": {
const binding = await this.prisma.projectGroupBinding.findUnique({
where: { chatId: resource.id },
select: { project: { select: { organizationId: true } } },
});
if (binding === null) {
throw new Error(`authorization resource missing: PROJECT_GROUP ${resource.id}`);
}
return binding.project.organizationId;
}
case "ARTIFACT":
throw new Error("authorization for ARTIFACT resources requires an organization resolver");
}
}
}
export function createPermissionAuthorizer(
+48 -7
View File
@@ -13,6 +13,9 @@ export interface ExternalPrincipalMembershipInput {
}
export interface SyncExternalPrincipalMembershipsInput {
readonly organizationId: string;
readonly provider?: string | undefined;
readonly providerTenantId?: string | undefined;
readonly source: string;
readonly replaceSource?: boolean | undefined;
readonly memberships: readonly ExternalPrincipalMembershipInput[];
@@ -30,6 +33,26 @@ 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: {
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,
},
select: { id: true },
});
for (const item of input.memberships) {
assertExternalPrincipalType(item.principalType);
@@ -40,15 +63,17 @@ export async function syncExternalPrincipalMemberships(
feishuOpenId: item.feishuOpenId,
displayName: item.displayName,
platformRoles: { create: { role: "TEACHER" } },
organizationMemberships: { create: { organizationId: input.organizationId, role: "MEMBER" } },
},
});
incomingKeys.add(externalMembershipKey(user.id, item.principalType, item.principalId));
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,
source: input.source,
connectionId: connection.id,
revokedAt: null,
},
select: { id: true },
@@ -57,9 +82,9 @@ export async function syncExternalPrincipalMemberships(
await prisma.externalPrincipalMembership.create({
data: {
userId: user.id,
connectionId: connection.id,
principalType: item.principalType,
principalId: item.principalId,
source: input.source,
syncedAt,
},
});
@@ -75,14 +100,15 @@ export async function syncExternalPrincipalMemberships(
let revoked = 0;
if (input.replaceSource === true) {
const active = await prisma.externalPrincipalMembership.findMany({
where: { source: input.source, revokedAt: null },
select: { id: true, userId: true, principalType: true, principalId: true },
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) {
@@ -97,8 +123,23 @@ export async function syncExternalPrincipalMemberships(
return { synced, revoked };
}
function externalMembershipKey(userId: string, type: PrincipalType, id: string): string {
return `${userId}:${type}:${id}`;
async function ensureOrganizationMembership(
prisma: PrismaClient,
organizationId: string,
userId: string,
): Promise<void> {
const active = await prisma.organizationMembership.findFirst({
where: { organizationId, userId, revokedAt: null },
select: { id: true },
});
if (active !== null) return;
await prisma.organizationMembership.create({
data: { organizationId, userId, role: "MEMBER" },
});
}
function externalMembershipKey(userId: string, type: PrincipalType, id: string, connectionId: string): string {
return `${connectionId}:${userId}:${type}:${id}`;
}
function assertExternalPrincipalType(type: PrincipalType): asserts type is ExternalPrincipalType {
+22 -5
View File
@@ -10,6 +10,10 @@ export interface ActorInput {
readonly chatId?: string | undefined;
}
export interface PrincipalResolutionScope {
readonly organizationId: string;
}
export type PrincipalSource =
| "actor-user"
| "context-chat"
@@ -25,19 +29,20 @@ export interface ResolvedPrincipal {
export interface PrincipalResolution {
readonly actor: ActorInput;
readonly organizationId: string;
readonly userId?: string | undefined;
readonly principals: readonly PrincipalRef[];
readonly resolved: readonly ResolvedPrincipal[];
}
export interface PrincipalResolver {
resolveActor(actor: ActorInput): Promise<PrincipalResolution>;
resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise<PrincipalResolution>;
}
export class PrismaPrincipalResolver implements PrincipalResolver {
constructor(private readonly prisma: PrismaClient) {}
async resolveActor(actor: ActorInput): Promise<PrincipalResolution> {
async resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise<PrincipalResolution> {
const resolved = new PrincipalCollector();
resolved.add({ type: "USER", id: actor.feishuOpenId }, "actor-user");
if (actor.chatId !== undefined && actor.chatId !== "") {
@@ -51,7 +56,11 @@ export class PrismaPrincipalResolver implements PrincipalResolver {
if (user !== null) {
const memberships = await this.prisma.teamMembership.findMany({
where: { userId: user.id, revokedAt: null, team: { archivedAt: null } },
where: {
userId: user.id,
revokedAt: null,
team: { organizationId: scope.organizationId, archivedAt: null },
},
select: { teamId: true },
});
for (const membership of memberships) {
@@ -59,7 +68,14 @@ export class PrismaPrincipalResolver implements PrincipalResolver {
}
const externalMemberships = await this.prisma.externalPrincipalMembership.findMany({
where: { userId: user.id, revokedAt: null },
where: {
userId: user.id,
revokedAt: null,
connection: {
organizationId: scope.organizationId,
revokedAt: null,
},
},
select: { principalType: true, principalId: true },
});
for (const membership of externalMemberships) {
@@ -75,7 +91,7 @@ export class PrismaPrincipalResolver implements PrincipalResolver {
const bindings = await this.prisma.teamExternalBinding.findMany({
where: {
revokedAt: null,
team: { archivedAt: null },
team: { organizationId: scope.organizationId, archivedAt: null },
OR: externalPrincipals.map((principal) => ({
principalType: principal.type,
principalId: principal.id,
@@ -94,6 +110,7 @@ export class PrismaPrincipalResolver implements PrincipalResolver {
return {
actor,
organizationId: scope.organizationId,
...(user !== null ? { userId: user.id } : {}),
principals: resolved.principals(),
resolved: resolved.entries(),
+218
View File
@@ -0,0 +1,218 @@
import type { PermissionRole, Prisma, PrismaClient } from "@prisma/client";
export interface GrantTeamProjectAccessInput {
readonly projectId: string;
readonly teamId?: string | undefined;
readonly teamSlug?: string | undefined;
readonly role: PermissionRole;
readonly createdByUserId?: string | undefined;
}
export interface RevokeTeamProjectAccessInput {
readonly projectId: string;
readonly teamId?: string | undefined;
readonly teamSlug?: string | undefined;
}
export interface ProjectTeamAccessEntry {
readonly grantId: string;
readonly projectId: string;
readonly organizationId: string;
readonly teamId: string;
readonly teamSlug: string;
readonly teamName: string;
readonly role: PermissionRole;
}
export async function grantTeamProjectAccess(
prisma: PrismaClient,
input: GrantTeamProjectAccessInput,
): Promise<ProjectTeamAccessEntry> {
return prisma.$transaction(async (tx) => {
const { project, team } = await resolveProjectAndTeam(tx, input);
const now = new Date();
const existing = await tx.permissionGrant.findFirst({
where: {
resourceType: "PROJECT",
resourceId: project.id,
principalType: "TEAM",
principalId: team.id,
revokedAt: null,
},
select: { id: true, role: true },
});
if (existing?.role === input.role) {
return entryFromGrant({
grantId: existing.id,
projectId: project.id,
organizationId: project.organizationId,
team,
role: existing.role,
});
}
await tx.permissionGrant.updateMany({
where: {
resourceType: "PROJECT",
resourceId: project.id,
principalType: "TEAM",
principalId: team.id,
revokedAt: null,
},
data: { revokedAt: now },
});
const grant = await tx.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: project.id,
principalType: "TEAM",
principalId: team.id,
role: input.role,
...(input.createdByUserId !== undefined ? { createdByUserId: input.createdByUserId } : {}),
},
select: { id: true, role: true },
});
return entryFromGrant({
grantId: grant.id,
projectId: project.id,
organizationId: project.organizationId,
team,
role: grant.role,
});
});
}
export async function revokeTeamProjectAccess(
prisma: PrismaClient,
input: RevokeTeamProjectAccessInput,
): Promise<number> {
return prisma.$transaction(async (tx) => {
const { project, team } = await resolveProjectAndTeam(tx, input);
const result = await tx.permissionGrant.updateMany({
where: {
resourceType: "PROJECT",
resourceId: project.id,
principalType: "TEAM",
principalId: team.id,
revokedAt: null,
},
data: { revokedAt: new Date() },
});
return result.count;
});
}
export async function listProjectTeamAccess(
prisma: PrismaClient,
projectId: string,
): Promise<readonly ProjectTeamAccessEntry[]> {
const project = await prisma.project.findUnique({
where: { id: projectId },
select: { id: true, organizationId: true },
});
if (project === null) {
throw new Error(`project not found: ${projectId}`);
}
const grants = await prisma.permissionGrant.findMany({
where: {
resourceType: "PROJECT",
resourceId: projectId,
principalType: "TEAM",
revokedAt: null,
},
select: { id: true, principalId: true, role: true },
orderBy: { createdAt: "asc" },
});
if (grants.length === 0) return [];
const teams = await prisma.team.findMany({
where: {
organizationId: project.organizationId,
id: { in: grants.map((grant) => grant.principalId) },
archivedAt: null,
},
select: { id: true, slug: true, name: true },
});
const teamsById = new Map(teams.map((team) => [team.id, team]));
const missing = grants.filter((grant) => !teamsById.has(grant.principalId));
if (missing.length > 0) {
throw new Error(
`project ${projectId} has active TEAM grants outside organization ${project.organizationId}: ${missing.map((grant) => grant.principalId).join(", ")}`,
);
}
return grants.map((grant) => entryFromGrant({
grantId: grant.id,
projectId: project.id,
organizationId: project.organizationId,
team: teamsById.get(grant.principalId)!,
role: grant.role,
}));
}
type ProjectForAccess = {
readonly id: string;
readonly organizationId: string;
};
type TeamForAccess = {
readonly id: string;
readonly slug: string;
readonly name: string;
};
async function resolveProjectAndTeam(
prisma: PrismaClient | Prisma.TransactionClient,
input: { readonly projectId: string; readonly teamId?: string | undefined; readonly teamSlug?: string | undefined },
): Promise<{ readonly project: ProjectForAccess; readonly team: TeamForAccess }> {
if ((input.teamId === undefined || input.teamId === "") && (input.teamSlug === undefined || input.teamSlug === "")) {
throw new Error("granting team project access requires teamId or teamSlug");
}
if (input.teamId !== undefined && input.teamId !== "" && input.teamSlug !== undefined && input.teamSlug !== "") {
throw new Error("granting team project access accepts only one of teamId or teamSlug");
}
const project = await prisma.project.findUnique({
where: { id: input.projectId },
select: { id: true, organizationId: true },
});
if (project === null) {
throw new Error(`project not found: ${input.projectId}`);
}
const team = await prisma.team.findFirst({
where:
input.teamId !== undefined && input.teamId !== ""
? { id: input.teamId, archivedAt: null }
: { organizationId: project.organizationId, slug: input.teamSlug!, archivedAt: null },
select: { id: true, slug: true, name: true, organizationId: true },
});
if (team === null) {
const label = input.teamId ?? input.teamSlug;
throw new Error(`active team not found for project ${input.projectId}: ${label}`);
}
if (team.organizationId !== project.organizationId) {
throw new Error(
`cross-organization team grant refused: project ${project.id} is in ${project.organizationId}, team ${team.id} is in ${team.organizationId}`,
);
}
return { project, team };
}
function entryFromGrant(input: {
readonly grantId: string;
readonly projectId: string;
readonly organizationId: string;
readonly team: TeamForAccess;
readonly role: PermissionRole;
}): ProjectTeamAccessEntry {
return {
grantId: input.grantId,
projectId: input.projectId,
organizationId: input.organizationId,
teamId: input.team.id,
teamSlug: input.team.slug,
teamName: input.team.name,
role: input.role,
};
}