Files
unkinben 20613afb26 Initial vault-plugin-secrets-gitea engine
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
2026-07-27 00:54:59 +10:00

169 lines
4.3 KiB
Go

// Command mockgitea is an in-memory stand-in for the subset of the Gitea REST
// API that vault-plugin-secrets-gitea uses: whoami, per-user access token
// create/delete, and admin password change. It is used by the e2e tests — no
// real Gitea, database, or git required. Not for production use.
//
// Authentication is HTTP Basic Auth against the *current* admin credentials
// (MOCKGITEA_ADMIN_USER / MOCKGITEA_ADMIN_PASS); a successful admin password
// change updates the accepted credentials, exactly as the plugin's rotate-root
// relies on.
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
)
type createTokenOption struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
}
type editUserOption struct {
LoginName string `json:"login_name"`
SourceID int64 `json:"source_id"`
Password string `json:"password"`
}
type store struct {
mu sync.Mutex
adminU string
adminP string
nextID int64
tokens map[string]bool // key: username/id
}
func randHex(n int) string {
buf := make([]byte, n)
_, _ = rand.Read(buf)
return hex.EncodeToString(buf)
}
func (s *store) authOK(r *http.Request) bool {
u, p, ok := r.BasicAuth()
if !ok {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
return u == s.adminU && p == s.adminP
}
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func (s *store) handleUser(w http.ResponseWriter, r *http.Request) {
if !s.authOK(r) {
w.WriteHeader(http.StatusUnauthorized)
return
}
s.mu.Lock()
login := s.adminU
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]interface{}{"login": login, "is_admin": true})
}
func (s *store) handleUsers(w http.ResponseWriter, r *http.Request) {
if !s.authOK(r) {
w.WriteHeader(http.StatusUnauthorized)
return
}
// /api/v1/users/{username}/tokens[/{id}]
rest := strings.TrimPrefix(r.URL.Path, "/api/v1/users/")
switch {
case r.Method == http.MethodPost && strings.HasSuffix(rest, "/tokens"):
username := strings.TrimSuffix(rest, "/tokens")
var in createTokenOption
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
s.mu.Lock()
s.nextID++
id := s.nextID
s.tokens[username+"/"+strconv.FormatInt(id, 10)] = true
s.mu.Unlock()
writeJSON(w, http.StatusCreated, map[string]interface{}{
"id": id,
"name": in.Name,
"sha1": randHex(20),
"token_last_eight": randHex(4),
"scopes": in.Scopes,
})
case r.Method == http.MethodDelete && strings.Contains(rest, "/tokens/"):
parts := strings.SplitN(rest, "/tokens/", 2)
if len(parts) != 2 {
w.WriteHeader(http.StatusNotFound)
return
}
k := parts[0] + "/" + parts[1]
s.mu.Lock()
exists := s.tokens[k]
delete(s.tokens, k)
s.mu.Unlock()
if !exists {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusNotFound)
}
}
func (s *store) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
if !s.authOK(r) {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.Method != http.MethodPatch {
w.WriteHeader(http.StatusNotFound)
return
}
var in editUserOption
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
s.mu.Lock()
if in.Password != "" {
s.adminP = in.Password
}
s.mu.Unlock()
w.WriteHeader(http.StatusOK)
}
func main() {
addr := os.Getenv("MOCKGITEA_ADDR")
if addr == "" {
addr = ":3000"
}
adminU := os.Getenv("MOCKGITEA_ADMIN_USER")
if adminU == "" {
adminU = "bot-admin"
}
adminP := os.Getenv("MOCKGITEA_ADMIN_PASS")
if adminP == "" {
adminP = "seed-password"
}
s := &store{adminU: adminU, adminP: adminP, tokens: map[string]bool{}}
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/user", s.handleUser)
mux.HandleFunc("/api/v1/users/", s.handleUsers)
mux.HandleFunc("/api/v1/admin/users/", s.handleAdminUsers)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
log.Printf("mock gitea API listening on %s (admin %s)", addr, adminU)
log.Fatal(http.ListenAndServe(addr, mux)) //nolint:gosec // test-only mock
}