refactor(org): keep archived-team grants as dead rows

Archiving a team no longer cascade-revokes its active TEAM->PROJECT grants
and memberships. The archived flag alone makes the team principal
unresolvable (permissions/principals.ts refuses archived teams), so the
dead grant/membership rows confer no access. listProjectTeamAccess now
filters archived teams out of the project view instead of relying on a
revokedAt cascade, and the org-admin teams page confirm copy is updated.
archiveTeam drops the revokedGrants count from its return shape.

ADR-0019 / Spec.System.Organization: principal resolution, not grant
mutation, is the access boundary for archived teams.
This commit is contained in:
2026-07-14 21:17:20 +08:00
parent cbe569d7e6
commit b574ef871c
4 changed files with 20 additions and 34 deletions
+8 -23
View File
@@ -1,8 +1,9 @@
/**
* Hub team lifecycle for org admin (ADR-0019 / ADR-0021).
*
* Archiving a team soft-archives the team row and revokes active TEAM→PROJECT
* grants that use this team as principal (product pin).
* Archiving a team soft-archives the team row. Active TEAM→PROJECT grants and
* memberships are left in place as dead rows: principal resolution refuses
* archived teams (see `permissions/principals.ts`), so they confer no access.
*/
import type { Prisma, PrismaClient } from "@prisma/client";
import { lockActiveOrganization } from "./status.js";
@@ -125,37 +126,21 @@ export async function updateTeam(
}
/**
* Soft-archive team and revoke its active project grants (TEAM principal).
* Soft-archive team. Active grants/memberships are not cascade-revoked; the
* archived flag alone makes the team's principal unresolvable (no access).
*/
export async function archiveTeam(
prisma: PrismaClient,
input: { readonly organizationId: string; readonly teamId: string },
): Promise<{ readonly archived: true; readonly teamId: string; readonly revokedGrants: number }> {
): Promise<{ readonly archived: true; readonly teamId: string }> {
return prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const team = await requireActiveTeam(tx, input.teamId, input.organizationId);
const now = new Date();
await tx.team.update({
where: { id: team.id },
data: { archivedAt: now },
data: { archivedAt: new Date() },
});
await tx.teamMembership.updateMany({
where: { teamId: team.id, revokedAt: null },
data: { revokedAt: now },
});
const grants = await tx.permissionGrant.updateMany({
where: {
principalType: "TEAM",
principalId: team.id,
revokedAt: null,
},
data: { revokedAt: now },
});
return {
archived: true as const,
teamId: team.id,
revokedGrants: grants.count,
};
return { archived: true as const, teamId: team.id };
});
}