feat(admin): local-only stdio MCP server to mint scoped service-account tokens (dispatch#67)
CD / Lint / Test / Vet (push) Failing after 4s
CD / Build & Import (push) Skipped
CD / Deploy via GitOps (push) Skipped

dispatch#67 fix #2 needed a read-only CI credential and hit a hard wall:
Gitea's own token-creation and admin-user endpoints reject token auth
outright -- POST /users/{u}/tokens returns 'auth required', POST
/admin/users returns 'token does not have at least one of required
scope(s), required=[write:admin]' -- for ANY existing token, by design. An
existing token cannot mint another token or create an account; the only
way through either endpoint is real basic auth as an actual site admin.
That is exactly what a sandboxed agent session cannot safely hold.

internal/adminbootstrap: the core bootstrap, HTTP-only, fully testable
against a fake transport (5 tests, no network). Given real admin
username/password, it creates the target account (or resets its password
if it already exists -- deliberately unconditional, gated behind a
required ConfirmServiceAccount flag since that reset is only safe for a
dedicated bot account), mints it a scoped token by authenticating AS that
account (Gitea requires this specifically -- an admin cannot mint on
another account's behalf), grants it read-only collaborator access on
named repos, and stores the result via a SecretStore -- never returning
the token or either password to the caller.

internal/localtools: the MCP tool wrapper (mint_service_account_token) and
the real op-CLI-backed SecretStore (token passed via stdin, never argv,
per the estate's own secret-handling rule). Deliberately its OWN package,
not internal/tools -- this capability is far too dangerous to risk it ever
being wired into RegisterAll and reachable from the shared HTTP gitea-mcp
server, even behind OIDC.

internal/mcp/stdio.go: a stdio JSON-RPC transport (one message per line,
standard MCP stdio framing) for the existing registry.Tool interface --
the only transport gitea-mcp had before this was HTTP (server.go), which a
sandboxed session can reach but a real admin password never should be
piped through.

cmd/gitea-admin-mcp: the binary. Meant to be spawned by a LOCAL Claude Code
session (.mcp.json stdio server) or run by hand, via 'op run --env-file
~/.op-env' so admin credentials arrive just-in-time and never touch a
sandboxed session.

11 new tests total, all passing under -race. Nothing in cmd/gitea-mcp (the
shared HTTP server) changed.
This commit is contained in:
2026-09-05 11:25:41 +02:00
parent 419ae19cdb
commit 4d6597639e
10 changed files with 1233 additions and 0 deletions
+284
View File
@@ -0,0 +1,284 @@
// Package adminbootstrap mints a scoped Gitea access token for a dedicated
// service/bot account, end to end, in one auditable call.
//
// dispatch#67 fix #2 needed a read-only CI credential and hit a hard wall:
// Gitea's own token-creation and admin-user endpoints reject token auth
// outright (POST /users/{u}/tokens and POST /admin/users both return
// "auth required" / a required-scope error for an existing token, by
// design — a leaked token being usable to mint further tokens would be a
// privilege-escalation footgun). Neither endpoint has a token-scoped
// workaround. The only way through is real basic auth as an actual admin.
//
// That is exactly what a sandboxed agent session cannot safely have — no
// interactive password, and handing one to a session would be broader
// exposure than the job needs. This package is meant to run LOCALLY, where
// real admin credentials exist just-in-time (1Password, `op run`), as a
// small stdio MCP tool (see cmd/gitea-admin-mcp) rather than ad hoc curl.
package adminbootstrap
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// HTTPDoer is the one method this package needs from *http.Client — narrow
// on purpose, so tests inject a fake transport instead of hitting a network.
type HTTPDoer interface {
Do(req *http.Request) (*http.Response, error)
}
// SecretStore persists the minted token somewhere durable and returns a
// reference safe to log or display — never the value itself. The real
// implementation (cmd/gitea-admin-mcp) shells out to the local `op` CLI;
// tests use an in-memory fake.
type SecretStore interface {
Store(ctx context.Context, name, value string) (ref string, err error)
}
// RepoRef names a repo to grant the new account read-only collaborator
// access to — least privilege: a service account should see only the repos
// it actually operates against, not every private repo on the instance.
type RepoRef struct {
Owner string
Name string
}
// Request describes one bootstrap. ConfirmServiceAccount is required and
// checked first: this package (re)sets whatever password Username already
// has, which is correct and safe for a dedicated bot account and dangerous
// for anything else.
type Request struct {
Username string
Email string
FullName string
Scopes []string
TokenName string
SecretName string
GrantReadRepos []RepoRef
ConfirmServiceAccount bool
}
// Result reports what happened. SecretRef, never the token value, is what a
// caller (or an MCP tool's text response) should ever surface.
type Result struct {
Username string
Created bool // false if the account already existed (password was reset, not created)
SecretRef string
GrantedRepos []string
}
// Bootstrapper holds the real admin credentials and where to reach Gitea —
// constructed once per invocation, never persisted.
type Bootstrapper struct {
BaseURL string
AdminUsername string
AdminPassword string
HTTP HTTPDoer // nil defaults to http.DefaultClient
Secrets SecretStore
}
func (b Bootstrapper) httpClient() HTTPDoer {
if b.HTTP != nil {
return b.HTTP
}
return http.DefaultClient
}
// Run creates (or resets the password of) Username, mints it a scoped
// token, grants it read-only access to GrantReadRepos, stores the token via
// Secrets, and returns without ever exposing the token value or either
// password to the caller.
func (b Bootstrapper) Run(ctx context.Context, req Request) (Result, error) {
if !req.ConfirmServiceAccount {
return Result{}, fmt.Errorf("refusing: ConfirmServiceAccount must be true — this resets %q's password unconditionally, which is only safe for a dedicated service/bot account", req.Username)
}
created, err := b.userExists(ctx, req.Username)
if err != nil {
return Result{}, fmt.Errorf("check existing account: %w", err)
}
created = !created // userExists returns true if found; Result.Created means "we created it"
botPassword, err := randomPassword()
if err != nil {
return Result{}, fmt.Errorf("generate account password: %w", err)
}
if created {
if err := b.createUser(ctx, req, botPassword); err != nil {
return Result{}, fmt.Errorf("create account: %w", err)
}
} else {
if err := b.resetPassword(ctx, req.Username, botPassword); err != nil {
return Result{}, fmt.Errorf("reset existing account's password: %w", err)
}
}
token, err := b.mintToken(ctx, req.Username, botPassword, req.TokenName, req.Scopes)
if err != nil {
return Result{}, fmt.Errorf("mint token: %w", err)
}
var granted []string
for _, r := range req.GrantReadRepos {
if err := b.grantReadAccess(ctx, r, req.Username); err != nil {
return Result{}, fmt.Errorf("grant read access on %s/%s: %w", r.Owner, r.Name, err)
}
granted = append(granted, r.Owner+"/"+r.Name)
}
ref, err := b.Secrets.Store(ctx, req.SecretName, token)
if err != nil {
return Result{}, fmt.Errorf("store token: %w", err)
}
return Result{
Username: req.Username,
Created: created,
SecretRef: ref,
GrantedRepos: granted,
}, nil
}
func randomPassword() (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func (b Bootstrapper) userExists(ctx context.Context, username string) (bool, error) {
resp, err := b.doAdmin(ctx, http.MethodGet, "/api/v1/users/"+username, nil)
if err != nil {
return false, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
default:
return false, fmt.Errorf("unexpected status %d checking account existence", resp.StatusCode)
}
}
func (b Bootstrapper) createUser(ctx context.Context, req Request, password string) error {
body := map[string]any{
"username": req.Username,
"email": req.Email,
"password": password,
"must_change_password": false,
}
if req.FullName != "" {
body["full_name"] = req.FullName
}
resp, err := b.doAdmin(ctx, http.MethodPost, "/api/v1/admin/users", body)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return statusErr(resp)
}
return nil
}
func (b Bootstrapper) resetPassword(ctx context.Context, username, password string) error {
resp, err := b.doAdmin(ctx, http.MethodPatch, "/api/v1/admin/users/"+username, map[string]any{
"password": password,
})
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return statusErr(resp)
}
return nil
}
// mintToken authenticates as the SERVICE ACCOUNT itself (not the admin) —
// Gitea's token-creation endpoint requires basic auth as the token's owner,
// and an admin's own credentials cannot mint on another account's behalf.
func (b Bootstrapper) mintToken(ctx context.Context, username, password, tokenName string, scopes []string) (string, error) {
raw, err := json.Marshal(map[string]any{"name": tokenName, "scopes": scopes})
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(b.BaseURL, "/")+"/api/v1/users/"+username+"/tokens", bytes.NewReader(raw))
if err != nil {
return "", err
}
req.SetBasicAuth(username, password)
req.Header.Set("Content-Type", "application/json")
resp, err := b.httpClient().Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return "", statusErr(resp)
}
var parsed struct {
SHA1 string `json:"sha1"`
}
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return "", fmt.Errorf("decode token response: %w", err)
}
if parsed.SHA1 == "" {
return "", fmt.Errorf("token response carried no sha1 value")
}
return parsed.SHA1, nil
}
// grantReadAccess is an admin action (adding a collaborator to a repo the
// admin owns), so it uses admin auth, not the new account's own.
func (b Bootstrapper) grantReadAccess(ctx context.Context, r RepoRef, username string) error {
path := fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s", r.Owner, r.Name, username)
resp, err := b.doAdmin(ctx, http.MethodPut, path, map[string]any{"permission": "read"})
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return statusErr(resp)
}
return nil
}
func (b Bootstrapper) doAdmin(ctx context.Context, method, path string, body map[string]any) (*http.Response, error) {
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(b.BaseURL, "/")+path, reader)
if err != nil {
return nil, err
}
req.SetBasicAuth(b.AdminUsername, b.AdminPassword)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return b.httpClient().Do(req)
}
func statusErr(resp *http.Response) error {
msg, _ := io.ReadAll(resp.Body)
return fmt.Errorf("gitea returned %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
}
@@ -0,0 +1,258 @@
package adminbootstrap_test
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// This exists to close dispatch#67 fix #2's blocker: minting a scoped Gitea
// token needs basic auth (POST /users/{u}/tokens and POST /admin/users both
// reject token auth outright, confirmed live against git.d-ma.be — an
// existing token cannot mint another token, by design). A sandboxed agent
// session has no interactive password, so this package is meant to run
// locally, where a real admin password is available just-in-time (1Password,
// `op run`), and to do the whole bootstrap in one auditable call rather than
// leaving it as ad hoc curl.
//
// Design choice, made explicit rather than silently assumed: this tool
// ALWAYS (re)sets the target account's password via admin basic auth before
// minting, whether the account is new or already existed. That is safe and
// expected for a dedicated service/bot account (its password is a mechanism
// to reach the token endpoint, never a human login credential) and would be
// dangerous pointed at a real person's account by mistake -- which is why
// Request.ConfirmServiceAccount exists and Run refuses to proceed without it.
type call struct {
method, path string
body map[string]any
authUser string // basic-auth username used for this call, "" if token/none
authPass string
}
type fakeTransport struct {
t *testing.T
responses map[string]fakeResponse // key: "METHOD path"
calls []call
}
type fakeResponse struct {
status int
body string
}
func (f *fakeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
var body map[string]any
if req.Body != nil {
raw, _ := io.ReadAll(req.Body)
if len(raw) > 0 {
require.NoError(f.t, json.Unmarshal(raw, &body))
}
}
user, pass, _ := req.BasicAuth()
f.calls = append(f.calls, call{method: req.Method, path: req.URL.Path, body: body, authUser: user, authPass: pass})
key := req.Method + " " + req.URL.Path
r, ok := f.responses[key]
if !ok {
f.t.Fatalf("unscripted request: %s (have: %v)", key, keys(f.responses))
}
return &http.Response{
StatusCode: r.status,
Body: io.NopCloser(strings.NewReader(r.body)),
Header: make(http.Header),
}, nil
}
func keys(m map[string]fakeResponse) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// fakeSecretStore records what would have been written to 1Password, without
// ever touching it -- and, critically, asserts the token value itself never
// leaks into anything but this one call.
type fakeSecretStore struct {
stored map[string]string
}
func (f *fakeSecretStore) Store(_ context.Context, name, value string) (string, error) {
if f.stored == nil {
f.stored = map[string]string{}
}
f.stored[name] = value
return "op://AgentSecrets/" + name + "/token", nil
}
func newBootstrapper(t *testing.T, responses map[string]fakeResponse) (adminbootstrap.Bootstrapper, *fakeTransport, *fakeSecretStore) {
ft := &fakeTransport{t: t, responses: responses}
fs := &fakeSecretStore{}
return adminbootstrap.Bootstrapper{
BaseURL: "http://fake.gitea",
AdminUsername: "mathias",
AdminPassword: "admin-pass-not-a-real-secret",
HTTP: &http.Client{Transport: ft},
Secrets: fs,
}, ft, fs
}
func TestRun_CreatesNewAccountMintsTokenAndStoresIt(t *testing.T) {
b, ft, fs := newBootstrapper(t, map[string]fakeResponse{
"GET /api/v1/users/dispatch-bot": {status: 404, body: `{}`},
"POST /api/v1/admin/users": {status: 201, body: `{"login":"dispatch-bot"}`},
"POST /api/v1/users/dispatch-bot/tokens": {status: 201, body: `{"sha1":"tok_abc123"}`},
})
result, err := b.Run(context.Background(), adminbootstrap.Request{
Username: "dispatch-bot",
Email: "dispatch@d-ma.be",
Scopes: []string{"read:repository", "read:issue"},
TokenName: "dispatch-ci-readonly",
SecretName: "gitea-dispatch-bot-ci-readonly",
ConfirmServiceAccount: true,
})
require.NoError(t, err)
assert.True(t, result.Created)
assert.Equal(t, "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/token", result.SecretRef)
assert.Equal(t, "tok_abc123", fs.stored["gitea-dispatch-bot-ci-readonly"])
// The account-creation call must use the ADMIN's basic auth, never the
// fresh account's own (unknown at that point) password.
create := findCall(t, ft.calls, "POST", "/api/v1/admin/users")
assert.Equal(t, "mathias", create.authUser)
assert.Equal(t, "dispatch-bot", create.body["username"])
assert.Equal(t, "dispatch@d-ma.be", create.body["email"])
assert.Equal(t, false, create.body["must_change_password"])
// The token-mint call must use basic auth AS THE NEW ACCOUNT -- Gitea
// requires this specifically (an admin token cannot mint on someone
// else's behalf) -- with the password this package generated and set,
// never the admin's own password.
mint := findCall(t, ft.calls, "POST", "/api/v1/users/dispatch-bot/tokens")
assert.Equal(t, "dispatch-bot", mint.authUser)
assert.NotEqual(t, "admin-pass-not-a-real-secret", mint.authPass)
assert.NotEmpty(t, mint.authPass)
assert.Equal(t, "dispatch-ci-readonly", mint.body["name"])
assert.ElementsMatch(t, []any{"read:repository", "read:issue"}, mint.body["scopes"])
}
func TestRun_ExistingAccountGetsPasswordResetNotRecreated(t *testing.T) {
b, ft, _ := newBootstrapper(t, map[string]fakeResponse{
"GET /api/v1/users/dispatch-bot": {status: 200, body: `{"login":"dispatch-bot"}`},
"PATCH /api/v1/admin/users/dispatch-bot": {status: 200, body: `{}`},
"POST /api/v1/users/dispatch-bot/tokens": {status: 201, body: `{"sha1":"tok_new"}`},
})
result, err := b.Run(context.Background(), adminbootstrap.Request{
Username: "dispatch-bot",
Email: "dispatch@d-ma.be",
Scopes: []string{"read:repository"},
TokenName: "dispatch-ci-readonly",
SecretName: "gitea-dispatch-bot-ci-readonly",
ConfirmServiceAccount: true,
})
require.NoError(t, err)
assert.False(t, result.Created, "must report the account as pre-existing, not newly created")
// Must PATCH (reset password), never POST (create) for an existing account.
assert.Empty(t, filterCalls(ft.calls, "POST", "/api/v1/admin/users"))
patch := findCall(t, ft.calls, "PATCH", "/api/v1/admin/users/dispatch-bot")
assert.Equal(t, "mathias", patch.authUser, "the reset must be done as the real admin")
assert.NotEmpty(t, patch.body["password"])
}
func TestRun_RefusesWithoutExplicitServiceAccountConfirmation(t *testing.T) {
// Guard against pointing this at a real person's account by accident --
// this tool resets whatever password the target account has.
b, ft, _ := newBootstrapper(t, map[string]fakeResponse{})
_, err := b.Run(context.Background(), adminbootstrap.Request{
Username: "mathias",
Email: "mthbqv@gmail.com",
Scopes: []string{"read:repository"},
TokenName: "whatever",
SecretName: "whatever",
// ConfirmServiceAccount left false/unset
})
require.Error(t, err)
assert.Contains(t, err.Error(), "ConfirmServiceAccount")
assert.Empty(t, ft.calls, "must not make any request at all without confirmation")
}
func TestRun_GrantsReadOnlyCollaboratorAccessViaAdminAuth(t *testing.T) {
b, ft, _ := newBootstrapper(t, map[string]fakeResponse{
"GET /api/v1/users/dispatch-bot": {status: 404, body: `{}`},
"POST /api/v1/admin/users": {status: 201, body: `{}`},
"POST /api/v1/users/dispatch-bot/tokens": {status: 201, body: `{"sha1":"tok"}`},
"PUT /api/v1/repos/mathias/cobalt-dingo/collaborators/dispatch-bot": {status: 204, body: ``},
})
result, err := b.Run(context.Background(), adminbootstrap.Request{
Username: "dispatch-bot",
Email: "dispatch@d-ma.be",
Scopes: []string{"read:repository"},
TokenName: "dispatch-ci-readonly",
SecretName: "gitea-dispatch-bot-ci-readonly",
GrantReadRepos: []adminbootstrap.RepoRef{{Owner: "mathias", Name: "cobalt-dingo"}},
ConfirmServiceAccount: true,
})
require.NoError(t, err)
assert.Equal(t, []string{"mathias/cobalt-dingo"}, result.GrantedRepos)
grant := findCall(t, ft.calls, "PUT", "/api/v1/repos/mathias/cobalt-dingo/collaborators/dispatch-bot")
assert.Equal(t, "mathias", grant.authUser, "granting collaborator access is an admin action, not the new account's own")
assert.Equal(t, "read", grant.body["permission"])
}
func TestRun_FailsClosedOnUpstreamError(t *testing.T) {
b, _, fs := newBootstrapper(t, map[string]fakeResponse{
"GET /api/v1/users/dispatch-bot": {status: 404, body: `{}`},
"POST /api/v1/admin/users": {status: 500, body: `{"message":"internal error"}`},
})
_, err := b.Run(context.Background(), adminbootstrap.Request{
Username: "dispatch-bot",
Email: "dispatch@d-ma.be",
Scopes: []string{"read:repository"},
TokenName: "x",
SecretName: "x",
ConfirmServiceAccount: true,
})
require.Error(t, err)
assert.Empty(t, fs.stored, "nothing must be written to the secret store if the flow didn't complete")
}
func findCall(t *testing.T, calls []call, method, path string) call {
t.Helper()
for _, c := range calls {
if c.method == method && c.path == path {
return c
}
}
t.Fatalf("no call found: %s %s (have: %+v)", method, path, calls)
return call{}
}
func filterCalls(calls []call, method, path string) []call {
var out []call
for _, c := range calls {
if c.method == method && c.path == path {
out = append(out, c)
}
}
return out
}