Merge pull request #1222 from trheyi/main

Enhance ACL enforcement and logging for improved error handling
This commit is contained in:
Max 2025-10-22 09:51:48 +08:00 committed by GitHub
commit e132c355d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 282 additions and 134 deletions

View file

@ -346,11 +346,27 @@ func HandleRequest(c *gin.Context) {
// e.g., WHERE user_id = authInfo.UserID // 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 { if authInfo.Constraints.TeamOnly {
// Only return data owned by current team // Only return data owned by current team
// e.g., WHERE team_id = authInfo.TeamID // 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... // Process request...
} }
``` ```
@ -362,13 +378,14 @@ After successful ACL enforcement, the `AuthorizedInfo` is automatically updated
```go ```go
// DataConstraints represents data access constraints // DataConstraints represents data access constraints
type DataConstraints struct { type DataConstraints struct {
OwnerOnly bool // Only access owner's data (filter by UserID) // Built-in constraints
TeamOnly bool // Only access team's data (filter by TeamID) 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: // Extra constraints (user-defined, flexible extension)
// DepartmentOnly bool Extra map[string]interface{} // Custom constraints like department_only, region_only, etc.
// ProjectOnly bool
// RegionOnly bool
} }
type AuthorizedInfo struct { type AuthorizedInfo struct {
@ -394,78 +411,79 @@ type AuthorizedInfo struct {
The constraint system uses a map-based approach for easy extension: The constraint system uses a map-based approach for easy extension:
```go ```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 { type DataConstraints struct {
OwnerOnly bool // Built-in constraints (pre-defined)
TeamOnly bool OwnerOnly bool
DepartmentOnly bool // New constraint CreatorOnly bool
EditorOnly bool
TeamOnly bool
// Extra constraints (user-defined, flexible)
Extra map[string]interface{}
} }
// Step 2: Add field to EndpointInfo (acl/types.go) // Define custom constraints in scope YAML:
type EndpointInfo struct { // collections:read:department:
OwnerOnly bool // description: "Read collections in user's department"
TeamOnly bool // extra:
DepartmentOnly bool // New constraint // 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) // ONLY if you need a new BUILT-IN constraint (used frequently across the system):
func (e *EndpointInfo) GetConstraints() map[string]interface{} { // Follow these steps to add it alongside OwnerOnly, CreatorOnly, etc.
constraints := make(map[string]interface{}) // But for most cases, using Extra is sufficient and more flexible!
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!
``` ```
**Example Endpoint Configuration**: **Example Endpoint Configuration**:
```yaml ```yaml
# openapi/scopes/collections/read.yml # openapi/scopes/collections/read.yml
collections:read: collections:read:own:
name: "collections:read" name: "collections:read:own"
description: "Read collections" description: "Read own collections"
owner: true # This sets OwnerOnly = true 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: endpoints:
- "GET /api/collections" - "GET /api/collections/own"
- "GET /api/collections/:id" - "GET /api/collections/own/:id"
``` ```
**Example API Handler**: **Example API Handler**:
@ -476,17 +494,25 @@ func GetCollections(c *gin.Context) {
query := db.Query("SELECT * FROM collections") query := db.Query("SELECT * FROM collections")
// Apply data access constraints // Apply built-in data access constraints
if authInfo.Constraints.OwnerOnly { if authInfo.Constraints.OwnerOnly {
query = query.Where("user_id = ?", authInfo.UserID) 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 { } else if authInfo.Constraints.TeamOnly {
query = query.Where("team_id = ?", authInfo.TeamID) query = query.Where("team_id = ?", authInfo.TeamID)
} }
// Future: Handle additional constraints // Apply extra constraints
// if authInfo.Constraints.DepartmentOnly { if dept, ok := authInfo.Constraints.Extra["department_only"].(bool); ok && dept {
// query = query.Where("department_id = ?", authInfo.DepartmentID) 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 // Execute query and return results
collections, _ := query.Get() 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 - Temporarily revoke access to certain endpoints without changing the base role
- Implement exceptions to general permissions - Implement exceptions to general permissions
**Q: How do OwnerOnly and TeamOnly constraints work?** **Q: How do data constraints work?**
A: After successful ACL enforcement: A: After successful ACL enforcement:
1. The system checks if the matched endpoint has `owner: true` or `team: true` in its scope definition 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` (`authInfo.Constraints.OwnerOnly`, `authInfo.Constraints.TeamOnly`) 2. These flags are automatically set in `AuthorizedInfo.Constraints`
3. API handlers can read these flags from `authorized.GetInfo(c)` and apply data filters 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` 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?** **Q: What's the difference between Owner, Creator, and Editor constraints?**
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`). A:
**Q: What happens if OwnerOnly is true but UserID is empty?** - **OwnerOnly**: Filters by current owner (who owns it now) - can be transferred
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). - **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).

View file

@ -118,7 +118,8 @@ collections:read:all:
- GET /kb/collections/:collectionID/exists - GET /kb/collections/:collectionID/exists
collections:read:own: 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" description: "Read knowledge base for own collections"
endpoints: endpoints:
- GET /kb/collections/own - GET /kb/collections/own
@ -127,6 +128,7 @@ collections:read:own:
collections:write:own: collections:write:own:
owner: true owner: true
editor: true # Only allow editing by last editor
description: "Write knowledge base for own collections" description: "Write knowledge base for own collections"
endpoints: endpoints:
- POST /kb/collections/own - POST /kb/collections/own
@ -134,21 +136,33 @@ collections:write:own:
- DELETE /kb/collections/own/:collectionID - DELETE /kb/collections/own/:collectionID
collections:read:team: collections:read:team:
team: true team: true # Only show team collections
description: "Read knowledge base for team collections" description: "Read knowledge base for team collections"
endpoints: endpoints:
- GET /kb/collections/team - GET /kb/collections/team
- GET /kb/collections/team/:collectionID - 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 #### Scope Definition Fields
| Field | Type | Required | Default | Description | | Field | Type | Required | Default | Description |
| ------------- | ------ | -------- | ------- | -------------------------------------------------------------------------------- | | ------------- | ------ | -------- | ------- | ---------------------------------------------------------------------------------- |
| `description` | string | No | "" | Human-readable description of the scope | | `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) | | `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) | | `creator` | bool | No | false | If `true`, data access is restricted to creator only (sets `CreatorOnly` constraint) |
| `endpoints` | array | Yes | - | List of API endpoints this scope grants access to | | `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 #### Endpoint Format

View file

@ -164,7 +164,7 @@ func (acl *ACL) enforceClient(ctx context.Context, authInfo *types.AuthorizedInf
if err != nil { if err != nil {
return false, nil, &Error{ return false, nil, &Error{
Type: ErrorTypeInternal, 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, Stage: EnforcementStageClient,
} }
} }
@ -174,7 +174,7 @@ func (acl *ACL) enforceClient(ctx context.Context, authInfo *types.AuthorizedInf
if err != nil { if err != nil {
return false, nil, &Error{ return false, nil, &Error{
Type: ErrorTypeInternal, 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, Stage: EnforcementStageClient,
} }
} }
@ -193,6 +193,9 @@ func (acl *ACL) enforceClient(ctx context.Context, authInfo *types.AuthorizedInf
Message: decision.Reason, Message: decision.Reason,
Stage: EnforcementStageClient, Stage: EnforcementStageClient,
Details: map[string]interface{}{ Details: map[string]interface{}{
"client_id": authInfo.ClientID,
"method": request.Method,
"path": request.Path,
"required_scopes": decision.RequiredScopes, "required_scopes": decision.RequiredScopes,
"missing_scopes": decision.MissingScopes, "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, Message: "access denied by restriction: " + restrictDecision.Reason,
Stage: EnforcementStageClient, Stage: EnforcementStageClient,
Details: map[string]interface{}{ Details: map[string]interface{}{
"client_id": authInfo.ClientID,
"method": request.Method,
"path": request.Path,
"restricted_scopes": restrictedScopes, "restricted_scopes": restrictedScopes,
"matched_pattern": restrictDecision.MatchedPattern, "matched_pattern": restrictDecision.MatchedPattern,
}, },
@ -259,6 +265,10 @@ func (acl *ACL) enforceScope(_ context.Context, authInfo *types.AuthorizedInfo,
Message: decision.Reason, Message: decision.Reason,
Stage: EnforcementStageScope, Stage: EnforcementStageScope,
Details: map[string]interface{}{ Details: map[string]interface{}{
"client_id": authInfo.ClientID,
"user_id": authInfo.UserID,
"method": request.Method,
"path": request.Path,
"required_scopes": decision.RequiredScopes, "required_scopes": decision.RequiredScopes,
"missing_scopes": decision.MissingScopes, "missing_scopes": decision.MissingScopes,
}, },
@ -277,7 +287,7 @@ func (acl *ACL) enforceUser(ctx context.Context, authInfo *types.AuthorizedInfo,
if err != nil { if err != nil {
return false, nil, &Error{ return false, nil, &Error{
Type: ErrorTypeInternal, 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, Stage: EnforcementStageUser,
} }
} }
@ -287,7 +297,7 @@ func (acl *ACL) enforceUser(ctx context.Context, authInfo *types.AuthorizedInfo,
if err != nil { if err != nil {
return false, nil, &Error{ return false, nil, &Error{
Type: ErrorTypeInternal, 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, Stage: EnforcementStageUser,
} }
} }
@ -306,6 +316,9 @@ func (acl *ACL) enforceUser(ctx context.Context, authInfo *types.AuthorizedInfo,
Message: decision.Reason, Message: decision.Reason,
Stage: EnforcementStageUser, Stage: EnforcementStageUser,
Details: map[string]interface{}{ Details: map[string]interface{}{
"user_id": authInfo.UserID,
"method": request.Method,
"path": request.Path,
"required_scopes": decision.RequiredScopes, "required_scopes": decision.RequiredScopes,
"missing_scopes": decision.MissingScopes, "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, Message: "access denied by restriction: " + restrictDecision.Reason,
Stage: EnforcementStageUser, Stage: EnforcementStageUser,
Details: map[string]interface{}{ Details: map[string]interface{}{
"user_id": authInfo.UserID,
"method": request.Method,
"path": request.Path,
"restricted_scopes": restrictedScopes, "restricted_scopes": restrictedScopes,
"matched_pattern": restrictDecision.MatchedPattern, "matched_pattern": restrictDecision.MatchedPattern,
}, },
@ -346,7 +362,7 @@ func (acl *ACL) enforceTeam(ctx context.Context, authInfo *types.AuthorizedInfo,
if err != nil { if err != nil {
return false, nil, &Error{ return false, nil, &Error{
Type: ErrorTypeInternal, 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, Stage: EnforcementStageTeam,
} }
} }
@ -356,7 +372,7 @@ func (acl *ACL) enforceTeam(ctx context.Context, authInfo *types.AuthorizedInfo,
if err != nil { if err != nil {
return false, nil, &Error{ return false, nil, &Error{
Type: ErrorTypeInternal, 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, Stage: EnforcementStageTeam,
} }
} }
@ -375,6 +391,10 @@ func (acl *ACL) enforceTeam(ctx context.Context, authInfo *types.AuthorizedInfo,
Message: decision.Reason, Message: decision.Reason,
Stage: EnforcementStageTeam, Stage: EnforcementStageTeam,
Details: map[string]interface{}{ Details: map[string]interface{}{
"team_id": authInfo.TeamID,
"user_id": authInfo.UserID,
"method": request.Method,
"path": request.Path,
"required_scopes": decision.RequiredScopes, "required_scopes": decision.RequiredScopes,
"missing_scopes": decision.MissingScopes, "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, Message: "access denied by restriction: " + restrictDecision.Reason,
Stage: EnforcementStageTeam, Stage: EnforcementStageTeam,
Details: map[string]interface{}{ Details: map[string]interface{}{
"team_id": authInfo.TeamID,
"user_id": authInfo.UserID,
"method": request.Method,
"path": request.Path,
"restricted_scopes": restrictedScopes, "restricted_scopes": restrictedScopes,
"matched_pattern": restrictDecision.MatchedPattern, "matched_pattern": restrictDecision.MatchedPattern,
}, },
@ -415,7 +439,7 @@ func (acl *ACL) enforceMember(ctx context.Context, authInfo *types.AuthorizedInf
if err != nil { if err != nil {
return false, nil, &Error{ return false, nil, &Error{
Type: ErrorTypeInternal, 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, Stage: EnforcementStageMember,
} }
} }
@ -425,7 +449,7 @@ func (acl *ACL) enforceMember(ctx context.Context, authInfo *types.AuthorizedInf
if err != nil { if err != nil {
return false, nil, &Error{ return false, nil, &Error{
Type: ErrorTypeInternal, 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, Stage: EnforcementStageMember,
} }
} }
@ -444,6 +468,10 @@ func (acl *ACL) enforceMember(ctx context.Context, authInfo *types.AuthorizedInf
Message: decision.Reason, Message: decision.Reason,
Stage: EnforcementStageMember, Stage: EnforcementStageMember,
Details: map[string]interface{}{ Details: map[string]interface{}{
"team_id": authInfo.TeamID,
"user_id": authInfo.UserID,
"method": request.Method,
"path": request.Path,
"required_scopes": decision.RequiredScopes, "required_scopes": decision.RequiredScopes,
"missing_scopes": decision.MissingScopes, "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, Message: "access denied by restriction: " + restrictDecision.Reason,
Stage: EnforcementStageMember, Stage: EnforcementStageMember,
Details: map[string]interface{}{ Details: map[string]interface{}{
"team_id": authInfo.TeamID,
"user_id": authInfo.UserID,
"method": request.Method,
"path": request.Path,
"restricted_scopes": restrictedScopes, "restricted_scopes": restrictedScopes,
"matched_pattern": restrictDecision.MatchedPattern, "matched_pattern": restrictDecision.MatchedPattern,
}, },

View file

@ -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 // Note: Teams might not have a role_id field, adjust based on your schema
roleID, ok := teamInfo["role_id"].(string) roleID, ok := teamInfo["role_id"].(string)
if !ok || roleID == "" { if !ok || roleID == "" {
// If team doesn't have a role, return a default team role return "", fmt.Errorf("team %s has no role_id assigned", teamID)
return "team:default", nil
} }
return roleID, nil return roleID, nil

View file

@ -240,7 +240,10 @@ func (m *ScopeManager) buildIndexes() error {
Name: name, Name: name,
Description: def.Description, Description: def.Description,
Owner: def.Owner, Owner: def.Owner,
Creator: def.Creator,
Editor: def.Editor,
Team: def.Team, Team: def.Team,
Extra: def.Extra,
Endpoints: def.Endpoints, Endpoints: def.Endpoints,
} }
} }
@ -315,16 +318,33 @@ func (m *ScopeManager) addEndpointRule(method, path, action string, scopes []str
RequiredScopes: scopes, RequiredScopes: scopes,
} }
// Set owner/team constraints from scope definitions // Set constraints from scope definitions
if len(scopes) > 0 { if len(scopes) > 0 {
for _, scopeName := range scopes { for _, scopeName := range scopes {
if def := m.scopes[scopeName]; def != nil { if def := m.scopes[scopeName]; def != nil {
// Built-in constraints
if def.Owner { if def.Owner {
info.OwnerOnly = true info.OwnerOnly = true
} }
if def.Creator {
info.CreatorOnly = true
}
if def.Editor {
info.EditorOnly = true
}
if def.Team { if def.Team {
info.TeamOnly = true 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
}
}
} }
} }
} }

View file

@ -78,11 +78,14 @@ type AliasConfig map[string][]string
// ScopeDefinition represents a scope definition (from subdirectory yml files) // ScopeDefinition represents a scope definition (from subdirectory yml files)
type ScopeDefinition struct { type ScopeDefinition struct {
Name string `json:"name" yaml:"name"` // Scope name (e.g. collections:read:all) Name string `json:"name" yaml:"name"` // Scope name (e.g. collections:read:all)
Description string `json:"description" yaml:"description"` // Description Description string `json:"description" yaml:"description"` // Description
Owner bool `json:"owner" yaml:"owner"` // Owner only Owner bool `json:"owner" yaml:"owner"` // Owner only (current owner)
Team bool `json:"team" yaml:"team"` // Team only Creator bool `json:"creator" yaml:"creator"` // Creator only (who created)
Endpoints []string `json:"endpoints" yaml:"endpoints"` // Endpoint list (format: METHOD /path) 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) ============ // ============ Runtime Structures (optimized for querying) ============
@ -143,9 +146,16 @@ type EndpointInfo struct {
// If Policy is require-scopes, the scopes required to access // If Policy is require-scopes, the scopes required to access
RequiredScopes []string // Scope list (OR relationship, any one satisfied) RequiredScopes []string // Scope list (OR relationship, any one satisfied)
// Resource constraints // Built-in resource constraints (common cases)
OwnerOnly bool // Owner only OwnerOnly bool // Owner only (current owner of the resource)
TeamOnly bool // Team only 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 // 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{}) constraints := make(map[string]interface{})
// Built-in constraints
if e.OwnerOnly { if e.OwnerOnly {
constraints["owner_only"] = true constraints["owner_only"] = true
} }
if e.CreatorOnly {
constraints["creator_only"] = true
}
if e.EditorOnly {
constraints["editor_only"] = true
}
if e.TeamOnly { if e.TeamOnly {
constraints["team_only"] = true constraints["team_only"] = true
} }
// Future constraints can be added here without breaking existing code // Merge extra constraints
// Example: if e.Extra != nil {
// if e.DepartmentOnly { for key, value := range e.Extra {
// constraints["department_only"] = true constraints[key] = value
// } }
}
return constraints return constraints
} }
@ -188,11 +208,14 @@ const (
// Scope represents a permission scope // Scope represents a permission scope
type Scope struct { type Scope struct {
Name string // Scope name Name string // Scope name
Description string // Description Description string // Description
Owner bool // Owner only Owner bool // Owner only (current owner)
Team bool // Team only Creator bool // Creator only (who created)
Endpoints []string // Associated endpoint list 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) ============ // ============ Request Context (permission check context) ============

View file

@ -55,24 +55,37 @@ func GetInfo(c *gin.Context) *types.AuthorizedInfo {
func GetConstraints(c *gin.Context) types.DataConstraints { func GetConstraints(c *gin.Context) types.DataConstraints {
constraints := types.DataConstraints{} constraints := types.DataConstraints{}
// Built-in constraints
if ownerOnly, ok := c.Get("__owner_only"); ok { if ownerOnly, ok := c.Get("__owner_only"); ok {
if ownerOnlyBool, ok := ownerOnly.(bool); ok { if ownerOnlyBool, ok := ownerOnly.(bool); ok {
constraints.OwnerOnly = ownerOnlyBool 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 teamOnly, ok := c.Get("__team_only"); ok {
if teamOnlyBool, ok := teamOnly.(bool); ok { if teamOnlyBool, ok := teamOnly.(bool); ok {
constraints.TeamOnly = teamOnlyBool constraints.TeamOnly = teamOnlyBool
} }
} }
// Future constraints can be read here: // Extra constraints
// if departmentOnly, ok := c.Get("__department_only"); ok { if extraConstraints, ok := c.Get("__extra_constraints"); ok {
// if deptBool, ok := departmentOnly.(bool); ok { if extra, ok := extraConstraints.(map[string]interface{}); ok {
// constraints.DepartmentOnly = deptBool constraints.Extra = extra
// } }
// } }
return constraints return constraints
} }

View file

@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/openapi/oauth/acl" "github.com/yaoapp/yao/openapi/oauth/acl"
"github.com/yaoapp/yao/openapi/oauth/authorized" "github.com/yaoapp/yao/openapi/oauth/authorized"
"github.com/yaoapp/yao/openapi/oauth/types" "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 // Check permissions and enforce rate limits when ACL is configured
ok, err := acl.Global.Enforce(c) ok, err := acl.Global.Enforce(c)
if err != nil { if err != nil {
log.Error("[OAuth] ACL enforcement failed: %v", err)
s.handleACLError(c, err) s.handleACLError(c, err)
return return
} }

View file

@ -142,7 +142,7 @@ var (
// DefaultTeamFields contains basic team fields // DefaultTeamFields contains basic team fields
DefaultTeamFields = []interface{}{ DefaultTeamFields = []interface{}{
"team_id", "name", "display_name", "description", "website", "logo", "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", "created_at", "updated_at",
} }
@ -150,7 +150,7 @@ var (
DefaultTeamDetailFields = []interface{}{ DefaultTeamDetailFields = []interface{}{
"team_id", "name", "display_name", "description", "website", "logo", "team_id", "name", "display_name", "description", "website", "logo",
"owner_id", "contact_email", "contact_phone", "is_verified", "verified_at", "verified_by", "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", "city", "state_province", "postal_code", "country", "country_name", "region", "zoneinfo",
"settings", "metadata", "created_at", "updated_at", "settings", "metadata", "created_at", "updated_at",
} }

View file

@ -599,13 +599,15 @@ type TokenClaims struct {
// DataConstraints represents data access constraints // DataConstraints represents data access constraints
// These constraints are set by ACL enforcement and used by API handlers to filter data // These constraints are set by ACL enforcement and used by API handlers to filter data
type DataConstraints struct { type DataConstraints struct {
OwnerOnly bool `json:"owner_only,omitempty"` // Only access owner's data (filter by UserID) // Built-in constraints
TeamOnly bool `json:"team_only,omitempty"` // Only access team's data (filter by TeamID) 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: // Extra constraints (user-defined, flexible extension)
// DepartmentOnly bool `json:"department_only,omitempty"` // Only access department's data // Examples: department_only, region_only, project_only
// ProjectOnly bool `json:"project_only,omitempty"` // Only access project's data Extra map[string]interface{} `json:"extra,omitempty"` // Extra constraints
// RegionOnly bool `json:"region_only,omitempty"` // Only access region's data
} }
// AuthorizedInfo represents authorized information // AuthorizedInfo represents authorized information

View file

@ -434,12 +434,13 @@ func TestGetTeamRole(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
defer provider.DeleteTeam(ctx, teamID) 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) roleID, err := manager.GetTeamRole(ctx, teamID)
assert.NoError(t, err) assert.Error(t, err, "Should return error when team has no role_id")
assert.Equal(t, "team:default", roleID) 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) { t.Run("GetRoleForNonExistentTeam", func(t *testing.T) {