Files
summercms/modules/cabana/token_smoke_test.go
Jakub Zych b45af8ebbf fix(cabana): scope the token last_used_at throttle to the guard instance
- 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
2026-10-11 15:19:55 +02:00

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)
}
}