Merge main
This commit is contained in:
+65
-23
@@ -352,24 +352,34 @@ export class GiteaAdmin {
|
||||
GiteaAdmin.fail("/orgs", res);
|
||||
}
|
||||
|
||||
/** Ensure the org's package team exists, granting read+write on packages, and return its id. The
|
||||
* team is found by name if it is already there, created otherwise; a lost create race is resolved
|
||||
* by re-listing. */
|
||||
/** Ensure the org's package team exists with exactly these units, and return its id. Found or
|
||||
* created, the units are applied either way — a team is configuration the reconcile loop owns,
|
||||
* the same as a user's password, so a unit this code gains reaches a team that already exists
|
||||
* rather than only the next mesh raised from scratch. A lost create race is resolved by
|
||||
* re-listing. */
|
||||
async ensureTeam(org: string, team: string, packageWrite: boolean): Promise<number> {
|
||||
// The units a consumer needs, and no more. `units_map` is exhaustive — a unit not named is a
|
||||
// unit the team does not have — so code read must be said here: without it gitea answers a
|
||||
// member's clone of a private repository with "not found", which is how the builder's first
|
||||
// credentialed clone failed against a team that named only packages.
|
||||
const units = {
|
||||
permission: "read",
|
||||
units_map: { "repo.code": "read", "repo.packages": packageWrite ? "write" : "read" },
|
||||
includes_all_repositories: true,
|
||||
can_create_org_repo: false,
|
||||
};
|
||||
const found = await this.findTeam(org, team);
|
||||
if (found !== null) return found;
|
||||
if (found !== null) {
|
||||
const patch = await this.request(`/teams/${found}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name: team, ...units }),
|
||||
});
|
||||
if (patch.status === 200) return found;
|
||||
GiteaAdmin.fail(`/teams/${found}`, patch);
|
||||
}
|
||||
const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: team,
|
||||
permission: "read",
|
||||
// Package access is a per-unit grant; the team needs write on the packages unit and nothing
|
||||
// else. includes_all_repositories keeps the team's repo view whole without widening its
|
||||
// repo permission beyond read.
|
||||
units_map: { "repo.packages": packageWrite ? "write" : "read" },
|
||||
includes_all_repositories: true,
|
||||
can_create_org_repo: false,
|
||||
}),
|
||||
body: JSON.stringify({ name: team, ...units }),
|
||||
});
|
||||
if (res.status === 201) return Number(res.body?.id);
|
||||
if (res.status === 422 || res.status === 409) {
|
||||
@@ -380,14 +390,18 @@ export class GiteaAdmin {
|
||||
}
|
||||
|
||||
private async findTeam(org: string, team: string): Promise<number | null> {
|
||||
const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`);
|
||||
const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`);
|
||||
if (res.status !== 200) return null;
|
||||
const match = (res.body as any[] | null)?.find((t) => t?.name === team);
|
||||
return match ? Number(match.id) : null;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
* password is patched — so the mesh minting a new secret takes on the next reconcile.
|
||||
*
|
||||
* The edit path is taken only when the user actually exists. A 422 from the create is also what
|
||||
* a plain validation failure returns, and reading it as "already there" made the follow-up edit
|
||||
* 404 — burying the create's own message, which is the one that says what is actually wrong. */
|
||||
async ensureUser(username: string, password: string, email: string): Promise<void> {
|
||||
const res = await this.request("/admin/users", {
|
||||
method: "POST",
|
||||
@@ -395,13 +409,18 @@ export class GiteaAdmin {
|
||||
});
|
||||
if (res.status === 201) return;
|
||||
if (res.status === 422 || res.status === 409) {
|
||||
const patch = await this.request(`/admin/users/${encodeURIComponent(username)}`, {
|
||||
method: "PATCH",
|
||||
// login_name is required by the admin edit endpoint; for a local user it is the username.
|
||||
body: JSON.stringify({ login_name: username, password, must_change_password: false }),
|
||||
});
|
||||
if (patch.status === 200) return;
|
||||
GiteaAdmin.fail(`/admin/users/${username}`, patch);
|
||||
const seen = await this.request(`/users/${encodeURIComponent(username)}`);
|
||||
if (seen.status === 200) {
|
||||
const patch = await this.request(`/admin/users/${encodeURIComponent(username)}`, {
|
||||
method: "PATCH",
|
||||
// login_name is required by the admin edit endpoint; for a local user it is the username.
|
||||
// active and prohibit_login: a deactivated or login-prohibited user is refused like a wrong
|
||||
// password, so the provisioner's check reports it lost; applying again must undo both.
|
||||
body: JSON.stringify({ login_name: username, password, must_change_password: false, active: true, prohibit_login: false }),
|
||||
});
|
||||
if (patch.status === 200) return;
|
||||
GiteaAdmin.fail(`/admin/users/${username}`, patch);
|
||||
}
|
||||
}
|
||||
GiteaAdmin.fail("/admin/users", res);
|
||||
}
|
||||
@@ -416,6 +435,29 @@ export class GiteaAdmin {
|
||||
GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a consumer's user logs in with exactly this password and is still a member of the
|
||||
* package team. Read-only: the password is checked as the consumer presents it, basic auth on the
|
||||
* API, and membership through the admin API. `false` for a refused login or a missing member; any
|
||||
* other answer rejects (novox/hq issue 120).
|
||||
*/
|
||||
async holdsTeamMember(org: string, team: string, username: string, password: string): Promise<boolean> {
|
||||
const me = await fetch(`${this.baseUrl}/api/v1/user`, {
|
||||
headers: { Authorization: "Basic " + Buffer.from(`${username}:${password}`).toString("base64") },
|
||||
});
|
||||
if (me.status === 401 || me.status === 403) return false;
|
||||
if (me.status !== 200) throw new Error(`Gitea GET /user as ${username}: ${me.status}`);
|
||||
const teams = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`);
|
||||
if (teams.status === 404) return false;
|
||||
if (teams.status !== 200) GiteaAdmin.fail(`/orgs/${org}/teams`, teams);
|
||||
const found = (teams.body as { id: number; name: string }[]).find((t) => t.name === team);
|
||||
if (!found) return false;
|
||||
const member = await this.request(`/teams/${found.id}/members/${encodeURIComponent(username)}`);
|
||||
if (member.status === 200 || member.status === 204) return true;
|
||||
if (member.status === 404) return false;
|
||||
GiteaAdmin.fail(`/teams/${found.id}/members/${username}`, member);
|
||||
}
|
||||
|
||||
/** Delete a user, purging what they own. A 404 means the mesh already withdrew them — success, not
|
||||
* an error, so a re-run of remove is safe. */
|
||||
async deleteUser(username: string): Promise<void> {
|
||||
|
||||
@@ -11,8 +11,16 @@
|
||||
"name": "gitea"
|
||||
},
|
||||
"route": {
|
||||
"label": "git",
|
||||
"port": 3000
|
||||
"web": {
|
||||
"label": "git",
|
||||
"port": 3000
|
||||
},
|
||||
"internal-api-refused": {
|
||||
"label": "git",
|
||||
"path": "/api/internal",
|
||||
"deny": true,
|
||||
"priority": 100000
|
||||
}
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -44,11 +44,21 @@ runProvisioner("npm-package-registry", {
|
||||
const teamId = await gitea.ensureTeam(ORG, PACKAGE_TEAM, true);
|
||||
// The user carries the consumer's login and the mesh's minted password, set every run so a
|
||||
// rotation takes. Membership of the package team is what grants read+write on packages.
|
||||
await gitea.ensureUser(p.as, p.password, `${p.as}@localhost`);
|
||||
//
|
||||
// The address is gitea's own convention for one that is not real: its email validation
|
||||
// requires a dotted domain, so `@localhost` was refused at create — the fault that had this
|
||||
// grant retrying for a day — while `@noreply.localhost` is the shape gitea itself gives
|
||||
// hidden addresses.
|
||||
await gitea.ensureUser(p.as, p.password, `${p.as}@noreply.localhost`);
|
||||
await gitea.addUserToTeam(teamId, p.as);
|
||||
},
|
||||
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
await gitea.deleteUser(p.as);
|
||||
},
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||
async holds(p: Provision): Promise<boolean> {
|
||||
return gitea.holdsTeamMember(ORG, PACKAGE_TEAM, p.as, p.password);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user