Enhance ACL configuration and enforcement with path prefix support

- Updated ACL configuration to include a PathPrefix field, allowing for dynamic path stripping from request URLs.
- Enhanced ACL enforcement logic to log the configured path prefix and adjust request paths accordingly during access checks.
- Improved logging throughout the enforcement process to provide clearer insights into access decisions and scope matching.
- Registered built-in scopes for temporary access tokens, enhancing flexibility in access control for specific endpoints.
- Updated scope management to support constraints for matched scopes, improving granularity in access control configurations.
This commit is contained in:
Max 2025-10-22 19:19:30 +08:00
parent 7aff66e3b4
commit cc181a52f6
9 changed files with 423 additions and 53 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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()

View file

@ -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

View file

@ -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
}

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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,