diff --git a/openapi/oauth/acl/README.md b/openapi/oauth/acl/README.md index 8c0e864a..aa4822b6 100644 --- a/openapi/oauth/acl/README.md +++ b/openapi/oauth/acl/README.md @@ -346,11 +346,27 @@ func HandleRequest(c *gin.Context) { // e.g., WHERE user_id = authInfo.UserID } + if authInfo.Constraints.CreatorOnly { + // Only return data created by current user + // e.g., WHERE created_by = authInfo.UserID + } + + if authInfo.Constraints.EditorOnly { + // Only return data last edited by current user + // e.g., WHERE updated_by = authInfo.UserID + } + if authInfo.Constraints.TeamOnly { // Only return data owned by current team // e.g., WHERE team_id = authInfo.TeamID } + // Check extra constraints + if dept, ok := authInfo.Constraints.Extra["department_only"].(bool); ok && dept { + // Apply department filter + // e.g., WHERE department_id = authInfo.DepartmentID + } + // Process request... } ``` @@ -362,13 +378,14 @@ After successful ACL enforcement, the `AuthorizedInfo` is automatically updated ```go // DataConstraints represents data access constraints type DataConstraints struct { - OwnerOnly bool // Only access owner's data (filter by UserID) - TeamOnly bool // Only access team's data (filter by TeamID) + // Built-in constraints + OwnerOnly bool // Only access owner's data (current owner) + CreatorOnly bool // Only access creator's data (who created the resource) + EditorOnly bool // Only access editor's data (who last updated the resource) + TeamOnly bool // Only access team's data (filter by TeamID) - // Future constraints: - // DepartmentOnly bool - // ProjectOnly bool - // RegionOnly bool + // Extra constraints (user-defined, flexible extension) + Extra map[string]interface{} // Custom constraints like department_only, region_only, etc. } type AuthorizedInfo struct { @@ -394,78 +411,79 @@ type AuthorizedInfo struct { The constraint system uses a map-based approach for easy extension: ```go -// Step 1: Add field to DataConstraints (types/types.go) +// The constraint system is already extensible through the Extra map! +// For custom constraints, use the Extra field directly - no code changes needed. + +// Current structure (already supports custom constraints): type DataConstraints struct { - OwnerOnly bool - TeamOnly bool - DepartmentOnly bool // New constraint + // Built-in constraints (pre-defined) + OwnerOnly bool + CreatorOnly bool + EditorOnly bool + TeamOnly bool + + // Extra constraints (user-defined, flexible) + Extra map[string]interface{} } -// Step 2: Add field to EndpointInfo (acl/types.go) -type EndpointInfo struct { - OwnerOnly bool - TeamOnly bool - DepartmentOnly bool // New constraint +// Define custom constraints in scope YAML: +// collections:read:department: +// description: "Read collections in user's department" +// extra: +// department_only: true +// region: "us-west" +// project_ids: ["proj1", "proj2"] +// endpoints: +// - GET /kb/collections/department + +// Access in handler code: +func GetCollections(c *gin.Context) { + authInfo := authorized.GetInfo(c) + + query := db.Query("SELECT * FROM collections") + + // Check built-in constraints + if authInfo.Constraints.OwnerOnly { + query = query.Where("user_id = ?", authInfo.UserID) + } + + // Check extra constraints (no code changes needed!) + if dept, ok := authInfo.Constraints.Extra["department_only"].(bool); ok && dept { + query = query.Where("department_id = ?", authInfo.DepartmentID) + } + + if region, ok := authInfo.Constraints.Extra["region"].(string); ok { + query = query.Where("region = ?", region) + } + + if projectIDs, ok := authInfo.Constraints.Extra["project_ids"].([]interface{}); ok { + query = query.Where("project_id IN (?)", projectIDs) + } + + // Execute query... } -// Step 3: Update GetConstraints to include new constraint (acl/types.go) -func (e *EndpointInfo) GetConstraints() map[string]interface{} { - constraints := make(map[string]interface{}) - - if e.OwnerOnly { - constraints["owner_only"] = true - } - - if e.TeamOnly { - constraints["team_only"] = true - } - - if e.DepartmentOnly { - constraints["department_only"] = true // New - } - - return constraints -} - -// Step 4: Update GetConstraints reader (authorized/utils.go) -func GetConstraints(c *gin.Context) types.DataConstraints { - constraints := types.DataConstraints{} - - if ownerOnly, ok := c.Get("__owner_only"); ok { - if ownerOnlyBool, ok := ownerOnly.(bool); ok { - constraints.OwnerOnly = ownerOnlyBool - } - } - - if teamOnly, ok := c.Get("__team_only"); ok { - if teamOnlyBool, ok := teamOnly.(bool); ok { - constraints.TeamOnly = teamOnlyBool - } - } - - if departmentOnly, ok := c.Get("__department_only"); ok { - if deptBool, ok := departmentOnly.(bool); ok { - constraints.DepartmentOnly = deptBool // New - } - } - - return constraints -} - -// No changes needed to enforce.go or handler code! +// ONLY if you need a new BUILT-IN constraint (used frequently across the system): +// Follow these steps to add it alongside OwnerOnly, CreatorOnly, etc. +// But for most cases, using Extra is sufficient and more flexible! ``` **Example Endpoint Configuration**: ```yaml # openapi/scopes/collections/read.yml -collections:read: - name: "collections:read" - description: "Read collections" - owner: true # This sets OwnerOnly = true +collections:read:own: + name: "collections:read:own" + description: "Read own collections" + owner: true # Sets OwnerOnly = true + creator: true # Sets CreatorOnly = true + editor: true # Sets EditorOnly = true + extra: # Sets Extra constraints + department_only: true + region: "us-west" endpoints: - - "GET /api/collections" - - "GET /api/collections/:id" + - "GET /api/collections/own" + - "GET /api/collections/own/:id" ``` **Example API Handler**: @@ -476,17 +494,25 @@ func GetCollections(c *gin.Context) { query := db.Query("SELECT * FROM collections") - // Apply data access constraints + // Apply built-in data access constraints if authInfo.Constraints.OwnerOnly { query = query.Where("user_id = ?", authInfo.UserID) + } else if authInfo.Constraints.CreatorOnly { + query = query.Where("created_by = ?", authInfo.UserID) + } else if authInfo.Constraints.EditorOnly { + query = query.Where("updated_by = ?", authInfo.UserID) } else if authInfo.Constraints.TeamOnly { query = query.Where("team_id = ?", authInfo.TeamID) } - // Future: Handle additional constraints - // if authInfo.Constraints.DepartmentOnly { - // query = query.Where("department_id = ?", authInfo.DepartmentID) - // } + // Apply extra constraints + if dept, ok := authInfo.Constraints.Extra["department_only"].(bool); ok && dept { + query = query.Where("department_id = ?", authInfo.DepartmentID) + } + + if region, ok := authInfo.Constraints.Extra["region"].(string); ok { + query = query.Where("region = ?", region) + } // Execute query and return results collections, _ := query.Get() @@ -601,16 +627,32 @@ A: Use restricted scopes when you want to: - Temporarily revoke access to certain endpoints without changing the base role - Implement exceptions to general permissions -**Q: How do OwnerOnly and TeamOnly constraints work?** +**Q: How do data constraints work?** A: After successful ACL enforcement: -1. The system checks if the matched endpoint has `owner: true` or `team: true` in its scope definition -2. These flags are automatically set in `AuthorizedInfo.Constraints` (`authInfo.Constraints.OwnerOnly`, `authInfo.Constraints.TeamOnly`) -3. API handlers can read these flags from `authorized.GetInfo(c)` and apply data filters +1. The system checks if the matched endpoint has constraint flags in its scope definition (`owner`, `creator`, `editor`, `team`, `extra`) +2. These flags are automatically set in `AuthorizedInfo.Constraints` +3. API handlers read these flags from `authorized.GetInfo(c)` and apply data filters 4. Example: If `authInfo.Constraints.OwnerOnly = true`, the API should only return records where `user_id = authInfo.UserID` -**Q: Can both OwnerOnly and TeamOnly be true at the same time?** -A: Yes, if a scope definition has both `owner: true` and `team: true`. In this case, the API handler should typically use the more restrictive filter (`authInfo.Constraints.OwnerOnly`). +**Q: What's the difference between Owner, Creator, and Editor constraints?** +A: -**Q: What happens if OwnerOnly is true but UserID is empty?** -A: This would be an edge case for pure client credential grants. The API handler should handle this gracefully (e.g., return empty results or an appropriate error). +- **OwnerOnly**: Filters by current owner (who owns it now) - can be transferred +- **CreatorOnly**: Filters by original creator (who created it) - immutable +- **EditorOnly**: Filters by last editor (who last updated it) - changes on each edit + +**Q: Can multiple constraints be true at the same time?** +A: Yes, a scope can have multiple constraints. The API handler should apply filters based on the most restrictive or appropriate constraint for the use case. + +**Q: How do I use Extra constraints?** +A: Define them in the scope configuration YAML under `extra:`, then access them in your handler: + +```go +if dept, ok := authInfo.Constraints.Extra["department_only"].(bool); ok && dept { + query = query.Where("department_id = ?", userDepartmentID) +} +``` + +**Q: What happens if constraints are set but the user context is missing?** +A: For client credential grants with no user context, the API handler should handle this gracefully (e.g., return empty results or an appropriate error). diff --git a/openapi/oauth/acl/SCOPES_CONFIGURATION.md b/openapi/oauth/acl/SCOPES_CONFIGURATION.md index c8c6a31a..194c13e7 100644 --- a/openapi/oauth/acl/SCOPES_CONFIGURATION.md +++ b/openapi/oauth/acl/SCOPES_CONFIGURATION.md @@ -118,7 +118,8 @@ collections:read:all: - GET /kb/collections/:collectionID/exists collections:read:own: - owner: true + owner: true # Only show collections owned by current user + creator: true # Only show collections created by current user description: "Read knowledge base for own collections" endpoints: - GET /kb/collections/own @@ -127,6 +128,7 @@ collections:read:own: collections:write:own: owner: true + editor: true # Only allow editing by last editor description: "Write knowledge base for own collections" endpoints: - POST /kb/collections/own @@ -134,21 +136,33 @@ collections:write:own: - DELETE /kb/collections/own/:collectionID collections:read:team: - team: true + team: true # Only show team collections description: "Read knowledge base for team collections" endpoints: - GET /kb/collections/team - GET /kb/collections/team/:collectionID + +collections:read:department: + extra: # Custom constraints + department_only: true + region: "us-west" + description: "Read collections for department in specific region" + endpoints: + - GET /kb/collections/department + - GET /kb/collections/department/:collectionID ``` #### Scope Definition Fields -| Field | Type | Required | Default | Description | -| ------------- | ------ | -------- | ------- | -------------------------------------------------------------------------------- | -| `description` | string | No | "" | Human-readable description of the scope | -| `owner` | bool | No | false | If `true`, data access is restricted to owner only (sets `OwnerOnly` constraint) | -| `team` | bool | No | false | If `true`, data access is restricted to team only (sets `TeamOnly` constraint) | -| `endpoints` | array | Yes | - | List of API endpoints this scope grants access to | +| Field | Type | Required | Default | Description | +| ------------- | ------ | -------- | ------- | ---------------------------------------------------------------------------------- | +| `description` | string | No | "" | Human-readable description of the scope | +| `owner` | bool | No | false | If `true`, data access is restricted to owner only (sets `OwnerOnly` constraint) | +| `creator` | bool | No | false | If `true`, data access is restricted to creator only (sets `CreatorOnly` constraint) | +| `editor` | bool | No | false | If `true`, data access is restricted to editor only (sets `EditorOnly` constraint) | +| `team` | bool | No | false | If `true`, data access is restricted to team only (sets `TeamOnly` constraint) | +| `extra` | map | No | {} | User-defined custom constraints (key-value pairs) | +| `endpoints` | array | Yes | - | List of API endpoints this scope grants access to | #### Endpoint Format diff --git a/openapi/oauth/acl/enforce.go b/openapi/oauth/acl/enforce.go index 6fb55a1d..5d967c18 100644 --- a/openapi/oauth/acl/enforce.go +++ b/openapi/oauth/acl/enforce.go @@ -164,7 +164,7 @@ func (acl *ACL) enforceClient(ctx context.Context, authInfo *types.AuthorizedInf if err != nil { return false, nil, &Error{ Type: ErrorTypeInternal, - Message: fmt.Sprintf("failed to get client role: %v", err), + Message: fmt.Sprintf("failed to get client role [client_id=%s]: %v", authInfo.ClientID, err), Stage: EnforcementStageClient, } } @@ -174,7 +174,7 @@ func (acl *ACL) enforceClient(ctx context.Context, authInfo *types.AuthorizedInf if err != nil { return false, nil, &Error{ Type: ErrorTypeInternal, - Message: fmt.Sprintf("failed to get client scopes: %v", err), + Message: fmt.Sprintf("failed to get client scopes [client_id=%s, role=%s]: %v", authInfo.ClientID, clientRole, err), Stage: EnforcementStageClient, } } @@ -193,6 +193,9 @@ func (acl *ACL) enforceClient(ctx context.Context, authInfo *types.AuthorizedInf 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, }, @@ -214,6 +217,9 @@ func (acl *ACL) enforceClient(ctx context.Context, authInfo *types.AuthorizedInf 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, }, @@ -259,6 +265,10 @@ func (acl *ACL) enforceScope(_ context.Context, authInfo *types.AuthorizedInfo, 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, }, @@ -277,7 +287,7 @@ func (acl *ACL) enforceUser(ctx context.Context, authInfo *types.AuthorizedInfo, if err != nil { return false, nil, &Error{ Type: ErrorTypeInternal, - Message: fmt.Sprintf("failed to get user role: %v", err), + Message: fmt.Sprintf("failed to get user role [user_id=%s]: %v", authInfo.UserID, err), Stage: EnforcementStageUser, } } @@ -287,7 +297,7 @@ func (acl *ACL) enforceUser(ctx context.Context, authInfo *types.AuthorizedInfo, if err != nil { return false, nil, &Error{ Type: ErrorTypeInternal, - Message: fmt.Sprintf("failed to get user scopes: %v", err), + Message: fmt.Sprintf("failed to get user scopes [user_id=%s, role=%s]: %v", authInfo.UserID, userRole, err), Stage: EnforcementStageUser, } } @@ -306,6 +316,9 @@ func (acl *ACL) enforceUser(ctx context.Context, authInfo *types.AuthorizedInfo, 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, }, @@ -327,6 +340,9 @@ func (acl *ACL) enforceUser(ctx context.Context, authInfo *types.AuthorizedInfo, 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, }, @@ -346,7 +362,7 @@ func (acl *ACL) enforceTeam(ctx context.Context, authInfo *types.AuthorizedInfo, if err != nil { return false, nil, &Error{ Type: ErrorTypeInternal, - Message: fmt.Sprintf("failed to get team role: %v", err), + Message: fmt.Sprintf("failed to get team role [team_id=%s]: %v", authInfo.TeamID, err), Stage: EnforcementStageTeam, } } @@ -356,7 +372,7 @@ func (acl *ACL) enforceTeam(ctx context.Context, authInfo *types.AuthorizedInfo, if err != nil { return false, nil, &Error{ Type: ErrorTypeInternal, - Message: fmt.Sprintf("failed to get team scopes: %v", err), + Message: fmt.Sprintf("failed to get team scopes [team_id=%s, role=%s]: %v", authInfo.TeamID, teamRole, err), Stage: EnforcementStageTeam, } } @@ -375,6 +391,10 @@ func (acl *ACL) enforceTeam(ctx context.Context, authInfo *types.AuthorizedInfo, 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, }, @@ -396,6 +416,10 @@ func (acl *ACL) enforceTeam(ctx context.Context, authInfo *types.AuthorizedInfo, 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, }, @@ -415,7 +439,7 @@ func (acl *ACL) enforceMember(ctx context.Context, authInfo *types.AuthorizedInf if err != nil { return false, nil, &Error{ Type: ErrorTypeInternal, - Message: fmt.Sprintf("failed to get member role: %v", err), + Message: fmt.Sprintf("failed to get member role [team_id=%s, user_id=%s]: %v", authInfo.TeamID, authInfo.UserID, err), Stage: EnforcementStageMember, } } @@ -425,7 +449,7 @@ func (acl *ACL) enforceMember(ctx context.Context, authInfo *types.AuthorizedInf if err != nil { return false, nil, &Error{ Type: ErrorTypeInternal, - Message: fmt.Sprintf("failed to get member scopes: %v", err), + 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, } } @@ -444,6 +468,10 @@ func (acl *ACL) enforceMember(ctx context.Context, authInfo *types.AuthorizedInf 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, }, @@ -465,6 +493,10 @@ func (acl *ACL) enforceMember(ctx context.Context, authInfo *types.AuthorizedInf 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, }, diff --git a/openapi/oauth/acl/role/role.go b/openapi/oauth/acl/role/role.go index 28a99263..d701ee57 100644 --- a/openapi/oauth/acl/role/role.go +++ b/openapi/oauth/acl/role/role.go @@ -188,8 +188,7 @@ func (m *Manager) getTeamRole(ctx context.Context, teamID string) (string, error // Note: Teams might not have a role_id field, adjust based on your schema roleID, ok := teamInfo["role_id"].(string) if !ok || roleID == "" { - // If team doesn't have a role, return a default team role - return "team:default", nil + return "", fmt.Errorf("team %s has no role_id assigned", teamID) } return roleID, nil diff --git a/openapi/oauth/acl/scope.go b/openapi/oauth/acl/scope.go index 34617197..1b6867bd 100644 --- a/openapi/oauth/acl/scope.go +++ b/openapi/oauth/acl/scope.go @@ -240,7 +240,10 @@ func (m *ScopeManager) buildIndexes() error { Name: name, Description: def.Description, Owner: def.Owner, + Creator: def.Creator, + Editor: def.Editor, Team: def.Team, + Extra: def.Extra, Endpoints: def.Endpoints, } } @@ -315,16 +318,33 @@ func (m *ScopeManager) addEndpointRule(method, path, action string, scopes []str RequiredScopes: scopes, } - // Set owner/team constraints from scope definitions + // Set constraints from scope definitions if len(scopes) > 0 { for _, scopeName := range scopes { if def := m.scopes[scopeName]; def != nil { + // Built-in constraints if def.Owner { info.OwnerOnly = true } + if def.Creator { + info.CreatorOnly = true + } + if def.Editor { + info.EditorOnly = true + } if def.Team { info.TeamOnly = true } + + // Merge extra constraints + if len(def.Extra) > 0 { + if info.Extra == nil { + info.Extra = make(map[string]interface{}) + } + for key, value := range def.Extra { + info.Extra[key] = value + } + } } } } diff --git a/openapi/oauth/acl/types.go b/openapi/oauth/acl/types.go index 66ef5f2c..ea376ab5 100644 --- a/openapi/oauth/acl/types.go +++ b/openapi/oauth/acl/types.go @@ -78,11 +78,14 @@ type AliasConfig map[string][]string // ScopeDefinition represents a scope definition (from subdirectory yml files) type ScopeDefinition struct { - Name string `json:"name" yaml:"name"` // Scope name (e.g. collections:read:all) - Description string `json:"description" yaml:"description"` // Description - Owner bool `json:"owner" yaml:"owner"` // Owner only - Team bool `json:"team" yaml:"team"` // Team only - Endpoints []string `json:"endpoints" yaml:"endpoints"` // Endpoint list (format: METHOD /path) + Name string `json:"name" yaml:"name"` // Scope name (e.g. collections:read:all) + Description string `json:"description" yaml:"description"` // Description + Owner bool `json:"owner" yaml:"owner"` // Owner only (current owner) + Creator bool `json:"creator" yaml:"creator"` // Creator only (who created) + Editor bool `json:"editor" yaml:"editor"` // Editor only (who last updated) + Team bool `json:"team" yaml:"team"` // Team only + Extra map[string]interface{} `json:"extra,omitempty" yaml:"extra,omitempty"` // Extra constraints + Endpoints []string `json:"endpoints" yaml:"endpoints"` // Endpoint list (format: METHOD /path) } // ============ Runtime Structures (optimized for querying) ============ @@ -143,9 +146,16 @@ type EndpointInfo struct { // If Policy is require-scopes, the scopes required to access RequiredScopes []string // Scope list (OR relationship, any one satisfied) - // Resource constraints - OwnerOnly bool // Owner only - TeamOnly bool // Team only + // Built-in resource constraints (common cases) + OwnerOnly bool // Owner only (current owner of the resource) + CreatorOnly bool // Creator only (who created the resource) + EditorOnly bool // Editor only (who last updated the resource) + TeamOnly bool // Team only + + // Extra constraints (user-defined, flexible extension) + // Examples: "department_only", "region_only", "project_only" + // Value can be bool, string, or other types for complex constraints + Extra map[string]interface{} `json:"extra,omitempty" yaml:"extra,omitempty"` } // GetConstraints returns all data access constraints as a map @@ -157,19 +167,29 @@ func (e *EndpointInfo) GetConstraints() map[string]interface{} { constraints := make(map[string]interface{}) + // Built-in constraints if e.OwnerOnly { constraints["owner_only"] = true } + if e.CreatorOnly { + constraints["creator_only"] = true + } + + if e.EditorOnly { + constraints["editor_only"] = true + } + if e.TeamOnly { constraints["team_only"] = true } - // Future constraints can be added here without breaking existing code - // Example: - // if e.DepartmentOnly { - // constraints["department_only"] = true - // } + // Merge extra constraints + if e.Extra != nil { + for key, value := range e.Extra { + constraints[key] = value + } + } return constraints } @@ -188,11 +208,14 @@ const ( // Scope represents a permission scope type Scope struct { - Name string // Scope name - Description string // Description - Owner bool // Owner only - Team bool // Team only - Endpoints []string // Associated endpoint list + Name string // Scope name + Description string // Description + Owner bool // Owner only (current owner) + Creator bool // Creator only (who created) + Editor bool // Editor only (who last updated) + Team bool // Team only + Extra map[string]interface{} // Extra constraints + Endpoints []string // Associated endpoint list } // ============ Request Context (permission check context) ============ diff --git a/openapi/oauth/authorized/utils.go b/openapi/oauth/authorized/utils.go index f13c85ed..8c7fe898 100644 --- a/openapi/oauth/authorized/utils.go +++ b/openapi/oauth/authorized/utils.go @@ -55,24 +55,37 @@ func GetInfo(c *gin.Context) *types.AuthorizedInfo { func GetConstraints(c *gin.Context) types.DataConstraints { constraints := types.DataConstraints{} + // Built-in constraints if ownerOnly, ok := c.Get("__owner_only"); ok { if ownerOnlyBool, ok := ownerOnly.(bool); ok { constraints.OwnerOnly = ownerOnlyBool } } + if creatorOnly, ok := c.Get("__creator_only"); ok { + if creatorOnlyBool, ok := creatorOnly.(bool); ok { + constraints.CreatorOnly = creatorOnlyBool + } + } + + if editorOnly, ok := c.Get("__editor_only"); ok { + if editorOnlyBool, ok := editorOnly.(bool); ok { + constraints.EditorOnly = editorOnlyBool + } + } + if teamOnly, ok := c.Get("__team_only"); ok { if teamOnlyBool, ok := teamOnly.(bool); ok { constraints.TeamOnly = teamOnlyBool } } - // Future constraints can be read here: - // if departmentOnly, ok := c.Get("__department_only"); ok { - // if deptBool, ok := departmentOnly.(bool); ok { - // constraints.DepartmentOnly = deptBool - // } - // } + // Extra constraints + if extraConstraints, ok := c.Get("__extra_constraints"); ok { + if extra, ok := extraConstraints.(map[string]interface{}); ok { + constraints.Extra = extra + } + } return constraints } diff --git a/openapi/oauth/guard.go b/openapi/oauth/guard.go index da377833..b7ab4c18 100644 --- a/openapi/oauth/guard.go +++ b/openapi/oauth/guard.go @@ -7,6 +7,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/yaoapp/kun/log" "github.com/yaoapp/yao/openapi/oauth/acl" "github.com/yaoapp/yao/openapi/oauth/authorized" "github.com/yaoapp/yao/openapi/oauth/types" @@ -50,6 +51,7 @@ func (s *Service) Guard(c *gin.Context) { // Check permissions and enforce rate limits when ACL is configured ok, err := acl.Global.Enforce(c) if err != nil { + log.Error("[OAuth] ACL enforcement failed: %v", err) s.handleACLError(c, err) return } diff --git a/openapi/oauth/providers/user/default.go b/openapi/oauth/providers/user/default.go index 5fedf228..c7e9f232 100644 --- a/openapi/oauth/providers/user/default.go +++ b/openapi/oauth/providers/user/default.go @@ -142,7 +142,7 @@ var ( // DefaultTeamFields contains basic team fields DefaultTeamFields = []interface{}{ "team_id", "name", "display_name", "description", "website", "logo", - "owner_id", "status", "type_id", "type", "is_verified", "verified_at", + "owner_id", "status", "role_id", "type_id", "type", "is_verified", "verified_at", "created_at", "updated_at", } @@ -150,7 +150,7 @@ var ( DefaultTeamDetailFields = []interface{}{ "team_id", "name", "display_name", "description", "website", "logo", "owner_id", "contact_email", "contact_phone", "is_verified", "verified_at", "verified_by", - "team_code", "team_code_type", "status", "type_id", "type", "address", "street_address", + "team_code", "team_code_type", "status", "role_id", "type_id", "type", "address", "street_address", "city", "state_province", "postal_code", "country", "country_name", "region", "zoneinfo", "settings", "metadata", "created_at", "updated_at", } diff --git a/openapi/oauth/types/types.go b/openapi/oauth/types/types.go index d2df6bc2..d97a87b5 100644 --- a/openapi/oauth/types/types.go +++ b/openapi/oauth/types/types.go @@ -599,13 +599,15 @@ type TokenClaims struct { // DataConstraints represents data access constraints // These constraints are set by ACL enforcement and used by API handlers to filter data type DataConstraints struct { - OwnerOnly bool `json:"owner_only,omitempty"` // Only access owner's data (filter by UserID) - TeamOnly bool `json:"team_only,omitempty"` // Only access team's data (filter by TeamID) + // Built-in constraints + OwnerOnly bool `json:"owner_only,omitempty"` // Only access owner's data (current owner) + CreatorOnly bool `json:"creator_only,omitempty"` // Only access creator's data (who created) + EditorOnly bool `json:"editor_only,omitempty"` // Only access editor's data (who last updated) + TeamOnly bool `json:"team_only,omitempty"` // Only access team's data (filter by TeamID) - // Future constraints can be added here: - // DepartmentOnly bool `json:"department_only,omitempty"` // Only access department's data - // ProjectOnly bool `json:"project_only,omitempty"` // Only access project's data - // RegionOnly bool `json:"region_only,omitempty"` // Only access region's data + // Extra constraints (user-defined, flexible extension) + // Examples: department_only, region_only, project_only + Extra map[string]interface{} `json:"extra,omitempty"` // Extra constraints } // AuthorizedInfo represents authorized information diff --git a/openapi/tests/oauth/acl/role/role_test.go b/openapi/tests/oauth/acl/role/role_test.go index 68776623..0962a81b 100644 --- a/openapi/tests/oauth/acl/role/role_test.go +++ b/openapi/tests/oauth/acl/role/role_test.go @@ -434,12 +434,13 @@ func TestGetTeamRole(t *testing.T) { require.NoError(t, err) defer provider.DeleteTeam(ctx, teamID) - // Get team role (should return default) + // Get team role (should return error when no role_id assigned) roleID, err := manager.GetTeamRole(ctx, teamID) - assert.NoError(t, err) - assert.Equal(t, "team:default", roleID) + assert.Error(t, err, "Should return error when team has no role_id") + assert.Contains(t, err.Error(), "has no role_id assigned", "Error message should indicate missing role_id") + assert.Empty(t, roleID, "Role ID should be empty when error occurs") - t.Logf("Successfully returned default role for team without role_id: %s", roleID) + t.Logf("Correctly returns error for team without role_id: %v", err) }) t.Run("GetRoleForNonExistentTeam", func(t *testing.T) {