yao/openapi/oauth/acl/enforce.go
Max 7aff66e3b4 Enhance ACL enforcement and logging for improved error handling
- Added detailed logging for ACL enforcement failures in the OAuth guard, providing clearer insights into permission issues.
- Updated error messages in the ACL enforcement logic to include contextual information such as client and user IDs, enhancing debugging capabilities.
- Introduced new constraints (CreatorOnly, EditorOnly) in the data access model, allowing for more granular control over data visibility based on user roles.
- Enhanced scope definitions to support additional constraints, improving flexibility in access control configurations.
- Updated documentation to reflect changes in data constraints and their usage in API handlers.
2025-10-22 09:51:06 +08:00

509 lines
15 KiB
Go

package acl
import (
"context"
"fmt"
"strings"
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/openapi/oauth/acl/role"
"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)
// Build access request
request := &AccessRequest{
Method: c.Request.Method,
Path: c.Request.URL.Path,
}
// Execute enforcement chain and collect endpoint info
allowed, endpointInfo, err := acl.enforce(c.Request.Context(), authInfo, request)
if err != nil {
return false, err
}
if !allowed {
return false, nil
}
// Update context with data access constraints from matched endpoint
if endpointInfo != nil {
constraints := endpointInfo.GetConstraints()
authorized.UpdateConstraints(c, constraints)
}
return true, nil
}
// enforce is the main enforcement chain that orchestrates all permission checks
// Each step independently validates permissions against the endpoint
// ALL checks must pass (AND logic) - if any check fails, access is denied
// Returns: (allowed bool, endpointInfo *EndpointInfo, error)
func (acl *ACL) enforce(ctx context.Context, authInfo *types.AuthorizedInfo, request *AccessRequest) (bool, *EndpointInfo, error) {
// Step 1: Check client permission - MUST pass
allowed, matchedEndpoint, err := acl.enforceClient(ctx, authInfo, request)
if err != nil {
return false, nil, err
}
if !allowed {
return false, nil, &Error{
Type: ErrorTypePermissionDenied,
Message: "access denied: client permission check failed",
Stage: EnforcementStageClient,
}
}
// Step 2: Check explicit scopes from token (if any) - MUST pass if present
// If token scope is empty, skip this check
if authInfo.Scope != "" {
allowed, endpoint, err := acl.enforceScope(ctx, authInfo, request)
if err != nil {
return false, nil, err
}
if !allowed {
return false, nil, &Error{
Type: ErrorTypePermissionDenied,
Message: "access denied: token scope check failed",
Stage: EnforcementStageScope,
}
}
// Collect endpoint info
if matchedEndpoint == nil && endpoint != nil {
matchedEndpoint = endpoint
}
}
// Step 3: Check team or user permissions
// 3.1: If TeamID is present, this is a team login
if 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 {
return false, nil, &Error{
Type: ErrorTypePermissionDenied,
Message: "access denied: team permission check failed",
Stage: EnforcementStageTeam,
}
}
// Collect endpoint info
if matchedEndpoint == nil && endpoint != nil {
matchedEndpoint = endpoint
}
// 3.1.2: Check member permissions (user's role in the team) - MUST pass
allowed, endpoint, err = acl.enforceMember(ctx, authInfo, request)
if err != nil {
return false, nil, err
}
if !allowed {
return false, nil, &Error{
Type: ErrorTypePermissionDenied,
Message: "access denied: member permission check failed",
Stage: EnforcementStageMember,
}
}
// Collect endpoint info
if matchedEndpoint == nil && endpoint != nil {
matchedEndpoint = endpoint
}
// All checks passed for team login
return true, matchedEndpoint, nil
}
// 3.2: This is a user login (no TeamID)
if authInfo.UserID != "" {
allowed, endpoint, err := acl.enforceUser(ctx, authInfo, request)
if err != nil {
return false, nil, err
}
if !allowed {
return false, nil, &Error{
Type: ErrorTypePermissionDenied,
Message: "access denied: user permission check failed",
Stage: EnforcementStageUser,
}
}
// Collect endpoint info
if matchedEndpoint == nil && endpoint != nil {
matchedEndpoint = endpoint
}
// All checks passed for user login
return true, matchedEndpoint, nil
}
// All checks passed (pure API call - only client check required)
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) {
// Get client role
clientRole, err := role.RoleManager.GetClientRole(ctx, authInfo.ClientID)
if err != nil {
return false, nil, &Error{
Type: ErrorTypeInternal,
Message: fmt.Sprintf("failed to get client role [client_id=%s]: %v", authInfo.ClientID, err),
Stage: EnforcementStageClient,
}
}
// Get scopes for client role
allowedScopes, restrictedScopes, err := role.RoleManager.GetScopes(ctx, clientRole)
if err != nil {
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,
}
}
// Step 1: Check if allowed scopes grant access
allowedRequest := &AccessRequest{
Method: request.Method,
Path: request.Path,
Scopes: allowedScopes,
}
decision := acl.Scope.Check(allowedRequest)
if !decision.Allowed {
return false, nil, &Error{
Type: ErrorTypePermissionDenied,
Message: decision.Reason,
Stage: EnforcementStageClient,
Details: map[string]interface{}{
"client_id": authInfo.ClientID,
"method": request.Method,
"path": request.Path,
"required_scopes": decision.RequiredScopes,
"missing_scopes": decision.MissingScopes,
},
}
}
// Step 2: Check if restricted scopes block access
if len(restrictedScopes) > 0 {
restrictedRequest := &AccessRequest{
Method: request.Method,
Path: request.Path,
Scopes: restrictedScopes,
}
restrictDecision := acl.Scope.CheckRestricted(restrictedRequest)
if !restrictDecision.Allowed {
return false, nil, &Error{
Type: ErrorTypePermissionDenied,
Message: "access denied by restriction: " + restrictDecision.Reason,
Stage: EnforcementStageClient,
Details: map[string]interface{}{
"client_id": authInfo.ClientID,
"method": request.Method,
"path": request.Path,
"restricted_scopes": restrictedScopes,
"matched_pattern": restrictDecision.MatchedPattern,
},
}
}
}
// Return matched endpoint info (contains OwnerOnly, TeamOnly, and future constraints)
return true, decision.MatchedEndpoint, 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) {
// Parse scopes from token (space-separated)
if authInfo.Scope == "" {
return false, nil, nil
}
// Split space-separated scopes
// e.g., "read:users write:users" -> ["read:users", "write:users"]
tokenScopes := strings.Split(authInfo.Scope, " ")
// Filter out empty strings
var scopes []string
for _, scope := range tokenScopes {
if scope != "" {
scopes = append(scopes, scope)
}
}
// Build request with token scopes and check
checkRequest := &AccessRequest{
Method: request.Method,
Path: request.Path,
Scopes: scopes,
}
decision := acl.Scope.Check(checkRequest)
if !decision.Allowed {
return false, nil, &Error{
Type: ErrorTypePermissionDenied,
Message: decision.Reason,
Stage: EnforcementStageScope,
Details: map[string]interface{}{
"client_id": authInfo.ClientID,
"user_id": authInfo.UserID,
"method": request.Method,
"path": request.Path,
"required_scopes": decision.RequiredScopes,
"missing_scopes": decision.MissingScopes,
},
}
}
// Return matched endpoint info
return true, decision.MatchedEndpoint, 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) {
// Get user role
userRole, err := role.RoleManager.GetUserRole(ctx, authInfo.UserID)
if err != nil {
return false, nil, &Error{
Type: ErrorTypeInternal,
Message: fmt.Sprintf("failed to get user role [user_id=%s]: %v", authInfo.UserID, err),
Stage: EnforcementStageUser,
}
}
// Get scopes for user role
allowedScopes, restrictedScopes, err := role.RoleManager.GetScopes(ctx, userRole)
if err != nil {
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,
}
}
// Step 1: Check if allowed scopes grant access
allowedRequest := &AccessRequest{
Method: request.Method,
Path: request.Path,
Scopes: allowedScopes,
}
decision := acl.Scope.Check(allowedRequest)
if !decision.Allowed {
return false, nil, &Error{
Type: ErrorTypePermissionDenied,
Message: decision.Reason,
Stage: EnforcementStageUser,
Details: map[string]interface{}{
"user_id": authInfo.UserID,
"method": request.Method,
"path": request.Path,
"required_scopes": decision.RequiredScopes,
"missing_scopes": decision.MissingScopes,
},
}
}
// Step 2: Check if restricted scopes block access
if len(restrictedScopes) > 0 {
restrictedRequest := &AccessRequest{
Method: request.Method,
Path: request.Path,
Scopes: restrictedScopes,
}
restrictDecision := acl.Scope.CheckRestricted(restrictedRequest)
if !restrictDecision.Allowed {
return false, nil, &Error{
Type: ErrorTypePermissionDenied,
Message: "access denied by restriction: " + restrictDecision.Reason,
Stage: EnforcementStageUser,
Details: map[string]interface{}{
"user_id": authInfo.UserID,
"method": request.Method,
"path": request.Path,
"restricted_scopes": restrictedScopes,
"matched_pattern": restrictDecision.MatchedPattern,
},
}
}
}
// Return matched endpoint info
return true, decision.MatchedEndpoint, 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) {
// Get team role
teamRole, err := role.RoleManager.GetTeamRole(ctx, authInfo.TeamID)
if err != nil {
return false, nil, &Error{
Type: ErrorTypeInternal,
Message: fmt.Sprintf("failed to get team role [team_id=%s]: %v", authInfo.TeamID, err),
Stage: EnforcementStageTeam,
}
}
// Get scopes for team role
allowedScopes, restrictedScopes, err := role.RoleManager.GetScopes(ctx, teamRole)
if err != nil {
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,
}
}
// Step 1: Check if allowed scopes grant access
allowedRequest := &AccessRequest{
Method: request.Method,
Path: request.Path,
Scopes: allowedScopes,
}
decision := acl.Scope.Check(allowedRequest)
if !decision.Allowed {
return false, nil, &Error{
Type: ErrorTypePermissionDenied,
Message: decision.Reason,
Stage: EnforcementStageTeam,
Details: map[string]interface{}{
"team_id": authInfo.TeamID,
"user_id": authInfo.UserID,
"method": request.Method,
"path": request.Path,
"required_scopes": decision.RequiredScopes,
"missing_scopes": decision.MissingScopes,
},
}
}
// Step 2: Check if restricted scopes block access
if len(restrictedScopes) > 0 {
restrictedRequest := &AccessRequest{
Method: request.Method,
Path: request.Path,
Scopes: restrictedScopes,
}
restrictDecision := acl.Scope.CheckRestricted(restrictedRequest)
if !restrictDecision.Allowed {
return false, nil, &Error{
Type: ErrorTypePermissionDenied,
Message: "access denied by restriction: " + restrictDecision.Reason,
Stage: EnforcementStageTeam,
Details: map[string]interface{}{
"team_id": authInfo.TeamID,
"user_id": authInfo.UserID,
"method": request.Method,
"path": request.Path,
"restricted_scopes": restrictedScopes,
"matched_pattern": restrictDecision.MatchedPattern,
},
}
}
}
// Return matched endpoint info
return true, decision.MatchedEndpoint, 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) {
// Get member role (user's role in the team)
memberRole, err := role.RoleManager.GetMemberRole(ctx, authInfo.TeamID, authInfo.UserID)
if err != nil {
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,
}
}
// Get scopes for member role
allowedScopes, restrictedScopes, err := role.RoleManager.GetScopes(ctx, memberRole)
if err != nil {
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,
}
}
// Step 1: Check if allowed scopes grant access
allowedRequest := &AccessRequest{
Method: request.Method,
Path: request.Path,
Scopes: allowedScopes,
}
decision := acl.Scope.Check(allowedRequest)
if !decision.Allowed {
return false, nil, &Error{
Type: ErrorTypePermissionDenied,
Message: decision.Reason,
Stage: EnforcementStageMember,
Details: map[string]interface{}{
"team_id": authInfo.TeamID,
"user_id": authInfo.UserID,
"method": request.Method,
"path": request.Path,
"required_scopes": decision.RequiredScopes,
"missing_scopes": decision.MissingScopes,
},
}
}
// Step 2: Check if restricted scopes block access
if len(restrictedScopes) > 0 {
restrictedRequest := &AccessRequest{
Method: request.Method,
Path: request.Path,
Scopes: restrictedScopes,
}
restrictDecision := acl.Scope.CheckRestricted(restrictedRequest)
if !restrictDecision.Allowed {
return false, nil, &Error{
Type: ErrorTypePermissionDenied,
Message: "access denied by restriction: " + restrictDecision.Reason,
Stage: EnforcementStageMember,
Details: map[string]interface{}{
"team_id": authInfo.TeamID,
"user_id": authInfo.UserID,
"method": request.Method,
"path": request.Path,
"restricted_scopes": restrictedScopes,
"matched_pattern": restrictDecision.MatchedPattern,
},
}
}
}
// Return matched endpoint info
return true, decision.MatchedEndpoint, nil
}