Merge pull request #1222 from trheyi/main
Enhance ACL enforcement and logging for improved error handling
This commit is contained in:
commit
e132c355d1
11 changed files with 282 additions and 134 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) ============
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue