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) { async function archiveTeam(t: TeamRow) {
if (!confirm(`归档团队 ${t.name}? 其活跃项目授权将被撤销。`)) return; if (!confirm(`归档团队 ${t.name}? 归档后该团队不再解析为项目授权主体。`)) return;
try { try {
await api.archiveTeam(slug, t.id); await api.archiveTeam(slug, t.id);
if (expandedId === t.id) expandedId = null; if (expandedId === t.id) expandedId = null;
+8 -23
View File
@@ -1,8 +1,9 @@
/** /**
* Hub team lifecycle for org admin (ADR-0019 / ADR-0021). * Hub team lifecycle for org admin (ADR-0019 / ADR-0021).
* *
* Archiving a team soft-archives the team row and revokes active TEAM→PROJECT * Archiving a team soft-archives the team row. Active TEAM→PROJECT grants and
* grants that use this team as principal (product pin). * 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 type { Prisma, PrismaClient } from "@prisma/client";
import { lockActiveOrganization } from "./status.js"; 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( export async function archiveTeam(
prisma: PrismaClient, prisma: PrismaClient,
input: { readonly organizationId: string; readonly teamId: string }, 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) => { return prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId); await lockActiveOrganization(tx, input.organizationId);
const team = await requireActiveTeam(tx, input.teamId, input.organizationId); const team = await requireActiveTeam(tx, input.teamId, input.organizationId);
const now = new Date();
await tx.team.update({ await tx.team.update({
where: { id: team.id }, where: { id: team.id },
data: { archivedAt: now }, data: { archivedAt: new Date() },
}); });
await tx.teamMembership.updateMany({ return { archived: true as const, teamId: team.id };
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,
};
}); });
} }
+10 -9
View File
@@ -131,9 +131,8 @@ export async function listProjectTeamAccess(
where: { where: {
organizationId: project.organizationId, organizationId: project.organizationId,
id: { in: grants.map((grant) => grant.principalId) }, 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 teamsById = new Map(teams.map((team) => [team.id, team]));
const missing = grants.filter((grant) => !teamsById.has(grant.principalId)); const missing = grants.filter((grant) => !teamsById.has(grant.principalId));
@@ -143,13 +142,15 @@ export async function listProjectTeamAccess(
); );
} }
return grants.map((grant) => entryFromGrant({ return grants
grantId: grant.id, .filter((grant) => teamsById.get(grant.principalId)?.archivedAt === null)
projectId: project.id, .map((grant) => entryFromGrant({
organizationId: project.organizationId, grantId: grant.id,
team: teamsById.get(grant.principalId)!, projectId: project.id,
role: grant.role, organizationId: project.organizationId,
})); team: teamsById.get(grant.principalId)!,
role: grant.role,
}));
} }
type ProjectForAccess = { type ProjectForAccess = {
@@ -176,7 +176,7 @@ describe("admin members + teams API", () => {
}); });
expect(archive.statusCode).toBe(200); expect(archive.statusCode).toBe(200);
expect(archive.json()).toEqual( expect(archive.json()).toEqual(
expect.objectContaining({ archived: true, revokedGrants: 1 }), expect.objectContaining({ archived: true, teamId: team.id }),
); );
const after = await app.inject({ const after = await app.inject({