feat(admin): local-only stdio MCP server to mint scoped service-account tokens (dispatch#67)
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:
@@ -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)))
|
||||
}
|
||||
Reference in New Issue
Block a user