- The process-global debounce map was keyed by token ID, so a stamp from one test database suppressed a later write in another and turned TestBackendTokenPostgres red depending on order - Each compositeGuard now owns a stampThrottle; the backend guard and the ResolveBackend guard of one activation share it, so a single-app binary still writes last_used_at at most once a minute per token - TestTokenLastUsedThrottlePerGuard pins the per-guard isolation
365 lines
11 KiB
Go
365 lines
11 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
)
|
|
|
|
type memTokenStore struct {
|
|
mu sync.Mutex
|
|
byHash map[string]*BackendAPIToken
|
|
stampN int
|
|
stampIDs []uint
|
|
}
|
|
|
|
func (s *memTokenStore) FindUsableByHash(_ context.Context, hash string, now time.Time) (*BackendAPIToken, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
row := s.byHash[hash]
|
|
if row == nil {
|
|
return nil, nil
|
|
}
|
|
if row.RevokedAt != nil {
|
|
return nil, nil
|
|
}
|
|
if row.ExpiresAt != nil && !row.ExpiresAt.After(now) {
|
|
return nil, nil
|
|
}
|
|
cp := *row
|
|
return &cp, nil
|
|
}
|
|
|
|
func (s *memTokenStore) StampLastUsed(_ context.Context, id uint, _ time.Time) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.stampN++
|
|
s.stampIDs = append(s.stampIDs, id)
|
|
return nil
|
|
}
|
|
|
|
func (s *memTokenStore) put(row *BackendAPIToken) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.byHash == nil {
|
|
s.byHash = map[string]*BackendAPIToken{}
|
|
}
|
|
s.byHash[row.TokenHash] = row
|
|
}
|
|
|
|
type stubUsers struct {
|
|
byID map[uint]*bouncer.Principal
|
|
}
|
|
|
|
func (s stubUsers) FindByID(_ context.Context, id uint) (*bouncer.Principal, error) {
|
|
if s.byID == nil {
|
|
return nil, nil
|
|
}
|
|
p := s.byID[id]
|
|
if p == nil {
|
|
return nil, nil
|
|
}
|
|
cp := *p
|
|
if p.PermissionGrants != nil {
|
|
cp.PermissionGrants = map[string]bool{}
|
|
for k, v := range p.PermissionGrants {
|
|
cp.PermissionGrants[k] = v
|
|
}
|
|
}
|
|
return &cp, nil
|
|
}
|
|
|
|
type stubJWTGuard struct {
|
|
called bool
|
|
}
|
|
|
|
func (g *stubJWTGuard) Authenticate(r *http.Request) (*bouncer.Principal, error) {
|
|
g.called = true
|
|
return &bouncer.Principal{ID: 99, Backend: true, IsSuperuser: true}, nil
|
|
}
|
|
|
|
func tracerCatalog() *tokenScopeCatalog {
|
|
cat := &tokenScopeCatalog{byName: map[string]*compiledTokenScope{}}
|
|
all := map[string]bool{"GET": true, "HEAD": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true}
|
|
cat.ordered = []*compiledTokenScope{
|
|
{Name: scopeAll, Methods: all, Full: true},
|
|
{Name: scopeAdminRead, Methods: map[string]bool{"GET": true, "HEAD": true}, Full: true},
|
|
{
|
|
Name: "demo",
|
|
Permissions: []string{"acme.demo.*"},
|
|
Methods: all,
|
|
PluginID: "acme.demo",
|
|
},
|
|
{
|
|
Name: "demo:reports",
|
|
Permissions: []string{"acme.demo.reports"},
|
|
Methods: map[string]bool{"GET": true, "HEAD": true},
|
|
PluginID: "acme.demo",
|
|
},
|
|
}
|
|
for _, s := range cat.ordered {
|
|
cat.byName[s.Name] = s
|
|
}
|
|
return cat
|
|
}
|
|
|
|
func TestTokenTracerSmoke(t *testing.T) {
|
|
cat := tracerCatalog()
|
|
reg := &Registry{tokenScopes: cat}
|
|
store := &memTokenStore{byHash: map[string]*BackendAPIToken{}}
|
|
dev := &bouncer.Principal{
|
|
ID: 1,
|
|
Backend: true,
|
|
PermissionGrants: map[string]bool{
|
|
"acme.demo.access": true,
|
|
"acme.demo.reports": true,
|
|
},
|
|
}
|
|
super := &bouncer.Principal{ID: 2, Backend: true, IsSuperuser: true}
|
|
users := stubUsers{byID: map[uint]*bouncer.Principal{1: dev, 2: super}}
|
|
jwt := &stubJWTGuard{}
|
|
guard := newCompositeGuard(jwt, store, users, reg)
|
|
|
|
mint := func(owner uint, scopes []string) string {
|
|
plain, hash, hint, err := mintAPIToken()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store.put(&BackendAPIToken{
|
|
ID: uint(len(store.byHash) + 1),
|
|
BackendUserID: owner,
|
|
Name: "t",
|
|
TokenHash: hash,
|
|
TokenHint: hint,
|
|
Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true},
|
|
})
|
|
return plain
|
|
}
|
|
|
|
guards := bouncer.NewRegistry()
|
|
if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mw, err := guards.Middleware("backend")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
p, _ := bouncer.User(r.Context())
|
|
if Allows(p, []string{"acme.demo.access"}) {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
|
}))
|
|
|
|
req := func(method, token string) *httptest.ResponseRecorder {
|
|
r := httptest.NewRequest(method, "/api/v1/widgets", nil)
|
|
if token != "" {
|
|
r.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, r)
|
|
return rec
|
|
}
|
|
|
|
demoTok := mint(1, []string{"demo"})
|
|
if rec := req(http.MethodGet, demoTok); rec.Code != http.StatusOK {
|
|
t.Fatalf("demo GET: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
reportsTok := mint(1, []string{"demo:reports"})
|
|
if rec := req(http.MethodGet, reportsTok); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("demo:reports GET: want 403, got %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
if rec := req(http.MethodPost, reportsTok); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("demo:reports POST: want 403, got %d", rec.Code)
|
|
} else {
|
|
var body struct {
|
|
Error struct {
|
|
Details map[string]any `json:"details"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Error.Details["reason"] != "token_scope" {
|
|
t.Fatalf("reason = %v", body.Error.Details["reason"])
|
|
}
|
|
}
|
|
|
|
superTok := mint(2, []string{"demo:reports"})
|
|
if rec := req(http.MethodGet, superTok); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("superuser demo:reports: want 403, got %d", rec.Code)
|
|
}
|
|
|
|
if rec := req(http.MethodGet, "g15_x"); rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("malformed: want 401, got %d", rec.Code)
|
|
}
|
|
|
|
jwt.called = false
|
|
if rec := req(http.MethodGet, "eyJhbGciOiJIUzI1NiJ9.e30.sig"); rec.Code != http.StatusOK {
|
|
t.Fatalf("jwt path: want 200 from stub, got %d", rec.Code)
|
|
}
|
|
if !jwt.called {
|
|
t.Fatal("jwt guard was not called for non-g15_ bearer")
|
|
}
|
|
if !strings.HasPrefix(demoTok, APITokenPrefix) || len(demoTok) != 44 {
|
|
t.Fatalf("mint shape %q", demoTok)
|
|
}
|
|
}
|
|
|
|
func TestTokenGatesSmoke(t *testing.T) {
|
|
cat := tracerCatalog()
|
|
reg := &Registry{tokenScopes: cat}
|
|
store := &memTokenStore{byHash: map[string]*BackendAPIToken{}}
|
|
dev := &bouncer.Principal{
|
|
ID: 1,
|
|
Backend: true,
|
|
PermissionGrants: map[string]bool{
|
|
"acme.demo.access": true,
|
|
"acme.demo.reports": true,
|
|
},
|
|
}
|
|
users := stubUsers{byID: map[uint]*bouncer.Principal{1: dev}}
|
|
guard := newCompositeGuard(&stubJWTGuard{}, store, users, reg)
|
|
|
|
mint := func(owner uint, scopes []string, id uint) string {
|
|
plain, hash, hint, err := mintAPIToken()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store.put(&BackendAPIToken{
|
|
ID: id,
|
|
BackendUserID: owner,
|
|
Name: "t",
|
|
TokenHash: hash,
|
|
TokenHint: hint,
|
|
Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true},
|
|
})
|
|
return plain
|
|
}
|
|
|
|
starTok := mint(1, []string{"*"}, 100)
|
|
demoTok := mint(1, []string{"demo"}, 101)
|
|
reportsTok := mint(1, []string{"demo:reports"}, 102)
|
|
adminReadTok := mint(1, []string{"admin:read"}, 103)
|
|
|
|
// interactiveOnly on PUT /account and refresh with raw g15_ Bearer.
|
|
accountH := interactiveOnly(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/account", nil)
|
|
req = req.WithContext(bouncer.WithCredential(req.Context(), &BackendAPIToken{ID: 100}))
|
|
accountH(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("account with token cred: %d", rec.Code)
|
|
}
|
|
var body struct {
|
|
Error struct {
|
|
Details map[string]any `json:"details"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Error.Details["reason"] != "interactive_only" {
|
|
t.Fatalf("account reason: %v %s", err, rec.Body.String())
|
|
}
|
|
|
|
refreshH := interactiveOnly(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
rec = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodPost, "/auth/refresh", nil)
|
|
req.Header.Set("Authorization", "Bearer "+starTok)
|
|
refreshH(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("refresh with g15_: %d", rec.Code)
|
|
}
|
|
|
|
// fieldTypesList gate
|
|
svc := &service{reg: reg}
|
|
runFieldTypes := func(token string) *httptest.ResponseRecorder {
|
|
r := httptest.NewRequest(http.MethodGet, "/field-types", nil)
|
|
r.Header.Set("Authorization", "Bearer "+token)
|
|
p, cred, err := guard.AuthenticateCredential(r)
|
|
if err != nil || p == nil {
|
|
t.Fatalf("auth: %v", err)
|
|
}
|
|
ctx := bouncer.WithUser(r.Context(), p)
|
|
if cred != nil {
|
|
ctx = bouncer.WithCredential(ctx, cred)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
svc.fieldTypesList(rec, r.WithContext(ctx))
|
|
return rec
|
|
}
|
|
if rec := runFieldTypes(demoTok); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("demo field-types: want 403, got %d", rec.Code)
|
|
} else if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Error.Details["reason"] != "token_scope" {
|
|
t.Fatalf("field-types reason: %v %s", err, rec.Body.String())
|
|
}
|
|
if rec := runFieldTypes(adminReadTok); rec.Code != http.StatusOK {
|
|
t.Fatalf("admin:read field-types: want 200, got %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// ResolveBackend
|
|
app := backpack.New(nil)
|
|
if err := app.Publish(&backendResolver{guard: guard}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := httptest.NewRequest(http.MethodPost, "/plugin", nil)
|
|
r.Header.Set("Authorization", "Bearer "+reportsTok)
|
|
if _, _, err := ResolveBackend(app, r, "acme.demo.reports"); !errors.Is(err, ErrTokenScope) {
|
|
t.Fatalf("reports POST cover: want ErrTokenScope, got %v", err)
|
|
}
|
|
r = httptest.NewRequest(http.MethodGet, "/plugin", nil)
|
|
r.Header.Set("Authorization", "Bearer "+demoTok)
|
|
if _, _, err := ResolveBackend(app, r, "acme.other.*"); !errors.Is(err, ErrTokenScope) {
|
|
t.Fatalf("demo cover other: want ErrTokenScope, got %v", err)
|
|
}
|
|
r = httptest.NewRequest(http.MethodGet, "/plugin", nil)
|
|
if _, _, err := ResolveBackend(app, r); !errors.Is(err, ErrUnauthenticated) {
|
|
t.Fatalf("no bearer: want ErrUnauthenticated, got %v", err)
|
|
}
|
|
r = httptest.NewRequest(http.MethodGet, "/plugin", nil)
|
|
r.Header.Set("Authorization", "Bearer g15_x")
|
|
if _, _, err := ResolveBackend(app, r); !errors.Is(err, ErrUnauthenticated) {
|
|
t.Fatalf("malformed: want ErrUnauthenticated, got %v", err)
|
|
}
|
|
r = httptest.NewRequest(http.MethodGet, "/plugin", nil)
|
|
r.Header.Set("Authorization", "Bearer "+demoTok)
|
|
out, p, err := ResolveBackend(app, r, "acme.demo.*")
|
|
if err != nil || p == nil || out == nil {
|
|
t.Fatalf("demo GET cover: %v", err)
|
|
}
|
|
if _, ok := bouncer.Credential(out.Context()); !ok {
|
|
t.Fatal("credential missing on resolved request")
|
|
}
|
|
|
|
// Stamp debounce: two auths within a minute → one store stamp.
|
|
guard.stamps.last.Delete(uint(101))
|
|
store.stampN = 0
|
|
r = httptest.NewRequest(http.MethodGet, "/x", nil)
|
|
r.Header.Set("Authorization", "Bearer "+demoTok)
|
|
if _, _, err := guard.AuthenticateCredential(r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := guard.AuthenticateCredential(r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if store.stampN != 1 {
|
|
t.Fatalf("stamp calls = %d, want 1", store.stampN)
|
|
}
|
|
}
|