From 0e260ffb6a11443bc85bdef40d5d1be2596d6e66 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 20 Oct 2025 08:50:20 +0800 Subject: [PATCH] Enhance OAuth guard with authorized info handling and ACL integration - Updated the OAuth guard to set authorized information in the context using the new authorized package. - Refactored the GetAuthorizedInfo function to utilize the authorized.GetInfo method, improving clarity and maintainability. - Enhanced the ACL implementation by adding scope resolution logic in the Enforce method, ensuring proper access control based on user roles and scopes. - Improved error handling and logging during ACL operations, providing better insights into access decisions. --- .gitignore | 1 + openapi/oauth/acl/acl.go | 28 +- openapi/oauth/acl/enforce.go | 79 ++- openapi/oauth/acl/scope.go | 547 +++++++++++++++++++ openapi/oauth/acl/types.go | 174 ++++++ openapi/oauth/authorized/utils.go | 86 +++ openapi/oauth/guard.go | 79 +-- openapi/tests/oauth/acl/acl_test.go | 149 +++++ openapi/tests/oauth/acl/enforce_test.go | 428 +++++++++++++++ openapi/tests/oauth/acl/scope_atomic_test.go | 511 +++++++++++++++++ openapi/tests/oauth/acl/scope_test.go | 368 +++++++++++++ 11 files changed, 2373 insertions(+), 77 deletions(-) create mode 100644 openapi/oauth/acl/scope.go create mode 100644 openapi/oauth/authorized/utils.go create mode 100644 openapi/tests/oauth/acl/acl_test.go create mode 100644 openapi/tests/oauth/acl/enforce_test.go create mode 100644 openapi/tests/oauth/acl/scope_atomic_test.go create mode 100644 openapi/tests/oauth/acl/scope_test.go diff --git a/.gitignore b/.gitignore index 68820620..b7a4dfbc 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ share/const.go.bak share/const.goe .cursor openapi/*.md +openapi/oauth/acl/*.md diff --git a/openapi/oauth/acl/acl.go b/openapi/oauth/acl/acl.go index 3ccaf3df..79be4db8 100644 --- a/openapi/oauth/acl/acl.go +++ b/openapi/oauth/acl/acl.go @@ -1,23 +1,45 @@ package acl +import ( + "github.com/yaoapp/kun/log" +) + // Global is the global ACL enforcer var Global Enforcer = nil // New creates a new ACL enforcer -func New(config *Config) Enforcer { +func New(config *Config) (Enforcer, error) { if config == nil { config = &DefaultConfig } - return &ACL{ + acl := &ACL{ Config: config, } + + // Load scope manager if ACL is enabled + if config.Enabled { + + // Load scope manager + manager, err := LoadScopes() + if err != nil { + return nil, err + } + acl.Scope = manager + log.Info("[ACL] Scope manager loaded successfully") + } + + return acl, nil } // Load loads the ACL enforcer func Load(config *Config) (Enforcer, error) { - Global = New(config) + enforcer, err := New(config) + if err != nil { + return nil, err + } + Global = enforcer return Global, nil } diff --git a/openapi/oauth/acl/enforce.go b/openapi/oauth/acl/enforce.go index e0959176..04314609 100644 --- a/openapi/oauth/acl/enforce.go +++ b/openapi/oauth/acl/enforce.go @@ -1,8 +1,83 @@ package acl -import "github.com/gin-gonic/gin" +import ( + "strings" -// Enforce checks if the user has access to the resource + "github.com/gin-gonic/gin" + "github.com/yaoapp/yao/openapi/oauth/authorized" + "github.com/yaoapp/yao/openapi/oauth/types" +) + +// Enforce checks if a user has access to a resource based on the request context func (acl *ACL) Enforce(c *gin.Context) (bool, error) { + // If ACL is not enabled, allow access + if !acl.Enabled() { + return true, nil + } + + // If scope manager not loaded, deny access + if acl.Scope == nil { + return false, nil + } + + // Get authorized info from context (set by OAuth guard middleware) + authInfo := authorized.GetInfo(c) + + // Resolve all scopes (client + user + team) from authorized info + // Note: This should include scope expansion from roles, aliases, etc. + scopes := getScopes(authInfo) + + // Build access request (focused on scope-based access control) + request := &AccessRequest{ + Method: c.Request.Method, + Path: c.Request.URL.Path, + Scopes: scopes, + } + + // Check scopes + decision := acl.Scope.Check(request) + + if !decision.Allowed { + // Return 403 Forbidden with details + c.JSON(403, map[string]interface{}{ + "code": 403, + "message": "Access denied", + "reason": decision.Reason, + "required_scopes": decision.RequiredScopes, + "missing_scopes": decision.MissingScopes, + }) + c.Abort() + return false, nil + } + return true, nil } + +// getScopes resolves all scopes from authorized info +// This function is responsible for the complete scope resolution process: +// 1. Get base scopes from token (authInfo.Scope) +// 2. Get user role scopes from database (if authInfo.UserID exists) +// 3. Get team role scopes from database (if authInfo.TeamID exists) +// 4. Merge all scopes and return the complete list +// +// Scope resolution logic: +// - Pure API call (no user_id): Returns client scopes from token +// - User call: Returns merged scopes (client + user roles + team roles) +// +// This keeps the ACL layer focused on scope-based access control, +// while relying on the authorized package for context extraction. +func getScopes(authInfo *types.AuthorizedInfo) []string { + // TODO: Implement scope resolution + // 1. Parse base scopes from authInfo.Scope (space-separated) + // 2. Query user roles and convert to scopes (if authInfo.UserID exists) + // 3. Query team roles and convert to scopes (if authInfo.TeamID exists) + // 4. Merge and deduplicate all scopes + + // For now, just return scopes from token + if authInfo.Scope == "" { + return []string{} + } + // Split space-separated scopes + // e.g., "read:users write:users" -> ["read:users", "write:users"] + return strings.Split(authInfo.Scope, " ") +} diff --git a/openapi/oauth/acl/scope.go b/openapi/oauth/acl/scope.go new file mode 100644 index 00000000..aa29d1f1 --- /dev/null +++ b/openapi/oauth/acl/scope.go @@ -0,0 +1,547 @@ +package acl + +import ( + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/yaoapp/gou/application" + "github.com/yaoapp/kun/log" + "gopkg.in/yaml.v3" +) + +// LoadScopes loads the scope configuration from the openapi/scopes directory +func LoadScopes() (*ScopeManager, error) { + manager := &ScopeManager{ + defaultAction: "deny", + publicPaths: make(map[string]struct{}), + endpointIndex: make(map[string]*PathMatcher), + scopeIndex: make(map[string]*Scope), + aliasIndex: make(map[string][]string), + scopes: make(map[string]*ScopeDefinition), + } + + // Check if scopes directory exists + scopesDir := filepath.Join("openapi", "scopes") + exists, err := application.App.Exists(scopesDir) + if err != nil { + return nil, err + } + if !exists { + log.Warn("[ACL] Scopes directory not found, using default deny policy") + return manager, nil + } + + // Load global configuration (scopes.yml) + if err := manager.loadGlobalConfig(); err != nil { + return nil, fmt.Errorf("failed to load global config: %w", err) + } + + // Load alias configuration (alias.yml) + if err := manager.loadAliasConfig(); err != nil { + return nil, fmt.Errorf("failed to load alias config: %w", err) + } + + // Load scope definitions from subdirectories + if err := manager.loadScopeDefinitions(); err != nil { + return nil, fmt.Errorf("failed to load scope definitions: %w", err) + } + + // Build runtime indexes + if err := manager.buildIndexes(); err != nil { + return nil, fmt.Errorf("failed to build indexes: %w", err) + } + + log.Info("[ACL] Loaded %d scopes, %d aliases", len(manager.scopeIndex), len(manager.aliasIndex)) + return manager, nil +} + +// loadGlobalConfig loads the global scopes configuration from scopes.yml +func (m *ScopeManager) loadGlobalConfig() error { + configPath := filepath.Join("openapi", "scopes", "scopes.yml") + exists, err := application.App.Exists(configPath) + if err != nil { + return err + } + if !exists { + log.Warn("[ACL] scopes.yml not found, using default configuration") + return nil + } + + raw, err := application.App.Read(configPath) + if err != nil { + return err + } + + var config GlobalConfig + if err := yaml.Unmarshal(raw, &config); err != nil { + return err + } + + m.globalConfig = &config + + // Set default action + if config.Default != "" { + m.defaultAction = config.Default + } + + // Parse public endpoints + for _, endpoint := range config.Public { + // Format: METHOD /path + parts := strings.Fields(endpoint) + if len(parts) == 2 { + key := parts[0] + " " + parts[1] + m.publicPaths[key] = struct{}{} + } + } + + return nil +} + +// loadAliasConfig loads the alias configuration from alias.yml +func (m *ScopeManager) loadAliasConfig() error { + configPath := filepath.Join("openapi", "scopes", "alias.yml") + exists, err := application.App.Exists(configPath) + if err != nil { + return err + } + if !exists { + log.Warn("[ACL] alias.yml not found") + return nil + } + + raw, err := application.App.Read(configPath) + if err != nil { + return err + } + + var config AliasConfig + if err := yaml.Unmarshal(raw, &config); err != nil { + return err + } + + m.aliasConfig = config + + // Expand aliases (resolve nested aliases) + for alias := range config { + expanded, err := m.expandAlias(alias, make(map[string]bool)) + if err != nil { + return fmt.Errorf("failed to expand alias %s: %w", alias, err) + } + m.aliasIndex[alias] = expanded + } + + return nil +} + +// expandAlias recursively expands an alias to its scopes, detecting circular references +func (m *ScopeManager) expandAlias(alias string, visited map[string]bool) ([]string, error) { + // Check for circular reference + if visited[alias] { + return nil, fmt.Errorf("circular alias reference detected: %s", alias) + } + visited[alias] = true + + scopes := m.aliasConfig[alias] + if scopes == nil { + // Not an alias, return as is + return []string{alias}, nil + } + + var expanded []string + for _, scope := range scopes { + // Check if this is another alias + if m.aliasConfig[scope] != nil { + // Recursively expand + subScopes, err := m.expandAlias(scope, visited) + if err != nil { + return nil, err + } + expanded = append(expanded, subScopes...) + } else { + expanded = append(expanded, scope) + } + } + + delete(visited, alias) + return expanded, nil +} + +// loadScopeDefinitions loads scope definitions from subdirectories +func (m *ScopeManager) loadScopeDefinitions() error { + scopesDir := filepath.Join("openapi", "scopes") + + // Subdirectories to scan + subDirs := []string{"kb", "job", "file", "user"} + + for _, subDir := range subDirs { + dirPath := filepath.Join(scopesDir, subDir) + exists, err := application.App.Exists(dirPath) + if err != nil { + return err + } + if !exists { + continue + } + + // Walk through all .yml files in the directory + err = application.App.Walk(dirPath, func(root, filename string, isdir bool) error { + if isdir { + return nil + } + + if !strings.HasSuffix(filename, ".yml") { + return nil + } + + if err := m.loadScopeFile(filename); err != nil { + log.Warn("[ACL] Failed to load %s: %v", filename, err) + } + + return nil + }, "*.yml") + + if err != nil { + return err + } + } + + return nil +} + +// loadScopeFile loads scope definitions from a single YAML file +func (m *ScopeManager) loadScopeFile(filePath string) error { + raw, err := application.App.Read(filePath) + if err != nil { + return err + } + + // Parse as map of scope definitions + var scopeMap map[string]*ScopeDefinition + if err := yaml.Unmarshal(raw, &scopeMap); err != nil { + return err + } + + // Store each scope definition + for name, def := range scopeMap { + def.Name = name + m.scopes[name] = def + } + + return nil +} + +// buildIndexes builds runtime indexes for efficient querying +func (m *ScopeManager) buildIndexes() error { + // Build scope index + for name, def := range m.scopes { + m.scopeIndex[name] = &Scope{ + Name: name, + Description: def.Description, + Owner: def.Owner, + Team: def.Team, + Endpoints: def.Endpoints, + } + } + + // Build endpoint index from global config + if m.globalConfig != nil { + for _, rule := range m.globalConfig.Endpoints { + if err := m.addEndpointRule(rule.Method, rule.Path, rule.Action, nil); err != nil { + return err + } + } + } + + // Build endpoint index from scope definitions + for name, def := range m.scopes { + for _, endpoint := range def.Endpoints { + // Format: METHOD /path + parts := strings.Fields(endpoint) + if len(parts) != 2 { + log.Warn("[ACL] Invalid endpoint format: %s", endpoint) + continue + } + + method, path := parts[0], parts[1] + if err := m.addEndpointRule(method, path, "require-scopes", []string{name}); err != nil { + return err + } + } + } + + // Sort wildcard paths by prefix length (longer first) + for _, matcher := range m.endpointIndex { + sort.Slice(matcher.wildcardPaths, func(i, j int) bool { + return len(matcher.wildcardPaths[i].Prefix) > len(matcher.wildcardPaths[j].Prefix) + }) + } + + return nil +} + +// addEndpointRule adds an endpoint rule to the index +func (m *ScopeManager) addEndpointRule(method, path, action string, scopes []string) error { + // Get or create PathMatcher for this method + matcher := m.endpointIndex[method] + if matcher == nil { + matcher = &PathMatcher{ + exactPaths: make(map[string]*EndpointInfo), + paramPaths: make(map[string]*EndpointInfo), + wildcardPaths: []*WildcardPath{}, + } + m.endpointIndex[method] = matcher + } + + // Determine policy + var policy EndpointPolicy + switch action { + case "allow": + policy = PolicyAllow + case "deny": + policy = PolicyDeny + case "require-scopes": + policy = PolicyRequireScopes + default: + return fmt.Errorf("unknown action: %s", action) + } + + // Create endpoint info + info := &EndpointInfo{ + Method: method, + Path: path, + Policy: policy, + RequiredScopes: scopes, + } + + // Set owner/team constraints from scope definitions + if len(scopes) > 0 { + for _, scopeName := range scopes { + if def := m.scopes[scopeName]; def != nil { + if def.Owner { + info.OwnerOnly = true + } + if def.Team { + info.TeamOnly = true + } + } + } + } + + // Classify path type and add to appropriate index + if strings.Contains(path, "*") { + // Wildcard path + prefix := strings.TrimSuffix(path, "*") + matcher.wildcardPaths = append(matcher.wildcardPaths, &WildcardPath{ + Pattern: path, + Prefix: prefix, + Endpoint: info, + }) + } else if strings.Contains(path, ":") { + // Parameter path + matcher.paramPaths[path] = info + } else { + // Exact path + matcher.exactPaths[path] = info + } + + return nil +} + +// Check checks if the request scopes satisfy the endpoint requirements +func (m *ScopeManager) Check(req *AccessRequest) *AccessDecision { + m.mu.RLock() + defer m.mu.RUnlock() + + decision := &AccessDecision{ + Allowed: false, + UserScopes: req.Scopes, + } + + // 1. Check if it's a public endpoint + publicKey := req.Method + " " + req.Path + if _, ok := m.publicPaths[publicKey]; ok { + decision.Allowed = true + decision.Reason = "public endpoint" + return decision + } + + // 2. Find matching endpoint + endpoint, pattern := m.matchEndpoint(req.Method, req.Path) + if endpoint == nil { + // No match found, use default policy + decision.Allowed = m.defaultAction == "allow" + decision.Reason = fmt.Sprintf("no match, default policy: %s", m.defaultAction) + return decision + } + + decision.MatchedEndpoint = endpoint + decision.MatchedPattern = pattern + + // 3. Check policy + switch endpoint.Policy { + case PolicyAllow: + decision.Allowed = true + decision.Reason = "policy: allow" + return decision + + case PolicyDeny: + decision.Allowed = false + decision.Reason = "policy: deny" + return decision + + case PolicyRequireScopes: + // Expand user scopes (include aliases) + expandedScopes := m.expandUserScopes(req.Scopes) + + // Check if user has any required scope (OR relationship) + decision.RequiredScopes = endpoint.RequiredScopes + hasScope := false + for _, required := range endpoint.RequiredScopes { + for _, userScope := range expandedScopes { + if userScope == required { + hasScope = true + break + } + } + if hasScope { + break + } + } + + if !hasScope { + decision.Allowed = false + decision.Reason = "missing required scopes" + decision.MissingScopes = m.findMissingScopes(expandedScopes, endpoint.RequiredScopes) + return decision + } + + decision.Allowed = true + decision.Reason = "scope matched" + return decision + } + + decision.Allowed = false + decision.Reason = "unknown policy" + return decision +} + +// matchEndpoint finds the matching endpoint for a request +func (m *ScopeManager) matchEndpoint(method, path string) (*EndpointInfo, string) { + matcher := m.endpointIndex[method] + if matcher == nil { + return nil, "" + } + + // 1. Try exact match + if info := matcher.exactPaths[path]; info != nil { + return info, path + } + + // 2. Try parameter match + for pattern, info := range matcher.paramPaths { + if m.matchParameterPath(pattern, path) { + return info, pattern + } + } + + // 3. Try wildcard match (already sorted by prefix length) + for _, wildcard := range matcher.wildcardPaths { + if strings.HasPrefix(path, wildcard.Prefix) { + return wildcard.Endpoint, wildcard.Pattern + } + } + + return nil, "" +} + +// matchParameterPath checks if a path matches a parameter pattern +func (m *ScopeManager) matchParameterPath(pattern, path string) bool { + patternParts := strings.Split(strings.Trim(pattern, "/"), "/") + pathParts := strings.Split(strings.Trim(path, "/"), "/") + + // Must have same number of segments + if len(patternParts) != len(pathParts) { + return false + } + + for i := range patternParts { + // Parameter segment (starts with :) + if strings.HasPrefix(patternParts[i], ":") { + continue + } + // Exact match required + if patternParts[i] != pathParts[i] { + return false + } + } + + return true +} + +// expandUserScopes expands user scopes by resolving aliases +func (m *ScopeManager) expandUserScopes(scopes []string) []string { + var expanded []string + seen := make(map[string]bool) + + for _, scope := range scopes { + // Check if it's an alias + if aliasScopes := m.aliasIndex[scope]; aliasScopes != nil { + for _, s := range aliasScopes { + if !seen[s] { + expanded = append(expanded, s) + seen[s] = true + } + } + } else { + if !seen[scope] { + expanded = append(expanded, scope) + seen[scope] = true + } + } + } + + return expanded +} + +// findMissingScopes finds which scopes are missing +func (m *ScopeManager) findMissingScopes(userScopes, requiredScopes []string) []string { + userScopeSet := make(map[string]bool) + for _, s := range userScopes { + userScopeSet[s] = true + } + + var missing []string + for _, required := range requiredScopes { + if !userScopeSet[required] { + missing = append(missing, required) + } + } + + return missing +} + +// Reload reloads the scope configuration +func (m *ScopeManager) Reload() error { + m.mu.Lock() + defer m.mu.Unlock() + + // Create a new manager + newManager, err := LoadScopes() + if err != nil { + return err + } + + // Replace current data with new data + m.defaultAction = newManager.defaultAction + m.publicPaths = newManager.publicPaths + m.endpointIndex = newManager.endpointIndex + m.scopeIndex = newManager.scopeIndex + m.aliasIndex = newManager.aliasIndex + m.globalConfig = newManager.globalConfig + m.aliasConfig = newManager.aliasConfig + m.scopes = newManager.scopes + + return nil +} diff --git a/openapi/oauth/acl/types.go b/openapi/oauth/acl/types.go index c4cab3ea..292cddd7 100644 --- a/openapi/oauth/acl/types.go +++ b/openapi/oauth/acl/types.go @@ -1,5 +1,11 @@ package acl +import ( + "fmt" + "strings" + "sync" +) + // DefaultConfig is the default configuration for the ACL var DefaultConfig = Config{ Enabled: false, @@ -13,4 +19,172 @@ type Config struct { // ACL is the ACL checker type ACL struct { Config *Config + Scope *ScopeManager +} + +// ============ Configuration Structures (loaded from config files) ============ + +// GlobalConfig represents global scopes configuration (from scopes.yml) +type GlobalConfig struct { + Default string `json:"default" yaml:"default"` // "allow" or "deny" - default policy + Public []string `json:"public" yaml:"public"` // Public endpoints (no authentication required) + Endpoints []EndpointRule `json:"endpoints" yaml:"endpoints"` // Default endpoint rules +} + +// EndpointRule represents an endpoint rule (format: METHOD /path action) +type EndpointRule struct { + Method string // HTTP method (GET, POST, PUT, DELETE, etc.) + Path string // URL path (supports wildcard *) + Action string // "allow" or "deny" +} + +// UnmarshalYAML implements custom YAML unmarshaling to support simple string format +// Supports both formats: +// - "GET /api/users allow" (simple string format) +// - {method: GET, path: /api/users, action: allow} (struct format) +func (e *EndpointRule) UnmarshalYAML(unmarshal func(interface{}) error) error { + // Try to unmarshal as string first (simple format) + var str string + if err := unmarshal(&str); err == nil { + // Parse string format: "METHOD /path action" + parts := strings.Fields(str) + if len(parts) != 3 { + return fmt.Errorf("invalid endpoint rule format: %q (expected: METHOD /path action)", str) + } + e.Method = parts[0] + e.Path = parts[1] + e.Action = parts[2] + return nil + } + + // Fallback to struct format + type endpointRule EndpointRule // Create alias to avoid recursion + var rule endpointRule + if err := unmarshal(&rule); err != nil { + return err + } + *e = EndpointRule(rule) + return nil +} + +// AliasConfig represents alias configuration (from alias.yml) +// Format: alias_name -> [scope1, scope2, ...] +type AliasConfig map[string][]string + +// ScopeDefinition represents a scope definition (from subdirectory yml files) +type ScopeDefinition struct { + Name string `json:"name" yaml:"name"` // Scope name (e.g. collections:read:all) + Description string `json:"description" yaml:"description"` // Description + Owner bool `json:"owner" yaml:"owner"` // Owner only + Team bool `json:"team" yaml:"team"` // Team only + Endpoints []string `json:"endpoints" yaml:"endpoints"` // Endpoint list (format: METHOD /path) +} + +// ============ Runtime Structures (optimized for querying) ============ + +// ScopeManager is the permission manager - global singleton, supports efficient querying and dynamic updates +type ScopeManager struct { + mu sync.RWMutex // Read-write lock for concurrent safety + + // Global configuration + defaultAction string // Default policy: allow or deny + publicPaths map[string]struct{} // Public path set (fast lookup) + + // Runtime indexes (optimized for performance) + endpointIndex map[string]*PathMatcher // method -> PathMatcher + scopeIndex map[string]*Scope // scope_name -> Scope details + aliasIndex map[string][]string // alias -> expanded scopes + + // Original configuration (for reloading) + globalConfig *GlobalConfig + aliasConfig AliasConfig + scopes map[string]*ScopeDefinition +} + +// PathMatcher stores path rules by priority +type PathMatcher struct { + // Exact match paths (highest priority) + // key: full path (e.g. "/kb/collections") + // value: endpoint info + exactPaths map[string]*EndpointInfo + + // Parameter paths (medium priority) + // Grouped by segment count, supports :param placeholder + // key: path pattern (e.g. "/kb/collections/:collectionID") + // value: endpoint info + paramPaths map[string]*EndpointInfo + + // Wildcard paths (lowest priority) + // Sorted by prefix length (longer first) + // e.g. ["/kb/collections/*", "/kb/*"] + wildcardPaths []*WildcardPath +} + +// WildcardPath represents a wildcard path rule +type WildcardPath struct { + Pattern string // Original pattern (e.g. "/kb/*") + Prefix string // Match prefix (e.g. "/kb/") + Endpoint *EndpointInfo // Endpoint info +} + +// EndpointInfo stores access control policy for an endpoint +type EndpointInfo struct { + Method string // HTTP method + Path string // Original path pattern + + // Access control policy + Policy EndpointPolicy // allow / deny / require-scopes + + // If Policy is require-scopes, the scopes required to access + RequiredScopes []string // Scope list (OR relationship, any one satisfied) + + // Resource constraints + OwnerOnly bool // Owner only + TeamOnly bool // Team only +} + +// EndpointPolicy represents the endpoint policy +type EndpointPolicy int + +const ( + // PolicyDeny denies access to the endpoint + PolicyDeny EndpointPolicy = iota + // PolicyAllow allows access to the endpoint without scope check + PolicyAllow + // PolicyRequireScopes requires specific scopes to access the endpoint + PolicyRequireScopes +) + +// Scope represents a permission scope +type Scope struct { + Name string // Scope name + Description string // Description + Owner bool // Owner only + Team bool // Team only + Endpoints []string // Associated endpoint list +} + +// ============ Request Context (permission check context) ============ + +// AccessRequest represents an access request for scope-based access control +// It focuses on the resource being accessed and the available scopes +type AccessRequest struct { + Method string // HTTP method + Path string // Request path + Scopes []string // User's scopes (should be resolved externally including user, team, and client scopes) +} + +// AccessDecision represents the access decision result +type AccessDecision struct { + Allowed bool // Whether access is allowed + Reason string // Decision reason (for debugging) + + // Matched endpoint info + MatchedEndpoint *EndpointInfo + MatchedPattern string // Matched path pattern + + // Permission check details + RequiredScopes []string // Required scopes + UserScopes []string // User's scopes + MissingScopes []string // Missing scopes } diff --git a/openapi/oauth/authorized/utils.go b/openapi/oauth/authorized/utils.go new file mode 100644 index 00000000..835cfdf9 --- /dev/null +++ b/openapi/oauth/authorized/utils.go @@ -0,0 +1,86 @@ +package authorized + +import ( + "github.com/gin-gonic/gin" + "github.com/yaoapp/yao/openapi/oauth/types" +) + +// GetInfo extracts authorized information from the gin context +// This function reads authorization data that was set by the OAuth guard middleware +func GetInfo(c *gin.Context) *types.AuthorizedInfo { + info := &types.AuthorizedInfo{} + + if subject, ok := c.Get("__subject"); ok { + info.Subject = subject.(string) + } + + if clientID, ok := c.Get("__client_id"); ok { + info.ClientID = clientID.(string) + } + + if userID, ok := c.Get("__user_id"); ok { + info.UserID = userID.(string) + } + + if scope, ok := c.Get("__scope"); ok { + info.Scope = scope.(string) + } + + if teamID, ok := c.Get("__team_id"); ok { + info.TeamID = teamID.(string) + } + + if tenantID, ok := c.Get("__tenant_id"); ok { + info.TenantID = tenantID.(string) + } + + if sessionID, ok := c.Get("__sid"); ok { + info.SessionID = sessionID.(string) + } + + if rememberMe, ok := c.Get("__remember_me"); ok { + if rmBool, ok := rememberMe.(bool); ok { + info.RememberMe = rmBool + } + } + + return info +} + +// SetInfo sets authorized information in the gin context +// This function should be called by the OAuth guard middleware after token validation +// userIDGetter is a function that resolves the user_id from clientID and subject +func SetInfo(c *gin.Context, claims *types.TokenClaims, sessionID string, userIDGetter func(clientID, subject string) (string, error)) { + // Set session ID in context + if sessionID != "" { + c.Set("__sid", sessionID) + } + + // Set user_id in context (resolve from claims) + if userIDGetter != nil { + userID, err := userIDGetter(claims.ClientID, claims.Subject) + if err == nil && userID != "" { + c.Set("__user_id", userID) + } + } + + // Set subject, scope, client_id in context + c.Set("__subject", claims.Subject) + c.Set("__scope", claims.Scope) + c.Set("__client_id", claims.ClientID) + + // Set team_id and tenant_id in context if available + if claims.TeamID != "" { + c.Set("__team_id", claims.TeamID) + } + if claims.TenantID != "" { + c.Set("__tenant_id", claims.TenantID) + } + + // Set custom claims from Extra field into context + if claims.Extra != nil { + for key, value := range claims.Extra { + c.Set("__"+key, value) + } + } +} diff --git a/openapi/oauth/guard.go b/openapi/oauth/guard.go index e473b462..dbfe5372 100644 --- a/openapi/oauth/guard.go +++ b/openapi/oauth/guard.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "github.com/yaoapp/yao/openapi/oauth/acl" + "github.com/yaoapp/yao/openapi/oauth/authorized" "github.com/yaoapp/yao/openapi/oauth/types" ) @@ -36,8 +37,9 @@ func (s *Service) Guard(c *gin.Context) { s.tryAutoRefreshToken(c, claims) } - // Set Authorized Info - s.setAuthorizedInfo(c, claims) + // Set Authorized Info in context + sessionID := s.getSessionID(c) + authorized.SetInfo(c, claims, sessionID, s.UserID) // Check if ACL is enabled if acl.Global == nil || !acl.Global.Enabled() { @@ -59,77 +61,10 @@ func (s *Service) Guard(c *gin.Context) { } } -// GetAuthorizedInfo Get Authorized Info from context +// GetAuthorizedInfo gets authorized info from context +// Deprecated: Use authorized.GetInfo(c) instead func GetAuthorizedInfo(c *gin.Context) *types.AuthorizedInfo { - info := &types.AuthorizedInfo{} - - if subject, ok := c.Get("__subject"); ok { - info.Subject = subject.(string) - } - - if clientID, ok := c.Get("__client_id"); ok { - info.ClientID = clientID.(string) - } - - if userID, ok := c.Get("__user_id"); ok { - info.UserID = userID.(string) - } - - if scope, ok := c.Get("__scope"); ok { - info.Scope = scope.(string) - } - - if teamID, ok := c.Get("__team_id"); ok { - info.TeamID = teamID.(string) - } - - if tenantID, ok := c.Get("__tenant_id"); ok { - info.TenantID = tenantID.(string) - } - - if rememberMe, ok := c.Get("__remember_me"); ok { - if rmBool, ok := rememberMe.(bool); ok { - info.RememberMe = rmBool - } - } - - return info -} - -// Set Authorized Info in context -func (s *Service) setAuthorizedInfo(c *gin.Context, claims *types.TokenClaims) { - sid := s.getSessionID(c) - - // Set __sid in context - if sid != "" { - c.Set("__sid", sid) - } - - // Set __userID in context - userID, err := s.UserID(claims.ClientID, claims.Subject) - if err == nil && userID != "" { - c.Set("__user_id", userID) - } - - // Set subject scope, client_id, user_id in context - c.Set("__subject", claims.Subject) - c.Set("__scope", claims.Scope) - c.Set("__client_id", claims.ClientID) - - // Set team_id and tenant_id in context if available - if claims.TeamID != "" { - c.Set("__team_id", claims.TeamID) - } - if claims.TenantID != "" { - c.Set("__tenant_id", claims.TenantID) - } - - // Set custom claims from Extra field into context - if claims.Extra != nil { - for key, value := range claims.Extra { - c.Set("__"+key, value) - } - } + return authorized.GetInfo(c) } func (s *Service) tryAutoRefreshToken(c *gin.Context, _ *types.TokenClaims) { diff --git a/openapi/tests/oauth/acl/acl_test.go b/openapi/tests/oauth/acl/acl_test.go new file mode 100644 index 00000000..32faf357 --- /dev/null +++ b/openapi/tests/oauth/acl/acl_test.go @@ -0,0 +1,149 @@ +package acl_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/openapi/oauth/acl" + "github.com/yaoapp/yao/openapi/tests/testutils" +) + +// ACL Test Suite +// +// PREREQUISITES: +// These tests require yao-dev-app to be available with scopes configuration. +// Before running tests, set the environment to point to yao-dev-app: +// +// export YAO_DEV=$HOME/Yao/yao-dev-app +// cd $YAO_DEV && source env.local.sh +// +// Then run tests: +// go test -v ./openapi/tests/oauth/acl/... -count=1 +// +// The tests will use the scopes configuration from yao-dev-app/openapi/scopes/ + +// TestNew tests the creation of a new ACL enforcer +func TestNew(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean() + + t.Run("CreateWithDefaultConfig", func(t *testing.T) { + // Create ACL with nil config (should use default) + enforcer, err := acl.New(nil) + assert.NoError(t, err) + assert.NotNil(t, enforcer) + + // Default config should have Enabled=false + assert.False(t, enforcer.Enabled()) + + t.Log("Successfully created ACL enforcer with default config") + }) + + t.Run("CreateWithDisabledConfig", func(t *testing.T) { + // Create ACL with disabled config + config := &acl.Config{ + Enabled: false, + } + + enforcer, err := acl.New(config) + assert.NoError(t, err) + assert.NotNil(t, enforcer) + assert.False(t, enforcer.Enabled()) + + t.Log("Successfully created ACL enforcer with disabled config") + }) + + t.Run("CreateWithEnabledConfig", func(t *testing.T) { + // Create ACL with enabled config + // Note: This will try to load scope configuration from openapi/scopes directory + config := &acl.Config{ + Enabled: true, + } + + enforcer, err := acl.New(config) + + // If scopes directory doesn't exist, it should still succeed with warning + // If scopes directory exists, it should load successfully + if err != nil { + t.Logf("Expected behavior: ACL loading may fail if scopes directory is not configured: %v", err) + } else { + assert.NotNil(t, enforcer) + assert.True(t, enforcer.Enabled()) + t.Log("Successfully created ACL enforcer with enabled config") + } + }) +} + +// TestLoad tests loading the ACL enforcer as global singleton +func TestLoad(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean() + + t.Run("LoadWithDefaultConfig", func(t *testing.T) { + // Load ACL with default config + enforcer, err := acl.Load(nil) + assert.NoError(t, err) + assert.NotNil(t, enforcer) + + // Should set global enforcer + assert.NotNil(t, acl.Global) + assert.Equal(t, enforcer, acl.Global) + + t.Log("Successfully loaded ACL enforcer as global singleton") + }) + + t.Run("LoadWithDisabledConfig", func(t *testing.T) { + config := &acl.Config{ + Enabled: false, + } + + enforcer, err := acl.Load(config) + assert.NoError(t, err) + assert.NotNil(t, enforcer) + assert.False(t, enforcer.Enabled()) + + // Global should be updated + assert.Equal(t, enforcer, acl.Global) + + t.Log("Successfully loaded disabled ACL enforcer") + }) +} + +// TestEnabled tests the Enabled method +func TestEnabled(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean() + + t.Run("DisabledACL", func(t *testing.T) { + config := &acl.Config{ + Enabled: false, + } + + enforcer, err := acl.New(config) + assert.NoError(t, err) + assert.False(t, enforcer.Enabled()) + }) + + t.Run("EnabledACL", func(t *testing.T) { + config := &acl.Config{ + Enabled: true, + } + + enforcer, err := acl.New(config) + + // May fail if scopes directory doesn't exist, which is expected + if err == nil { + assert.True(t, enforcer.Enabled()) + } + }) +} + +// TestDefaultConfig tests the default configuration +func TestDefaultConfig(t *testing.T) { + t.Run("DefaultConfigValues", func(t *testing.T) { + // The default config should have Enabled=false + assert.False(t, acl.DefaultConfig.Enabled, "Default ACL config should be disabled") + + t.Log("Default config verified: Enabled=false") + }) +} diff --git a/openapi/tests/oauth/acl/enforce_test.go b/openapi/tests/oauth/acl/enforce_test.go new file mode 100644 index 00000000..c421fd92 --- /dev/null +++ b/openapi/tests/oauth/acl/enforce_test.go @@ -0,0 +1,428 @@ +package acl_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/openapi/oauth/acl" + "github.com/yaoapp/yao/openapi/oauth/authorized" + "github.com/yaoapp/yao/openapi/oauth/types" +) + +// setupGinContext creates a test gin context with authorized info +func setupGinContext(method, path string, scopes []string) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + // Create test request + req, _ := http.NewRequest(method, path, nil) + c.Request = req + + // Set authorized info in context + authInfo := &types.AuthorizedInfo{ + Subject: "test-subject", + ClientID: "test-client", + UserID: "test-user", + Scope: joinScopes(scopes), + } + + // Simulate what authorized.SetInfo would do + c.Set("__subject", authInfo.Subject) + c.Set("__client_id", authInfo.ClientID) + c.Set("__user_id", authInfo.UserID) + c.Set("__scope", authInfo.Scope) + + return c, w +} + +// joinScopes joins scopes array into space-separated string +func joinScopes(scopes []string) string { + if len(scopes) == 0 { + return "" + } + result := scopes[0] + for i := 1; i < len(scopes); i++ { + result += " " + scopes[i] + } + return result +} + +// TestEnforce tests the Enforce method +func TestEnforce(t *testing.T) { + t.Run("EnforceWithDisabledACL", func(t *testing.T) { + // Create disabled ACL + config := &acl.Config{ + Enabled: false, + } + + aclEnforcer, err := acl.New(config) + assert.NoError(t, err) + + // Setup test context + c, _ := setupGinContext("GET", "/test/endpoint", []string{"read:test"}) + + // Enforce should allow access when ACL is disabled + allowed, err := aclEnforcer.Enforce(c) + assert.NoError(t, err) + assert.True(t, allowed, "Should allow access when ACL is disabled") + + t.Log("Disabled ACL correctly allows all access") + }) + + t.Run("EnforceWithEnabledACLNoScope", func(t *testing.T) { + // Create enabled ACL + config := &acl.Config{ + Enabled: true, + } + + aclEnforcer, err := acl.New(config) + + // May fail if scopes directory doesn't exist + if err != nil { + t.Skipf("Skipping test: ACL initialization failed (expected if scopes directory missing): %v", err) + return + } + + // Setup test context with no scopes + c, w := setupGinContext("GET", "/test/endpoint", []string{}) + + // Enforce should check permissions + allowed, err := aclEnforcer.Enforce(c) + assert.NoError(t, err) + + t.Logf("Access decision: allowed=%v, status=%d", allowed, w.Code) + }) + + t.Run("EnforceWithScopes", func(t *testing.T) { + // Create enabled ACL + config := &acl.Config{ + Enabled: true, + } + + aclEnforcer, err := acl.New(config) + + if err != nil { + t.Skipf("Skipping test: ACL initialization failed: %v", err) + return + } + + // Setup test context with scopes + c, w := setupGinContext("GET", "/api/users", []string{"read:users", "write:users"}) + + // Enforce should check permissions + allowed, err := aclEnforcer.Enforce(c) + assert.NoError(t, err) + + t.Logf("Access with scopes: allowed=%v, status=%d", allowed, w.Code) + + if !allowed && w.Code == 403 { + t.Log("Access correctly denied with 403 response") + } + }) + + t.Run("EnforceChecksContext", func(t *testing.T) { + // Test that Enforce extracts info from context correctly + config := &acl.Config{ + Enabled: true, + } + + aclEnforcer, err := acl.New(config) + + if err != nil { + t.Skipf("Skipping test: ACL initialization failed: %v", err) + return + } + + // Setup context with specific authorized info + c, _ := setupGinContext("POST", "/kb/collections", []string{ + "collections:create", + "collections:read", + }) + + // Verify authorized info can be extracted + authInfo := authorized.GetInfo(c) + assert.NotNil(t, authInfo) + assert.Equal(t, "test-user", authInfo.UserID) + assert.Equal(t, "test-client", authInfo.ClientID) + assert.Contains(t, authInfo.Scope, "collections:create") + + // Enforce + allowed, err := aclEnforcer.Enforce(c) + assert.NoError(t, err) + + t.Logf("Enforce with collections scopes: allowed=%v", allowed) + }) +} + +// TestEnforceResponseFormat tests the response format when access is denied +func TestEnforceResponseFormat(t *testing.T) { + t.Run("DeniedAccessResponse", func(t *testing.T) { + config := &acl.Config{ + Enabled: true, + } + + aclEnforcer, err := acl.New(config) + + if err != nil { + t.Skipf("Skipping test: ACL initialization failed: %v", err) + return + } + + // Setup context with insufficient scopes for a protected endpoint + c, w := setupGinContext("POST", "/protected/admin", []string{"read:basic"}) + + // Enforce + allowed, err := aclEnforcer.Enforce(c) + assert.NoError(t, err) + + // If access is denied, check response format + if !allowed { + assert.Equal(t, 403, w.Code, "Should return 403 Forbidden") + + // Response should be JSON + contentType := w.Header().Get("Content-Type") + assert.Contains(t, contentType, "application/json") + + // Response body should contain error details + body := w.Body.String() + assert.Contains(t, body, "Access denied") + + t.Logf("Denied access response format correct: %s", body) + } else { + t.Log("Access was allowed (no scope configuration for this endpoint)") + } + }) +} + +// TestGetScopes tests the internal getScopes function behavior +func TestGetScopes(t *testing.T) { + t.Run("ExtractScopesFromContext", func(t *testing.T) { + // Setup context with scopes + c, _ := setupGinContext("GET", "/test", []string{ + "scope1", + "scope2", + "scope3", + }) + + // Get authorized info (which getScopes would use) + authInfo := authorized.GetInfo(c) + assert.NotNil(t, authInfo) + + // Verify scope string contains all scopes + assert.Contains(t, authInfo.Scope, "scope1") + assert.Contains(t, authInfo.Scope, "scope2") + assert.Contains(t, authInfo.Scope, "scope3") + + t.Logf("Scope string: %s", authInfo.Scope) + }) + + t.Run("EmptyScopes", func(t *testing.T) { + // Setup context with no scopes + c, _ := setupGinContext("GET", "/test", []string{}) + + authInfo := authorized.GetInfo(c) + assert.NotNil(t, authInfo) + assert.Empty(t, authInfo.Scope) + + t.Log("Empty scopes handled correctly") + }) + + t.Run("SingleScope", func(t *testing.T) { + // Setup context with single scope + c, _ := setupGinContext("GET", "/test", []string{"single:scope"}) + + authInfo := authorized.GetInfo(c) + assert.NotNil(t, authInfo) + assert.Equal(t, "single:scope", authInfo.Scope) + + t.Log("Single scope handled correctly") + }) +} + +// TestEnforceIntegration tests the complete enforcement flow +func TestEnforceIntegration(t *testing.T) { + t.Run("CompleteFlow", func(t *testing.T) { + // Create enabled ACL + config := &acl.Config{ + Enabled: true, + } + + aclEnforcer, err := acl.New(config) + + if err != nil { + t.Skipf("Skipping integration test: ACL initialization failed: %v", err) + return + } + + // Test cases with real endpoints from yao-dev-app scopes configuration + testCases := []struct { + name string + method string + path string + scopes []string + expected string // "allow" or "deny" or "unknown" + }{ + { + name: "PublicEndpoint", + method: "GET", + path: "/user/entry", + scopes: []string{}, + expected: "allow", // Public endpoint from scopes.yml + }, + { + name: "PublicCaptcha", + method: "GET", + path: "/user/entry/captcha", + scopes: []string{}, + expected: "allow", // Public endpoint + }, + { + name: "KBReadWithScope", + method: "GET", + path: "/kb/collections", + scopes: []string{"collections:read:all"}, + expected: "allow", // Should be allowed with scope + }, + { + name: "KBWriteWithoutScope", + method: "POST", + path: "/kb/collections", + scopes: []string{"collections:read:all"}, + expected: "deny", // POST requires write scope + }, + { + name: "KBWriteWithScope", + method: "POST", + path: "/kb/collections", + scopes: []string{"collections:write:all"}, + expected: "allow", // Should be allowed with write scope + }, + { + name: "ProfileReadWithScope", + method: "GET", + path: "/user/profile", + scopes: []string{"profile:read:own"}, + expected: "allow", // Should be allowed + }, + { + name: "WildcardAllowedRead", + method: "GET", + path: "/kb/documents/doc-123", + scopes: []string{}, + expected: "allow", // GET /kb/* allow from scopes.yml + }, + { + name: "UnmatchedEndpoint", + method: "GET", + path: "/unmatched/endpoint", + scopes: []string{"some:scope"}, + expected: "deny", // Default policy is deny + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + c, w := setupGinContext(tc.method, tc.path, tc.scopes) + + allowed, err := aclEnforcer.Enforce(c) + assert.NoError(t, err) + + t.Logf("%s %s with scopes %v: allowed=%v, status=%d", + tc.method, tc.path, tc.scopes, allowed, w.Code) + + // Verify response is properly formatted + if !allowed { + assert.Equal(t, 403, w.Code) + } + }) + } + }) +} + +// TestEnforcerInterface tests that ACL implements the Enforcer interface +func TestEnforcerInterface(t *testing.T) { + t.Run("ImplementsInterface", func(t *testing.T) { + config := &acl.Config{ + Enabled: false, + } + + enforcer, err := acl.New(config) + assert.NoError(t, err) + + // Should implement Enforcer interface methods + assert.Implements(t, (*acl.Enforcer)(nil), enforcer) + + t.Log("ACL correctly implements Enforcer interface") + }) +} + +// TestEnforceEdgeCases tests edge cases in enforcement +func TestEnforceEdgeCases(t *testing.T) { + t.Run("NilContext", func(t *testing.T) { + config := &acl.Config{ + Enabled: false, + } + + aclEnforcer, err := acl.New(config) + assert.NoError(t, err) + + // Disabled ACL should handle nil context gracefully + // (though this shouldn't happen in practice) + c, _ := setupGinContext("GET", "/test", []string{}) + c.Request = nil // Simulate edge case + + // Should not panic + assert.NotPanics(t, func() { + // Disabled ACL returns early, so won't access c.Request + _, _ = aclEnforcer.Enforce(c) + }) + }) + + t.Run("SpecialCharactersInPath", func(t *testing.T) { + config := &acl.Config{ + Enabled: true, + } + + aclEnforcer, err := acl.New(config) + + if err != nil { + t.Skipf("Skipping test: %v", err) + return + } + + // Test with special characters in path + c, _ := setupGinContext("GET", "/api/users/%20with%20spaces", []string{"read:users"}) + + allowed, err := aclEnforcer.Enforce(c) + assert.NoError(t, err) + + t.Logf("Path with special chars: allowed=%v", allowed) + }) + + t.Run("VeryLongScope", func(t *testing.T) { + config := &acl.Config{ + Enabled: true, + } + + aclEnforcer, err := acl.New(config) + + if err != nil { + t.Skipf("Skipping test: %v", err) + return + } + + // Test with very long scope name + longScope := "very:long:scope:name:with:many:segments:to:test:handling:of:long:strings" + c, _ := setupGinContext("GET", "/test", []string{longScope}) + + allowed, err := aclEnforcer.Enforce(c) + assert.NoError(t, err) + + t.Logf("Long scope handling: allowed=%v", allowed) + }) +} diff --git a/openapi/tests/oauth/acl/scope_atomic_test.go b/openapi/tests/oauth/acl/scope_atomic_test.go new file mode 100644 index 00000000..033d6fb3 --- /dev/null +++ b/openapi/tests/oauth/acl/scope_atomic_test.go @@ -0,0 +1,511 @@ +package acl_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/openapi/oauth/acl" + "github.com/yaoapp/yao/openapi/tests/testutils" +) + +// TestScopeAtomic_ExactPathMatch tests exact path matching +func TestScopeAtomic_ExactPathMatch(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean() + + manager, err := acl.LoadScopes() + assert.NoError(t, err) + assert.NotNil(t, manager) + + t.Run("ExactMatch_Allow", func(t *testing.T) { + // Test exact match: GET /user/entry (public endpoint) + request := &acl.AccessRequest{ + Method: "GET", + Path: "/user/entry", + Scopes: []string{}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + assert.True(t, decision.Allowed, "Exact public path should allow") + assert.Equal(t, "public endpoint", decision.Reason) + t.Logf("✓ Exact match '/user/entry': Allowed=%v, Reason=%s", decision.Allowed, decision.Reason) + }) + + t.Run("ExactMatch_WithScope", func(t *testing.T) { + // Test exact match with scope: GET /kb/collections + request := &acl.AccessRequest{ + Method: "GET", + Path: "/kb/collections", + Scopes: []string{"collections:read:all"}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("✓ Exact match '/kb/collections' with scope: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason) + }) + + t.Run("ExactMatch_DifferentMethods", func(t *testing.T) { + // Test same path with different methods + testCases := []struct { + method string + path string + scopes []string + desc string + }{ + {"GET", "/kb/collections", []string{}, "GET without scope"}, + {"GET", "/kb/collections", []string{"collections:read:all"}, "GET with read scope"}, + {"POST", "/kb/collections", []string{}, "POST without scope"}, + {"POST", "/kb/collections", []string{"collections:write:all"}, "POST with write scope"}, + } + + for _, tc := range testCases { + request := &acl.AccessRequest{ + Method: tc.method, + Path: tc.path, + Scopes: tc.scopes, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("✓ %s %s (%s): Allowed=%v, Reason=%s", + tc.method, tc.path, tc.desc, decision.Allowed, decision.Reason) + } + }) +} + +// TestScopeAtomic_WildcardMatch tests wildcard path matching +func TestScopeAtomic_WildcardMatch(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean() + + manager, err := acl.LoadScopes() + assert.NoError(t, err) + assert.NotNil(t, manager) + + t.Run("Wildcard_SingleLevel", func(t *testing.T) { + // Test wildcard matching: GET /kb/* + testPaths := []string{ + "/kb/collections", + "/kb/documents", + "/kb/search", + } + + for _, path := range testPaths { + request := &acl.AccessRequest{ + Method: "GET", + Path: path, + Scopes: []string{}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("✓ Wildcard match '%s': Allowed=%v, Reason=%s", path, decision.Allowed, decision.Reason) + } + }) + + t.Run("Wildcard_MultiLevel", func(t *testing.T) { + // Test wildcard with nested paths: GET /kb/* + testPaths := []string{ + "/kb/collections/test-123", + "/kb/documents/doc-456/content", + "/kb/search/query/results", + } + + for _, path := range testPaths { + request := &acl.AccessRequest{ + Method: "GET", + Path: path, + Scopes: []string{}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("✓ Wildcard multi-level '%s': Allowed=%v, Reason=%s", path, decision.Allowed, decision.Reason) + } + }) + + t.Run("Wildcard_DifferentScopes", func(t *testing.T) { + // Test wildcard with different scopes + testCases := []struct { + path string + scopes []string + desc string + }{ + {"/kb/collections", []string{"collections:read:all"}, "with read scope"}, + {"/kb/collections", []string{"collections:write:all"}, "with write scope"}, + {"/kb/documents", []string{"documents:read:all"}, "with documents scope"}, + } + + for _, tc := range testCases { + request := &acl.AccessRequest{ + Method: "GET", + Path: tc.path, + Scopes: tc.scopes, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("✓ Wildcard '%s' %s: Allowed=%v, Reason=%s", + tc.path, tc.desc, decision.Allowed, decision.Reason) + } + }) +} + +// TestScopeAtomic_ParameterPath tests parameter path matching +func TestScopeAtomic_ParameterPath(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean() + + manager, err := acl.LoadScopes() + assert.NoError(t, err) + assert.NotNil(t, manager) + + t.Run("Parameter_CollectionID", func(t *testing.T) { + // Test parameter matching: /kb/collections/:collectionID + testPaths := []string{ + "/kb/collections/abc123", + "/kb/collections/test-collection", + "/kb/collections/12345", + } + + for _, path := range testPaths { + request := &acl.AccessRequest{ + Method: "GET", + Path: path, + Scopes: []string{"collections:read:all"}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("✓ Parameter path '%s': Allowed=%v, Reason=%s", path, decision.Allowed, decision.Reason) + } + }) + + t.Run("Parameter_WithDifferentMethods", func(t *testing.T) { + // Test parameter path with different methods + testCases := []struct { + method string + path string + scopes []string + }{ + {"GET", "/kb/collections/test-123", []string{"collections:read:all"}}, + {"POST", "/kb/collections/test-123", []string{"collections:write:all"}}, + {"PUT", "/kb/collections/test-123", []string{"collections:write:all"}}, + {"DELETE", "/kb/collections/test-123", []string{"collections:delete:all"}}, + } + + for _, tc := range testCases { + request := &acl.AccessRequest{ + Method: tc.method, + Path: tc.path, + Scopes: tc.scopes, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("✓ %s parameter path: Allowed=%v, Reason=%s", + tc.method, decision.Allowed, decision.Reason) + } + }) +} + +// TestScopeAtomic_AliasExpansion tests scope alias expansion +func TestScopeAtomic_AliasExpansion(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean() + + manager, err := acl.LoadScopes() + assert.NoError(t, err) + assert.NotNil(t, manager) + + t.Run("Alias_KBRead", func(t *testing.T) { + // Test kb:read alias expansion + // According to alias.yml: kb:read -> collections:read:all, documents:read:all, etc. + request := &acl.AccessRequest{ + Method: "GET", + Path: "/kb/collections", + Scopes: []string{"kb:read"}, // Use alias instead of direct scope + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("✓ Alias 'kb:read' expansion: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason) + + // Test if alias works for different kb endpoints + endpoints := []string{ + "/kb/collections", + "/kb/documents", + "/kb/search", + } + + for _, endpoint := range endpoints { + req := &acl.AccessRequest{ + Method: "GET", + Path: endpoint, + Scopes: []string{"kb:read"}, + } + dec := manager.Check(req) + t.Logf("✓ Alias 'kb:read' on '%s': Allowed=%v, Reason=%s", + endpoint, dec.Allowed, dec.Reason) + } + }) + + t.Run("Alias_KBWrite", func(t *testing.T) { + // Test kb:write alias expansion + request := &acl.AccessRequest{ + Method: "POST", + Path: "/kb/collections", + Scopes: []string{"kb:write"}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("✓ Alias 'kb:write' expansion: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason) + }) + + t.Run("Alias_WithDirectScope", func(t *testing.T) { + // Test mixing alias and direct scopes + request := &acl.AccessRequest{ + Method: "GET", + Path: "/kb/collections", + Scopes: []string{"kb:read", "collections:read:all"}, // Mix alias and direct + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("✓ Mixed alias+direct scope: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason) + }) + + t.Run("Alias_JobRead", func(t *testing.T) { + // Test job:read alias + request := &acl.AccessRequest{ + Method: "GET", + Path: "/job/jobs", + Scopes: []string{"job:read"}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("✓ Alias 'job:read' expansion: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason) + }) +} + +// TestScopeAtomic_ScopeValidation tests scope validation logic +func TestScopeAtomic_ScopeValidation(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean() + + manager, err := acl.LoadScopes() + assert.NoError(t, err) + assert.NotNil(t, manager) + + t.Run("Scope_Required_Present", func(t *testing.T) { + // Test with required scope present + request := &acl.AccessRequest{ + Method: "POST", + Path: "/kb/collections", + Scopes: []string{"collections:write:all"}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("✓ Required scope present: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason) + if !decision.Allowed { + t.Logf(" Missing scopes: %v", decision.MissingScopes) + } + }) + + t.Run("Scope_Required_Missing", func(t *testing.T) { + // Test with required scope missing + request := &acl.AccessRequest{ + Method: "POST", + Path: "/kb/collections", + Scopes: []string{"collections:read:all"}, // Wrong scope (read instead of write) + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("✓ Required scope missing: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason) + if !decision.Allowed { + t.Logf(" Missing scopes: %v", decision.MissingScopes) + t.Logf(" User scopes: %v", decision.UserScopes) + } + }) + + t.Run("Scope_NoScopeRequired", func(t *testing.T) { + // Test endpoint that doesn't require scopes (allow policy) + request := &acl.AccessRequest{ + Method: "GET", + Path: "/kb/collections", + Scopes: []string{}, // No scopes + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("✓ No scope required endpoint: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason) + }) + + t.Run("Scope_ExtraScopes", func(t *testing.T) { + // Test with extra scopes beyond required + request := &acl.AccessRequest{ + Method: "GET", + Path: "/kb/collections", + Scopes: []string{ + "collections:read:all", + "collections:write:all", + "admin:all", + }, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("✓ Extra scopes present: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason) + }) +} + +// TestScopeAtomic_DefaultPolicy tests default policy behavior +func TestScopeAtomic_DefaultPolicy(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean() + + manager, err := acl.LoadScopes() + assert.NoError(t, err) + assert.NotNil(t, manager) + + t.Run("DefaultPolicy_UnmatchedPath", func(t *testing.T) { + // Test unmatched path (should use default policy) + request := &acl.AccessRequest{ + Method: "GET", + Path: "/unmatched/endpoint/path", + Scopes: []string{"any:scope"}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + assert.Contains(t, decision.Reason, "default policy") + t.Logf("✓ Unmatched path uses default: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason) + }) + + t.Run("DefaultPolicy_UnmatchedMethod", func(t *testing.T) { + // Test matched path but unmatched method + request := &acl.AccessRequest{ + Method: "PATCH", // Uncommon method + Path: "/kb/collections", + Scopes: []string{"collections:write:all"}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("✓ Unmatched method: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason) + }) +} + +// TestScopeAtomic_PublicEndpoints tests public endpoint behavior +func TestScopeAtomic_PublicEndpoints(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean() + + manager, err := acl.LoadScopes() + assert.NoError(t, err) + assert.NotNil(t, manager) + + t.Run("Public_NoScopeNeeded", func(t *testing.T) { + // Test public endpoints from scopes.yml + publicEndpoints := []struct { + method string + path string + }{ + {"GET", "/user/entry"}, + {"GET", "/user/entry/captcha"}, + {"POST", "/user/entry/verify"}, + } + + for _, ep := range publicEndpoints { + request := &acl.AccessRequest{ + Method: ep.method, + Path: ep.path, + Scopes: []string{}, // No scopes + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + assert.True(t, decision.Allowed, "Public endpoint should allow: %s %s", ep.method, ep.path) + assert.Equal(t, "public endpoint", decision.Reason) + t.Logf("✓ Public endpoint %s %s: Allowed=%v", ep.method, ep.path, decision.Allowed) + } + }) + + t.Run("Public_WithScopes", func(t *testing.T) { + // Test public endpoint with scopes (should still allow) + request := &acl.AccessRequest{ + Method: "GET", + Path: "/user/entry", + Scopes: []string{"user:read", "admin:all"}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + assert.True(t, decision.Allowed, "Public endpoint should allow even with scopes") + t.Logf("✓ Public endpoint with scopes: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason) + }) +} + +// TestScopeAtomic_ComplexScenarios tests complex real-world scenarios +func TestScopeAtomic_ComplexScenarios(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean() + + manager, err := acl.LoadScopes() + assert.NoError(t, err) + assert.NotNil(t, manager) + + t.Run("Complex_MultipleEndpointsSameScope", func(t *testing.T) { + // Test one scope allowing access to multiple endpoints + scope := "collections:read:all" + endpoints := []string{ + "/kb/collections", + "/kb/collections/test-123", + "/kb/collections/test-456/documents", + } + + for _, endpoint := range endpoints { + request := &acl.AccessRequest{ + Method: "GET", + Path: endpoint, + Scopes: []string{scope}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("✓ Scope '%s' on '%s': Allowed=%v, Reason=%s", + scope, endpoint, decision.Allowed, decision.Reason) + } + }) + + t.Run("Complex_ScopeInheritance", func(t *testing.T) { + // Test if write scope implies read access (based on config) + testCases := []struct { + path string + scopes []string + desc string + }{ + {"/kb/collections", []string{"collections:read:all"}, "read scope"}, + {"/kb/collections", []string{"collections:write:all"}, "write scope"}, + {"/kb/collections", []string{"kb:read"}, "kb:read alias"}, + {"/kb/collections", []string{"kb:write"}, "kb:write alias"}, + } + + for _, tc := range testCases { + request := &acl.AccessRequest{ + Method: "GET", + Path: tc.path, + Scopes: tc.scopes, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("✓ %s: Allowed=%v, Reason=%s", tc.desc, decision.Allowed, decision.Reason) + } + }) +} diff --git a/openapi/tests/oauth/acl/scope_test.go b/openapi/tests/oauth/acl/scope_test.go new file mode 100644 index 00000000..c54351f3 --- /dev/null +++ b/openapi/tests/oauth/acl/scope_test.go @@ -0,0 +1,368 @@ +package acl_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/openapi/oauth/acl" + "github.com/yaoapp/yao/openapi/tests/testutils" +) + +// TestLoadScopes tests loading scope configuration +func TestLoadScopes(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean() + + t.Run("LoadWithMissingDirectory", func(t *testing.T) { + // Test loading when scopes directory doesn't exist + // Should return a valid manager with default deny policy + manager, err := acl.LoadScopes() + assert.NoError(t, err, "Should succeed even when scopes directory is missing") + assert.NotNil(t, manager) + + t.Log("Successfully created scope manager with missing scopes directory") + }) +} + +// TestScopeManagerCheck tests the Check method +func TestScopeManagerCheck(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean() + + // Create a basic scope manager for testing + manager, err := acl.LoadScopes() + assert.NoError(t, err) + assert.NotNil(t, manager) + + t.Run("CheckWithNoScopes", func(t *testing.T) { + // Test request with no scopes to unmatched endpoint + request := &acl.AccessRequest{ + Method: "GET", + Path: "/unmatched/endpoint", + Scopes: []string{}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + + // Without configuration or no matching endpoint, should use default policy (deny) + assert.False(t, decision.Allowed) + assert.Contains(t, decision.Reason, "default policy") + + t.Logf("Decision: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason) + }) + + t.Run("CheckPublicEndpoint", func(t *testing.T) { + // Test public endpoint (from scopes.yml: GET /user/entry) + request := &acl.AccessRequest{ + Method: "GET", + Path: "/user/entry", + Scopes: []string{}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + + // Public endpoints should be allowed without scopes + if decision.Allowed { + assert.Equal(t, "public endpoint", decision.Reason) + } + + t.Logf("Public endpoint decision: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason) + }) + + t.Run("CheckWithScopes", func(t *testing.T) { + // Test request with KB read scope (from alias: kb:read -> collections:read:all) + request := &acl.AccessRequest{ + Method: "GET", + Path: "/kb/collections", + Scopes: []string{"collections:read:all"}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + assert.NotEmpty(t, decision.Reason) + + // Should record user scopes in decision + assert.Equal(t, request.Scopes, decision.UserScopes) + + t.Logf("KB read decision: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason) + }) + + t.Run("CheckKBWriteWithoutScope", func(t *testing.T) { + // Test KB write operation without required scope + request := &acl.AccessRequest{ + Method: "POST", + Path: "/kb/collections", + Scopes: []string{"collections:read:all"}, // Only read scope + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + + // POST to KB should be denied without write scope + if !decision.Allowed { + t.Logf("Correctly denied KB write without scope: %s", decision.Reason) + } + + t.Logf("KB write without scope: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason) + }) + + t.Run("CheckDifferentMethods", func(t *testing.T) { + // Test different HTTP methods on KB collections + testCases := []struct { + method string + scopes []string + }{ + {"GET", []string{"collections:read:all"}}, + {"POST", []string{"collections:write:all"}}, + {"PUT", []string{"collections:write:all"}}, + {"DELETE", []string{"collections:delete:all"}}, + } + + for _, tc := range testCases { + request := &acl.AccessRequest{ + Method: tc.method, + Path: "/kb/collections", + Scopes: tc.scopes, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("Method %s with scopes %v: Allowed=%v, Reason=%s", + tc.method, tc.scopes, decision.Allowed, decision.Reason) + } + }) + + t.Run("CheckWildcardPath", func(t *testing.T) { + // Test wildcard path matching (from scopes.yml: GET /kb/* allow) + testCases := []struct { + path string + scopes []string + }{ + {"/kb/collections", []string{"collections:read:all"}}, + {"/kb/collections/test-123", []string{"collections:read:all"}}, + {"/kb/documents/doc-456", []string{"documents:read:all"}}, + {"/kb/search", []string{"search:read:all"}}, + } + + for _, tc := range testCases { + request := &acl.AccessRequest{ + Method: "GET", + Path: tc.path, + Scopes: tc.scopes, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("Path %s with scopes %v: Allowed=%v, Reason=%s", + tc.path, tc.scopes, decision.Allowed, decision.Reason) + } + }) +} + +// TestAccessRequest tests the AccessRequest structure +func TestAccessRequest(t *testing.T) { + t.Run("CreateAccessRequest", func(t *testing.T) { + // Test creating an access request + request := &acl.AccessRequest{ + Method: "POST", + Path: "/api/collections", + Scopes: []string{"collections:create", "collections:read"}, + } + + assert.Equal(t, "POST", request.Method) + assert.Equal(t, "/api/collections", request.Path) + assert.Len(t, request.Scopes, 2) + assert.Contains(t, request.Scopes, "collections:create") + assert.Contains(t, request.Scopes, "collections:read") + + t.Log("AccessRequest structure validated successfully") + }) + + t.Run("AccessRequestWithEmptyScopes", func(t *testing.T) { + // Test request with empty scopes + request := &acl.AccessRequest{ + Method: "GET", + Path: "/public/info", + Scopes: []string{}, + } + + assert.Empty(t, request.Scopes) + assert.NotNil(t, request.Scopes) // Should be initialized, not nil + + t.Log("Empty scopes handled correctly") + }) +} + +// TestAccessDecision tests the AccessDecision structure +func TestAccessDecision(t *testing.T) { + t.Run("CreateAccessDecision", func(t *testing.T) { + // Test creating an access decision + decision := &acl.AccessDecision{ + Allowed: true, + Reason: "scope matched", + RequiredScopes: []string{"read:data"}, + UserScopes: []string{"read:data", "write:data"}, + MissingScopes: []string{}, + } + + assert.True(t, decision.Allowed) + assert.Equal(t, "scope matched", decision.Reason) + assert.Len(t, decision.RequiredScopes, 1) + assert.Len(t, decision.UserScopes, 2) + assert.Empty(t, decision.MissingScopes) + + t.Log("AccessDecision structure validated successfully") + }) + + t.Run("AccessDecisionDenied", func(t *testing.T) { + // Test denied access decision + decision := &acl.AccessDecision{ + Allowed: false, + Reason: "missing required scopes", + RequiredScopes: []string{"admin:write", "admin:delete"}, + UserScopes: []string{"admin:read"}, + MissingScopes: []string{"admin:write", "admin:delete"}, + } + + assert.False(t, decision.Allowed) + assert.Contains(t, decision.Reason, "missing") + assert.Len(t, decision.MissingScopes, 2) + + t.Log("Denied decision structure validated successfully") + }) +} + +// TestEndpointPolicy tests endpoint policy constants +func TestEndpointPolicy(t *testing.T) { + t.Run("PolicyConstants", func(t *testing.T) { + // Verify policy constants are distinct + assert.NotEqual(t, acl.PolicyDeny, acl.PolicyAllow) + assert.NotEqual(t, acl.PolicyAllow, acl.PolicyRequireScopes) + assert.NotEqual(t, acl.PolicyDeny, acl.PolicyRequireScopes) + + t.Log("Endpoint policy constants are distinct") + }) +} + +// TestScopeExpansion tests scope expansion with aliases +func TestScopeExpansion(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean() + + manager, err := acl.LoadScopes() + assert.NoError(t, err) + assert.NotNil(t, manager) + + t.Run("DirectScopeNoAlias", func(t *testing.T) { + // Test with direct scopes (no aliases) + request := &acl.AccessRequest{ + Method: "GET", + Path: "/kb/collections", + Scopes: []string{"collections:read:all", "documents:read:all"}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + + // User scopes should be recorded + assert.Equal(t, request.Scopes, decision.UserScopes) + + t.Log("Direct scopes processed correctly") + }) + + t.Run("AliasScopeExpansion", func(t *testing.T) { + // Test alias expansion (kb:read -> collections:read:all, documents:read:all, etc.) + request := &acl.AccessRequest{ + Method: "GET", + Path: "/kb/collections", + Scopes: []string{"kb:read"}, // Alias that should expand + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + + t.Logf("Alias expansion test: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason) + }) +} + +// TestPathMatching tests various path matching scenarios +func TestPathMatching(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean() + + manager, err := acl.LoadScopes() + assert.NoError(t, err) + + t.Run("ExactPathMatch", func(t *testing.T) { + // Test exact path matching (GET /kb/collections) + request := &acl.AccessRequest{ + Method: "GET", + Path: "/kb/collections", + Scopes: []string{"collections:read:all"}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("Exact path decision: %v - %s", decision.Allowed, decision.Reason) + }) + + t.Run("ParameterPath", func(t *testing.T) { + // Test path with parameters (GET /kb/collections/:collectionID) + request := &acl.AccessRequest{ + Method: "GET", + Path: "/kb/collections/test-collection-123", + Scopes: []string{"collections:read:all"}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + t.Logf("Parameter path decision: %v - %s", decision.Allowed, decision.Reason) + }) + + t.Run("WildcardPath", func(t *testing.T) { + // Test wildcard path matching (GET /kb/* allow from scopes.yml) + request := &acl.AccessRequest{ + Method: "GET", + Path: "/kb/anything/nested/path", + Scopes: []string{}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + + // Should be allowed by wildcard rule in scopes.yml + if decision.Allowed { + t.Logf("Wildcard rule correctly allowed access: %s", decision.Reason) + } + + t.Logf("Wildcard path decision: %v - %s", decision.Allowed, decision.Reason) + }) +} + +// TestDefaultPolicy tests default policy behavior +func TestDefaultPolicy(t *testing.T) { + testutils.Prepare(t) + defer testutils.Clean() + + manager, err := acl.LoadScopes() + assert.NoError(t, err) + + t.Run("UnmatchedEndpoint", func(t *testing.T) { + // Test unmatched endpoint falls back to default policy + request := &acl.AccessRequest{ + Method: "GET", + Path: "/unregistered/endpoint", + Scopes: []string{"some:scope"}, + } + + decision := manager.Check(request) + assert.NotNil(t, decision) + + // Should mention default policy in reason + assert.Contains(t, decision.Reason, "default policy") + + t.Logf("Default policy applied: %v - %s", decision.Allowed, decision.Reason) + }) +}