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
169 lines
4.3 KiB
Go
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
|
|
}
|