diff --git a/modules/bouncer/context.go b/modules/bouncer/context.go index e8d88d5..8e57a46 100644 --- a/modules/bouncer/context.go +++ b/modules/bouncer/context.go @@ -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. diff --git a/modules/cabana/api_tokens.go b/modules/cabana/api_tokens.go new file mode 100644 index 0000000..b3639fa --- /dev/null +++ b/modules/cabana/api_tokens.go @@ -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 +} diff --git a/modules/cabana/contracts.go b/modules/cabana/contracts.go index 175b3f9..c8d0ff8 100644 --- a/modules/cabana/contracts.go +++ b/modules/cabana/contracts.go @@ -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 } diff --git a/modules/cabana/export_test.go b/modules/cabana/export_test.go index 9afbedd..b4e4cc5 100644 --- a/modules/cabana/export_test.go +++ b/modules/cabana/export_test.go @@ -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 +} diff --git a/modules/cabana/http.go b/modules/cabana/http.go index a746c01..9f6b267 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -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 diff --git a/modules/cabana/token_guard.go b/modules/cabana/token_guard.go new file mode 100644 index 0000000..c683d30 --- /dev/null +++ b/modules/cabana/token_guard.go @@ -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...) +} diff --git a/modules/cabana/token_postgres_smoke_test.go b/modules/cabana/token_postgres_smoke_test.go new file mode 100644 index 0000000..d4d7e10 --- /dev/null +++ b/modules/cabana/token_postgres_smoke_test.go @@ -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()) + } +} diff --git a/modules/cabana/token_scopes.go b/modules/cabana/token_scopes.go new file mode 100644 index 0000000..469aa5e --- /dev/null +++ b/modules/cabana/token_scopes.go @@ -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 +} diff --git a/modules/cabana/token_smoke_test.go b/modules/cabana/token_smoke_test.go new file mode 100644 index 0000000..9a198eb --- /dev/null +++ b/modules/cabana/token_smoke_test.go @@ -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) + } +} diff --git a/modules/lagoon/backend_admin_migrations.go b/modules/lagoon/backend_admin_migrations.go index ddb7d37..1777eee 100644 --- a/modules/lagoon/backend_admin_migrations.go +++ b/modules/lagoon/backend_admin_migrations.go @@ -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 + }, + }, } diff --git a/modules/lagoon/backend_admin_migrations_test.go b/modules/lagoon/backend_admin_migrations_test.go index c7c2010..84146a7 100644 --- a/modules/lagoon/backend_admin_migrations_test.go +++ b/modules/lagoon/backend_admin_migrations_test.go @@ -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) } diff --git a/modules/pact/capabilities.go b/modules/pact/capabilities.go index 55a9944..2918938 100644 --- a/modules/pact/capabilities.go +++ b/modules/pact/capabilities.go @@ -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 {