20613afb26
Add a Vault/OpenBao secrets engine that mints ephemeral, scoped Gitea access tokens on demand. The engine holds a single seeded Gitea site-admin Basic-Auth credential and, per role, mints a fresh per-user token via the admin API, bound to a Vault lease and deleted from Gitea on revocation. Gitea requires Basic Auth for token management (token auth is rejected), and reqSelfOrAdmin lets a site admin manage any user's tokens, which is the mechanism this relies on. Gitea tokens never expire server-side, so the Vault lease is the sole expiry mechanism. - add backend wiring, config (+ rotate-root), roles, creds paths - add the gitea client (Basic Auth create/delete token, admin password change) - add scope validation against Gitea's access-token scope set - add unit tests (fake Gitea API) and a Vault+OpenBao e2e harness - add Makefile, nfpm RPM packaging, and Woodpecker build/test/release pipelines Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
196 lines
6.1 KiB
Go
196 lines
6.1 KiB
Go
package gitea
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const defaultHTTPTimeout = 30 * time.Second
|
|
|
|
// giteaClient talks to the Gitea REST API using the seeded site-admin
|
|
// credentials over HTTP Basic Auth. Basic Auth is mandatory: Gitea's
|
|
// token-management endpoints reject token/bearer auth (go-gitea/gitea#21186).
|
|
type giteaClient struct {
|
|
baseURL string
|
|
username string
|
|
password string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// accessToken mirrors the subset of Gitea's AccessToken JSON we read. The token
|
|
// value (sha1) is only ever returned in the create response.
|
|
type accessToken struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
SHA1 string `json:"sha1"`
|
|
TokenLastEight string `json:"token_last_eight"`
|
|
Scopes []string `json:"scopes"`
|
|
}
|
|
|
|
// createTokenOption is Gitea's CreateAccessTokenOption request body.
|
|
type createTokenOption struct {
|
|
Name string `json:"name"`
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
}
|
|
|
|
// editUserOption is the subset of Gitea's EditUserOption used to rotate the
|
|
// admin password. Gitea binds login_name as Required, so it must be sent even
|
|
// for a password-only change.
|
|
type editUserOption struct {
|
|
LoginName string `json:"login_name"`
|
|
SourceID int64 `json:"source_id"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
func newClient(cfg *giteaConfig) (*giteaClient, error) {
|
|
if cfg == nil {
|
|
return nil, errors.New("gitea client configuration is nil")
|
|
}
|
|
if cfg.GiteaURL == "" {
|
|
return nil, errors.New("gitea_url is required")
|
|
}
|
|
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
|
|
return nil, errors.New("admin_username and admin_password are required")
|
|
}
|
|
|
|
timeout := defaultHTTPTimeout
|
|
if cfg.RequestTimeoutSeconds > 0 {
|
|
timeout = time.Duration(cfg.RequestTimeoutSeconds) * time.Second
|
|
}
|
|
|
|
tlsConfig := &tls.Config{InsecureSkipVerify: cfg.TLSSkipVerify} //nolint:gosec // opt-in via config
|
|
if cfg.CACert != "" {
|
|
pool := x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM([]byte(cfg.CACert)) {
|
|
return nil, errors.New("ca_cert is not a valid PEM certificate")
|
|
}
|
|
tlsConfig.RootCAs = pool
|
|
}
|
|
|
|
return &giteaClient{
|
|
baseURL: strings.TrimRight(cfg.GiteaURL, "/"),
|
|
username: cfg.AdminUsername,
|
|
password: cfg.AdminPassword,
|
|
httpClient: &http.Client{
|
|
Timeout: timeout,
|
|
Transport: &http.Transport{TLSClientConfig: tlsConfig},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// withPassword returns a shallow copy of the client authenticating with a
|
|
// different password. Used to roll a rotation back to the previous password.
|
|
func (c *giteaClient) withPassword(password string) *giteaClient {
|
|
clone := *c
|
|
clone.password = password
|
|
return &clone
|
|
}
|
|
|
|
// errNotFound flags a 404 so callers can treat absence as non-fatal.
|
|
var errNotFound = errors.New("not found")
|
|
|
|
// CreateToken mints a new access token for username with the given scopes and
|
|
// returns its value (sha1) and numeric id (as a string). The admin's Basic Auth
|
|
// credentials authorise minting for another user (reqSelfOrAdmin).
|
|
func (c *giteaClient) CreateToken(ctx context.Context, username, name string, scopes []string) (value, id string, err error) {
|
|
body := createTokenOption{Name: name, Scopes: scopes}
|
|
var out accessToken
|
|
if err := c.do(ctx, http.MethodPost, "/api/v1/users/"+username+"/tokens", body, &out); err != nil {
|
|
return "", "", err
|
|
}
|
|
if out.SHA1 == "" {
|
|
return "", "", errors.New("gitea returned an empty token value")
|
|
}
|
|
return out.SHA1, strconv.FormatInt(out.ID, 10), nil
|
|
}
|
|
|
|
// DeleteToken removes an access token from username by its id (Gitea also
|
|
// accepts the token name here). A missing token is treated as success.
|
|
func (c *giteaClient) DeleteToken(ctx context.Context, username, id string) error {
|
|
if id == "" {
|
|
return nil
|
|
}
|
|
err := c.do(ctx, http.MethodDelete, "/api/v1/users/"+username+"/tokens/"+id, nil, nil)
|
|
if errors.Is(err, errNotFound) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// SetAdminPassword changes the seeded admin user's password via the admin API.
|
|
// login_name / source_id are echoed from config because Gitea requires them.
|
|
func (c *giteaClient) SetAdminPassword(ctx context.Context, username, loginName string, sourceID int64, newPassword string) error {
|
|
body := editUserOption{LoginName: loginName, SourceID: sourceID, Password: newPassword}
|
|
return c.do(ctx, http.MethodPatch, "/api/v1/admin/users/"+username, body, nil)
|
|
}
|
|
|
|
// VerifyAdmin confirms the seeded credentials authenticate and belong to a site
|
|
// admin, returning a clear error otherwise. Used to fail config writes fast.
|
|
func (c *giteaClient) VerifyAdmin(ctx context.Context) error {
|
|
var out struct {
|
|
Login string `json:"login"`
|
|
IsAdmin bool `json:"is_admin"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, "/api/v1/user", nil, &out); err != nil {
|
|
return err
|
|
}
|
|
if !out.IsAdmin {
|
|
return fmt.Errorf("user %q is not a Gitea site admin; the engine requires an admin to manage other users' tokens", out.Login)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *giteaClient) do(ctx context.Context, method, path string, payload, out interface{}) error {
|
|
var body io.Reader
|
|
if payload != nil {
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("encoding request body: %w", err)
|
|
}
|
|
body = bytes.NewReader(raw)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
|
|
if err != nil {
|
|
return fmt.Errorf("building request: %w", err)
|
|
}
|
|
req.SetBasicAuth(c.username, c.password)
|
|
req.Header.Set("Accept", "application/json")
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("calling gitea %s %s: %w", method, path, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return errNotFound
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("gitea %s %s returned %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(respBody)))
|
|
}
|
|
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(respBody, out); err != nil {
|
|
return fmt.Errorf("decoding gitea response: %w", err)
|
|
}
|
|
return nil
|
|
}
|