Add the initial repospawner service
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

repospawner turns JSON new-repo requests into terraform-git pull requests
via kubernetes Jobs, follows those PRs to merge and optionally activates
the repository in Woodpecker.
This commit is contained in:
2026-08-30 14:33:31 +10:00
parent c104212dda
commit f1bcb8cd3a
41 changed files with 5388 additions and 1 deletions
+55
View File
@@ -0,0 +1,55 @@
// Package auth enforces Authentik group membership from the oauth2-proxy
// identity header. oauth2-proxy already gates the route, but repospawner opens
// pull requests against the estate's source of truth, so it re-checks the group
// server-side rather than trusting the front door alone.
package auth
import (
"net/http"
"git.unkin.net/unkin/repospawner/internal/config"
)
// Middleware rejects requests whose group header carries none of the allowed
// groups. header is the request header to read; allowed must be non-empty.
type Middleware struct {
header string
allowed map[string]bool
}
// New builds a Middleware. An empty allowed set denies everything, which is the
// correct fail-closed behaviour if config validation is ever bypassed.
func New(header string, allowed []string) *Middleware {
m := &Middleware{header: header, allowed: make(map[string]bool, len(allowed))}
for _, g := range allowed {
m.allowed[g] = true
}
return m
}
// Permit reports whether the request carries an allowed group.
func (m *Middleware) Permit(r *http.Request) bool {
if len(m.allowed) == 0 {
return false
}
for _, v := range r.Header.Values(m.header) {
for _, g := range config.ParseGroups(v) {
if m.allowed[g] {
return true
}
}
}
return false
}
// Wrap gates next behind Permit, answering 403 with a plain body that never
// echoes the submitted groups back to the caller.
func (m *Middleware) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !m.Permit(r) {
http.Error(w, "forbidden: missing required group", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
+71
View File
@@ -0,0 +1,71 @@
package auth
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestPermit(t *testing.T) {
m := New("X-Forwarded-Groups", []string{"akP-repospawner-user", "akR-platform"})
cases := []struct {
name string
values []string
want bool
}{
{name: "no header", want: false},
{name: "unrelated group", values: []string{"akP-mediamark-user"}, want: false},
{name: "exact group", values: []string{"akP-repospawner-user"}, want: true},
{name: "comma list", values: []string{"a,akR-platform,b"}, want: true},
{name: "repeated header", values: []string{"nope", "akR-platform"}, want: true},
{name: "empty value", values: []string{""}, want: false},
{name: "prefix only", values: []string{"akP-repospawner"}, want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/api/requests", nil)
for _, v := range tc.values {
r.Header.Add("X-Forwarded-Groups", v)
}
if got := m.Permit(r); got != tc.want {
t.Errorf("Permit = %v, want %v", got, tc.want)
}
})
}
}
func TestEmptyAllowListDeniesEverything(t *testing.T) {
m := New("X-Forwarded-Groups", nil)
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("X-Forwarded-Groups", "anything")
if m.Permit(r) {
t.Error("an empty allow-list must deny")
}
}
func TestWrap(t *testing.T) {
m := New("X-Forwarded-Groups", []string{"ok"})
var reached bool
h := m.Wrap(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
reached = true
w.WriteHeader(http.StatusNoContent)
}))
denied := httptest.NewRecorder()
h.ServeHTTP(denied, httptest.NewRequest(http.MethodGet, "/", nil))
if denied.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", denied.Code)
}
if reached {
t.Error("the wrapped handler ran for a denied request")
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Forwarded-Groups", "ok")
allowed := httptest.NewRecorder()
h.ServeHTTP(allowed, req)
if allowed.Code != http.StatusNoContent || !reached {
t.Errorf("status = %d reached = %v", allowed.Code, reached)
}
}