Files
mesh-catalog/modules/gitea/teamrights.ts
T
jschoubben 36d5993323
mesh/delivery merged: source.main moved to novox/mesh-catalog@bf94af0a820f
mesh/check checking 36d59933239e on bf94af0a820f
gitea_team_rights: refuse a forge user that does not exist; keep ForgeRepository's doc comment on it (issue 1563)
2026-10-27 21:54:51 +01:00

104 lines
5.6 KiB
TypeScript

// 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<string, string>;
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<ForgeTeam[]>;
teamMembers(teamId: number): Promise<string[]>;
accountExists(user: string): Promise<boolean>;
everyRepository(): Promise<{ full_name: string; owner: string; name: string }[]>;
repositoryPermission(owner: string, repo: string, user: string): Promise<string>;
}
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<string, string>;
/** 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<string, number> = { 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<string, number>;
/** 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<string, number> = {};
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;
}