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.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
# End-to-end test stack. A mock ghp admin API (in-memory, no db/GitHub) plus two
|
||||
# secrets-engine hosts running the exact same plugin binary: HashiCorp Vault and
|
||||
# OpenBao. Bind mounts use ":z" so they work under SELinux.
|
||||
services:
|
||||
ghp:
|
||||
image: golang:1.25-alpine
|
||||
working_dir: /src
|
||||
environment:
|
||||
MOCKGHP_ADDR: ":3000"
|
||||
MOCKGHP_ADMIN_TOKEN: "ghpsvc_seed"
|
||||
GOFLAGS: "-mod=mod"
|
||||
command: ["go", "run", "./test/mockghp"]
|
||||
volumes:
|
||||
- ..:/src:ro,z
|
||||
ports:
|
||||
- "3000:3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 40
|
||||
|
||||
vault:
|
||||
image: hashicorp/vault:1.18
|
||||
depends_on:
|
||||
ghp:
|
||||
condition: service_healthy
|
||||
cap_add: [IPC_LOCK]
|
||||
environment:
|
||||
VAULT_DEV_ROOT_TOKEN_ID: root
|
||||
VAULT_ADDR: http://127.0.0.1:8200
|
||||
VAULT_TOKEN: root
|
||||
command: ["server", "-dev", "-dev-listen-address=0.0.0.0:8200", "-config=/vault/vault.hcl"]
|
||||
volumes:
|
||||
- ../dist:/vault/plugins:ro,z
|
||||
- ./vault/vault.hcl:/vault/vault.hcl:ro,z
|
||||
ports: ["8200:8200"]
|
||||
healthcheck:
|
||||
test: ["CMD", "vault", "status", "-address=http://127.0.0.1:8200"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
openbao:
|
||||
image: openbao/openbao:latest
|
||||
depends_on:
|
||||
ghp:
|
||||
condition: service_healthy
|
||||
cap_add: [IPC_LOCK]
|
||||
environment:
|
||||
BAO_DEV_ROOT_TOKEN_ID: root
|
||||
BAO_ADDR: http://127.0.0.1:8200
|
||||
BAO_TOKEN: root
|
||||
command: ["server", "-dev", "-dev-listen-address=0.0.0.0:8200", "-config=/openbao/bao.hcl"]
|
||||
volumes:
|
||||
- ../dist:/openbao/plugins:ro,z
|
||||
- ./openbao/bao.hcl:/openbao/bao.hcl:ro,z
|
||||
ports: ["8300:8200"]
|
||||
healthcheck:
|
||||
test: ["CMD", "bao", "status", "-address=http://127.0.0.1:8200"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
@@ -0,0 +1,139 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# OpenBao is plugin-protocol compatible with Vault, so the very same plugin
|
||||
# binary registers and runs here unchanged. Combined with `-dev` at runtime.
|
||||
plugin_directory = "/openbao/plugins"
|
||||
api_addr = "http://127.0.0.1:8200"
|
||||
@@ -0,0 +1,4 @@
|
||||
# Combined with `-dev` at runtime; supplies the plugin_directory the dev server
|
||||
# would otherwise leave unset, so the plugin binary in ../dist can be registered.
|
||||
plugin_directory = "/vault/plugins"
|
||||
api_addr = "http://127.0.0.1:8200"
|
||||
Reference in New Issue
Block a user