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.
This commit is contained in:
parent
667d40b5a1
commit
0e260ffb6a
11 changed files with 2373 additions and 77 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -47,3 +47,4 @@ share/const.go.bak
|
|||
share/const.goe
|
||||
.cursor
|
||||
openapi/*.md
|
||||
openapi/oauth/acl/*.md
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, " ")
|
||||
}
|
||||
|
|
|
|||
547
openapi/oauth/acl/scope.go
Normal file
547
openapi/oauth/acl/scope.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
86
openapi/oauth/authorized/utils.go
Normal file
86
openapi/oauth/authorized/utils.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
149
openapi/tests/oauth/acl/acl_test.go
Normal file
149
openapi/tests/oauth/acl/acl_test.go
Normal file
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
428
openapi/tests/oauth/acl/enforce_test.go
Normal file
428
openapi/tests/oauth/acl/enforce_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
511
openapi/tests/oauth/acl/scope_atomic_test.go
Normal file
511
openapi/tests/oauth/acl/scope_atomic_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
368
openapi/tests/oauth/acl/scope_test.go
Normal file
368
openapi/tests/oauth/acl/scope_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue