diff --git a/openapi/oauth/acl/acl.go b/openapi/oauth/acl/acl.go index dc2a40b1..a3c7a367 100644 --- a/openapi/oauth/acl/acl.go +++ b/openapi/oauth/acl/acl.go @@ -33,6 +33,13 @@ func New(config *Config) (Enforcer, error) { // Init Role Manager role.RoleManager = role.NewManager(config.Cache, config.Provider) log.Info("[ACL] Role manager loaded successfully") + + // Log PathPrefix configuration + if config.PathPrefix != "" { + log.Info("[ACL] Path prefix configured: %s (will be stripped from request paths)", config.PathPrefix) + } else { + log.Info("[ACL] No path prefix configured") + } } return acl, nil diff --git a/openapi/oauth/acl/enforce.go b/openapi/oauth/acl/enforce.go index 5d967c18..061540e0 100644 --- a/openapi/oauth/acl/enforce.go +++ b/openapi/oauth/acl/enforce.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/gin-gonic/gin" + "github.com/yaoapp/kun/log" "github.com/yaoapp/yao/openapi/oauth/acl/role" "github.com/yaoapp/yao/openapi/oauth/authorized" "github.com/yaoapp/yao/openapi/oauth/types" @@ -15,30 +16,44 @@ import ( func (acl *ACL) Enforce(c *gin.Context) (bool, error) { // If ACL is not enabled, allow access if !acl.Enabled() { + log.Trace("[ACL] ACL is disabled, allowing access") return true, nil } // If scope manager not loaded, deny access if acl.Scope == nil { + log.Trace("[ACL] Scope manager not loaded, denying access") return false, nil } // Get authorized info from context (set by OAuth guard middleware) authInfo := authorized.GetInfo(c) + // Get request path and strip PathPrefix if configured + requestPath := c.Request.URL.Path + if acl.Config.PathPrefix != "" && strings.HasPrefix(requestPath, acl.Config.PathPrefix) { + requestPath = strings.TrimPrefix(requestPath, acl.Config.PathPrefix) + log.Trace("[ACL] Stripped path prefix %s from request path, new path: %s", acl.Config.PathPrefix, requestPath) + } + // Build access request request := &AccessRequest{ Method: c.Request.Method, - Path: c.Request.URL.Path, + Path: requestPath, } + log.Trace("[ACL] Starting enforcement chain: method=%s, path=%s (original=%s), client_id=%s, user_id=%s, team_id=%s, scope=%s", + request.Method, request.Path, c.Request.URL.Path, authInfo.ClientID, authInfo.UserID, authInfo.TeamID, authInfo.Scope) + // Execute enforcement chain and collect endpoint info allowed, endpointInfo, err := acl.enforce(c.Request.Context(), authInfo, request) if err != nil { + log.Trace("[ACL] Enforcement failed with error: %v", err) return false, err } if !allowed { + log.Trace("[ACL] Access denied by enforcement chain") return false, nil } @@ -46,6 +61,9 @@ func (acl *ACL) Enforce(c *gin.Context) (bool, error) { if endpointInfo != nil { constraints := endpointInfo.GetConstraints() authorized.UpdateConstraints(c, constraints) + log.Trace("[ACL] Access granted, constraints applied: %+v", constraints) + } else { + log.Trace("[ACL] Access granted, no constraints") } return true, nil @@ -62,6 +80,7 @@ func (acl *ACL) enforce(ctx context.Context, authInfo *types.AuthorizedInfo, req return false, nil, err } if !allowed { + log.Trace("[ACL] Enforcement chain terminated: client permission check failed") return false, nil, &Error{ Type: ErrorTypePermissionDenied, Message: "access denied: client permission check failed", @@ -77,107 +96,132 @@ func (acl *ACL) enforce(ctx context.Context, authInfo *types.AuthorizedInfo, req return false, nil, err } if !allowed { + log.Trace("[ACL] Enforcement chain terminated: token scope check failed") return false, nil, &Error{ Type: ErrorTypePermissionDenied, Message: "access denied: token scope check failed", Stage: EnforcementStageScope, } } - // Collect endpoint info - if matchedEndpoint == nil && endpoint != nil { + // Update endpoint info (later stages override earlier ones) + if endpoint != nil { matchedEndpoint = endpoint + log.Trace("[ACL] Step 2: Updated matched endpoint from token scope check") } + } else { + log.Trace("[ACL] Step 2: Token scope is empty, skipping scope check") } // Step 3: Check team or user permissions // 3.1: If TeamID is present, this is a team login if authInfo.TeamID != "" { + log.Trace("[ACL] Detected team login (team_id=%s), checking team and member permissions", authInfo.TeamID) + // 3.1.1: Check team permissions - MUST pass allowed, endpoint, err := acl.enforceTeam(ctx, authInfo, request) if err != nil { return false, nil, err } if !allowed { + log.Trace("[ACL] Enforcement chain terminated: team permission check failed") return false, nil, &Error{ Type: ErrorTypePermissionDenied, Message: "access denied: team permission check failed", Stage: EnforcementStageTeam, } } - // Collect endpoint info - if matchedEndpoint == nil && endpoint != nil { + // Update endpoint info (later stages override earlier ones) + if endpoint != nil { matchedEndpoint = endpoint + log.Trace("[ACL] Step 3.1: Updated matched endpoint from team check") } // 3.1.2: Check member permissions (user's role in the team) - MUST pass + // This is the final stage for team login, its constraints take precedence allowed, endpoint, err = acl.enforceMember(ctx, authInfo, request) if err != nil { return false, nil, err } if !allowed { + log.Trace("[ACL] Enforcement chain terminated: member permission check failed") return false, nil, &Error{ Type: ErrorTypePermissionDenied, Message: "access denied: member permission check failed", Stage: EnforcementStageMember, } } - // Collect endpoint info - if matchedEndpoint == nil && endpoint != nil { + // Update endpoint info (FINAL stage for team login - takes precedence) + if endpoint != nil { matchedEndpoint = endpoint + log.Trace("[ACL] Step 3.1.2: Updated matched endpoint from member check (FINAL)") } // All checks passed for team login + log.Trace("[ACL] Enforcement chain completed successfully: all team login checks passed") return true, matchedEndpoint, nil } // 3.2: This is a user login (no TeamID) if authInfo.UserID != "" { + log.Trace("[ACL] Detected user login (user_id=%s), checking user permissions", authInfo.UserID) + + // This is the final stage for user login, its constraints take precedence allowed, endpoint, err := acl.enforceUser(ctx, authInfo, request) if err != nil { return false, nil, err } if !allowed { + log.Trace("[ACL] Enforcement chain terminated: user permission check failed") return false, nil, &Error{ Type: ErrorTypePermissionDenied, Message: "access denied: user permission check failed", Stage: EnforcementStageUser, } } - // Collect endpoint info - if matchedEndpoint == nil && endpoint != nil { + // Update endpoint info (FINAL stage for user login - takes precedence) + if endpoint != nil { matchedEndpoint = endpoint + log.Trace("[ACL] Step 3.2: Updated matched endpoint from user check (FINAL)") } // All checks passed for user login + log.Trace("[ACL] Enforcement chain completed successfully: all user login checks passed") return true, matchedEndpoint, nil } // All checks passed (pure API call - only client check required) + log.Trace("[ACL] Enforcement chain completed successfully: pure API call (client only)") return true, matchedEndpoint, nil } // enforceClient checks client permissions independently // Returns: (allowed bool, endpointInfo *EndpointInfo, error) func (acl *ACL) enforceClient(ctx context.Context, authInfo *types.AuthorizedInfo, request *AccessRequest) (bool, *EndpointInfo, error) { + log.Trace("[ACL] Step 1: enforceClient - Starting client permission check for client_id=%s", authInfo.ClientID) + // Get client role clientRole, err := role.RoleManager.GetClientRole(ctx, authInfo.ClientID) if err != nil { + log.Trace("[ACL] Step 1: enforceClient - Failed to get client role: %v", err) return false, nil, &Error{ Type: ErrorTypeInternal, Message: fmt.Sprintf("failed to get client role [client_id=%s]: %v", authInfo.ClientID, err), Stage: EnforcementStageClient, } } + log.Trace("[ACL] Step 1: enforceClient - Retrieved client role: %s", clientRole) // Get scopes for client role allowedScopes, restrictedScopes, err := role.RoleManager.GetScopes(ctx, clientRole) if err != nil { + log.Trace("[ACL] Step 1: enforceClient - Failed to get scopes for role %s: %v", clientRole, err) return false, nil, &Error{ Type: ErrorTypeInternal, Message: fmt.Sprintf("failed to get client scopes [client_id=%s, role=%s]: %v", authInfo.ClientID, clientRole, err), Stage: EnforcementStageClient, } } + log.Trace("[ACL] Step 1: enforceClient - Retrieved scopes: allowed=%v, restricted=%v", allowedScopes, restrictedScopes) // Step 1: Check if allowed scopes grant access allowedRequest := &AccessRequest{ @@ -187,7 +231,11 @@ func (acl *ACL) enforceClient(ctx context.Context, authInfo *types.AuthorizedInf } decision := acl.Scope.Check(allowedRequest) + log.Trace("[ACL] Step 1: enforceClient - Allowed scopes check: allowed=%v, reason=%s, required_scopes=%v, missing_scopes=%v, matched_pattern=%s", + decision.Allowed, decision.Reason, decision.RequiredScopes, decision.MissingScopes, decision.MatchedPattern) + if !decision.Allowed { + log.Trace("[ACL] Step 1: enforceClient - Access denied by allowed scopes check") return false, nil, &Error{ Type: ErrorTypePermissionDenied, Message: decision.Reason, @@ -211,7 +259,11 @@ func (acl *ACL) enforceClient(ctx context.Context, authInfo *types.AuthorizedInf } restrictDecision := acl.Scope.CheckRestricted(restrictedRequest) + log.Trace("[ACL] Step 1: enforceClient - Restricted scopes check: allowed=%v, reason=%s, matched_pattern=%s", + restrictDecision.Allowed, restrictDecision.Reason, restrictDecision.MatchedPattern) + if !restrictDecision.Allowed { + log.Trace("[ACL] Step 1: enforceClient - Access denied by restricted scopes") return false, nil, &Error{ Type: ErrorTypePermissionDenied, Message: "access denied by restriction: " + restrictDecision.Reason, @@ -227,15 +279,32 @@ func (acl *ACL) enforceClient(ctx context.Context, authInfo *types.AuthorizedInf } } - // Return matched endpoint info (contains OwnerOnly, TeamOnly, and future constraints) - return true, decision.MatchedEndpoint, nil + // Return matched endpoint info with scope-specific constraints + var endpointInfo *EndpointInfo + if decision.MatchedEndpoint != nil { + if decision.MatchedScope != "" { + // Get constraints for the specific matched scope + endpointInfo = acl.Scope.GetScopeConstraints(decision.MatchedScope, request.Method, decision.MatchedPattern) + log.Trace("[ACL] Step 1: enforceClient - Success, matched scope '%s': %+v", decision.MatchedScope, endpointInfo) + } else { + // No specific scope matched (e.g., public endpoint) + endpointInfo = decision.MatchedEndpoint + log.Trace("[ACL] Step 1: enforceClient - Success, matched endpoint: %+v", endpointInfo) + } + } else { + log.Trace("[ACL] Step 1: enforceClient - Success, no endpoint info") + } + return true, endpointInfo, nil } // enforceScope checks the explicit scopes from token independently // Returns: (allowed bool, endpointInfo *EndpointInfo, error) func (acl *ACL) enforceScope(_ context.Context, authInfo *types.AuthorizedInfo, request *AccessRequest) (bool, *EndpointInfo, error) { + log.Trace("[ACL] Step 2: enforceScope - Starting token scope check") + // Parse scopes from token (space-separated) if authInfo.Scope == "" { + log.Trace("[ACL] Step 2: enforceScope - Token scope is empty, skipping") return false, nil, nil } @@ -250,6 +319,7 @@ func (acl *ACL) enforceScope(_ context.Context, authInfo *types.AuthorizedInfo, scopes = append(scopes, scope) } } + log.Trace("[ACL] Step 2: enforceScope - Parsed token scopes: %v", scopes) // Build request with token scopes and check checkRequest := &AccessRequest{ @@ -259,7 +329,11 @@ func (acl *ACL) enforceScope(_ context.Context, authInfo *types.AuthorizedInfo, } decision := acl.Scope.Check(checkRequest) + log.Trace("[ACL] Step 2: enforceScope - Token scope check: allowed=%v, reason=%s, required_scopes=%v, missing_scopes=%v, matched_pattern=%s", + decision.Allowed, decision.Reason, decision.RequiredScopes, decision.MissingScopes, decision.MatchedPattern) + if !decision.Allowed { + log.Trace("[ACL] Step 2: enforceScope - Access denied by token scope") return false, nil, &Error{ Type: ErrorTypePermissionDenied, Message: decision.Reason, @@ -275,32 +349,52 @@ func (acl *ACL) enforceScope(_ context.Context, authInfo *types.AuthorizedInfo, } } - // Return matched endpoint info - return true, decision.MatchedEndpoint, nil + // Return matched endpoint info with scope-specific constraints + var endpointInfo *EndpointInfo + if decision.MatchedEndpoint != nil { + if decision.MatchedScope != "" { + // Get constraints for the specific matched scope + endpointInfo = acl.Scope.GetScopeConstraints(decision.MatchedScope, request.Method, decision.MatchedPattern) + log.Trace("[ACL] Step 2: enforceScope - Success, matched scope '%s': %+v", decision.MatchedScope, endpointInfo) + } else { + // No specific scope matched (e.g., public endpoint) + endpointInfo = decision.MatchedEndpoint + log.Trace("[ACL] Step 2: enforceScope - Success, matched endpoint: %+v", endpointInfo) + } + } else { + log.Trace("[ACL] Step 2: enforceScope - Success, no endpoint info") + } + return true, endpointInfo, nil } // enforceUser checks user permissions independently // Returns: (allowed bool, endpointInfo *EndpointInfo, error) func (acl *ACL) enforceUser(ctx context.Context, authInfo *types.AuthorizedInfo, request *AccessRequest) (bool, *EndpointInfo, error) { + log.Trace("[ACL] Step 3.2: enforceUser - Starting user permission check for user_id=%s", authInfo.UserID) + // Get user role userRole, err := role.RoleManager.GetUserRole(ctx, authInfo.UserID) if err != nil { + log.Trace("[ACL] Step 3.2: enforceUser - Failed to get user role: %v", err) return false, nil, &Error{ Type: ErrorTypeInternal, Message: fmt.Sprintf("failed to get user role [user_id=%s]: %v", authInfo.UserID, err), Stage: EnforcementStageUser, } } + log.Trace("[ACL] Step 3.2: enforceUser - Retrieved user role: %s", userRole) // Get scopes for user role allowedScopes, restrictedScopes, err := role.RoleManager.GetScopes(ctx, userRole) if err != nil { + log.Trace("[ACL] Step 3.2: enforceUser - Failed to get scopes for role %s: %v", userRole, err) return false, nil, &Error{ Type: ErrorTypeInternal, Message: fmt.Sprintf("failed to get user scopes [user_id=%s, role=%s]: %v", authInfo.UserID, userRole, err), Stage: EnforcementStageUser, } } + log.Trace("[ACL] Step 3.2: enforceUser - Retrieved scopes: allowed=%v, restricted=%v", allowedScopes, restrictedScopes) // Step 1: Check if allowed scopes grant access allowedRequest := &AccessRequest{ @@ -310,7 +404,11 @@ func (acl *ACL) enforceUser(ctx context.Context, authInfo *types.AuthorizedInfo, } decision := acl.Scope.Check(allowedRequest) + log.Trace("[ACL] Step 3.2: enforceUser - Allowed scopes check: allowed=%v, reason=%s, required_scopes=%v, missing_scopes=%v, matched_pattern=%s", + decision.Allowed, decision.Reason, decision.RequiredScopes, decision.MissingScopes, decision.MatchedPattern) + if !decision.Allowed { + log.Trace("[ACL] Step 3.2: enforceUser - Access denied by allowed scopes check") return false, nil, &Error{ Type: ErrorTypePermissionDenied, Message: decision.Reason, @@ -334,7 +432,11 @@ func (acl *ACL) enforceUser(ctx context.Context, authInfo *types.AuthorizedInfo, } restrictDecision := acl.Scope.CheckRestricted(restrictedRequest) + log.Trace("[ACL] Step 3.2: enforceUser - Restricted scopes check: allowed=%v, reason=%s, matched_pattern=%s", + restrictDecision.Allowed, restrictDecision.Reason, restrictDecision.MatchedPattern) + if !restrictDecision.Allowed { + log.Trace("[ACL] Step 3.2: enforceUser - Access denied by restricted scopes") return false, nil, &Error{ Type: ErrorTypePermissionDenied, Message: "access denied by restriction: " + restrictDecision.Reason, @@ -350,32 +452,52 @@ func (acl *ACL) enforceUser(ctx context.Context, authInfo *types.AuthorizedInfo, } } - // Return matched endpoint info - return true, decision.MatchedEndpoint, nil + // Return matched endpoint info with scope-specific constraints + var endpointInfo *EndpointInfo + if decision.MatchedEndpoint != nil { + if decision.MatchedScope != "" { + // Get constraints for the specific matched scope + endpointInfo = acl.Scope.GetScopeConstraints(decision.MatchedScope, request.Method, decision.MatchedPattern) + log.Trace("[ACL] Step 3.2: enforceUser - Success, matched scope '%s': %+v", decision.MatchedScope, endpointInfo) + } else { + // No specific scope matched (e.g., public endpoint) + endpointInfo = decision.MatchedEndpoint + log.Trace("[ACL] Step 3.2: enforceUser - Success, matched endpoint: %+v", endpointInfo) + } + } else { + log.Trace("[ACL] Step 3.2: enforceUser - Success, no endpoint info") + } + return true, endpointInfo, nil } // enforceTeam checks team permissions independently // Returns: (allowed bool, endpointInfo *EndpointInfo, error) func (acl *ACL) enforceTeam(ctx context.Context, authInfo *types.AuthorizedInfo, request *AccessRequest) (bool, *EndpointInfo, error) { + log.Trace("[ACL] Step 3.1: enforceTeam - Starting team permission check for team_id=%s", authInfo.TeamID) + // Get team role teamRole, err := role.RoleManager.GetTeamRole(ctx, authInfo.TeamID) if err != nil { + log.Trace("[ACL] Step 3.1: enforceTeam - Failed to get team role: %v", err) return false, nil, &Error{ Type: ErrorTypeInternal, Message: fmt.Sprintf("failed to get team role [team_id=%s]: %v", authInfo.TeamID, err), Stage: EnforcementStageTeam, } } + log.Trace("[ACL] Step 3.1: enforceTeam - Retrieved team role: %s", teamRole) // Get scopes for team role allowedScopes, restrictedScopes, err := role.RoleManager.GetScopes(ctx, teamRole) if err != nil { + log.Trace("[ACL] Step 3.1: enforceTeam - Failed to get scopes for role %s: %v", teamRole, err) return false, nil, &Error{ Type: ErrorTypeInternal, Message: fmt.Sprintf("failed to get team scopes [team_id=%s, role=%s]: %v", authInfo.TeamID, teamRole, err), Stage: EnforcementStageTeam, } } + log.Trace("[ACL] Step 3.1: enforceTeam - Retrieved scopes: allowed=%v, restricted=%v", allowedScopes, restrictedScopes) // Step 1: Check if allowed scopes grant access allowedRequest := &AccessRequest{ @@ -385,7 +507,11 @@ func (acl *ACL) enforceTeam(ctx context.Context, authInfo *types.AuthorizedInfo, } decision := acl.Scope.Check(allowedRequest) + log.Trace("[ACL] Step 3.1: enforceTeam - Allowed scopes check: allowed=%v, reason=%s, required_scopes=%v, missing_scopes=%v, matched_pattern=%s", + decision.Allowed, decision.Reason, decision.RequiredScopes, decision.MissingScopes, decision.MatchedPattern) + if !decision.Allowed { + log.Trace("[ACL] Step 3.1: enforceTeam - Access denied by allowed scopes check") return false, nil, &Error{ Type: ErrorTypePermissionDenied, Message: decision.Reason, @@ -410,7 +536,11 @@ func (acl *ACL) enforceTeam(ctx context.Context, authInfo *types.AuthorizedInfo, } restrictDecision := acl.Scope.CheckRestricted(restrictedRequest) + log.Trace("[ACL] Step 3.1: enforceTeam - Restricted scopes check: allowed=%v, reason=%s, matched_pattern=%s", + restrictDecision.Allowed, restrictDecision.Reason, restrictDecision.MatchedPattern) + if !restrictDecision.Allowed { + log.Trace("[ACL] Step 3.1: enforceTeam - Access denied by restricted scopes") return false, nil, &Error{ Type: ErrorTypePermissionDenied, Message: "access denied by restriction: " + restrictDecision.Reason, @@ -427,32 +557,52 @@ func (acl *ACL) enforceTeam(ctx context.Context, authInfo *types.AuthorizedInfo, } } - // Return matched endpoint info - return true, decision.MatchedEndpoint, nil + // Return matched endpoint info with scope-specific constraints + var endpointInfo *EndpointInfo + if decision.MatchedEndpoint != nil { + if decision.MatchedScope != "" { + // Get constraints for the specific matched scope + endpointInfo = acl.Scope.GetScopeConstraints(decision.MatchedScope, request.Method, decision.MatchedPattern) + log.Trace("[ACL] Step 3.1: enforceTeam - Success, matched scope '%s': %+v", decision.MatchedScope, endpointInfo) + } else { + // No specific scope matched (e.g., public endpoint) + endpointInfo = decision.MatchedEndpoint + log.Trace("[ACL] Step 3.1: enforceTeam - Success, matched endpoint: %+v", endpointInfo) + } + } else { + log.Trace("[ACL] Step 3.1: enforceTeam - Success, no endpoint info") + } + return true, endpointInfo, nil } // enforceMember checks member permissions independently // Returns: (allowed bool, endpointInfo *EndpointInfo, error) func (acl *ACL) enforceMember(ctx context.Context, authInfo *types.AuthorizedInfo, request *AccessRequest) (bool, *EndpointInfo, error) { + log.Trace("[ACL] Step 3.1.2: enforceMember - Starting member permission check for team_id=%s, user_id=%s", authInfo.TeamID, authInfo.UserID) + // Get member role (user's role in the team) memberRole, err := role.RoleManager.GetMemberRole(ctx, authInfo.TeamID, authInfo.UserID) if err != nil { + log.Trace("[ACL] Step 3.1.2: enforceMember - Failed to get member role: %v", err) return false, nil, &Error{ Type: ErrorTypeInternal, Message: fmt.Sprintf("failed to get member role [team_id=%s, user_id=%s]: %v", authInfo.TeamID, authInfo.UserID, err), Stage: EnforcementStageMember, } } + log.Trace("[ACL] Step 3.1.2: enforceMember - Retrieved member role: %s", memberRole) // Get scopes for member role allowedScopes, restrictedScopes, err := role.RoleManager.GetScopes(ctx, memberRole) if err != nil { + log.Trace("[ACL] Step 3.1.2: enforceMember - Failed to get scopes for role %s: %v", memberRole, err) return false, nil, &Error{ Type: ErrorTypeInternal, Message: fmt.Sprintf("failed to get member scopes [team_id=%s, user_id=%s, role=%s]: %v", authInfo.TeamID, authInfo.UserID, memberRole, err), Stage: EnforcementStageMember, } } + log.Trace("[ACL] Step 3.1.2: enforceMember - Retrieved scopes: allowed=%v, restricted=%v", allowedScopes, restrictedScopes) // Step 1: Check if allowed scopes grant access allowedRequest := &AccessRequest{ @@ -462,7 +612,11 @@ func (acl *ACL) enforceMember(ctx context.Context, authInfo *types.AuthorizedInf } decision := acl.Scope.Check(allowedRequest) + log.Trace("[ACL] Step 3.1.2: enforceMember - Allowed scopes check: allowed=%v, reason=%s, required_scopes=%v, missing_scopes=%v, matched_pattern=%s", + decision.Allowed, decision.Reason, decision.RequiredScopes, decision.MissingScopes, decision.MatchedPattern) + if !decision.Allowed { + log.Trace("[ACL] Step 3.1.2: enforceMember - Access denied by allowed scopes check") return false, nil, &Error{ Type: ErrorTypePermissionDenied, Message: decision.Reason, @@ -487,7 +641,11 @@ func (acl *ACL) enforceMember(ctx context.Context, authInfo *types.AuthorizedInf } restrictDecision := acl.Scope.CheckRestricted(restrictedRequest) + log.Trace("[ACL] Step 3.1.2: enforceMember - Restricted scopes check: allowed=%v, reason=%s, matched_pattern=%s", + restrictDecision.Allowed, restrictDecision.Reason, restrictDecision.MatchedPattern) + if !restrictDecision.Allowed { + log.Trace("[ACL] Step 3.1.2: enforceMember - Access denied by restricted scopes") return false, nil, &Error{ Type: ErrorTypePermissionDenied, Message: "access denied by restriction: " + restrictDecision.Reason, @@ -504,6 +662,20 @@ func (acl *ACL) enforceMember(ctx context.Context, authInfo *types.AuthorizedInf } } - // Return matched endpoint info - return true, decision.MatchedEndpoint, nil + // Return matched endpoint info with scope-specific constraints + var endpointInfo *EndpointInfo + if decision.MatchedEndpoint != nil { + if decision.MatchedScope != "" { + // Get constraints for the specific matched scope + endpointInfo = acl.Scope.GetScopeConstraints(decision.MatchedScope, request.Method, decision.MatchedPattern) + log.Trace("[ACL] Step 3.1.2: enforceMember - Success, matched scope '%s': %+v", decision.MatchedScope, endpointInfo) + } else { + // No specific scope matched (e.g., public endpoint) + endpointInfo = decision.MatchedEndpoint + log.Trace("[ACL] Step 3.1.2: enforceMember - Success, matched endpoint: %+v", endpointInfo) + } + } else { + log.Trace("[ACL] Step 3.1.2: enforceMember - Success, no endpoint info") + } + return true, endpointInfo, nil } diff --git a/openapi/oauth/acl/scope.go b/openapi/oauth/acl/scope.go index 1b6867bd..91190f20 100644 --- a/openapi/oauth/acl/scope.go +++ b/openapi/oauth/acl/scope.go @@ -5,12 +5,45 @@ import ( "path/filepath" "sort" "strings" + "sync" "github.com/yaoapp/gou/application" "github.com/yaoapp/kun/log" "gopkg.in/yaml.v3" ) +// builtinScopes stores scopes registered by code (before configuration loading) +var builtinScopes = make(map[string]*ScopeDefinition) +var builtinScopesMutex sync.RWMutex + +// Register registers built-in scopes that will be automatically loaded +// This should be called in init() functions before the ACL system is initialized +// Supports registering multiple scopes at once +// +// Example: +// +// acl.Register( +// &acl.ScopeDefinition{ +// Name: "builtin:mfa:verification", +// Description: "MFA verification - temporary access for MFA setup", +// Endpoints: []string{"POST /user/mfa/totp/enable", "POST /user/mfa/totp/verify"}, +// }, +// &acl.ScopeDefinition{ +// Name: "builtin:team:selection", +// Description: "Team selection scope", +// Endpoints: []string{"POST /user/teams/select"}, +// }, +// ) +func Register(scopes ...*ScopeDefinition) { + builtinScopesMutex.Lock() + defer builtinScopesMutex.Unlock() + + for _, scope := range scopes { + builtinScopes[scope.Name] = scope + log.Trace("[ACL] Registered builtin scope: %s (%d endpoints)", scope.Name, len(scope.Endpoints)) + } +} + // LoadScopes loads the scope configuration from the openapi/scopes directory func LoadScopes() (*ScopeManager, error) { manager := &ScopeManager{ @@ -22,6 +55,20 @@ func LoadScopes() (*ScopeManager, error) { scopes: make(map[string]*ScopeDefinition), } + // Step 1: Load builtin scopes first (registered by code) + builtinScopesMutex.RLock() + builtinCount := len(builtinScopes) + for name, scopeDef := range builtinScopes { + // Create a copy to avoid mutation + defCopy := *scopeDef + manager.scopes[name] = &defCopy + } + builtinScopesMutex.RUnlock() + + if builtinCount > 0 { + log.Info("[ACL] Loaded %d builtin scopes from code registration", builtinCount) + } + // Check if scopes directory exists scopesDir := filepath.Join("openapi", "scopes") exists, err := application.App.Exists(scopesDir) @@ -30,30 +77,38 @@ func LoadScopes() (*ScopeManager, error) { } if !exists { log.Warn("[ACL] Scopes directory not found, using default deny policy") + // Still build indexes for builtin scopes + if builtinCount > 0 { + if err := manager.buildIndexes(); err != nil { + return nil, fmt.Errorf("failed to build indexes for builtin scopes: %w", err) + } + } return manager, nil } - // Load global configuration (scopes.yml) + // Step 2: 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) + // Step 3: 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 + // Step 4: Load scope definitions from subdirectories + // This will merge with builtin scopes (file scopes override builtin if same name) if err := manager.loadScopeDefinitions(); err != nil { return nil, fmt.Errorf("failed to load scope definitions: %w", err) } - // Build runtime indexes + // Step 5: 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)) + log.Info("[ACL] Loaded %d scopes (%d builtin, %d from files), %d aliases", + len(manager.scopeIndex), builtinCount, len(manager.scopes)-builtinCount, len(manager.aliasIndex)) return manager, nil } @@ -359,11 +414,55 @@ func (m *ScopeManager) addEndpointRule(method, path, action string, scopes []str Endpoint: info, }) } else if strings.Contains(path, ":") { - // Parameter path - matcher.paramPaths[path] = info + // Parameter path - merge with existing if present (support multiple scopes per endpoint) + if existing := matcher.paramPaths[path]; existing != nil { + // Endpoint already exists, merge scopes and constraints + existing.RequiredScopes = append(existing.RequiredScopes, info.RequiredScopes...) + // Merge constraints (OR logic: if any scope requires it, set to true) + existing.OwnerOnly = existing.OwnerOnly || info.OwnerOnly + existing.CreatorOnly = existing.CreatorOnly || info.CreatorOnly + existing.EditorOnly = existing.EditorOnly || info.EditorOnly + existing.TeamOnly = existing.TeamOnly || info.TeamOnly + // Merge extra constraints + if info.Extra != nil { + if existing.Extra == nil { + existing.Extra = make(map[string]interface{}) + } + for key, value := range info.Extra { + existing.Extra[key] = value + } + } + log.Trace("[ACL] Merged endpoint %s %s: scopes=%v, owner=%v, team=%v", + method, path, existing.RequiredScopes, existing.OwnerOnly, existing.TeamOnly) + } else { + matcher.paramPaths[path] = info + log.Trace("[ACL] Added endpoint %s %s: scopes=%v, owner=%v, team=%v", + method, path, info.RequiredScopes, info.OwnerOnly, info.TeamOnly) + } } else { - // Exact path - matcher.exactPaths[path] = info + // Exact path - merge with existing if present (support multiple scopes per endpoint) + if existing := matcher.exactPaths[path]; existing != nil { + // Endpoint already exists, merge scopes and constraints + existing.RequiredScopes = append(existing.RequiredScopes, info.RequiredScopes...) + existing.OwnerOnly = existing.OwnerOnly || info.OwnerOnly + existing.CreatorOnly = existing.CreatorOnly || info.CreatorOnly + existing.EditorOnly = existing.EditorOnly || info.EditorOnly + existing.TeamOnly = existing.TeamOnly || info.TeamOnly + if info.Extra != nil { + if existing.Extra == nil { + existing.Extra = make(map[string]interface{}) + } + for key, value := range info.Extra { + existing.Extra[key] = value + } + } + log.Trace("[ACL] Merged endpoint %s %s: scopes=%v, owner=%v, team=%v", + method, path, existing.RequiredScopes, existing.OwnerOnly, existing.TeamOnly) + } else { + matcher.exactPaths[path] = info + log.Trace("[ACL] Added endpoint %s %s: scopes=%v, owner=%v, team=%v", + method, path, info.RequiredScopes, info.OwnerOnly, info.TeamOnly) + } } return nil @@ -417,27 +516,29 @@ func (m *ScopeManager) Check(req *AccessRequest) *AccessDecision { // Check if user has any required scope (OR relationship) decision.RequiredScopes = endpoint.RequiredScopes - hasScope := false + var matchedScope string for _, required := range endpoint.RequiredScopes { for _, userScope := range expandedScopes { // Check for exact match or wildcard match if userScope == required || m.matchesWildcardScope(userScope, required) { - hasScope = true + matchedScope = required break } } - if hasScope { + if matchedScope != "" { break } } - if !hasScope { + if matchedScope == "" { decision.Allowed = false decision.Reason = "missing required scopes" decision.MissingScopes = m.findMissingScopes(expandedScopes, endpoint.RequiredScopes) return decision } + // Record which scope was matched + decision.MatchedScope = matchedScope decision.Allowed = true decision.Reason = "scope matched" return decision @@ -663,6 +764,42 @@ func (m *ScopeManager) findMissingScopes(userScopes, requiredScopes []string) [] return missing } +// GetScopeConstraints returns the constraints for a specific scope +// This allows getting the original constraints for a matched scope, +// instead of using merged constraints from multiple scopes +func (m *ScopeManager) GetScopeConstraints(scopeName string, method, path string) *EndpointInfo { + m.mu.RLock() + defer m.mu.RUnlock() + + // Get the scope definition + scopeDef := m.scopes[scopeName] + if scopeDef == nil { + return nil + } + + // Create an EndpointInfo with this scope's constraints + info := &EndpointInfo{ + Method: method, + Path: path, + Policy: PolicyRequireScopes, + RequiredScopes: []string{scopeName}, + OwnerOnly: scopeDef.Owner, + CreatorOnly: scopeDef.Creator, + EditorOnly: scopeDef.Editor, + TeamOnly: scopeDef.Team, + } + + // Copy extra constraints + if len(scopeDef.Extra) > 0 { + info.Extra = make(map[string]interface{}) + for key, value := range scopeDef.Extra { + info.Extra[key] = value + } + } + + return info +} + // Reload reloads the scope configuration func (m *ScopeManager) Reload() error { m.mu.Lock() diff --git a/openapi/oauth/acl/types.go b/openapi/oauth/acl/types.go index ea376ab5..a05da5fc 100644 --- a/openapi/oauth/acl/types.go +++ b/openapi/oauth/acl/types.go @@ -16,9 +16,10 @@ var DefaultConfig = Config{ // Config is the configuration for the ACL type Config struct { - Enabled bool `json:"enabled"` - Cache store.Store `json:"-"` - Provider types.UserProvider `json:"-"` + Enabled bool `json:"enabled"` + PathPrefix string `json:"path_prefix"` // BaseURL prefix to strip from request paths (e.g., "/v1") + Cache store.Store `json:"-"` + Provider types.UserProvider `json:"-"` } // ACL is the ACL checker @@ -236,6 +237,7 @@ type AccessDecision struct { // Matched endpoint info MatchedEndpoint *EndpointInfo MatchedPattern string // Matched path pattern + MatchedScope string // Which scope was actually matched (for constraint lookup) // Permission check details RequiredScopes []string // Required scopes diff --git a/openapi/openapi.go b/openapi/openapi.go index 81a5a129..5e272909 100644 --- a/openapi/openapi.go +++ b/openapi/openapi.go @@ -65,7 +65,12 @@ func Load(appConfig config.Config) (*OpenAPI, error) { } // Load the ACL enforcer - _, err = acl.Load(&acl.Config{Enabled: true, Cache: oauthConfig.Cache, Provider: oauthConfig.UserProvider}) + _, err = acl.Load(&acl.Config{ + Enabled: true, + PathPrefix: config.BaseURL, + Cache: oauthConfig.Cache, + Provider: oauthConfig.UserProvider, + }) if err != nil { return nil, err } diff --git a/openapi/user/member.go b/openapi/user/member.go index 100e6979..4f65226c 100644 --- a/openapi/user/member.go +++ b/openapi/user/member.go @@ -15,6 +15,7 @@ import ( "github.com/yaoapp/kun/log" "github.com/yaoapp/kun/maps" "github.com/yaoapp/yao/openapi/oauth" + "github.com/yaoapp/yao/openapi/oauth/authorized" "github.com/yaoapp/yao/openapi/response" ) @@ -22,8 +23,8 @@ import ( // GinMemberList handles GET /teams/:team_id/members - Get team members func GinMemberList(c *gin.Context) { - // Get authorized user info - authInfo := oauth.GetAuthorizedInfo(c) + + authInfo := authorized.GetInfo(c) if authInfo == nil || authInfo.UserID == "" { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidClient.Code, diff --git a/openapi/user/team.go b/openapi/user/team.go index bfc3309f..39f8b85f 100644 --- a/openapi/user/team.go +++ b/openapi/user/team.go @@ -14,6 +14,7 @@ import ( "github.com/yaoapp/kun/log" "github.com/yaoapp/kun/maps" "github.com/yaoapp/yao/openapi/oauth" + "github.com/yaoapp/yao/openapi/oauth/authorized" "github.com/yaoapp/yao/openapi/oauth/providers/user" "github.com/yaoapp/yao/openapi/response" ) @@ -76,7 +77,7 @@ func GinTeamList(c *gin.Context) { // GinTeamGet handles GET /teams/:id - Get user team details func GinTeamGet(c *gin.Context) { // Get authorized user info - authInfo := oauth.GetAuthorizedInfo(c) + authInfo := authorized.GetInfo(c) if authInfo == nil || authInfo.UserID == "" { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidClient.Code, @@ -129,16 +130,16 @@ func GinTeamGet(c *gin.Context) { return } - // Check if user owns this team - ownerID := toString(teamData["owner_id"]) - if ownerID != authInfo.UserID { - errorResp := &response.ErrorResponse{ - Code: response.ErrAccessDenied.Code, - ErrorDescription: "Access denied: you don't own this team", - } - response.RespondWithError(c, response.StatusForbidden, errorResp) - return - } + // // Check if user owns this team + // ownerID := toString(teamData["owner_id"]) + // if ownerID != authInfo.UserID { + // errorResp := &response.ErrorResponse{ + // Code: response.ErrAccessDenied.Code, + // ErrorDescription: "Access denied: you don't own this team", + // } + // response.RespondWithError(c, response.StatusForbidden, errorResp) + // return + // } // Convert to response format team := mapToTeamDetailResponse(teamData) diff --git a/openapi/user/types.go b/openapi/user/types.go index bcf21ef7..9d957716 100644 --- a/openapi/user/types.go +++ b/openapi/user/types.go @@ -34,13 +34,13 @@ const ( const ( // ScopeMFAVerification is the MFA verification scope for temporary access token - ScopeMFAVerification = "mfa_verification" + ScopeMFAVerification = "builtin:mfa:verification" // ScopeTeamSelection is the team selection scope for temporary access token - ScopeTeamSelection = "team_selection" + ScopeTeamSelection = "builtin:teams:selection" // ScopeInviteVerification is the invite verification scope for temporary access token - ScopeInviteVerification = "invite_verification" + ScopeInviteVerification = "builtin:invite:verification" // ScopeEntryVerification is the entry verification scope for temporary access token (login or register) - ScopeEntryVerification = "entry_verification" + ScopeEntryVerification = "builtin:entry:verification" ) // FormConfig represents the form configuration diff --git a/openapi/user/user.go b/openapi/user/user.go index eb85d861..cde1720f 100644 --- a/openapi/user/user.go +++ b/openapi/user/user.go @@ -5,10 +5,55 @@ import ( "github.com/gin-gonic/gin" "github.com/yaoapp/gou/process" + "github.com/yaoapp/yao/openapi/oauth/acl" "github.com/yaoapp/yao/openapi/oauth/types" ) func init() { + // Register builtin scopes for temporary tokens (before ACL initialization) + // These scopes grant limited access to specific endpoints for special purposes + acl.Register( + // MFA verification scope - allows users to complete MFA setup during login + &acl.ScopeDefinition{ + Name: ScopeMFAVerification, + Description: "MFA verification - temporary access for completing MFA challenge", + Endpoints: []string{ + "POST /user/mfa/totp/verify", + "POST /user/mfa/sms/verify", + "GET /user/mfa/totp", + }, + }, + // Team selection scope - allows users to select a team and issue new tokens + &acl.ScopeDefinition{ + Name: ScopeTeamSelection, + Description: "Team selection - temporary access for selecting a team after login", + Endpoints: []string{ + "POST /user/teams/select", + "GET /user/teams/config", + }, + }, + // Invite verification scope - allows users to accept team invitations + &acl.ScopeDefinition{ + Name: ScopeInviteVerification, + Description: "Invite verification - temporary access for accepting team invitations", + Endpoints: []string{ + "POST /user/teams/invitations/:invitation_id/accept", + "GET /user/teams/invitations/:invitation_id", + }, + }, + // Entry verification scope - allows users to complete registration or login verification + &acl.ScopeDefinition{ + Name: ScopeEntryVerification, + Description: "Entry verification - temporary access for completing registration or login verification", + Endpoints: []string{ + "POST /user/entry/register", + "POST /user/entry/login", + "POST /user/entry/invite/verify", + "POST /user/entry/otp", + }, + }, + ) + // Register user process handlers process.RegisterGroup("user", map[string]process.Handler{ "team.list": ProcessTeamList,