From b574ef871c627c918d24c96ed28a66551fa6c274 Mon Sep 17 00:00:00 2001 From: ChickenPige0n <2336983354@qq.com> Date: Tue, 14 Jul 2026 21:17:20 +0800 Subject: [PATCH] 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. --- .../admin/org/[slug]/teams/+page.svelte | 2 +- hub/src/org/teams.ts | 31 +++++-------------- hub/src/permissions/projectTeamAccess.ts | 19 ++++++------ .../integration/admin-members-teams.test.ts | 2 +- 4 files changed, 20 insertions(+), 34 deletions(-) diff --git a/hub/admin-web/src/routes/admin/org/[slug]/teams/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/teams/+page.svelte index 81a866f..e552856 100644 --- a/hub/admin-web/src/routes/admin/org/[slug]/teams/+page.svelte +++ b/hub/admin-web/src/routes/admin/org/[slug]/teams/+page.svelte @@ -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; diff --git a/hub/src/org/teams.ts b/hub/src/org/teams.ts index 6684afa..e918b89 100644 --- a/hub/src/org/teams.ts +++ b/hub/src/org/teams.ts @@ -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 }; }); } diff --git a/hub/src/permissions/projectTeamAccess.ts b/hub/src/permissions/projectTeamAccess.ts index fedd6eb..11db94e 100644 --- a/hub/src/permissions/projectTeamAccess.ts +++ b/hub/src/permissions/projectTeamAccess.ts @@ -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,13 +142,15 @@ export async function listProjectTeamAccess( ); } - return grants.map((grant) => entryFromGrant({ - grantId: grant.id, - projectId: project.id, - organizationId: project.organizationId, - team: teamsById.get(grant.principalId)!, - role: grant.role, - })); + return grants + .filter((grant) => teamsById.get(grant.principalId)?.archivedAt === null) + .map((grant) => entryFromGrant({ + grantId: grant.id, + projectId: project.id, + organizationId: project.organizationId, + team: teamsById.get(grant.principalId)!, + role: grant.role, + })); } type ProjectForAccess = { diff --git a/hub/test/integration/admin-members-teams.test.ts b/hub/test/integration/admin-members-teams.test.ts index e24c459..895d1a7 100644 --- a/hub/test/integration/admin-members-teams.test.ts +++ b/hub/test/integration/admin-members-teams.test.ts @@ -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({