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
@@ -67,7 +67,7 @@
}
async function archiveTeam(t: TeamRow) {
if (!confirm(`归档团队 ${t.name}? 其活跃项目授权将被撤销。`)) return;
if (!confirm(`归档团队 ${t.name}? 归档后该团队不再解析为项目授权主体。`)) return;
try {
await api.archiveTeam(slug, t.id);
if (expandedId === t.id) expandedId = null;
+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 };
});
}
+4 -3
View File
@@ -131,9 +131,8 @@ export async function listProjectTeamAccess(
where: {
organizationId: project.organizationId,
id: { in: grants.map((grant) => grant.principalId) },
archivedAt: null,
},
select: { id: true, slug: true, name: true },
select: { id: true, slug: true, name: true, archivedAt: true },
});
const teamsById = new Map(teams.map((team) => [team.id, team]));
const missing = grants.filter((grant) => !teamsById.has(grant.principalId));
@@ -143,7 +142,9 @@ export async function listProjectTeamAccess(
);
}
return grants.map((grant) => entryFromGrant({
return grants
.filter((grant) => teamsById.get(grant.principalId)?.archivedAt === null)
.map((grant) => entryFromGrant({
grantId: grant.id,
projectId: project.id,
organizationId: project.organizationId,
@@ -176,7 +176,7 @@ describe("admin members + teams API", () => {
});
expect(archive.statusCode).toBe(200);
expect(archive.json()).toEqual(
expect.objectContaining({ archived: true, revokedGrants: 1 }),
expect.objectContaining({ archived: true, teamId: team.id }),
);
const after = await app.inject({