// 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))) }