gitea: gitea_team_rights reads a forge team's members and units and a user's effective rights, read-only (issue 1563) #524

Merged
mesh-tools merged 2 commits from fix/1563-team-rights into main 2026-10-27 21:00:58 +00:00
6 changed files with 265 additions and 1 deletions
+8
View File
@@ -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
+30
View File
@@ -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. */
@@ -1225,6 +1226,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<ForgeTeam[]> {
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<string[]> {
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.
*
+103
View File
@@ -0,0 +1,103 @@
// 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;
}
+102
View File
@@ -0,0 +1,102 @@
// 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<string, unknown>) => ({
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<string, string[]> = { "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));
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) {
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<void>((r) => server.listen(0, "127.0.0.1", () => r()));
after(() => new Promise<void>((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<string, unknown>) => tools.find((t) => t.name === "gitea_team_rights")!.run(args) as Promise<any>;
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);
});
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/);
});
+1 -1
View File
@@ -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",
+21
View File
@@ -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.",