feat(16.3-01): tracer — g15_ token scopes through composite backend guard
- Add pact.TokenScope/HasTokenScopes and bouncer.ScopeLimit on Principal - Append backend_api_tokens migration; Allows evaluates scope before superuser - Composite guard routes Bearer g15_… to token lookup; smoke tests cover path Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,10 +7,22 @@ import (
|
||||
|
||||
type userKey struct{}
|
||||
|
||||
// ScopeLimit restricts a principal to the permission patterns of its personal
|
||||
// API token. Nil means unrestricted (a JWT session). Only a token guard sets
|
||||
// Scope, already filtered to scopes whose methods include the request method.
|
||||
// Full is true for framework scopes that act as the owner on every allowed
|
||||
// method ("*") or on GET/HEAD ("admin:read"). Otherwise Scopes holds one
|
||||
// pattern slice per method-allowed scope (D-03 literal reading).
|
||||
type ScopeLimit struct {
|
||||
Full bool
|
||||
Scopes [][]string
|
||||
}
|
||||
|
||||
// Principal is the authenticated identity stored on the request context.
|
||||
// PreferredLocale empty means no override. TokensValidAfter zero means no cutoff.
|
||||
// IsSuperuser and PermissionGrants are set only for backend-admin principals.
|
||||
// A grant ending in ".*" matches permission codes by prefix.
|
||||
// Scope is set only for personal API token principals.
|
||||
type Principal struct {
|
||||
ID uint
|
||||
MustChangePassword bool
|
||||
@@ -19,6 +31,7 @@ type Principal struct {
|
||||
Backend bool `json:"-"`
|
||||
IsSuperuser bool `json:"-"`
|
||||
PermissionGrants map[string]bool `json:"-"`
|
||||
Scope *ScopeLimit `json:"-"`
|
||||
}
|
||||
|
||||
// WithUser stores the verified principal on ctx.
|
||||
|
||||
124
modules/cabana/api_tokens.go
Normal file
124
modules/cabana/api_tokens.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// APITokenPrefix is the fixed plaintext prefix of every personal admin API token.
|
||||
const APITokenPrefix = "g15_"
|
||||
|
||||
// BackendAPIToken is one personal admin API token row. Only the sha256 hex of
|
||||
// the plaintext and a four-character hint are stored; the plaintext is never
|
||||
// persisted.
|
||||
type BackendAPIToken struct {
|
||||
ID uint `gorm:"column:id;primaryKey" json:"id"`
|
||||
BackendUserID uint `gorm:"column:backend_user_id" json:"backend_user_id"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
TokenHash string `gorm:"column:token_hash" json:"-"`
|
||||
TokenHint string `gorm:"column:token_hint" json:"hint"`
|
||||
Scopes lagoon.Jsonable[[]string] `gorm:"column:scopes" json:"scopes"`
|
||||
ExpiresAt *time.Time `gorm:"column:expires_at" json:"expires_at"`
|
||||
LastUsedAt *time.Time `gorm:"column:last_used_at" json:"last_used_at"`
|
||||
RevokedAt *time.Time `gorm:"column:revoked_at" json:"revoked_at"`
|
||||
RevokedBy *uint `gorm:"column:revoked_by" json:"revoked_by"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName is the framework-owned personal API token table.
|
||||
func (BackendAPIToken) TableName() string { return "backend_api_tokens" }
|
||||
|
||||
func mintAPIToken() (plain, hash, hint string, err error) {
|
||||
var b [20]byte
|
||||
if _, err = rand.Read(b[:]); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
plain = APITokenPrefix + hex.EncodeToString(b[:])
|
||||
hash = hashAPIToken(plain)
|
||||
hint = plain[len(plain)-4:]
|
||||
return plain, hash, hint, nil
|
||||
}
|
||||
|
||||
func hashAPIToken(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func wellFormedAPIToken(v string) bool {
|
||||
if len(v) != len(APITokenPrefix)+40 || !strings.HasPrefix(v, APITokenPrefix) {
|
||||
return false
|
||||
}
|
||||
for _, c := range v[len(APITokenPrefix):] {
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// issueAPIToken mints a token for ownerID with the given scopes and optional
|
||||
// expiry, inserts the row, and returns the plaintext exactly once. Task 3
|
||||
// grows this into the shared API/CLI validation path.
|
||||
func issueAPIToken(db *gorm.DB, ownerID uint, name string, scopes []string, expiresAt *time.Time) (plain string, row *BackendAPIToken, err error) {
|
||||
if db == nil || ownerID == 0 {
|
||||
return "", nil, fmt.Errorf("cabana: issue API token: missing database or owner")
|
||||
}
|
||||
if name == "" {
|
||||
name = "token"
|
||||
}
|
||||
if len(scopes) == 0 {
|
||||
return "", nil, fmt.Errorf("cabana: issue API token: scopes required")
|
||||
}
|
||||
plain, hash, hint, err := mintAPIToken()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
row = &BackendAPIToken{
|
||||
BackendUserID: ownerID,
|
||||
Name: name,
|
||||
TokenHash: hash,
|
||||
TokenHint: hint,
|
||||
Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true},
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return plain, row, nil
|
||||
}
|
||||
|
||||
// tokenStore looks up usable personal API tokens by hash.
|
||||
type tokenStore interface {
|
||||
FindUsableByHash(ctx context.Context, hash string, now time.Time) (*BackendAPIToken, error)
|
||||
}
|
||||
|
||||
type gormTokenStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func (s gormTokenStore) FindUsableByHash(ctx context.Context, hash string, now time.Time) (*BackendAPIToken, error) {
|
||||
if s.db == nil {
|
||||
return nil, fmt.Errorf("cabana: token store has no database")
|
||||
}
|
||||
var row BackendAPIToken
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("token_hash = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)", hash, now).
|
||||
Limit(1).
|
||||
Find(&row).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
@@ -152,6 +152,9 @@ type Registry struct {
|
||||
// fieldTypes are the plugin field types of this boot
|
||||
// (pact.HasFormFieldTypes); nil when no plugin registers one.
|
||||
fieldTypes *fieldTypeRegistry
|
||||
// tokenScopes is the compiled personal API token catalog (* , admin:read,
|
||||
// then plugin scopes in activation order).
|
||||
tokenScopes *tokenScopeCatalog
|
||||
}
|
||||
|
||||
// Get returns the compiled controller for a D-09 id (vendor.plugin.controller).
|
||||
@@ -178,15 +181,21 @@ func (r *Registry) Setting(code string) (*CompiledSetting, bool) {
|
||||
}
|
||||
|
||||
// Allows reports whether principal satisfies any of the required permission
|
||||
// codes, the way Winter's hasAnyAccess does. A nil principal fails. Superusers
|
||||
// pass. An empty requirement list allows any authenticated principal. Both
|
||||
// sides may use a wildcard: a grant ending in ".*" covers every code with that
|
||||
// prefix, and a required code ending in ".*" (or starting with "*") is met by
|
||||
// any granted code that matches it.
|
||||
// codes, the way Winter's hasAnyAccess does. A nil principal fails. When the
|
||||
// principal carries a non-full personal API token scope limit, the scope is
|
||||
// evaluated first (owner ∩ scope); a superuser with a scoped token is still
|
||||
// limited by the scope, and an empty requirement list is refused. Otherwise
|
||||
// superusers pass and an empty requirement list allows any authenticated
|
||||
// principal. Both sides may use a wildcard: a grant ending in ".*" covers
|
||||
// every code with that prefix, and a required code ending in ".*" (or
|
||||
// starting with "*") is met by any granted code that matches it.
|
||||
func Allows(principal *bouncer.Principal, required []string) bool {
|
||||
if principal == nil {
|
||||
return false
|
||||
}
|
||||
if principal.Scope != nil && !principal.Scope.Full {
|
||||
return allowsUnderScope(principal, required)
|
||||
}
|
||||
if principal.IsSuperuser || len(required) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AdminAPIRoutes exposes the admin API route inventory (method plus
|
||||
// prefix-relative path) to the external conformance test.
|
||||
func AdminAPIRoutes() []string {
|
||||
@@ -11,3 +17,11 @@ func AdminAPIRoutes() []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IssueTestAPIToken mints a personal API token for ownerID with the given
|
||||
// scopes and optional expiry. Used by external Postgres smoke tests before
|
||||
// the HTTP create route exists.
|
||||
func IssueTestAPIToken(db *gorm.DB, ownerID uint, scopes []string, expiresAt *time.Time) (plain string, err error) {
|
||||
plain, _, err = issueAPIToken(db, ownerID, "test", scopes, expiresAt)
|
||||
return plain, err
|
||||
}
|
||||
|
||||
@@ -125,6 +125,9 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||
if err := compileContributions(reg, plugins); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := compileTokenScopes(reg, plugins); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if app == nil {
|
||||
return nil, errors.New("cabana: app is nil")
|
||||
}
|
||||
@@ -132,6 +135,9 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||
if err := validateMessageKeys(reg, tr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateTokenScopeMessageKeys(reg, tr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
guards, ok := app.Lookup[*bouncer.Registry]()
|
||||
if !ok || guards == nil {
|
||||
@@ -142,7 +148,12 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||
}
|
||||
bl := adminBlacklist(app)
|
||||
users := lazyBackendUsers{app: app, reg: reg}
|
||||
guard := bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated, AdminCookieName)
|
||||
jwtGuard := bouncer.NewBackendJWTGuard(secret, users, bl, writeUnauthenticated, AdminCookieName)
|
||||
var store tokenStore
|
||||
if gdb != nil {
|
||||
store = gormTokenStore{db: gdb}
|
||||
}
|
||||
guard := newCompositeGuard(jwtGuard, store, users, reg)
|
||||
// The admin API is only as strong as this guard (audience, secret, user
|
||||
// provider), so cabana always registers its own and never mounts the API
|
||||
// behind a guard another plugin already put under the name "backend": a
|
||||
|
||||
166
modules/cabana/token_guard.go
Normal file
166
modules/cabana/token_guard.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
)
|
||||
|
||||
// ErrUnauthenticated is returned by ResolveBackend when the request has no
|
||||
// usable Bearer JWT or personal API token.
|
||||
var ErrUnauthenticated = errors.New("cabana: unauthenticated")
|
||||
|
||||
// ErrTokenScope is returned when a personal API token is authenticated but
|
||||
// its scopes do not allow the request method or cover codes.
|
||||
var ErrTokenScope = errors.New("cabana: token scope")
|
||||
|
||||
// scopeError wraps ErrTokenScope so WriteUnauthorized can answer 403.
|
||||
// errors.Is(err, ErrTokenScope) is true.
|
||||
type scopeError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *scopeError) Error() string {
|
||||
if e == nil || e.err == nil {
|
||||
return ErrTokenScope.Error()
|
||||
}
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e *scopeError) Unwrap() error { return ErrTokenScope }
|
||||
|
||||
func newScopeError() error { return &scopeError{err: ErrTokenScope} }
|
||||
|
||||
// compositeGuard routes Bearer g15_… values to the personal API token path and
|
||||
// every other request to the wrapped JWT guard. WriteUnauthorized answers 403
|
||||
// for a scopeError and the usual 401 unauthenticated envelope otherwise.
|
||||
type compositeGuard struct {
|
||||
jwt bouncer.Guard
|
||||
store tokenStore
|
||||
users bouncer.UserProvider
|
||||
reg *Registry
|
||||
}
|
||||
|
||||
var (
|
||||
_ bouncer.CredentialGuard = (*compositeGuard)(nil)
|
||||
_ bouncer.UnauthorizedWriter = (*compositeGuard)(nil)
|
||||
)
|
||||
|
||||
func newCompositeGuard(jwt bouncer.Guard, store tokenStore, users bouncer.UserProvider, reg *Registry) *compositeGuard {
|
||||
return &compositeGuard{jwt: jwt, store: store, users: users, reg: reg}
|
||||
}
|
||||
|
||||
func (g *compositeGuard) AuthenticateCredential(r *http.Request) (*bouncer.Principal, any, error) {
|
||||
if g == nil || r == nil {
|
||||
return nil, nil, ErrUnauthenticated
|
||||
}
|
||||
raw := authorizationBearer(r)
|
||||
if strings.HasPrefix(raw, APITokenPrefix) {
|
||||
return g.authenticateToken(r, raw)
|
||||
}
|
||||
if g.jwt == nil {
|
||||
return nil, nil, ErrUnauthenticated
|
||||
}
|
||||
p, err := g.jwt.Authenticate(r)
|
||||
return p, nil, err
|
||||
}
|
||||
|
||||
func (g *compositeGuard) authenticateToken(r *http.Request, raw string) (*bouncer.Principal, any, error) {
|
||||
if !wellFormedAPIToken(raw) {
|
||||
logTokenAuth(r, "failed", 0, 0)
|
||||
return nil, nil, ErrUnauthenticated
|
||||
}
|
||||
if g.store == nil {
|
||||
logTokenAuth(r, "failed", 0, 0)
|
||||
return nil, nil, ErrUnauthenticated
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
hash := hashAPIToken(raw)
|
||||
row, err := g.store.FindUsableByHash(r.Context(), hash, now)
|
||||
if err != nil || row == nil {
|
||||
logTokenAuth(r, "failed", 0, 0)
|
||||
return nil, nil, ErrUnauthenticated
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(row.TokenHash), []byte(hash)) != 1 {
|
||||
logTokenAuth(r, "failed", 0, row.ID)
|
||||
return nil, nil, ErrUnauthenticated
|
||||
}
|
||||
if g.users == nil {
|
||||
logTokenAuth(r, "failed", 0, row.ID)
|
||||
return nil, nil, ErrUnauthenticated
|
||||
}
|
||||
principal, err := g.users.FindByID(r.Context(), row.BackendUserID)
|
||||
if err != nil || principal == nil {
|
||||
logTokenAuth(r, "failed", row.BackendUserID, row.ID)
|
||||
return nil, nil, ErrUnauthenticated
|
||||
}
|
||||
stored := row.Scopes.Get()
|
||||
if !row.Scopes.Valid {
|
||||
stored = nil
|
||||
}
|
||||
limit, known, methodOK := methodFilterScopes(g.reg.tokenScopes, stored, r.Method)
|
||||
if known == 0 {
|
||||
logTokenAuth(r, "failed", principal.ID, row.ID)
|
||||
return nil, nil, ErrUnauthenticated
|
||||
}
|
||||
if methodOK == 0 {
|
||||
logTokenAuth(r, "denied", principal.ID, row.ID)
|
||||
return nil, nil, newScopeError()
|
||||
}
|
||||
principal.Scope = limit
|
||||
logTokenAuth(r, "success", principal.ID, row.ID)
|
||||
return principal, row, nil
|
||||
}
|
||||
|
||||
// WriteUnauthorized answers 403 forbidden with details.reason token_scope for
|
||||
// a scopeError; every other failure uses the 401 unauthenticated envelope.
|
||||
func (g *compositeGuard) WriteUnauthorized(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, ErrTokenScope) {
|
||||
WriteErrorDetails(w, http.StatusForbidden, "forbidden", msgForbidden, map[string]any{
|
||||
"reason": "token_scope",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeUnauthenticated(w, err)
|
||||
}
|
||||
|
||||
func authorizationBearer(r *http.Request) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
token, ok := strings.CutPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer ")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(token)
|
||||
}
|
||||
|
||||
func logTokenAuth(r *http.Request, outcome string, adminID, tokenID uint) {
|
||||
remote := ""
|
||||
method := ""
|
||||
path := ""
|
||||
if r != nil {
|
||||
method = r.Method
|
||||
if r.URL != nil {
|
||||
path = r.URL.Path
|
||||
}
|
||||
remote = r.RemoteAddr
|
||||
if host, _, err := net.SplitHostPort(remote); err == nil {
|
||||
remote = host
|
||||
}
|
||||
}
|
||||
args := []any{"outcome", outcome, "method", method, "path", path, "remote", remote}
|
||||
if adminID != 0 {
|
||||
args = append(args, "admin_id", adminID)
|
||||
}
|
||||
if tokenID != 0 {
|
||||
args = append(args, "token_id", tokenID)
|
||||
}
|
||||
slog.Default().Info("admin.auth", args...)
|
||||
}
|
||||
156
modules/cabana/token_postgres_smoke_test.go
Normal file
156
modules/cabana/token_postgres_smoke_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type tokenDemoWidget struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
|
||||
func (tokenDemoWidget) TableName() string { return "cabana_token_demo_widgets" }
|
||||
func (tokenDemoWidget) Fillable() []string { return []string{"name"} }
|
||||
func (tokenDemoWidget) Rules() map[string]string { return map[string]string{} }
|
||||
|
||||
type tokenDemoPlugin struct{ fsys fs.FS }
|
||||
|
||||
func (p tokenDemoPlugin) ID() string { return "acme.demo" }
|
||||
func (p tokenDemoPlugin) Requires() []string { return nil }
|
||||
func (p tokenDemoPlugin) Register(*backpack.App) error { return nil }
|
||||
func (p tokenDemoPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (p tokenDemoPlugin) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{tokenDemoController{}}
|
||||
}
|
||||
func (p tokenDemoPlugin) Models() []any { return []any{&tokenDemoWidget{}} }
|
||||
func (p tokenDemoPlugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{
|
||||
{Code: "acme.demo.access", Roles: []string{"developer"}},
|
||||
{Code: "acme.demo.reports", Roles: []string{"developer"}},
|
||||
}
|
||||
}
|
||||
func (p tokenDemoPlugin) TokenScopes() []pact.TokenScope {
|
||||
all := []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}
|
||||
return []pact.TokenScope{
|
||||
{
|
||||
Name: "demo", Label: "Demo", Description: "Full demo access",
|
||||
Permissions: []string{"acme.demo.*"}, Methods: all,
|
||||
},
|
||||
{
|
||||
Name: "demo:reports", Label: "Demo reports", Description: "Reports only",
|
||||
Permissions: []string{"acme.demo.reports"}, Methods: []string{"GET", "HEAD"},
|
||||
},
|
||||
}
|
||||
}
|
||||
func (p tokenDemoPlugin) AdminFS() fs.FS { return p.fsys }
|
||||
|
||||
type tokenDemoController struct{}
|
||||
|
||||
func (tokenDemoController) ID() string { return "acme.demo.widgets" }
|
||||
func (tokenDemoController) ModelName() string { return "Widget" }
|
||||
func (tokenDemoController) ConfigDir() string { return "controllers/widgets" }
|
||||
func (tokenDemoController) NewRecord() any { return &tokenDemoWidget{} }
|
||||
func (tokenDemoController) RequiredPermissions() []string { return []string{"acme.demo.access"} }
|
||||
|
||||
func tokenDemoFS() fs.FS {
|
||||
return fstest.MapFS{
|
||||
"controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\nshowSearch: true\n")},
|
||||
"models/widget/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n searchable: true\n")},
|
||||
}
|
||||
}
|
||||
|
||||
func tokenAdminHandler(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
gdb := adminGorm(t)
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-token\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{
|
||||
Dir: dir,
|
||||
Environ: []string{
|
||||
"SUMMER_ENV=development",
|
||||
"SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.Set("http.body_limits.default_bytes", 1048576); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.Set("http.body_limits.upload_bytes", 1048576); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := surf.Assemble(app, []party.Plugin{tokenDemoPlugin{fsys: tokenDemoFS()}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func TestTokenTracerPostgres(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
if err := gdb.AutoMigrate(&tokenDemoWidget{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := tokenAdminHandler(t)
|
||||
user := insertAdmin(t, gdb, "tok-dev", "tok-dev@example.test", "secret-pass", true, false)
|
||||
|
||||
demoPlain, err := cabana.IssueTestAPIToken(gdb, user.ID, []string{"demo"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reportsPlain, err := cabana.IssueTestAPIToken(gdb, user.ID, []string{"demo:reports"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
listPath := adminAPI("/acme/demo/widgets")
|
||||
if rec := getAuth(t, h, listPath, demoPlain); rec.Code != http.StatusOK {
|
||||
t.Fatalf("demo list: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, reportsPlain); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("reports list: want 403, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := postAuth(t, h, http.MethodPost, listPath, reportsPlain, []byte(`{}`)); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("reports POST: want 403, got %d %s", rec.Code, rec.Body.String())
|
||||
} 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=%s", body.Error.Details["reason"], rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
if err := gdb.Delete(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := getAuth(t, h, listPath, demoPlain); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("soft-deleted owner: want 401, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
413
modules/cabana/token_scopes.go
Normal file
413
modules/cabana/token_scopes.go
Normal file
@@ -0,0 +1,413 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
)
|
||||
|
||||
const (
|
||||
scopeAll = "*"
|
||||
scopeAdminRead = "admin:read"
|
||||
)
|
||||
|
||||
var tokenScopeNameRE = regexp.MustCompile(`^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`)
|
||||
|
||||
var allowedTokenMethods = map[string]bool{
|
||||
"GET": true, "HEAD": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true,
|
||||
}
|
||||
|
||||
type compiledTokenScope struct {
|
||||
Name string
|
||||
Label string
|
||||
Description string
|
||||
Permissions []string
|
||||
Methods map[string]bool
|
||||
Full bool // framework scopes * and admin:read
|
||||
PluginID string
|
||||
}
|
||||
|
||||
type tokenScopeCatalog struct {
|
||||
ordered []*compiledTokenScope
|
||||
byName map[string]*compiledTokenScope
|
||||
}
|
||||
|
||||
const (
|
||||
matchNone = iota
|
||||
matchPartial
|
||||
matchFull
|
||||
)
|
||||
|
||||
func compileTokenScopes(reg *Registry, plugins []party.Plugin) error {
|
||||
if reg == nil {
|
||||
return fmt.Errorf("cabana: registry is nil")
|
||||
}
|
||||
cat := &tokenScopeCatalog{
|
||||
byName: map[string]*compiledTokenScope{},
|
||||
}
|
||||
// Framework scopes first.
|
||||
allMethods := map[string]bool{}
|
||||
for m := range allowedTokenMethods {
|
||||
allMethods[m] = true
|
||||
}
|
||||
cat.ordered = append(cat.ordered, &compiledTokenScope{
|
||||
Name: scopeAll,
|
||||
Label: "backend::lang.api_tokens.scope_all",
|
||||
Description: "backend::lang.api_tokens.scope_all_description",
|
||||
Methods: allMethods,
|
||||
Full: true,
|
||||
})
|
||||
cat.byName[scopeAll] = cat.ordered[0]
|
||||
cat.ordered = append(cat.ordered, &compiledTokenScope{
|
||||
Name: scopeAdminRead,
|
||||
Label: "backend::lang.api_tokens.scope_admin_read",
|
||||
Description: "backend::lang.api_tokens.scope_admin_read_description",
|
||||
Methods: map[string]bool{"GET": true, "HEAD": true},
|
||||
Full: true,
|
||||
})
|
||||
cat.byName[scopeAdminRead] = cat.ordered[1]
|
||||
|
||||
for _, plugin := range plugins {
|
||||
if plugin == nil {
|
||||
continue
|
||||
}
|
||||
src, ok := plugin.(pact.HasTokenScopes)
|
||||
if !ok || src == nil {
|
||||
continue
|
||||
}
|
||||
for _, scope := range src.TokenScopes() {
|
||||
if err := validatePluginTokenScope(reg, plugin.ID(), scope, cat); err != nil {
|
||||
return err
|
||||
}
|
||||
methods := map[string]bool{}
|
||||
for _, m := range scope.Methods {
|
||||
methods[m] = true
|
||||
}
|
||||
perms := append([]string(nil), scope.Permissions...)
|
||||
compiled := &compiledTokenScope{
|
||||
Name: scope.Name,
|
||||
Label: scope.Label,
|
||||
Description: scope.Description,
|
||||
Permissions: perms,
|
||||
Methods: methods,
|
||||
PluginID: plugin.ID(),
|
||||
}
|
||||
cat.ordered = append(cat.ordered, compiled)
|
||||
cat.byName[scope.Name] = compiled
|
||||
}
|
||||
}
|
||||
reg.tokenScopes = cat
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePluginTokenScope(reg *Registry, pluginID string, scope pact.TokenScope, cat *tokenScopeCatalog) error {
|
||||
name := strings.TrimSpace(scope.Name)
|
||||
if name == "" || !tokenScopeNameRE.MatchString(name) {
|
||||
return fmt.Errorf("cabana: plugin %s registered invalid token scope name %q", pluginID, scope.Name)
|
||||
}
|
||||
if name == scopeAll || name == "admin" || strings.HasPrefix(name, "admin:") {
|
||||
return fmt.Errorf("cabana: plugin %s registered reserved token scope name %q", pluginID, name)
|
||||
}
|
||||
if existing, ok := cat.byName[name]; ok {
|
||||
other := existing.PluginID
|
||||
if other == "" {
|
||||
other = "framework"
|
||||
}
|
||||
return fmt.Errorf("cabana: duplicate token scope %q registered by %s and %s", name, other, pluginID)
|
||||
}
|
||||
if strings.TrimSpace(scope.Label) == "" {
|
||||
return fmt.Errorf("cabana: plugin %s token scope %s has an empty label", pluginID, name)
|
||||
}
|
||||
if strings.TrimSpace(scope.Description) == "" {
|
||||
return fmt.Errorf("cabana: plugin %s token scope %s has an empty description", pluginID, name)
|
||||
}
|
||||
if len(scope.Permissions) == 0 {
|
||||
return fmt.Errorf("cabana: plugin %s token scope %s has empty permissions", pluginID, name)
|
||||
}
|
||||
if len(scope.Methods) == 0 {
|
||||
return fmt.Errorf("cabana: plugin %s token scope %s has empty methods", pluginID, name)
|
||||
}
|
||||
hasGET := false
|
||||
seen := map[string]bool{}
|
||||
for _, m := range scope.Methods {
|
||||
if m != strings.ToUpper(m) || !allowedTokenMethods[m] {
|
||||
return fmt.Errorf("cabana: plugin %s token scope %s has invalid method %q", pluginID, name, m)
|
||||
}
|
||||
if seen[m] {
|
||||
continue
|
||||
}
|
||||
seen[m] = true
|
||||
if m == "GET" {
|
||||
hasGET = true
|
||||
}
|
||||
}
|
||||
if !hasGET {
|
||||
return fmt.Errorf("cabana: plugin %s token scope %s methods must include GET", pluginID, name)
|
||||
}
|
||||
for _, code := range scope.Permissions {
|
||||
if !permissionCode(code, true) || !reg.permissionExists(code) {
|
||||
return fmt.Errorf("cabana: plugin %s token scope %s names unknown permission %q", pluginID, name, code)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTokenScopeMessageKeys(reg *Registry, tr *phrasebook.Translator) error {
|
||||
if reg == nil || reg.tokenScopes == nil || tr == nil {
|
||||
return nil
|
||||
}
|
||||
for _, scope := range reg.tokenScopes.ordered {
|
||||
// Framework scopes use backend::lang.api_tokens.* keys added with the
|
||||
// account token API; only plugin scopes are checked at boot here.
|
||||
if scope.PluginID == "" {
|
||||
continue
|
||||
}
|
||||
for _, text := range []string{scope.Label, scope.Description} {
|
||||
if strings.Contains(text, "::") && !tr.Has(text) {
|
||||
return fmt.Errorf("cabana: token scope %s (%s) names missing phrase key %s", scope.Name, scope.PluginID, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scopeMatch reports how pattern covers required (plan match table).
|
||||
func scopeMatch(pattern, required string) int {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
required = strings.TrimSpace(required)
|
||||
if pattern == "" || required == "" {
|
||||
return matchNone
|
||||
}
|
||||
patLeading := len(pattern) > 1 && strings.HasPrefix(pattern, "*") && !strings.HasSuffix(pattern, ".*")
|
||||
reqLeading := len(required) > 1 && strings.HasPrefix(required, "*") && !strings.HasSuffix(required, ".*")
|
||||
patSuffixWild := strings.HasSuffix(pattern, ".*")
|
||||
reqSuffixWild := strings.HasSuffix(required, ".*")
|
||||
|
||||
// Leading *.suffix: exact required only → partial via the pattern; any
|
||||
// wildcard required or pattern → none (conservative).
|
||||
if patLeading {
|
||||
if reqSuffixWild || reqLeading {
|
||||
return matchNone
|
||||
}
|
||||
suffix := strings.TrimPrefix(pattern, "*")
|
||||
if strings.HasSuffix(required, suffix) && required != suffix {
|
||||
return matchPartial
|
||||
}
|
||||
return matchNone
|
||||
}
|
||||
if reqLeading {
|
||||
return matchNone
|
||||
}
|
||||
|
||||
switch {
|
||||
case !patSuffixWild && !reqSuffixWild:
|
||||
if pattern == required {
|
||||
return matchFull
|
||||
}
|
||||
return matchNone
|
||||
case patSuffixWild && !reqSuffixWild:
|
||||
patPrefix := strings.TrimSuffix(pattern, "*")
|
||||
if patPrefix != required && strings.HasPrefix(required, patPrefix) {
|
||||
return matchFull
|
||||
}
|
||||
return matchNone
|
||||
case !patSuffixWild && reqSuffixWild:
|
||||
reqPrefix := strings.TrimSuffix(required, "*")
|
||||
if reqPrefix != pattern && strings.HasPrefix(pattern, reqPrefix) {
|
||||
return matchPartial
|
||||
}
|
||||
return matchNone
|
||||
default:
|
||||
patPrefix := strings.TrimSuffix(pattern, "*")
|
||||
reqPrefix := strings.TrimSuffix(required, "*")
|
||||
if patPrefix == reqPrefix || strings.HasPrefix(reqPrefix, patPrefix) {
|
||||
return matchFull
|
||||
}
|
||||
if strings.HasPrefix(patPrefix, reqPrefix) {
|
||||
return matchPartial
|
||||
}
|
||||
return matchNone
|
||||
}
|
||||
}
|
||||
|
||||
func ownerHas(p *bouncer.Principal, code string) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
return p.IsSuperuser || granted(p.PermissionGrants, code)
|
||||
}
|
||||
|
||||
// allowsUnderScope implements D-03 literal reading: some method-allowed scope
|
||||
// matches every required code, and some required code is owner-held inside it.
|
||||
func allowsUnderScope(p *bouncer.Principal, required []string) bool {
|
||||
if p == nil || p.Scope == nil || p.Scope.Full {
|
||||
return false
|
||||
}
|
||||
if len(required) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, patterns := range p.Scope.Scopes {
|
||||
if scopeSliceAllows(p, patterns, required) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func scopeSliceAllows(p *bouncer.Principal, patterns, required []string) bool {
|
||||
type hit struct {
|
||||
kind int
|
||||
pattern string
|
||||
}
|
||||
hits := make([]hit, len(required))
|
||||
for i, code := range required {
|
||||
best := matchNone
|
||||
bestPat := ""
|
||||
for _, pattern := range patterns {
|
||||
kind := scopeMatch(pattern, code)
|
||||
if kind > best {
|
||||
best = kind
|
||||
bestPat = pattern
|
||||
}
|
||||
}
|
||||
if best == matchNone {
|
||||
return false
|
||||
}
|
||||
hits[i] = hit{kind: best, pattern: bestPat}
|
||||
}
|
||||
for i, code := range required {
|
||||
h := hits[i]
|
||||
switch h.kind {
|
||||
case matchFull:
|
||||
if ownerHas(p, code) {
|
||||
return true
|
||||
}
|
||||
case matchPartial:
|
||||
if ownerHas(p, h.pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// scopeCoversAll reports whether some scope slice matches every cover code
|
||||
// (full or partial). Used by ResolveBackend (Task 2).
|
||||
func scopeCoversAll(limit *bouncer.ScopeLimit, cover []string) bool {
|
||||
if limit == nil || limit.Full {
|
||||
return true
|
||||
}
|
||||
if len(cover) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, patterns := range limit.Scopes {
|
||||
ok := true
|
||||
for _, code := range cover {
|
||||
matched := false
|
||||
for _, pattern := range patterns {
|
||||
if scopeMatch(pattern, code) != matchNone {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// scopeOverlaps is the D-17 picker/issue rule (Task 3 grows callers).
|
||||
func scopeOverlaps(cat *tokenScopeCatalog, owner *bouncer.Principal, name string) bool {
|
||||
if cat == nil || owner == nil {
|
||||
return false
|
||||
}
|
||||
if name == scopeAll {
|
||||
return true
|
||||
}
|
||||
scope, ok := cat.byName[name]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if scope.Full {
|
||||
// admin:read: offered when superuser or holds at least one catalog code.
|
||||
if owner.IsSuperuser {
|
||||
return true
|
||||
}
|
||||
for code := range catPermissionCodes(cat) {
|
||||
if ownerHas(owner, code) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Fall through: also check against registry permissions via owner grants.
|
||||
for code := range owner.PermissionGrants {
|
||||
if owner.PermissionGrants[code] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return owner.IsSuperuser
|
||||
}
|
||||
for _, c := range scope.Permissions {
|
||||
if scopeMatch(c, c) == matchFull && ownerHas(owner, c) {
|
||||
return true
|
||||
}
|
||||
// Some registered catalog code has a full match in the scope and ownerHas(c).
|
||||
_ = c
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func catPermissionCodes(cat *tokenScopeCatalog) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if cat == nil {
|
||||
return out
|
||||
}
|
||||
for _, scope := range cat.ordered {
|
||||
for _, p := range scope.Permissions {
|
||||
out[p] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func methodFilterScopes(cat *tokenScopeCatalog, stored []string, method string) (limit *bouncer.ScopeLimit, known int, methodOK int) {
|
||||
if cat == nil {
|
||||
return nil, 0, 0
|
||||
}
|
||||
method = strings.ToUpper(method)
|
||||
var scopes [][]string
|
||||
full := false
|
||||
for _, name := range stored {
|
||||
scope, ok := cat.byName[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
known++
|
||||
if !scope.Methods[method] {
|
||||
continue
|
||||
}
|
||||
methodOK++
|
||||
if scope.Full {
|
||||
full = true
|
||||
continue
|
||||
}
|
||||
scopes = append(scopes, append([]string(nil), scope.Permissions...))
|
||||
}
|
||||
if known == 0 {
|
||||
return nil, 0, 0
|
||||
}
|
||||
if methodOK == 0 {
|
||||
return nil, known, 0
|
||||
}
|
||||
return &bouncer.ScopeLimit{Full: full, Scopes: scopes}, known, methodOK
|
||||
}
|
||||
209
modules/cabana/token_smoke_test.go
Normal file
209
modules/cabana/token_smoke_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
)
|
||||
|
||||
type memTokenStore struct {
|
||||
mu sync.Mutex
|
||||
byHash map[string]*BackendAPIToken
|
||||
}
|
||||
|
||||
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) 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)
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,11 @@ const BackendJWTBlacklistTable = "backend_jwt_blacklist"
|
||||
|
||||
// BackendAdminMigrations creates Winter-shaped backend identity tables, seeds
|
||||
// the developer and publisher system roles, makes backend user emails
|
||||
// unique case-insensitively and adds WinterCMS's nullable jsonable metadata
|
||||
// column to backend_users (the admin subtitle lives there). History is isolated under the summercms.cabana
|
||||
// plugin id. DDL is re-runnable so the system-role seed stays idempotent if
|
||||
// the history row is removed.
|
||||
// unique case-insensitively, adds WinterCMS's nullable jsonable metadata
|
||||
// column to backend_users (the admin subtitle lives there), and creates
|
||||
// backend_api_tokens for personal admin API tokens. History is isolated under
|
||||
// the summercms.cabana plugin id. DDL is re-runnable so the system-role seed
|
||||
// stays idempotent if the history row is removed.
|
||||
var BackendAdminMigrations = []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202609240001_backend_admin_identity",
|
||||
@@ -130,4 +131,35 @@ FROM backend_users GROUP BY lower(email) HAVING count(*) > 1 ORDER BY 1`).Scan(&
|
||||
return tx.Exec(`ALTER TABLE backend_users DROP COLUMN IF EXISTS metadata`).Error
|
||||
},
|
||||
},
|
||||
{
|
||||
// Personal admin API tokens (sha256 of g15_… only; plaintext never stored).
|
||||
ID: "202610110001_backend_api_tokens",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
for _, stmt := range []string{
|
||||
`CREATE TABLE IF NOT EXISTS backend_api_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
backend_user_id INTEGER NOT NULL REFERENCES backend_users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
token_hash CHAR(64) NOT NULL UNIQUE,
|
||||
token_hint TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_by INTEGER REFERENCES backend_users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS backend_api_tokens_backend_user_id_index ON backend_api_tokens (backend_user_id)`,
|
||||
} {
|
||||
if err := tx.Exec(stmt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`DROP TABLE IF EXISTS backend_api_tokens`).Error
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestPhase09MigrationsFreshRollback(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rollbackBackendAdmin(t, admin)
|
||||
for _, name := range []string{"backend_users", "backend_user_roles", "backend_jwt_blacklist"} {
|
||||
for _, name := range []string{"backend_users", "backend_user_roles", "backend_jwt_blacklist", "backend_api_tokens"} {
|
||||
if gdb.Migrator().HasTable(name) {
|
||||
t.Fatalf("%s survived admin rollback", name)
|
||||
}
|
||||
@@ -558,10 +558,20 @@ func TestBackendAdminMetadataColumn(t *testing.T) {
|
||||
if err := gdb.Table(history).Order("id").Pluck("id", &ids).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) == 0 || ids[len(ids)-1] != "202610090001_backend_users_metadata" {
|
||||
if len(ids) == 0 || ids[len(ids)-1] != "202610110001_backend_api_tokens" {
|
||||
t.Fatalf("cabana history = %v", ids)
|
||||
}
|
||||
if last := BackendAdminMigrations[len(BackendAdminMigrations)-1].ID; last != "202610090001_backend_users_metadata" {
|
||||
foundMeta := false
|
||||
for _, id := range ids {
|
||||
if id == "202610090001_backend_users_metadata" {
|
||||
foundMeta = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundMeta {
|
||||
t.Fatalf("cabana history missing metadata migration: %v", ids)
|
||||
}
|
||||
if last := BackendAdminMigrations[len(BackendAdminMigrations)-1].ID; last != "202610110001_backend_api_tokens" {
|
||||
t.Fatalf("last BackendAdminMigrations entry = %s", last)
|
||||
}
|
||||
|
||||
@@ -569,6 +579,11 @@ func TestBackendAdminMetadataColumn(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Newest is backend_api_tokens; roll it back, then metadata, so the
|
||||
// metadata column is gone while the email index remains.
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.RollbackLast(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -505,6 +505,21 @@ type HasPermissions interface {
|
||||
Permissions() []Permission
|
||||
}
|
||||
|
||||
// TokenScope is one personal API token scope a plugin registers at boot.
|
||||
// Label and Description are phrase keys (containing "::") or literal text.
|
||||
type TokenScope struct {
|
||||
Name string
|
||||
Label string
|
||||
Description string
|
||||
Permissions []string
|
||||
Methods []string
|
||||
}
|
||||
|
||||
// HasTokenScopes is implemented by plugins that declare personal API token scopes.
|
||||
type HasTokenScopes interface {
|
||||
TokenScopes() []TokenScope
|
||||
}
|
||||
|
||||
// NavigationItem is one registerNavigation() entry. Controller is the admin
|
||||
// controller ID; the SPA derives its route from that ID.
|
||||
type NavigationItem struct {
|
||||
|
||||
Reference in New Issue
Block a user