Files
vault-plugin-secrets-ghp/test/mockghp/main.go
T
unkin-agent 64d9b89dcd
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Scaffold ghp secrets engine modelled on vault-plugin-secrets-gitea
Mints ephemeral, scoped ghp access tokens via ghp's admin token API
(POST /api/tokens), bound to a Vault lease and revoked on lease
expiry (DELETE /api/tokens/{id}).

- config: base_url + write-only admin_token (ghpsvc_ service token),
  TLS settings; verifies the token is a ghp admin on write. No
  rotate-root: the service token is static and operator-managed.
- roles: token_type (agent/proxy), installation_id, app_record_id,
  repositories, scopes (permission:level), session_prefix, ttl/max_ttl.
- creds: mint a lease-bound token; ghp-side duration bounded by the
  lease ceiling as defence in depth.
- secret ghp_token: idempotent revoke + lease renew.
- Unit tests (config/role/creds/client/scopes/revocation), mock-ghp
  e2e on Vault + OpenBao, Woodpecker pre-commit/build/test/release,
  Makefile patch/minor/major, nfpm RPM packaging.
2026-08-15 19:13:44 +10:00

140 lines
3.9 KiB
Go

// Command mockghp is an in-memory stand-in for the subset of the ghp admin API
// that vault-plugin-secrets-ghp uses: admin check (GET /api/users), token
// create (POST /api/tokens) and token revoke (DELETE /api/tokens/{id}). It is
// used by the e2e tests — no real ghp, database, or GitHub App required. Not for
// production use.
//
// Authentication is a bearer service token (MOCKGHP_ADMIN_TOKEN) matched exactly;
// a valid token is treated as a synthetic admin, exactly as real ghp treats its
// configured service tokens.
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
)
type createTokenRequest struct {
Type string `json:"type"`
AppRecordID string `json:"app_record_id"`
Repositories []string `json:"repositories"`
InstallationID int64 `json:"installation_id"`
Scopes string `json:"scopes"`
Duration string `json:"duration"`
SessionID string `json:"session_id"`
}
type store struct {
mu sync.Mutex
adminTok string
nextID int64
tokens map[string]bool // key: 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 {
tok, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
s.mu.Lock()
defer s.mu.Unlock()
return ok && tok == s.adminTok
}
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) handleUsers(w http.ResponseWriter, r *http.Request) {
if !s.authOK(r) {
w.WriteHeader(http.StatusUnauthorized)
return
}
writeJSON(w, http.StatusOK, []map[string]interface{}{{"id": "svc-admin", "role": "admin"}})
}
func (s *store) handleTokens(w http.ResponseWriter, r *http.Request) {
if !s.authOK(r) {
w.WriteHeader(http.StatusUnauthorized)
return
}
switch {
case r.Method == http.MethodPost && r.URL.Path == "/api/tokens":
var in createTokenRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
tt := in.Type
if tt == "" {
tt = "proxy"
}
s.mu.Lock()
s.nextID++
id := "tok-" + strconv.FormatInt(s.nextID, 10)
s.tokens[id] = true
s.mu.Unlock()
scopes := map[string]string{}
for _, part := range strings.Split(in.Scopes, ",") {
kv := strings.SplitN(part, ":", 2)
if len(kv) == 2 {
scopes[kv[0]] = kv[1]
}
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"token": "gha_" + randHex(16),
"id": id,
"type": tt,
"repositories": in.Repositories,
"scopes": scopes,
"expires_at": "2030-01-01T00:00:00Z",
"session_id": in.SessionID,
})
case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/api/tokens/"):
id := strings.TrimPrefix(r.URL.Path, "/api/tokens/")
s.mu.Lock()
exists := s.tokens[id]
delete(s.tokens, id)
s.mu.Unlock()
if !exists {
writeJSON(w, http.StatusNotFound, map[string]string{"message": "Token not found"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "Token revoked"})
default:
w.WriteHeader(http.StatusNotFound)
}
}
func main() {
addr := os.Getenv("MOCKGHP_ADDR")
if addr == "" {
addr = ":3000"
}
adminTok := os.Getenv("MOCKGHP_ADMIN_TOKEN")
if adminTok == "" {
adminTok = "ghpsvc_seed"
}
s := &store{adminTok: adminTok, tokens: map[string]bool{}}
mux := http.NewServeMux()
mux.HandleFunc("/api/users", s.handleUsers)
mux.HandleFunc("/api/tokens", s.handleTokens)
mux.HandleFunc("/api/tokens/", s.handleTokens)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
log.Printf("mock ghp API listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, mux)) //nolint:gosec // test-only mock
}