// Who is in a forge team and what the team may do (issue 1563; novox/hq ADR 0466 ยง3, issue 971). // // The build keys sit on one forge user, nox-builder, in the team `builders`, which reads every repository and pushes // nothing. The tools' account cannot read a team (the forge answers /orgs/novox/teams 403: it lacks read:organization), // so the read goes with the site administrator's login, and only the read, as the branch protection read does (ADR 0375): // GET /orgs/{org}/teams, GET /teams/{id}/members and, for a user's effective right on a repository, the repository as // that user sees it (GiteaAdmin.repositoryPermission, which counts access through a team). Nothing is written. /** A team as the forge's Team object answers it (GET /orgs/{org}/teams): the fields read here. */ export interface ForgeTeam { id: number; name: string; description?: string; /** The team's overall permission: none, read, write, admin, owner. */ permission?: string; includes_all_repositories?: boolean; can_create_org_repo?: boolean; /** Per unit, the right the team has: units_map {"repo.code": "read", ...}. */ units_map?: Record; units?: string[]; } /** What the module asks of the forge for this, with the site administrator's login (GiteaAdmin, client.ts). */ export interface TeamForge { teams(org: string): Promise; teamMembers(teamId: number): Promise; accountExists(user: string): Promise; everyRepository(): Promise<{ full_name: string; owner: string; name: string }[]>; repositoryPermission(owner: string, repo: string, user: string): Promise; } export interface TeamRights { team: string; id: number; description: string; members: string[]; /** The team's overall permission. */ permission: string; includes_all_repositories: boolean; can_create_org_repo: boolean; /** Per unit, read / write / admin / none. */ units: Record; /** True when no unit of the team is above read: the team pushes nothing, writes no issue, no pull request. */ read_only: boolean; } const RANK: Record = { none: 0, read: 1, write: 2, admin: 3, owner: 4 }; /** One team as a person reads it. A team the forge gives no units_map is read from its `permission`, which is what the * older forge versions answer. */ export function teamRights(t: ForgeTeam, members: string[]): TeamRights { const units = t.units_map && Object.keys(t.units_map).length ? { ...t.units_map } : Object.fromEntries((t.units ?? []).map((u) => [u, t.permission ?? "none"])); const highest = Math.max(0, ...Object.values(units).map((r) => RANK[r] ?? 0)); return { team: t.name, id: t.id, description: t.description ?? "", members: [...members].sort(), permission: t.permission ?? "none", includes_all_repositories: t.includes_all_repositories === true, can_create_org_repo: t.can_create_org_repo === true, units, read_only: highest <= RANK.read && (RANK[t.permission ?? "none"] ?? 0) <= RANK.read, }; } export interface UserRights { user: string; /** The teams of the organisation the user is a member of. */ teams: string[]; /** How many repositories the forge gives the user each right on, effective (through a team or as a collaborator). */ repositories: Record; /** The repositories where the user may do more than read: empty for a read-only user. */ writes: { repository: string; permission: string }[]; /** Every repository and the right on it; only when asked. */ every?: { repository: string; permission: string }[]; } /** The teams of an organisation (or the one named), each with its members and rights; and for one user, the teams it is * in and its effective right on every repository (or on those named), as the forge says it. Only reads. */ export async function readTeamRights(forge: TeamForge, org: string, opts: { team?: string; user?: string; repositories?: string[]; list?: boolean } = {}): Promise<{ organisation: string; teams: TeamRights[]; user?: UserRights }> { if (opts.user && !(await forge.accountExists(opts.user))) throw new Error(`no forge user ${opts.user}`); const all = await forge.teams(org); const named = opts.team ? all.filter((t) => t.name === opts.team) : all; if (opts.team && !named.length) throw new Error(`no team "${opts.team}" in ${org}: it holds ${all.map((t) => t.name).join(", ") || "none"}`); // Membership is read for every team when a user is asked, so the user's teams are all found. const wanted = opts.user ? all : named; const rights: TeamRights[] = []; for (const t of wanted) rights.push(teamRights(t, await forge.teamMembers(t.id))); const shown = opts.team ? rights.filter((r) => r.team === opts.team) : rights; const out: { organisation: string; teams: TeamRights[]; user?: UserRights } = { organisation: org, teams: shown }; if (!opts.user) return out; const user = opts.user; const mine = rights.filter((r) => r.members.includes(user)).map((r) => r.team); const repos = (await forge.everyRepository()).filter((r) => !opts.repositories?.length || opts.repositories.includes(r.full_name)); const counts: Record = {}; const writes: { repository: string; permission: string }[] = []; const every: { repository: string; permission: string }[] = []; for (const r of repos) { const permission = await forge.repositoryPermission(r.owner, r.name, user); counts[permission] = (counts[permission] ?? 0) + 1; if ((RANK[permission] ?? 0) > RANK.read) writes.push({ repository: r.full_name, permission }); every.push({ repository: r.full_name, permission }); } out.user = { user, teams: mine, repositories: counts, writes, ...(opts.list ? { every } : {}) }; return out; }