64d9b89dcd
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.
433 lines
12 KiB
Go
433 lines
12 KiB
Go
package ghp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/hashicorp/vault/sdk/logical"
|
|
)
|
|
|
|
// fakeGHP is an in-memory stand-in for the subset of ghp's admin API the plugin
|
|
// uses: admin check (GET /api/users), token create (POST /api/tokens) and token
|
|
// revoke (DELETE /api/tokens/{id}). Bearer auth is validated against the seeded
|
|
// service token, and non-admin behaviour can be simulated by rejecting it.
|
|
type fakeGHP struct {
|
|
mu sync.Mutex
|
|
adminTok string
|
|
nextID int64
|
|
tokens map[string]createdToken // key: id
|
|
minted int
|
|
forbidden bool // when true, a valid token is treated as non-admin (403)
|
|
}
|
|
|
|
type createdToken struct {
|
|
id string
|
|
tokenType string
|
|
repositories []string
|
|
scopes map[string]string
|
|
sessionID string
|
|
}
|
|
|
|
func newFakeGHP(adminTok string) *fakeGHP {
|
|
return &fakeGHP{adminTok: adminTok, tokens: map[string]createdToken{}}
|
|
}
|
|
|
|
func (f *fakeGHP) authOK(r *http.Request) bool {
|
|
tok, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return ok && tok == f.adminTok
|
|
}
|
|
|
|
func (f *fakeGHP) server(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !f.authOK(r) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
f.mu.Lock()
|
|
forbidden := f.forbidden
|
|
f.mu.Unlock()
|
|
|
|
switch {
|
|
case r.Method == http.MethodGet && r.URL.Path == "/api/users":
|
|
if forbidden {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, []map[string]interface{}{{"id": "svc-admin"}})
|
|
|
|
case r.Method == http.MethodPost && r.URL.Path == "/api/tokens":
|
|
var in createTokenRequest
|
|
_ = json.NewDecoder(r.Body).Decode(&in)
|
|
scopes := map[string]string{}
|
|
if in.Scopes != "" {
|
|
for _, part := range strings.Split(in.Scopes, ",") {
|
|
kv := strings.SplitN(part, ":", 2)
|
|
if len(kv) == 2 {
|
|
scopes[kv[0]] = kv[1]
|
|
}
|
|
}
|
|
}
|
|
f.mu.Lock()
|
|
f.nextID++
|
|
id := "tok-" + strconv.FormatInt(f.nextID, 10)
|
|
f.minted++
|
|
tt := in.Type
|
|
if tt == "" {
|
|
tt = tokenTypeProxy
|
|
}
|
|
f.tokens[id] = createdToken{id: id, tokenType: tt, repositories: in.Repositories, scopes: scopes, sessionID: in.SessionID}
|
|
f.mu.Unlock()
|
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
|
"token": "gha_" + id,
|
|
"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/")
|
|
f.mu.Lock()
|
|
_, exists := f.tokens[id]
|
|
delete(f.tokens, id)
|
|
f.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 (f *fakeGHP) has(id string) bool {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
_, ok := f.tokens[id]
|
|
return ok
|
|
}
|
|
|
|
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 newTestBackend(t *testing.T) (*ghpBackend, logical.Storage) {
|
|
t.Helper()
|
|
config := logical.TestBackendConfig()
|
|
config.StorageView = &logical.InmemStorage{}
|
|
b, err := Factory(context.Background(), config)
|
|
if err != nil {
|
|
t.Fatalf("Factory: %v", err)
|
|
}
|
|
return b.(*ghpBackend), config.StorageView
|
|
}
|
|
|
|
func req(t *testing.T, b *ghpBackend, s logical.Storage, op logical.Operation, path string, data map[string]interface{}) *logical.Response {
|
|
t.Helper()
|
|
resp, err := b.HandleRequest(context.Background(), &logical.Request{
|
|
Operation: op,
|
|
Path: path,
|
|
Data: data,
|
|
Storage: s,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", op, path, err)
|
|
}
|
|
if resp != nil && resp.IsError() {
|
|
t.Fatalf("%s %s: %v", op, path, resp.Error())
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func configure(t *testing.T, b *ghpBackend, s logical.Storage, url, token string) {
|
|
t.Helper()
|
|
req(t, b, s, logical.CreateOperation, "config", map[string]interface{}{
|
|
"base_url": url,
|
|
"admin_token": token,
|
|
})
|
|
}
|
|
|
|
func TestLifecycle(t *testing.T) {
|
|
fake := newFakeGHP("ghpsvc_seed")
|
|
srv := fake.server(t)
|
|
defer srv.Close()
|
|
|
|
b, s := newTestBackend(t)
|
|
configure(t, b, s, srv.URL, "ghpsvc_seed")
|
|
|
|
req(t, b, s, logical.CreateOperation, "roles/agent", map[string]interface{}{
|
|
"token_type": "agent",
|
|
"installation_id": 4242,
|
|
"scopes": "contents:read,pull_requests:write",
|
|
"repositories": "unkin/ghp,unkin/teabot",
|
|
"ttl": "1h",
|
|
"max_ttl": "24h",
|
|
})
|
|
|
|
creds := req(t, b, s, logical.ReadOperation, "creds/agent", nil)
|
|
if creds.Secret == nil {
|
|
t.Fatal("creds returned no secret")
|
|
}
|
|
tokenID, _ := creds.Data["token_id"].(string)
|
|
tokenVal, _ := creds.Data["token"].(string)
|
|
if tokenID == "" || tokenVal == "" {
|
|
t.Fatalf("creds missing token/token_id: %#v", creds.Data)
|
|
}
|
|
if !strings.HasPrefix(tokenVal, "gha_") {
|
|
t.Errorf("token = %q, want gha_ prefix", tokenVal)
|
|
}
|
|
if creds.Data["token_type"].(string) != "agent" {
|
|
t.Errorf("token_type = %q, want agent", creds.Data["token_type"])
|
|
}
|
|
if creds.Data["base_url"].(string) != srv.URL {
|
|
t.Errorf("base_url = %q, want %q", creds.Data["base_url"], srv.URL)
|
|
}
|
|
if !fake.has(tokenID) {
|
|
t.Error("minted token not present in ghp")
|
|
}
|
|
|
|
// Renew keeps the same secret.
|
|
if _, err := b.HandleRequest(context.Background(), &logical.Request{
|
|
Operation: logical.RenewOperation,
|
|
Secret: creds.Secret,
|
|
Storage: s,
|
|
}); err != nil {
|
|
t.Fatalf("renew: %v", err)
|
|
}
|
|
|
|
// Revoke deletes the token from ghp.
|
|
if _, err := b.HandleRequest(context.Background(), &logical.Request{
|
|
Operation: logical.RevokeOperation,
|
|
Secret: creds.Secret,
|
|
Storage: s,
|
|
}); err != nil {
|
|
t.Fatalf("revoke: %v", err)
|
|
}
|
|
if fake.has(tokenID) {
|
|
t.Error("token still present after revoke")
|
|
}
|
|
}
|
|
|
|
func TestRevokeIsIdempotent(t *testing.T) {
|
|
fake := newFakeGHP("ghpsvc_seed")
|
|
srv := fake.server(t)
|
|
defer srv.Close()
|
|
|
|
b, s := newTestBackend(t)
|
|
configure(t, b, s, srv.URL, "ghpsvc_seed")
|
|
req(t, b, s, logical.CreateOperation, "roles/agent", map[string]interface{}{
|
|
"token_type": "agent", "installation_id": 7,
|
|
})
|
|
creds := req(t, b, s, logical.ReadOperation, "creds/agent", nil)
|
|
|
|
revoke := func() error {
|
|
_, err := b.HandleRequest(context.Background(), &logical.Request{
|
|
Operation: logical.RevokeOperation,
|
|
Secret: creds.Secret,
|
|
Storage: s,
|
|
})
|
|
return err
|
|
}
|
|
if err := revoke(); err != nil {
|
|
t.Fatalf("first revoke: %v", err)
|
|
}
|
|
// A second revoke (token already gone → 404) must still succeed.
|
|
if err := revoke(); err != nil {
|
|
t.Fatalf("second revoke should be idempotent, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAgentRoleRequiresInstallationID(t *testing.T) {
|
|
fake := newFakeGHP("ghpsvc_seed")
|
|
srv := fake.server(t)
|
|
defer srv.Close()
|
|
|
|
b, s := newTestBackend(t)
|
|
configure(t, b, s, srv.URL, "ghpsvc_seed")
|
|
|
|
resp, err := b.HandleRequest(context.Background(), &logical.Request{
|
|
Operation: logical.CreateOperation,
|
|
Path: "roles/bad",
|
|
Data: map[string]interface{}{"token_type": "agent"},
|
|
Storage: s,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if resp == nil || !resp.IsError() {
|
|
t.Fatal("expected error for agent role without installation_id")
|
|
}
|
|
}
|
|
|
|
func TestScopeValidationOnRole(t *testing.T) {
|
|
fake := newFakeGHP("ghpsvc_seed")
|
|
srv := fake.server(t)
|
|
defer srv.Close()
|
|
|
|
b, s := newTestBackend(t)
|
|
configure(t, b, s, srv.URL, "ghpsvc_seed")
|
|
|
|
// Bad level rejected.
|
|
resp, err := b.HandleRequest(context.Background(), &logical.Request{
|
|
Operation: logical.CreateOperation,
|
|
Path: "roles/bad",
|
|
Data: map[string]interface{}{"token_type": "agent", "installation_id": 1, "scopes": "contents:admin"},
|
|
Storage: s,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if resp == nil || !resp.IsError() {
|
|
t.Fatal("expected error for invalid scope level")
|
|
}
|
|
|
|
// Valid scopes normalise (level lower-cased, duplicates dropped) and read back.
|
|
req(t, b, s, logical.CreateOperation, "roles/ok", map[string]interface{}{
|
|
"token_type": "agent", "installation_id": 1,
|
|
"scopes": " contents:Read , contents:read, pull_requests:write",
|
|
})
|
|
role := req(t, b, s, logical.ReadOperation, "roles/ok", nil)
|
|
got, _ := role.Data["scopes"].([]string)
|
|
if len(got) != 2 || got[0] != "contents:read" || got[1] != "pull_requests:write" {
|
|
t.Errorf("normalised scopes = %v, want [contents:read pull_requests:write]", got)
|
|
}
|
|
}
|
|
|
|
func TestProxyRoleAllowedWithoutInstallation(t *testing.T) {
|
|
fake := newFakeGHP("ghpsvc_seed")
|
|
srv := fake.server(t)
|
|
defer srv.Close()
|
|
|
|
b, s := newTestBackend(t)
|
|
configure(t, b, s, srv.URL, "ghpsvc_seed")
|
|
|
|
req(t, b, s, logical.CreateOperation, "roles/proxy", map[string]interface{}{
|
|
"token_type": "proxy", "repositories": "unkin/ghp",
|
|
})
|
|
creds := req(t, b, s, logical.ReadOperation, "creds/proxy", nil)
|
|
if creds.Secret == nil {
|
|
t.Fatal("proxy mint returned no secret")
|
|
}
|
|
if creds.Data["token_type"].(string) != "proxy" {
|
|
t.Errorf("token_type = %q, want proxy", creds.Data["token_type"])
|
|
}
|
|
}
|
|
|
|
func TestConfigRequiresTokenAndVerifies(t *testing.T) {
|
|
fake := newFakeGHP("ghpsvc_seed")
|
|
srv := fake.server(t)
|
|
defer srv.Close()
|
|
|
|
b, s := newTestBackend(t)
|
|
|
|
// Missing admin_token.
|
|
resp, err := b.HandleRequest(context.Background(), &logical.Request{
|
|
Operation: logical.CreateOperation,
|
|
Path: "config",
|
|
Data: map[string]interface{}{"base_url": srv.URL},
|
|
Storage: s,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if resp == nil || !resp.IsError() {
|
|
t.Fatal("expected error when admin_token missing")
|
|
}
|
|
|
|
// Wrong token fails verification against the fake (401).
|
|
resp, err = b.HandleRequest(context.Background(), &logical.Request{
|
|
Operation: logical.CreateOperation,
|
|
Path: "config",
|
|
Data: map[string]interface{}{"base_url": srv.URL, "admin_token": "wrong"},
|
|
Storage: s,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if resp == nil || !resp.IsError() {
|
|
t.Fatal("expected error when admin_token fails verification")
|
|
}
|
|
|
|
// config read must never leak the token.
|
|
configure(t, b, s, srv.URL, "ghpsvc_seed")
|
|
read := req(t, b, s, logical.ReadOperation, "config", nil)
|
|
if _, leaked := read.Data["admin_token"]; leaked {
|
|
t.Fatal("config read leaked admin_token")
|
|
}
|
|
if read.Data["base_url"].(string) != srv.URL {
|
|
t.Errorf("base_url = %q, want %q", read.Data["base_url"], srv.URL)
|
|
}
|
|
}
|
|
|
|
func TestConfigRejectsNonAdminToken(t *testing.T) {
|
|
fake := newFakeGHP("ghpsvc_seed")
|
|
fake.forbidden = true
|
|
srv := fake.server(t)
|
|
defer srv.Close()
|
|
|
|
b, s := newTestBackend(t)
|
|
resp, err := b.HandleRequest(context.Background(), &logical.Request{
|
|
Operation: logical.CreateOperation,
|
|
Path: "config",
|
|
Data: map[string]interface{}{"base_url": srv.URL, "admin_token": "ghpsvc_seed"},
|
|
Storage: s,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if resp == nil || !resp.IsError() {
|
|
t.Fatal("expected error when token authenticates but is not admin")
|
|
}
|
|
if !strings.Contains(resp.Error().Error(), "not a ghp admin") {
|
|
t.Errorf("unexpected error: %v", resp.Error())
|
|
}
|
|
}
|
|
|
|
func TestCredsBeforeConfig(t *testing.T) {
|
|
b, s := newTestBackend(t)
|
|
req(t, b, s, logical.CreateOperation, "roles/x", map[string]interface{}{
|
|
"token_type": "agent", "installation_id": 1,
|
|
})
|
|
_, err := b.HandleRequest(context.Background(), &logical.Request{
|
|
Operation: logical.ReadOperation,
|
|
Path: "creds/x",
|
|
Storage: s,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected creds read to fail without config")
|
|
}
|
|
}
|
|
|
|
func TestRolesList(t *testing.T) {
|
|
fake := newFakeGHP("ghpsvc_seed")
|
|
srv := fake.server(t)
|
|
defer srv.Close()
|
|
|
|
b, s := newTestBackend(t)
|
|
configure(t, b, s, srv.URL, "ghpsvc_seed")
|
|
req(t, b, s, logical.CreateOperation, "roles/a", map[string]interface{}{"token_type": "agent", "installation_id": 1})
|
|
req(t, b, s, logical.CreateOperation, "roles/b", map[string]interface{}{"token_type": "agent", "installation_id": 2})
|
|
|
|
list := req(t, b, s, logical.ListOperation, "roles/", nil)
|
|
keys, _ := list.Data["keys"].([]string)
|
|
if len(keys) != 2 {
|
|
t.Fatalf("roles list = %v, want 2 entries", keys)
|
|
}
|
|
}
|