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 }