yao/openapi/oauth/acl/enforce.go
Max e96896eb40 Implement detailed ACL enforcement logic and data access constraints
- Refactored the ACL enforcement process to include a comprehensive chain of permission checks for clients, users, teams, and members.
- Introduced data access constraints (OwnerOnly, TeamOnly) to enhance access control based on endpoint requirements.
- Updated the Error struct to include the stage of permission checks where failures occur, improving error reporting.
- Enhanced scope management with wildcard matching capabilities and added checks for restricted scopes.
- Implemented tests to validate the enforcement logic and ensure correct handling of data access constraints.
2025-10-21 17:04:20 +08:00

477 lines
14 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: %v", 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: %v", 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{}{
"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{}{
"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{}{
"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: %v", 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: %v", 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{}{
"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{}{
"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: %v", 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: %v", 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{}{
"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{}{
"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: %v", 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: %v", 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{}{
"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{}{
"restricted_scopes": restrictedScopes,
"matched_pattern": restrictDecision.MatchedPattern,
},
}
}
}
// Return matched endpoint info
return true, decision.MatchedEndpoint, nil
}