Every upstream Gitea call used the single GITEA_MCP_DEFAULT_TOKEN service PAT, regardless of caller. auth.Caller(ctx) (from oauth2-proxy/Authentik forward-auth headers) was only used for cosmetic "on behalf of X" comment/PR footers — never for actual authorization. With more than one real Gitea user now on the horizon (Authentik SSO wired up for git.d-ma.be, see infra commit a32801c), every caller effectively acted with the service account's permissions.
Fix
PassthroughMiddleware (internal/auth/passthrough.go): if the request's bearer token validates directly against Gitea's own /api/v1/user (Client.ValidateToken), it's used for every upstream call this request makes (gitea.WithToken context override, read in Client.doOnce/doRaw), and the caller identity is set from Gitea's own login name — not the shared default token. Any other bearer (static token, JWT, or none) falls through unchanged to the existing chassis static/JWT auth — this only adds a capability, doesn't remove the existing paths.
Each user authenticates the MCP connection with their own Gitea PAT (minted from their own, Authentik-linked, Gitea account). Gitea's own permission model then gates what they can touch.
Deferred
GITEA_MCP_ALLOWED_OWNERS (default mathias) still gates at the MCP layer regardless of which token authenticated. Worth revisiting now that per-user identity is real — either widen it or make it derive from the caller's own Gitea permissions instead of a static allowlist.
Acceptance criteria (met)
internal/gitea: WithToken/TokenFromContext context override, preferred over the client's configured default token in both doOnce and doRaw.
internal/gitea: ValidateToken(ctx, token) — resolves a token's Gitea login via /api/v1/user.
internal/auth: PassthroughMiddleware — valid Gitea PAT → straight to the MCP server with caller+token in context; invalid/absent → falls through to the existing chassis auth chain.
go test -race ./... and task check green.
## Problem
Every upstream Gitea call used the single `GITEA_MCP_DEFAULT_TOKEN` service PAT, regardless of caller. `auth.Caller(ctx)` (from oauth2-proxy/Authentik forward-auth headers) was only used for cosmetic "on behalf of X" comment/PR footers — never for actual authorization. With more than one real Gitea user now on the horizon (Authentik SSO wired up for git.d-ma.be, see infra commit `a32801c`), every caller effectively acted with the service account's permissions.
## Fix
`PassthroughMiddleware` (`internal/auth/passthrough.go`): if the request's bearer token validates directly against Gitea's own `/api/v1/user` (`Client.ValidateToken`), it's used for every upstream call this request makes (`gitea.WithToken` context override, read in `Client.doOnce`/`doRaw`), and the caller identity is set from Gitea's own login name — not the shared default token. Any other bearer (static token, JWT, or none) falls through unchanged to the existing chassis static/JWT auth — this only adds a capability, doesn't remove the existing paths.
Each user authenticates the MCP connection with their own Gitea PAT (minted from their own, Authentik-linked, Gitea account). Gitea's own permission model then gates what they can touch.
## Deferred
`GITEA_MCP_ALLOWED_OWNERS` (default `mathias`) still gates at the MCP layer regardless of which token authenticated. Worth revisiting now that per-user identity is real — either widen it or make it derive from the caller's own Gitea permissions instead of a static allowlist.
## Acceptance criteria (met)
- `internal/gitea`: `WithToken`/`TokenFromContext` context override, preferred over the client's configured default token in both `doOnce` and `doRaw`.
- `internal/gitea`: `ValidateToken(ctx, token)` — resolves a token's Gitea login via `/api/v1/user`.
- `internal/auth`: `PassthroughMiddleware` — valid Gitea PAT → straight to the MCP server with caller+token in context; invalid/absent → falls through to the existing chassis auth chain.
- `go test -race ./...` and `task check` green.
Deferred item resolved in 4371404: Allowlist.Check(ctx, owner) now skips the static GITEA_MCP_ALLOWED_OWNERS list entirely when the caller authenticated via their own Gitea PAT (pass-through) — Gitea's own permission model gates that caller precisely, a coarse owner-name list would only ever be wrong in that case (either too strict, blocking a legit second user's own repos, or too permissive if widened blindly). The static list still applies unchanged for the shared static-token/JWT path.
Mechanical follow-through: 41 call sites updated to pass ctx (already in scope everywhere), no other tool behavior changed. task check green.
Deferred item resolved in `4371404`: `Allowlist.Check(ctx, owner)` now skips the static `GITEA_MCP_ALLOWED_OWNERS` list entirely when the caller authenticated via their own Gitea PAT (pass-through) — Gitea's own permission model gates that caller precisely, a coarse owner-name list would only ever be wrong in that case (either too strict, blocking a legit second user's own repos, or too permissive if widened blindly). The static list still applies unchanged for the shared static-token/JWT path.
Mechanical follow-through: 41 call sites updated to pass `ctx` (already in scope everywhere), no other tool behavior changed. `task check` green.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Problem
Every upstream Gitea call used the single
GITEA_MCP_DEFAULT_TOKENservice PAT, regardless of caller.auth.Caller(ctx)(from oauth2-proxy/Authentik forward-auth headers) was only used for cosmetic "on behalf of X" comment/PR footers — never for actual authorization. With more than one real Gitea user now on the horizon (Authentik SSO wired up for git.d-ma.be, see infra commita32801c), every caller effectively acted with the service account's permissions.Fix
PassthroughMiddleware(internal/auth/passthrough.go): if the request's bearer token validates directly against Gitea's own/api/v1/user(Client.ValidateToken), it's used for every upstream call this request makes (gitea.WithTokencontext override, read inClient.doOnce/doRaw), and the caller identity is set from Gitea's own login name — not the shared default token. Any other bearer (static token, JWT, or none) falls through unchanged to the existing chassis static/JWT auth — this only adds a capability, doesn't remove the existing paths.Each user authenticates the MCP connection with their own Gitea PAT (minted from their own, Authentik-linked, Gitea account). Gitea's own permission model then gates what they can touch.
Deferred
GITEA_MCP_ALLOWED_OWNERS(defaultmathias) still gates at the MCP layer regardless of which token authenticated. Worth revisiting now that per-user identity is real — either widen it or make it derive from the caller's own Gitea permissions instead of a static allowlist.Acceptance criteria (met)
internal/gitea:WithToken/TokenFromContextcontext override, preferred over the client's configured default token in bothdoOnceanddoRaw.internal/gitea:ValidateToken(ctx, token)— resolves a token's Gitea login via/api/v1/user.internal/auth:PassthroughMiddleware— valid Gitea PAT → straight to the MCP server with caller+token in context; invalid/absent → falls through to the existing chassis auth chain.go test -race ./...andtask checkgreen.Deferred item resolved in
4371404:Allowlist.Check(ctx, owner)now skips the staticGITEA_MCP_ALLOWED_OWNERSlist entirely when the caller authenticated via their own Gitea PAT (pass-through) — Gitea's own permission model gates that caller precisely, a coarse owner-name list would only ever be wrong in that case (either too strict, blocking a legit second user's own repos, or too permissive if widened blindly). The static list still applies unchanged for the shared static-token/JWT path.Mechanical follow-through: 41 call sites updated to pass
ctx(already in scope everywhere), no other tool behavior changed.task checkgreen.