- 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.
547 lines
13 KiB
Go
547 lines
13 KiB
Go
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
|
|
}
|