feat(forgejo): deny non-admin repository creation; reconcile the live fleet #670

Merged
binjovi-bot merged 2 commits from feat/forgejo-repo-creation-lockdown into trunk 2026-09-12 13:16:23 +00:00
Owner

Why

sean/ex_mcp was created on 2026-09-11 straight against the Forgejo API. It got no ForgejoRepository, no ForgejoPushMirror and no branch protection, so it had no off-cluster backup — and Binjovi pins it in apps/binjovi_api/mix.exs at 2bbb22ba, a commit that existed only on one Forgejo feature branch.

make rebuild wipes Forgejo. The next rebuild would have destroyed the source of Binjovi's own MCP server, exactly as sean/linux was lost on 2026-05-31. Every guard in the suite passed the whole time.

The fork is now backed up at codeberg.org/someara/ex-mcp (46 refs, verified identical). This change stops it happening again.

The hole

make forgejo-cred mints a write:repository PAT, and in Forgejo that scope authorizes POST /user/repos. Token scopes cannot separate "write repository content" from "create a repository", so the limit has to be set on the server.

The fix

  • FORGEJO__REPOSITORY__MAX_CREATION_LIMIT=0 — denies every non-admin creator.
  • FORGEJO__ADMIN__DISABLE_REGULAR_ORG_CREATION=truerequired, not belt-and-braces. Forgejo's documentation says a user can bypass the creation limit by making an organization; without this the first setting is decorative.

forgejo-operator is unaffected — checked, not assumed

This was the risk worth proving, because if the operator lost repo creation a cold make rebuild could not seed the fleet. Against Forgejo v16.0.2, the deployed version:

// services/repository/create.go:201
if !doer.IsAdmin && !u.CanCreateRepo() { return ErrReachLimitOfRepo }

The operator authenticates as the gitea_admin site admin, and both its paths pass that admin as doer:

Path Call site
POST /admin/users/{u}/repos routers/api/v1/repo/repo.go:268CreateRepository(ctx, ctx.Doer(), owner, …)
POST /repos/migrate (cold-rebuild seed) routers/api/v1/repo/migrate.go:180CreateRepositoryDirectly(ctx, ctx.Doer(), repoOwner, …)

Break-glass is unchanged and explicit: make forgejo-cred-admin.

Guards

tests/gitmirror-fleet.sh could never have caught this. It walks the files that exist, and says so itself: "this checks declared state only". A repository that was never declared is invisible to it.

  • tests/forgejo-repo-creation-locked.sh (hermetic, in CI) — keeps both settings in place, and refuses an explicit -1. Negative control checked: removing either setting fails it.
  • tests/forgejo-repo-reconcile/run-all.sh (live, NOT_IN_CI) — asks the live server and compares both directions. Same split as tests/upstream-inventory.sh / tests/upstream-drift, and listed with a reason. It uses /repos/search because /users/{owner}/repos needs read:user, which the routine token does not hold — an asymmetry that is itself the point: the contributor token may create a repository but may not list them.

It currently reports five undeclared repositories:

binjovi-plans  ex_mcp  hetzner-node-image  pipelines  pipelines-operator

pipelines and pipelines-operator belong to the retired system and may be deliberate orphans. Triaging those, and onboarding ex-mcp as a full Binjovi project, are follow-ups — this change is the enforcement and the detection.

Verification

  • Full tests/check.sh green (116 guards; 106 gated by CI).
  • Guard proved non-vacuous by removing each setting in turn.
  • Codeberg backup verified: git ls-remote identical on both sides, and the pinned commit re-fetched from Codeberg alone with a matching tree hash.

https://claude.ai/code/session_01XdBRc9CHgFhAxCyHbdVtt6

## Why `sean/ex_mcp` was created on 2026-09-11 straight against the Forgejo API. It got no `ForgejoRepository`, no `ForgejoPushMirror` and no branch protection, so it had **no off-cluster backup** — and Binjovi pins it in `apps/binjovi_api/mix.exs` at `2bbb22ba`, a commit that existed only on one Forgejo feature branch. `make rebuild` wipes Forgejo. The next rebuild would have destroyed the source of Binjovi's own MCP server, exactly as `sean/linux` was lost on 2026-05-31. Every guard in the suite passed the whole time. The fork is now backed up at `codeberg.org/someara/ex-mcp` (46 refs, verified identical). This change stops it happening again. ## The hole `make forgejo-cred` mints a `write:repository` PAT, and in Forgejo that scope authorizes `POST /user/repos`. Token scopes cannot separate "write repository content" from "create a repository", so the limit has to be set on the server. ## The fix - `FORGEJO__REPOSITORY__MAX_CREATION_LIMIT=0` — denies every non-admin creator. - `FORGEJO__ADMIN__DISABLE_REGULAR_ORG_CREATION=true` — **required**, not belt-and-braces. Forgejo's documentation says a user can bypass the creation limit by making an organization; without this the first setting is decorative. ### forgejo-operator is unaffected — checked, not assumed This was the risk worth proving, because if the operator lost repo creation a cold `make rebuild` could not seed the fleet. Against Forgejo **v16.0.2**, the deployed version: ```go // services/repository/create.go:201 if !doer.IsAdmin && !u.CanCreateRepo() { return ErrReachLimitOfRepo } ``` The operator authenticates as the `gitea_admin` site admin, and **both** its paths pass that admin as `doer`: | Path | Call site | | --- | --- | | `POST /admin/users/{u}/repos` | `routers/api/v1/repo/repo.go:268` — `CreateRepository(ctx, ctx.Doer(), owner, …)` | | `POST /repos/migrate` (cold-rebuild seed) | `routers/api/v1/repo/migrate.go:180` — `CreateRepositoryDirectly(ctx, ctx.Doer(), repoOwner, …)` | Break-glass is unchanged and explicit: `make forgejo-cred-admin`. ## Guards `tests/gitmirror-fleet.sh` could never have caught this. It walks the files that exist, and says so itself: *"this checks declared state only"*. A repository that was never declared is invisible to it. - **`tests/forgejo-repo-creation-locked.sh`** (hermetic, in CI) — keeps both settings in place, and refuses an explicit `-1`. Negative control checked: removing either setting fails it. - **`tests/forgejo-repo-reconcile/run-all.sh`** (live, `NOT_IN_CI`) — asks the live server and compares both directions. Same split as `tests/upstream-inventory.sh` / `tests/upstream-drift`, and listed with a reason. It uses `/repos/search` because `/users/{owner}/repos` needs `read:user`, which the routine token does not hold — an asymmetry that is itself the point: the contributor token may **create** a repository but may not **list** them. It currently reports five undeclared repositories: ``` binjovi-plans ex_mcp hetzner-node-image pipelines pipelines-operator ``` `pipelines` and `pipelines-operator` belong to the retired system and may be deliberate orphans. Triaging those, and onboarding `ex-mcp` as a full Binjovi project, are follow-ups — this change is the enforcement and the detection. ## Verification - Full `tests/check.sh` green (116 guards; 106 gated by CI). - Guard proved non-vacuous by removing each setting in turn. - Codeberg backup verified: `git ls-remote` identical on both sides, and the pinned commit re-fetched from Codeberg alone with a matching tree hash. https://claude.ai/code/session_01XdBRc9CHgFhAxCyHbdVtt6
feat(forgejo): deny non-admin repository creation; reconcile the live fleet
All checks were successful
binjovi/ci Binjovi completed the frozen plan
8e6d1cae57
`sean/ex_mcp` was created on 2026-09-11 straight against the Forgejo API. It
got no ForgejoRepository, no ForgejoPushMirror and no branch protection, so it
had no off-cluster backup. Binjovi pins that repository in
apps/binjovi_api/mix.exs at a commit that existed only on one Forgejo feature
branch. `make rebuild` wipes Forgejo, so the next rebuild would have destroyed
the source of Binjovi's own MCP server. This is how `sean/linux` was lost on
2026-05-31.

The contributor token from `make forgejo-cred` holds `write:repository`, and
in Forgejo that scope authorizes POST /user/repos. Token scopes cannot
separate writing repository content from creating a repository, so the limit
belongs on the server.

MAX_CREATION_LIMIT=0 denies every non-admin creator. DISABLE_REGULAR_ORG_CREATION
closes the organization bypass that the Forgejo documentation names; without it
the first setting is decorative.

forgejo-operator is unaffected. It authenticates as the `gitea_admin` site
admin, and both its create path (routers/api/v1/repo/repo.go:268) and its
cold-rebuild migrate path (routers/api/v1/repo/migrate.go:180) pass that admin
as `doer`. services/repository/create.go:201 reads
`!doer.IsAdmin && !u.CanCreateRepo()`, so the operator passes through and a
cold `make rebuild` still seeds the fleet. Checked against Forgejo v16.0.2,
the deployed version.

tests/gitmirror-fleet.sh could not have caught this. It walks the files that
exist and says so itself: "this checks declared state only".
tests/forgejo-repo-reconcile/run-all.sh asks the live server instead and
compares both directions. It is a report, not a merge gate, and is listed in
NOT_IN_CI beside tests/upstream-drift for the same reason. It uses
/repos/search because /users/{owner}/repos needs read:user, which the routine
token does not hold. It currently names five undeclared repositories.

Claude-Session: https://claude.ai/code/session_01XdBRc9CHgFhAxCyHbdVtt6
fix(forgejo-reconcile): read both CR kinds, so PR #371 cannot blind the report
All checks were successful
binjovi/ci Binjovi completed the frozen plan
510a07c2c7
The reconciler read spec.name from ForgejoRepository only. Open PR #371,
"Retire ForgejoRepository authority to Binjovi", removes those 38 CRs and
keeps the push mirrors. The day it lands, that parse would have seen an empty
fleet and reported every live repository as undeclared -- noise, arriving
exactly when the report needed to be trusted.

ForgejoPushMirror.spec.repository.name carries the same value, so the parse
now unions both kinds and survives the migration in either direction. Proved
by stripping every ForgejoRepository doc from the fleet: 36 repositories still
parse, and the report names the same five undeclared repositories.

Claude-Session: https://claude.ai/code/session_01XdBRc9CHgFhAxCyHbdVtt6
binjovi-bot deleted branch feat/forgejo-repo-creation-lockdown 2026-09-12 13:16:23 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
sean/seanfarm!670
No description provided.