From f8ddcc98f682f0671c04354dcf3213209c14de61 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 27 Oct 2026 21:50:59 +0100 Subject: [PATCH 1/2] Add gitea_team_rights: read a forge team's members, units and a user's effective rights, with the administrator's login, GET only (issue 1563) --- modules/gitea/README.md | 8 ++ modules/gitea/client.ts | 31 ++++++++ modules/gitea/teamrights.ts | 101 +++++++++++++++++++++++++ modules/gitea/test/team-rights.test.ts | 96 +++++++++++++++++++++++ modules/gitea/test/token.test.ts | 2 +- modules/gitea/tools/index.ts | 21 +++++ 6 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 modules/gitea/teamrights.ts create mode 100644 modules/gitea/test/team-rights.test.ts diff --git a/modules/gitea/README.md b/modules/gitea/README.md index 1bfe7723..23411ea1 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -304,6 +304,14 @@ named and asked nothing; the start's pass is run again ten minutes apart until e grant by hand, now, on every repository or on those named, for every account or those named; each grant read back from the forge, a refusal answered in the forge's own words). +### Teams are read with the administrator's login + +`gitea_team_rights` (issue 1563) answers the forge's teams of an organisation (novox when not given), or one team: its +members, permission, units (read, write or admin per unit), whether it covers all repositories, and whether it is +read-only. With a `user` it adds the teams that user is in and its effective right on every repository (or those named), +listing those above read: the check that a build key's user, `nox-builder` in `builders`, reads and pushes nothing. The +tools' token lacks `read:organization` (the forge answers 403), so the read goes with the administrator's login, GET only. + ### The merging account writes the repositories the mesh delivers `mesh-tools` sets every commit status and merges; outside the organisation it only reads (above), so a pull request of diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 6628bfd7..17384a31 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -930,6 +930,8 @@ interface AdminResponse { } /** A repository as the site administrator sees it: what saying why an account cannot read it needs (readall.ts). */ +import type { ForgeTeam } from "./teamrights.js"; + export interface ForgeRepository { full_name: string; owner: string; @@ -1225,6 +1227,35 @@ export class GiteaAdmin { return match ? Number(match.id) : null; } + /** The teams of an organisation, as the forge's Team objects (issue 1563). With the administrator's login: the tools' + * account lacks read:organization, so the list answers it 403. Only a read. */ + async teams(org: string): Promise { + const all: ForgeTeam[] = []; + for (let page = 1; page <= 50; page++) { + const path = `/orgs/${encodeURIComponent(org)}/teams?limit=50&page=${page}`; + const res = await this.request(path); + if (res.status !== 200) GiteaAdmin.fail(`/orgs/${org}/teams`, res); + const list: ForgeTeam[] = Array.isArray(res.body) ? res.body : []; + all.push(...list); + if (list.length < 50) break; + } + return all; + } + + /** The logins of a team's members (GET /teams/{id}/members). Only a read. */ + async teamMembers(teamId: number): Promise { + const all: string[] = []; + for (let page = 1; page <= 50; page++) { + const path = `/teams/${teamId}/members?limit=50&page=${page}`; + const res = await this.request(path); + if (res.status !== 200) GiteaAdmin.fail(`/teams/${teamId}/members`, res); + const list: any[] = Array.isArray(res.body) ? res.body : []; + all.push(...list.map((u) => String(u?.login ?? u?.username ?? "")).filter(Boolean)); + if (list.length < 50) break; + } + return all; + } + /** Ensure a user exists with exactly this password. Created if absent; if already there, its * password is patched — so the mesh minting a new secret takes on the next reconcile. * diff --git a/modules/gitea/teamrights.ts b/modules/gitea/teamrights.ts new file mode 100644 index 00000000..7904f96d --- /dev/null +++ b/modules/gitea/teamrights.ts @@ -0,0 +1,101 @@ +// 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; + 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 }> { + 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; +} diff --git a/modules/gitea/test/team-rights.test.ts b/modules/gitea/test/team-rights.test.ts new file mode 100644 index 00000000..1a54463b --- /dev/null +++ b/modules/gitea/test/team-rights.test.ts @@ -0,0 +1,96 @@ +// Issue 1563: no tool read a forge team's members and rights, so the build keys' read-only user (nox-builder, team +// builders, ADR 0466 §3) could not be checked live. The tools' token answers /orgs/novox/teams 403; the read goes with +// the administrator's login, GET only. +// +// The answers below keep the shape of the forge's own API (gitea's swagger, "Team": id, name, description, +// organization, includes_all_repositories, permission, units, units_map, can_create_org_repo; "User": id, login, ...; +// repos/search: {ok, data:[Repository]}; Repository.permissions {admin, push, pull}). The live answer is read once the +// tool is delivered (issue 1563 AC2). + +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; + +import { GiteaAdmin, GiteaClient } from "../dist/client.js"; +import { ConfiguredToken } from "../dist/token.js"; +import { getGiteaTools } from "../dist/tools/index.js"; + +const adminBasic = "Basic " + Buffer.from("mesh-admin:secret").toString("base64"); +const org = { id: 2, username: "novox", name: "novox" }; +const team = (id: number, name: string, over: Record) => ({ + id, name, description: "", organization: org, includes_all_repositories: true, permission: "read", can_create_org_repo: false, + units: ["repo.code"], units_map: { "repo.code": "read" }, ...over, +}); +const teams = [ + team(5, "builders", { description: "the build modules' fetch" }), + team(3, "mesh-tools", { permission: "write", units: ["repo.code", "repo.issues", "repo.pulls"], + units_map: { "repo.code": "write", "repo.issues": "write", "repo.pulls": "write" } }), +]; +const members: Record = { "5": ["nox-builder"], "3": ["mesh-tools"] }; +const user = (login: string) => ({ id: 23, login, username: login, email: `${login}@noreply.localhost` }); +const repo = (owner: string, name: string) => ({ full_name: `${owner}/${name}`, name, owner: { login: owner }, private: true, archived: false }); +const repos = [repo("novox", "mesh-catalog"), repo("jschoubben", "car-hunter")]; +const seen: string[] = []; + +const server = createServer((req, res) => { + const url = new URL(req.url ?? "", "http://x"); + const path = url.pathname.replace(/^\/api\/v1/, ""); + const as = req.headers.authorization === adminBasic ? "admin" : "tools"; + seen.push(`${req.method} ${path} as ${as}`); + const send = (status: number, body: unknown) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); }; + if (as !== "admin" && path.startsWith("/orgs/")) return send(403, { message: "token does not have at least one of required scope(s): [read:organization]" }); + if (path === "/orgs/novox/teams") return send(200, teams); + const m = path.match(/^\/teams\/(\d+)\/members$/); + if (m) return send(200, (members[m[1]] ?? []).map(user)); + if (path === "/repos/search") return send(200, { ok: true, data: repos }); + const r = path.match(/^\/repos\/([^/]+)\/([^/]+)$/); + if (r) { + const sudo = url.searchParams.get("sudo"); + const found = repos.find((x) => x.full_name === `${r[1]}/${r[2]}`); + // nox-builder reads the organisation's repositories through its team and the other one not at all; mesh-tools writes. + if (!found || (sudo === "nox-builder" && r[1] !== "novox")) return send(404, { message: "not found" }); + const push = sudo === "mesh-tools"; + return send(200, { ...found, permissions: { admin: false, push, pull: true } }); + } + send(404, { message: "not found" }); +}); +await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); +after(() => new Promise((r) => server.close(() => r()))); +const { port } = server.address() as { port: number }; +const url = `http://127.0.0.1:${port}`; +const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret")); +const run = (args: Record) => tools.find((t) => t.name === "gitea_team_rights")!.run(args) as Promise; + +test("gitea_team_rights answers every team of the organisation with its members and units, read as the administrator", async () => { + const out = await run({}); + assert.equal(out.organisation, "novox"); + assert.deepEqual(out.teams.map((t: any) => t.team), ["builders", "mesh-tools"]); + const builders = out.teams[0]; + assert.deepEqual(builders.members, ["nox-builder"]); + assert.deepEqual(builders.units, { "repo.code": "read" }); + assert.equal(builders.includes_all_repositories, true); + assert.equal(builders.read_only, true); + assert.equal(out.teams[1].read_only, false); + assert.ok(seen.every((s) => s.startsWith("GET ") && s.endsWith("as admin")), `only reads, as the administrator: ${seen.join("; ")}`); +}); + +test("gitea_team_rights answers one team by name, and refuses an unknown one naming the teams there are", async () => { + const out = await run({ team: "builders" }); + assert.deepEqual(out.teams.map((t: any) => t.team), ["builders"]); + await assert.rejects(run({ team: "nobody" }), /no team "nobody" in novox: it holds builders, mesh-tools/); +}); + +test("gitea_team_rights answers a user's teams and effective right on each repository: nox-builder writes nowhere", async () => { + const out = await run({ user: "nox-builder" }); + assert.deepEqual(out.user.teams, ["builders"]); + assert.deepEqual(out.user.repositories, { read: 1, none: 1 }); + assert.deepEqual(out.user.writes, []); + const tools2 = await run({ user: "mesh-tools", list: true }); + assert.deepEqual(tools2.user.writes, [{ repository: "novox/mesh-catalog", permission: "write" }, { repository: "jschoubben/car-hunter", permission: "write" }]); + assert.equal(tools2.user.every.length, 2); +}); + +test("the tools' own token is refused the teams, which is why the administrator's login reads them", async () => { + const res = await fetch(`${url}/api/v1/orgs/novox/teams`, { headers: { Authorization: "token tools-token" } }); + assert.equal(res.status, 403); +}); diff --git a/modules/gitea/test/token.test.ts b/modules/gitea/test/token.test.ts index 437cdd4c..a0f83af2 100644 --- a/modules/gitea/test/token.test.ts +++ b/modules/gitea/test/token.test.ts @@ -538,7 +538,7 @@ test("the tools register once there is a way to a token, and the first call mint "gitea_get_file", "gitea_list_branches", "gitea_delete_branch", - "gitea_branch_protection_get", "gitea_branch_protection_set", + "gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set", "gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_commit_trees", "gitea_contains", "gitea_list_labels", "gitea_create_label", "gitea_api", diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 5db52501..fc3b4d0b 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -11,6 +11,7 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { emit } from "@novox/mesh-sdk/events"; import { GiteaAdmin, GiteaClient, type BranchProtection } from "../client.js"; +import { readTeamRights } from "../teamrights.js"; import { appendNote, run, viewBody, viewComment } from "../delivery.js"; import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js"; import { @@ -438,6 +439,26 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini return { branch: String(args.branch), rule: rule ? summarised(rule) : null }; }, }, + // ---- Forge teams (issue 1563) ---- + { + name: "gitea_team_rights", + description: "Read-only: the forge's teams of an organisation, who is in each (members) and what each may do — its permission, its units read, write or admin per unit (code, issues, pull requests), whether it covers all repositories, and whether it is read-only. Give a user to see which teams it is in and its effective right (read, write, admin) on every repository, with those it can write listed: the check that a build key's forge user (nox-builder, team builders) reads and pushes nothing. Forge team members and rights; GET only, no secret answered.", + input: { + org: { type: "string", description: "the organisation; novox when not given" }, + team: { type: "string", description: "one team by name; every team of the organisation when not given" }, + user: { type: "string", description: "a forge user: its teams and effective right on each repository" }, + repositories: { type: "array", items: { type: "string" }, description: "owner/name of the repositories to look at for the user; every repository when not given" }, + list: { type: "boolean", description: "with a user, list the right on every repository, not only those above read" }, + }, + run: async (args) => { + const strings = Array.isArray(args.repositories) ? args.repositories.map(String) : undefined; + // The second read made as the forge's administrator (ADR 0375's precedent, issue 1563): the tools' token lacks + // read:organization, so a team is not theirs to read. GET only. + return readTeamRights(admin ? admin() : (() => { throw new Error("the forge's administrator login is not set: a team is read with it"); })(), + String(args.org || "novox"), { team: args.team ? String(args.team) : undefined, user: args.user ? String(args.user) : undefined, + repositories: strings, list: args.list === true }); + }, + }, { name: "gitea_branch_protection_set", description: "Protect a branch. Never weaker (novox/hq ADR 0266): a change that lets a person push directly or lets an administrator merge past the rule is refused, and on main so is a change that drops the merge whitelist (only mesh-tools merges there) or widens it (novox/hq ADR 0398 §2, issue 948) — that is the operator's, at the forge. The forge requires no status to merge (ADR 0534): the rule written requires none, and one the rule required is taken off; the checks are judged by mesh-delivery and the merge tool. Edits the branch's rule (everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.", From 36d59933239e5d2d542f9ece477a283b0efd1a5c Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 27 Oct 2026 21:54:51 +0100 Subject: [PATCH 2/2] gitea_team_rights: refuse a forge user that does not exist; keep ForgeRepository's doc comment on it (issue 1563) --- modules/gitea/client.ts | 3 +-- modules/gitea/teamrights.ts | 2 ++ modules/gitea/test/team-rights.test.ts | 6 ++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 17384a31..60372f6f 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -9,6 +9,7 @@ import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js"; import { MAIN_BRANCH, mainRuleBody, protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js"; import { Refused, checkSentPath, ruleCovers, pathSegment, pathSegments } from "./guard.js"; import { isCommit } from "./checks.js"; +import type { ForgeTeam } from "./teamrights.js"; /** A repository's path on the forge, its owner and name each one segment (guard.ts pathSegment): uniform for every * call, whether the names came from a caller or from the forge's own answer. */ @@ -930,8 +931,6 @@ interface AdminResponse { } /** A repository as the site administrator sees it: what saying why an account cannot read it needs (readall.ts). */ -import type { ForgeTeam } from "./teamrights.js"; - export interface ForgeRepository { full_name: string; owner: string; diff --git a/modules/gitea/teamrights.ts b/modules/gitea/teamrights.ts index 7904f96d..27cf9bdc 100644 --- a/modules/gitea/teamrights.ts +++ b/modules/gitea/teamrights.ts @@ -24,6 +24,7 @@ export interface ForgeTeam { 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; } @@ -74,6 +75,7 @@ export interface UserRights { * 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"}`); diff --git a/modules/gitea/test/team-rights.test.ts b/modules/gitea/test/team-rights.test.ts index 1a54463b..65e79be5 100644 --- a/modules/gitea/test/team-rights.test.ts +++ b/modules/gitea/test/team-rights.test.ts @@ -42,6 +42,8 @@ const server = createServer((req, res) => { if (path === "/orgs/novox/teams") return send(200, teams); const m = path.match(/^\/teams\/(\d+)\/members$/); if (m) return send(200, (members[m[1]] ?? []).map(user)); + const u = path.match(/^\/users\/([^/]+)$/); + if (u) return send(u[1] === "nobody" ? 404 : 200, u[1] === "nobody" ? { message: "not found" } : user(u[1])); if (path === "/repos/search") return send(200, { ok: true, data: repos }); const r = path.match(/^\/repos\/([^/]+)\/([^/]+)$/); if (r) { @@ -94,3 +96,7 @@ test("the tools' own token is refused the teams, which is why the administrator' const res = await fetch(`${url}/api/v1/orgs/novox/teams`, { headers: { Authorization: "token tools-token" } }); assert.equal(res.status, 403); }); + +test("a user the forge does not know is refused, not answered as writing nowhere", async () => { + await assert.rejects(run({ user: "nobody" }), /no forge user nobody/); +});