Merge pull request #1221 from trheyi/main
Implement detailed ACL enforcement logic and data access constraints
This commit is contained in:
commit
a321ed7405
12 changed files with 2819 additions and 60 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -46,5 +46,4 @@ data/bindata.go.bak
|
|||
share/const.go.bak
|
||||
share/const.goe
|
||||
.cursor
|
||||
openapi/*.md
|
||||
openapi/oauth/acl/*.md
|
||||
openapi/*.md
|
||||
273
openapi/oauth/acl/DESIGN.md
Normal file
273
openapi/oauth/acl/DESIGN.md
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
# ACL System Design Document
|
||||
|
||||
## I. Design Goals
|
||||
|
||||
1. **High Performance**: Permission checks should be very fast (O(1) or O(log n) level)
|
||||
2. **Concurrency Safe**: Support multi-threaded concurrent reads and safe dynamic updates
|
||||
3. **Flexible Configuration**: Support multi-level permission configuration (global, alias, specific scopes)
|
||||
4. **Path Matching**: Support exact match, parameter match (:id), and wildcard match (\*)
|
||||
|
||||
## II. Data Structure Design
|
||||
|
||||
### 2.1 Configuration Layer
|
||||
|
||||
Raw data loaded from configuration files:
|
||||
|
||||
```
|
||||
GlobalConfig (scopes.yml)
|
||||
├── default: "allow" | "deny" # Default policy
|
||||
├── public: []string # Public endpoints (no authentication required)
|
||||
└── endpoints: []EndpointRule # Default endpoint rules
|
||||
|
||||
AliasConfig (alias.yml)
|
||||
└── map[string][]string # Alias -> scopes list
|
||||
|
||||
ScopeDefinition (kb/*.yml, job/*.yml...)
|
||||
├── name: string # Scope name
|
||||
├── description: string # Description
|
||||
├── owner: bool # Owner only
|
||||
├── team: bool # Team only
|
||||
└── endpoints: []string # Endpoint list
|
||||
```
|
||||
|
||||
### 2.2 Runtime Layer
|
||||
|
||||
Optimized index structures for fast queries:
|
||||
|
||||
```
|
||||
ScopeManager
|
||||
├── mu: sync.RWMutex # Read-write lock (supports concurrency)
|
||||
├── defaultAction: string # Default policy
|
||||
├── publicPaths: map[string]struct{} # Public path set - O(1) lookup
|
||||
├── endpointIndex: map[string]*PathMatcher # method -> path matcher
|
||||
├── scopeIndex: map[string]*Scope # scope_name -> Scope details
|
||||
└── aliasIndex: map[string][]string # alias -> expanded scopes
|
||||
```
|
||||
|
||||
### 2.3 Path Matcher
|
||||
|
||||
Organize path rules by priority:
|
||||
|
||||
```
|
||||
PathMatcher (per HTTP method)
|
||||
├── exactPaths: map[string]*EndpointInfo
|
||||
│ └── "/kb/collections" -> EndpointInfo # Exact match (priority 1)
|
||||
│
|
||||
├── paramPaths: map[string]*EndpointInfo
|
||||
│ └── "/kb/collections/:id" -> EndpointInfo # Parameter match (priority 2)
|
||||
│
|
||||
└── wildcardPaths: []*WildcardPath
|
||||
├── "/kb/collections/*" -> EndpointInfo # Longer prefix first
|
||||
└── "/kb/*" -> EndpointInfo # Wildcard match (priority 3)
|
||||
```
|
||||
|
||||
**Matching Logic**:
|
||||
|
||||
1. Check exactPaths first (O(1) map lookup)
|
||||
2. Then check paramPaths (O(1) map lookup, requires path normalization)
|
||||
3. Finally iterate wildcardPaths (sorted by prefix length, longer first)
|
||||
|
||||
### 2.4 Endpoint Info
|
||||
|
||||
Store access control policy for each endpoint:
|
||||
|
||||
```
|
||||
EndpointInfo
|
||||
├── Method: string # HTTP method
|
||||
├── Path: string # Path pattern
|
||||
├── Policy: EndpointPolicy # allow / deny / require-scopes
|
||||
├── RequiredScopes: []string # Required scopes (OR relationship)
|
||||
├── OwnerOnly: bool # Owner only
|
||||
└── TeamOnly: bool # Team only
|
||||
```
|
||||
|
||||
## III. Permission Check Flow
|
||||
|
||||
```
|
||||
Check(method, path, scopes)
|
||||
│
|
||||
├─1. Check if public path (O(1))
|
||||
│ └─→ Yes: Allow access
|
||||
│
|
||||
├─2. Get PathMatcher by method (O(1))
|
||||
│ └─→ Not found: Use default policy
|
||||
│
|
||||
├─3. Path matching (by priority)
|
||||
│ ├─ 3.1 Exact match (O(1))
|
||||
│ ├─ 3.2 Parameter match (O(1))
|
||||
│ └─ 3.3 Wildcard match (O(n), n is small)
|
||||
│
|
||||
├─4. Apply policy based on match result
|
||||
│ ├─ PolicyAllow: Allow access
|
||||
│ ├─ PolicyDeny: Deny access
|
||||
│ └─ PolicyRequireScopes:
|
||||
│ │
|
||||
│ ├─ 4.1 Expand aliases (if any)
|
||||
│ ├─ 4.2 Check if user has any required scope (OR relationship)
|
||||
│ ├─ 4.3 Check resource constraints (owner/team)
|
||||
│ └─ 4.4 Return decision result
|
||||
│
|
||||
└─5. Return AccessDecision (with detailed information)
|
||||
```
|
||||
|
||||
## IV. Performance Optimizations
|
||||
|
||||
### 4.1 Index Optimization
|
||||
|
||||
- **Method Grouping**: Independent indexes for different HTTP methods, reducing search space
|
||||
- **Multi-layer Matching**: Exact > Parameter > Wildcard, fast location
|
||||
- **Map Lookup**: O(1) time complexity
|
||||
|
||||
### 4.2 Concurrency Optimization
|
||||
|
||||
- **Read-Write Lock**: Use `sync.RWMutex` for read-heavy scenarios
|
||||
- **Non-blocking Reads**: Multiple goroutines can read concurrently
|
||||
- **Safe Writes**: Acquire write lock when updating configuration
|
||||
|
||||
### 4.3 Cache Optimization (Optional, future implementation)
|
||||
|
||||
- Can cache recent permission check results
|
||||
- Use LRU cache to avoid repeated calculations
|
||||
|
||||
### 4.4 Path Normalization
|
||||
|
||||
- Pre-process path patterns, extract parameter positions
|
||||
- Sort wildcard paths by prefix length to avoid redundant matching
|
||||
|
||||
## V. Configuration Loading Flow
|
||||
|
||||
```
|
||||
Load(config *Config)
|
||||
│
|
||||
├─1. Load scopes.yml (global configuration)
|
||||
│
|
||||
├─2. Load alias.yml (alias configuration)
|
||||
│
|
||||
├─3. Recursively scan subdirectories (kb/, job/, user/, file/)
|
||||
│ └─→ Load all *.yml files, parse ScopeDefinition
|
||||
│
|
||||
├─4. Build runtime indexes
|
||||
│ ├─ 4.1 Process global endpoints rules
|
||||
│ ├─ 4.2 Process endpoints for each ScopeDefinition
|
||||
│ ├─ 4.3 Build PathMatcher indexes
|
||||
│ └─ 4.4 Build scopeIndex and aliasIndex
|
||||
│
|
||||
├─5. Set global variable acl.Global
|
||||
│
|
||||
└─6. Return ScopeManager
|
||||
```
|
||||
|
||||
## VI. Usage Examples
|
||||
|
||||
### 6.1 Permission Check
|
||||
|
||||
```go
|
||||
// Parse user information from token
|
||||
userScopes := []string{"kb:read", "file:own"}
|
||||
userID := "user123"
|
||||
teamID := "team456"
|
||||
|
||||
// Build access request
|
||||
request := &AccessRequest{
|
||||
Method: "GET",
|
||||
Path: "/kb/collections/abc123",
|
||||
Scopes: userScopes,
|
||||
UserID: userID,
|
||||
TeamID: teamID,
|
||||
}
|
||||
|
||||
// Execute permission check
|
||||
decision := acl.Global.Scope.Check(request)
|
||||
|
||||
if decision.Allowed {
|
||||
// Allow access
|
||||
} else {
|
||||
// Deny access: decision.Reason
|
||||
// Missing permissions: decision.MissingScopes
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Gin Middleware Integration
|
||||
|
||||
```go
|
||||
func (acl *ACL) Enforce(c *gin.Context) (bool, error) {
|
||||
// Get user information from context
|
||||
userScopes := getUserScopes(c)
|
||||
userID := getUserID(c)
|
||||
teamID := getTeamID(c)
|
||||
|
||||
// Build request
|
||||
request := &AccessRequest{
|
||||
Method: c.Request.Method,
|
||||
Path: c.Request.URL.Path,
|
||||
Scopes: userScopes,
|
||||
UserID: userID,
|
||||
TeamID: teamID,
|
||||
}
|
||||
|
||||
// Check permission
|
||||
decision := acl.Scope.Check(request)
|
||||
|
||||
if !decision.Allowed {
|
||||
c.JSON(403, gin.H{
|
||||
"error": "Access denied",
|
||||
"reason": decision.Reason,
|
||||
"missing_scopes": decision.MissingScopes,
|
||||
})
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
```
|
||||
|
||||
## VII. Key Issues Handling
|
||||
|
||||
### 7.1 Alias Expansion
|
||||
|
||||
- Aliases can contain aliases (recursive)
|
||||
- Need to detect circular references
|
||||
- Expand and cache during pre-loading
|
||||
|
||||
### 7.2 Path Parameter Matching
|
||||
|
||||
- `/kb/collections/:id` should match `/kb/collections/abc123`
|
||||
- Use path normalization: extract `/kb/collections/` prefix, mark parameter positions
|
||||
- Verify segment count matches during matching
|
||||
|
||||
### 7.3 Wildcard Matching
|
||||
|
||||
- `/kb/*` should match `/kb/collections` and `/kb/collections/abc123`
|
||||
- Sort by prefix length: `/kb/collections/*` takes priority over `/kb/*`
|
||||
- Avoid greedy matching
|
||||
|
||||
### 7.4 Concurrent Updates
|
||||
|
||||
- Use RWMutex to protect all index structures
|
||||
- On update: Lock() -> rebuild indexes -> Unlock()
|
||||
- On read: RLock() -> query -> RUnlock()
|
||||
|
||||
## VIII. Future Extensions
|
||||
|
||||
### 8.1 Dynamic Updates
|
||||
|
||||
- Provide `Reload()` method to reload configuration
|
||||
- Provide `Update(scope)` method to dynamically add/modify scopes
|
||||
- Hot updates should not affect ongoing requests
|
||||
|
||||
### 8.2 Audit Logging
|
||||
|
||||
- Record all permission check results
|
||||
- Facilitate debugging and security auditing
|
||||
|
||||
### 8.3 Performance Monitoring
|
||||
|
||||
- Record permission check duration
|
||||
- Monitor cache hit rate
|
||||
- Identify performance bottlenecks
|
||||
|
||||
### 8.4 More Complex Policies
|
||||
|
||||
- AND relationships: require multiple scopes simultaneously
|
||||
- Conditional expressions: dynamic permissions based on request parameters
|
||||
- Time restrictions: certain permissions only valid during specific time periods
|
||||
616
openapi/oauth/acl/README.md
Normal file
616
openapi/oauth/acl/README.md
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
# ACL Enforcement Logic
|
||||
|
||||
## Overview
|
||||
|
||||
The ACL (Access Control List) enforcement system provides a comprehensive permission validation mechanism for OAuth-protected APIs. It validates permissions through multiple layers: **Client**, **Token Scope**, **Team**, **Member**, and **User** levels.
|
||||
|
||||
**Key Principle**: All applicable validation steps must pass (AND logic). If any check fails, access is immediately denied with a specific error indicating which stage failed.
|
||||
|
||||
---
|
||||
|
||||
## Enforcement Flow Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start([Start: HTTP Request]) --> CheckEnabled{Is ACL Enabled?}
|
||||
|
||||
CheckEnabled -->|No| AllowAccess([Allow Access])
|
||||
CheckEnabled -->|Yes| CheckScope{Is Scope Manager<br/>Loaded?}
|
||||
|
||||
CheckScope -->|No| DenyAccess([Deny Access])
|
||||
CheckScope -->|Yes| GetAuth[Get AuthorizedInfo]
|
||||
|
||||
GetAuth --> EnforceClient[Step 1: enforceClient<br/>Validate Client Permissions]
|
||||
|
||||
EnforceClient -->|Failed| DenyClient([Deny: Insufficient Client Permissions<br/>Stage: client])
|
||||
EnforceClient -->|Success| CheckTokenScope{Does Token Scope<br/>Exist?}
|
||||
|
||||
CheckTokenScope -->|No| CheckLoginType{Determine Login Type}
|
||||
CheckTokenScope -->|Yes| EnforceScope[Step 2: enforceScope<br/>Validate Token Scope]
|
||||
|
||||
EnforceScope -->|Failed| DenyScope([Deny: Insufficient Token Scope<br/>Stage: scope])
|
||||
EnforceScope -->|Success| CheckLoginType
|
||||
|
||||
CheckLoginType -->|Has TeamID| EnforceTeam[Step 3.1: enforceTeam<br/>Validate Team Permissions]
|
||||
CheckLoginType -->|Has UserID<br/>No TeamID| EnforceUser[Step 3.2: enforceUser<br/>Validate User Permissions]
|
||||
CheckLoginType -->|No UserID| AllowAccess
|
||||
|
||||
EnforceTeam -->|Failed| DenyTeam([Deny: Insufficient Team Permissions<br/>Stage: team])
|
||||
EnforceTeam -->|Success| EnforceMember[Step 3.1.2: enforceMember<br/>Validate Member Permissions]
|
||||
|
||||
EnforceMember -->|Failed| DenyMember([Deny: Insufficient Member Permissions<br/>Stage: member])
|
||||
EnforceMember -->|Success| AllowAccess
|
||||
|
||||
EnforceUser -->|Failed| DenyUser([Deny: Insufficient User Permissions<br/>Stage: user])
|
||||
EnforceUser -->|Success| AllowAccess
|
||||
|
||||
style Start fill:#e1f5e1
|
||||
style AllowAccess fill:#90ee90
|
||||
style DenyAccess fill:#ffcccb
|
||||
style DenyClient fill:#ffcccb
|
||||
style DenyScope fill:#ffcccb
|
||||
style DenyTeam fill:#ffcccb
|
||||
style DenyMember fill:#ffcccb
|
||||
style DenyUser fill:#ffcccb
|
||||
style EnforceClient fill:#fff4b3
|
||||
style EnforceScope fill:#fff4b3
|
||||
style EnforceTeam fill:#fff4b3
|
||||
style EnforceMember fill:#fff4b3
|
||||
style EnforceUser fill:#fff4b3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation Steps
|
||||
|
||||
### Step 1: Client Validation (`enforceClient`)
|
||||
|
||||
**Purpose**: Validate that the OAuth client has permission to access the endpoint.
|
||||
|
||||
**Process**:
|
||||
|
||||
1. Get client role from `RoleManager.GetClientRole(clientID)`
|
||||
2. Retrieve client's scopes: `RoleManager.GetScopes(clientRole)`
|
||||
- Returns: `allowedScopes` and `restrictedScopes`
|
||||
3. **Step 1**: Check allowed scopes
|
||||
- Build `AccessRequest` with `allowedScopes`
|
||||
- Call `Scope.Check(request)` to validate
|
||||
- If fails → deny access immediately
|
||||
4. **Step 2**: Check restricted scopes (if any)
|
||||
- Build `AccessRequest` with `restrictedScopes`
|
||||
- Call `Scope.CheckRestricted(request)` for reverse validation
|
||||
- If endpoint matches restricted scopes → deny access immediately
|
||||
|
||||
**Result**:
|
||||
|
||||
- ✅ **Pass**: Both checks pass → Continue to Step 2
|
||||
- ❌ **Fail**: Either check fails → Immediately deny with `Stage: client`
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Token Scope Validation (`enforceScope`)
|
||||
|
||||
**Purpose**: Validate explicit scopes granted in the OAuth token.
|
||||
|
||||
**Process**:
|
||||
|
||||
1. Check if `authInfo.Scope` is not empty
|
||||
- If empty: Skip this step (continue to Step 3)
|
||||
2. Parse token scopes (space-separated string)
|
||||
- Example: `"read:users write:users"` → `["read:users", "write:users"]`
|
||||
3. Build `AccessRequest` with parsed scopes
|
||||
4. Call `Scope.Check(request)` to validate
|
||||
|
||||
**Result**:
|
||||
|
||||
- ⏭️ **Skip**: If no token scopes, continue to Step 3
|
||||
- ✅ **Pass**: Continue to Step 3
|
||||
- ❌ **Fail**: Immediately deny with `Stage: scope`
|
||||
|
||||
---
|
||||
|
||||
### Step 3: User/Team Validation
|
||||
|
||||
The validation path depends on the login type determined by `AuthorizedInfo`:
|
||||
|
||||
#### **3.1 Team Login** (Has `TeamID`)
|
||||
|
||||
When a user logs in as part of a team:
|
||||
|
||||
##### **3.1.1 Team Permission Validation (`enforceTeam`)**
|
||||
|
||||
**Process**:
|
||||
|
||||
1. Get team role from `RoleManager.GetTeamRole(teamID)`
|
||||
2. Retrieve team's scopes: `RoleManager.GetScopes(teamRole)`
|
||||
- Returns: `allowedScopes` and `restrictedScopes`
|
||||
3. **Step 1**: Check allowed scopes
|
||||
- Build `AccessRequest` with `allowedScopes`
|
||||
- Call `Scope.Check(request)` to validate
|
||||
- If fails → deny access immediately
|
||||
4. **Step 2**: Check restricted scopes (if any)
|
||||
- Build `AccessRequest` with `restrictedScopes`
|
||||
- Call `Scope.CheckRestricted(request)` for reverse validation
|
||||
- If endpoint matches restricted scopes → deny access immediately
|
||||
|
||||
**Result**:
|
||||
|
||||
- ✅ **Pass**: Both checks pass → Continue to Step 3.1.2
|
||||
- ❌ **Fail**: Either check fails → Immediately deny with `Stage: team`
|
||||
|
||||
##### **3.1.2 Member Permission Validation (`enforceMember`)**
|
||||
|
||||
**Process**:
|
||||
|
||||
1. Get member role from `RoleManager.GetMemberRole(teamID, userID)`
|
||||
- Represents the user's role within the team
|
||||
2. Retrieve member's scopes: `RoleManager.GetScopes(memberRole)`
|
||||
- Returns: `allowedScopes` and `restrictedScopes`
|
||||
3. **Step 1**: Check allowed scopes
|
||||
- Build `AccessRequest` with `allowedScopes`
|
||||
- Call `Scope.Check(request)` to validate
|
||||
- If fails → deny access immediately
|
||||
4. **Step 2**: Check restricted scopes (if any)
|
||||
- Build `AccessRequest` with `restrictedScopes`
|
||||
- Call `Scope.CheckRestricted(request)` for reverse validation
|
||||
- If endpoint matches restricted scopes → deny access immediately
|
||||
|
||||
**Result**:
|
||||
|
||||
- ✅ **Pass**: Both checks pass → Allow access
|
||||
- ❌ **Fail**: Either check fails → Immediately deny with `Stage: member`
|
||||
|
||||
---
|
||||
|
||||
#### **3.2 User Login** (Has `UserID`, No `TeamID`)
|
||||
|
||||
When a user logs in directly (not as part of a team):
|
||||
|
||||
##### **User Permission Validation (`enforceUser`)**
|
||||
|
||||
**Process**:
|
||||
|
||||
1. Get user role from `RoleManager.GetUserRole(userID)`
|
||||
2. Retrieve user's scopes: `RoleManager.GetScopes(userRole)`
|
||||
- Returns: `allowedScopes` and `restrictedScopes`
|
||||
3. **Step 1**: Check allowed scopes
|
||||
- Build `AccessRequest` with `allowedScopes`
|
||||
- Call `Scope.Check(request)` to validate
|
||||
- If fails → deny access immediately
|
||||
4. **Step 2**: Check restricted scopes (if any)
|
||||
- Build `AccessRequest` with `restrictedScopes`
|
||||
- Call `Scope.CheckRestricted(request)` for reverse validation
|
||||
- If endpoint matches restricted scopes → deny access immediately
|
||||
|
||||
**Result**:
|
||||
|
||||
- ✅ **Pass**: Both checks pass → Allow access
|
||||
- ❌ **Fail**: Either check fails → Immediately deny with `Stage: user`
|
||||
|
||||
---
|
||||
|
||||
#### **3.3 Pure API Call** (No `UserID`)
|
||||
|
||||
For client credential grants or service-to-service calls:
|
||||
|
||||
- Only Step 1 (client validation) is required
|
||||
- If client validation passes, access is allowed
|
||||
|
||||
---
|
||||
|
||||
## Enforcement Stages
|
||||
|
||||
Each validation failure is tagged with a specific stage for debugging and error reporting:
|
||||
|
||||
| Stage | Constant | Description |
|
||||
| ------ | ------------------------ | ----------------------------------- |
|
||||
| Client | `EnforcementStageClient` | Client permission check failed |
|
||||
| Scope | `EnforcementStageScope` | Token scope check failed |
|
||||
| Team | `EnforcementStageTeam` | Team permission check failed |
|
||||
| Member | `EnforcementStageMember` | Team member permission check failed |
|
||||
| User | `EnforcementStageUser` | User permission check failed |
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
When a validation step fails, an `Error` is returned with:
|
||||
|
||||
```go
|
||||
type Error struct {
|
||||
Type ErrorType // e.g., ErrorTypePermissionDenied
|
||||
Message string // Human-readable error message
|
||||
Stage EnforcementStage // Which stage failed
|
||||
Details map[string]interface{} // Additional context
|
||||
}
|
||||
```
|
||||
|
||||
**Example Error Response**:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "permission_denied",
|
||||
"message": "Access denied: insufficient permissions",
|
||||
"stage": "member",
|
||||
"details": {
|
||||
"required_scopes": ["collections:write"],
|
||||
"missing_scopes": ["collections:write"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Components
|
||||
|
||||
### RoleManager
|
||||
|
||||
The `RoleManager` is responsible for retrieving roles and their associated scopes:
|
||||
|
||||
```go
|
||||
// Get role for different entities
|
||||
RoleManager.GetClientRole(ctx, clientID) -> roleID
|
||||
RoleManager.GetUserRole(ctx, userID) -> roleID
|
||||
RoleManager.GetTeamRole(ctx, teamID) -> roleID
|
||||
RoleManager.GetMemberRole(ctx, teamID, userID) -> roleID
|
||||
|
||||
// Get scopes for a role
|
||||
RoleManager.GetScopes(ctx, roleID) -> (allowedScopes, restrictedScopes, error)
|
||||
```
|
||||
|
||||
### ScopeManager
|
||||
|
||||
The `ScopeManager` validates endpoint access based on scopes with two methods:
|
||||
|
||||
#### `Check(request)` - Positive Validation
|
||||
|
||||
Checks if the given scopes **grant access** to the endpoint:
|
||||
|
||||
```go
|
||||
type AccessRequest struct {
|
||||
Method string // HTTP method (GET, POST, etc.)
|
||||
Path string // Request path
|
||||
Scopes []string // User's scopes
|
||||
}
|
||||
|
||||
decision := ScopeManager.Check(request)
|
||||
// Returns: AccessDecision with Allowed, Reason, MissingScopes, etc.
|
||||
// Allowed = true: User has required scopes
|
||||
// Allowed = false: User lacks required scopes
|
||||
```
|
||||
|
||||
#### `CheckRestricted(request)` - Negative Validation (Reverse Check)
|
||||
|
||||
Checks if the given scopes **restrict access** to the endpoint:
|
||||
|
||||
```go
|
||||
decision := ScopeManager.CheckRestricted(request)
|
||||
// Returns: AccessDecision with Allowed, Reason, etc.
|
||||
// Allowed = true: Endpoint is NOT restricted by these scopes
|
||||
// Allowed = false: Endpoint IS restricted by these scopes (deny access)
|
||||
```
|
||||
|
||||
**How Restrictions Work**:
|
||||
|
||||
1. If an endpoint matches any scope in `restrictedScopes`, access is **denied**
|
||||
2. Restrictions override allowed scopes - even if `allowedScopes` grant access, `restrictedScopes` can block it
|
||||
3. This allows fine-grained control: "User can access most endpoints, except these specific ones"
|
||||
|
||||
**Example**:
|
||||
|
||||
```go
|
||||
// Role has:
|
||||
allowedScopes = ["collections:*", "documents:*"]
|
||||
restrictedScopes = ["collections:delete"]
|
||||
|
||||
// Request: DELETE /api/collections/123
|
||||
// Check(allowedScopes) → Pass (collections:* matches)
|
||||
// CheckRestricted(restrictedScopes) → Fail (collections:delete matches)
|
||||
// Final result: Access DENIED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Example
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```go
|
||||
// In your HTTP handler
|
||||
func HandleRequest(c *gin.Context) {
|
||||
// ACL enforcement is called by middleware
|
||||
allowed, err := acl.Enforce(c)
|
||||
|
||||
if err != nil {
|
||||
aclErr := err.(*acl.Error)
|
||||
c.JSON(403, gin.H{
|
||||
"error": aclErr.Type,
|
||||
"message": aclErr.Message,
|
||||
"stage": aclErr.Stage,
|
||||
"details": aclErr.Details,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
c.JSON(403, gin.H{"error": "access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// After successful ACL enforcement, get authorized info with data constraints
|
||||
authInfo := authorized.GetInfo(c)
|
||||
|
||||
// Check data access constraints to filter query results
|
||||
if authInfo.Constraints.OwnerOnly {
|
||||
// Only return data owned by current user
|
||||
// e.g., WHERE user_id = authInfo.UserID
|
||||
}
|
||||
|
||||
if authInfo.Constraints.TeamOnly {
|
||||
// Only return data owned by current team
|
||||
// e.g., WHERE team_id = authInfo.TeamID
|
||||
}
|
||||
|
||||
// Process request...
|
||||
}
|
||||
```
|
||||
|
||||
### Data Access Constraints
|
||||
|
||||
After successful ACL enforcement, the `AuthorizedInfo` is automatically updated with data access constraints from the matched endpoint:
|
||||
|
||||
```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)
|
||||
|
||||
// Future constraints:
|
||||
// DepartmentOnly bool
|
||||
// ProjectOnly bool
|
||||
// RegionOnly bool
|
||||
}
|
||||
|
||||
type AuthorizedInfo struct {
|
||||
UserID string
|
||||
TeamID string
|
||||
// ... other fields ...
|
||||
|
||||
// Data access constraints (set by ACL enforcement)
|
||||
Constraints DataConstraints
|
||||
}
|
||||
```
|
||||
|
||||
**How it works**:
|
||||
|
||||
1. During ACL enforcement, if validation passes, the system extracts constraints from the matched endpoint
|
||||
2. `EndpointInfo.GetConstraints()` returns a map of all constraints
|
||||
3. Constraints are automatically stored in the context via `authorized.UpdateConstraints()`
|
||||
4. `authorized.GetInfo(c)` reads constraints and populates the `Constraints` struct
|
||||
5. API handlers can access constraints through `authInfo.Constraints.OwnerOnly`, etc.
|
||||
|
||||
**Extensibility**:
|
||||
|
||||
The constraint system uses a map-based approach for easy extension:
|
||||
|
||||
```go
|
||||
// Step 1: Add field to DataConstraints (types/types.go)
|
||||
type DataConstraints struct {
|
||||
OwnerOnly bool
|
||||
TeamOnly bool
|
||||
DepartmentOnly bool // New constraint
|
||||
}
|
||||
|
||||
// Step 2: Add field to EndpointInfo (acl/types.go)
|
||||
type EndpointInfo struct {
|
||||
OwnerOnly bool
|
||||
TeamOnly bool
|
||||
DepartmentOnly bool // New constraint
|
||||
}
|
||||
|
||||
// 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!
|
||||
```
|
||||
|
||||
**Example Endpoint Configuration**:
|
||||
|
||||
```yaml
|
||||
# openapi/scopes/collections/read.yml
|
||||
collections:read:
|
||||
name: "collections:read"
|
||||
description: "Read collections"
|
||||
owner: true # This sets OwnerOnly = true
|
||||
endpoints:
|
||||
- "GET /api/collections"
|
||||
- "GET /api/collections/:id"
|
||||
```
|
||||
|
||||
**Example API Handler**:
|
||||
|
||||
```go
|
||||
func GetCollections(c *gin.Context) {
|
||||
authInfo := authorized.GetInfo(c)
|
||||
|
||||
query := db.Query("SELECT * FROM collections")
|
||||
|
||||
// Apply data access constraints
|
||||
if authInfo.Constraints.OwnerOnly {
|
||||
query = query.Where("user_id = ?", 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)
|
||||
// }
|
||||
|
||||
// Execute query and return results
|
||||
collections, _ := query.Get()
|
||||
c.JSON(200, collections)
|
||||
}
|
||||
```
|
||||
|
||||
### Checking Enforcement Stage
|
||||
|
||||
```go
|
||||
allowed, err := acl.Enforce(c)
|
||||
if err != nil {
|
||||
aclErr := err.(*acl.Error)
|
||||
|
||||
switch aclErr.Stage {
|
||||
case acl.EnforcementStageClient:
|
||||
// Client doesn't have permission
|
||||
log.Error("Client permission denied", "client_id", authInfo.ClientID)
|
||||
|
||||
case acl.EnforcementStageUser:
|
||||
// User doesn't have permission
|
||||
log.Error("User permission denied", "user_id", authInfo.UserID)
|
||||
|
||||
case acl.EnforcementStageMember:
|
||||
// Team member doesn't have permission
|
||||
log.Error("Member permission denied",
|
||||
"user_id", authInfo.UserID,
|
||||
"team_id", authInfo.TeamID)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Enabling ACL
|
||||
|
||||
```go
|
||||
config := &acl.Config{
|
||||
Enabled: true,
|
||||
Cache: cacheStore,
|
||||
Provider: userProvider,
|
||||
}
|
||||
|
||||
aclInstance := acl.New(config)
|
||||
```
|
||||
|
||||
### Role Manager Setup
|
||||
|
||||
```go
|
||||
roleManager := role.NewManager(cacheStore, userProvider)
|
||||
role.RoleManager = roleManager // Set global instance
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Defense in Depth**: Multiple layers of validation ensure comprehensive security
|
||||
2. **Fail-Safe**: Any validation failure results in access denial
|
||||
3. **Explicit Stages**: Clear error messages indicate exactly where validation failed
|
||||
4. **Independent Validation**: Each stage validates independently against the same endpoint
|
||||
5. **Role-Based**: Permissions are managed through roles and scopes
|
||||
6. **Dual Validation**: Each stage performs both positive (allowed) and negative (restricted) checks
|
||||
- **Allowed scopes**: Must grant access to the endpoint
|
||||
- **Restricted scopes**: Must NOT match the endpoint (reverse check)
|
||||
- Both conditions must be satisfied for access to be granted
|
||||
7. **Restriction Priority**: Restricted scopes override allowed scopes for fine-grained control
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Caching**: RoleManager caches role and scope lookups
|
||||
- **Early Exit**: Validation stops immediately on first failure
|
||||
- **Concurrent Safe**: Uses `sync.RWMutex` for thread-safe operations
|
||||
- **Efficient Matching**: PathMatcher uses optimized data structures (exact → param → wildcard)
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: What happens if RoleManager is not configured?**
|
||||
A: The RoleManager is automatically initialized when ACL is enabled. If role or permission retrieval fails (e.g., role not found), the enforcement will return an error with the appropriate stage information. For performance reasons, RoleManager should always be properly configured when ACL is enabled.
|
||||
|
||||
**Q: Can a user have multiple roles?**
|
||||
A: Currently, each entity (client/user/team/member) has one role. Multiple scopes are supported through role configuration.
|
||||
|
||||
**Q: What's the difference between Team and Member validation?**
|
||||
A: Team validation checks the team's overall permissions, while Member validation checks the specific user's role within that team.
|
||||
|
||||
**Q: Is scope matching case-sensitive?**
|
||||
A: Yes, scope names are case-sensitive (e.g., `read:users` ≠ `Read:Users`).
|
||||
|
||||
**Q: What if I want OR logic instead of AND?**
|
||||
A: The current design uses AND logic for security. For OR logic, consider assigning appropriate scopes to the client or user role that encompasses all required permissions.
|
||||
|
||||
**Q: How do restricted scopes work exactly?**
|
||||
A: Restricted scopes use reverse validation:
|
||||
|
||||
- `Check(allowedScopes)` asks: "Do these scopes grant access?"
|
||||
- `CheckRestricted(restrictedScopes)` asks: "Do these scopes forbid access?"
|
||||
- If an endpoint matches any restricted scope, access is denied regardless of allowed scopes
|
||||
|
||||
**Q: When should I use restricted scopes?**
|
||||
A: Use restricted scopes when you want to:
|
||||
|
||||
- Grant broad access but block specific operations (e.g., allow all collections operations except delete)
|
||||
- Temporarily revoke access to certain endpoints without changing the base role
|
||||
- Implement exceptions to general permissions
|
||||
|
||||
**Q: How do OwnerOnly and TeamOnly 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
|
||||
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 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).
|
||||
802
openapi/oauth/acl/SCOPES_CONFIGURATION.md
Normal file
802
openapi/oauth/acl/SCOPES_CONFIGURATION.md
Normal file
|
|
@ -0,0 +1,802 @@
|
|||
# ACL Scopes Configuration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide explains how to configure and manage ACL (Access Control List) scopes for your OAuth-protected APIs. Scopes define what resources and actions are accessible to different users, teams, and clients.
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
All scope configurations should be placed in the `openapi/scopes/` directory with the following structure:
|
||||
|
||||
```
|
||||
openapi/scopes/
|
||||
├── scopes.yml # Global configuration and default policies
|
||||
├── alias.yml # Scope aliases for simplified permission management
|
||||
└── <resource>/ # Resource-specific scope definitions
|
||||
├── collections.yml # Collections resource scopes
|
||||
├── documents.yml # Documents resource scopes
|
||||
└── ...
|
||||
```
|
||||
|
||||
**Organization Guidelines**:
|
||||
|
||||
- Group related scopes by resource (e.g., `kb/`, `user/`, `job/`, `file/`)
|
||||
- Use descriptive filenames matching the resource name
|
||||
- Keep each file focused on a single resource or logical grouping
|
||||
|
||||
---
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### 1. Global Configuration (`scopes.yml`)
|
||||
|
||||
The `scopes.yml` file defines global ACL behavior, public endpoints, and default rules.
|
||||
|
||||
#### Structure
|
||||
|
||||
```yaml
|
||||
# Default action for unmatched API endpoints
|
||||
default: deny # Options: "deny" or "allow"
|
||||
|
||||
# Public endpoints (accessible without authentication)
|
||||
public:
|
||||
- GET /user/entry
|
||||
- GET /user/entry/captcha
|
||||
- POST /user/entry/verify
|
||||
- GET /user/teams/invitations/:invitation_id
|
||||
|
||||
# Default endpoint rules (can be overridden by specific scopes)
|
||||
endpoints:
|
||||
# Read operations allowed for authenticated users
|
||||
- GET /kb/* allow
|
||||
- GET /kb/collections allow
|
||||
|
||||
# Write operations require specific scopes
|
||||
- POST /kb/* deny
|
||||
- PUT /kb/* deny
|
||||
- DELETE /kb/* deny
|
||||
```
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ----------- | ------ | -------- | ------------------------------------------------------------- |
|
||||
| `default` | string | Yes | Default policy for unmatched endpoints: `"allow"` or `"deny"` |
|
||||
| `public` | array | No | List of public endpoints (no authentication required) |
|
||||
| `endpoints` | array | No | Default endpoint rules (see Endpoint Rules below) |
|
||||
|
||||
#### Endpoint Rules Format
|
||||
|
||||
Each endpoint rule can be specified as:
|
||||
|
||||
**Simple String Format** (recommended):
|
||||
|
||||
```yaml
|
||||
- GET /api/users allow
|
||||
- POST /api/users deny
|
||||
- DELETE /api/users/* deny
|
||||
```
|
||||
|
||||
**Struct Format**:
|
||||
|
||||
```yaml
|
||||
- method: GET
|
||||
path: /api/users
|
||||
action: allow
|
||||
```
|
||||
|
||||
**Path Patterns**:
|
||||
|
||||
- **Exact path**: `/kb/collections` - matches exactly
|
||||
- **Parameter path**: `/kb/collections/:collectionID` - matches with parameters
|
||||
- **Wildcard path**: `/kb/*` - matches all paths under `/kb/`
|
||||
|
||||
**Best Practices**:
|
||||
|
||||
- Set `default: deny` for security (deny by default, allow explicitly)
|
||||
- List public endpoints explicitly (login, registration, health checks)
|
||||
- Use wildcards for broad policies, then override with specific scopes
|
||||
- Order matters: more specific rules should come after general ones
|
||||
|
||||
---
|
||||
|
||||
### 2. Scope Definitions (Resource Files)
|
||||
|
||||
Scope definition files define specific permissions for resources. Each file contains multiple scope definitions.
|
||||
|
||||
#### Structure
|
||||
|
||||
```yaml
|
||||
# Scope naming convention: resource:action:level
|
||||
collections:read:all:
|
||||
description: "Read knowledge base for all users"
|
||||
endpoints:
|
||||
- GET /kb/collections
|
||||
- GET /kb/collections/:collectionID
|
||||
- GET /kb/collections/:collectionID/exists
|
||||
|
||||
collections:read:own:
|
||||
owner: true
|
||||
description: "Read knowledge base for own collections"
|
||||
endpoints:
|
||||
- GET /kb/collections/own
|
||||
- GET /kb/collections/own/:collectionID
|
||||
- GET /kb/collections/own/:collectionID/exists
|
||||
|
||||
collections:write:own:
|
||||
owner: true
|
||||
description: "Write knowledge base for own collections"
|
||||
endpoints:
|
||||
- POST /kb/collections/own
|
||||
- PUT /kb/collections/own/:collectionID
|
||||
- DELETE /kb/collections/own/:collectionID
|
||||
|
||||
collections:read:team:
|
||||
team: true
|
||||
description: "Read knowledge base for team collections"
|
||||
endpoints:
|
||||
- GET /kb/collections/team
|
||||
- GET /kb/collections/team/: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 |
|
||||
|
||||
#### Endpoint Format
|
||||
|
||||
Each endpoint in the `endpoints` array should be formatted as:
|
||||
|
||||
```
|
||||
METHOD /path
|
||||
```
|
||||
|
||||
**Examples**:
|
||||
|
||||
```yaml
|
||||
endpoints:
|
||||
- GET /kb/collections
|
||||
- GET /kb/collections/:collectionID
|
||||
- POST /kb/collections/own
|
||||
- PUT /kb/collections/:collectionID
|
||||
- DELETE /kb/collections/own/:collectionID
|
||||
```
|
||||
|
||||
**Supported HTTP Methods**:
|
||||
|
||||
- `GET` - Read operations
|
||||
- `POST` - Create operations
|
||||
- `PUT` - Update operations
|
||||
- `DELETE` - Delete operations
|
||||
- `PATCH` - Partial update operations
|
||||
|
||||
**Path Parameters**:
|
||||
|
||||
- Use `:paramName` syntax for path parameters (e.g., `:collectionID`, `:userID`)
|
||||
- Parameter names should be descriptive and consistent
|
||||
|
||||
---
|
||||
|
||||
### 3. Scope Aliases (`alias.yml`)
|
||||
|
||||
Aliases allow you to group multiple scopes under a single name for simplified permission management.
|
||||
|
||||
#### Structure
|
||||
|
||||
```yaml
|
||||
# Alias naming: category:level
|
||||
user:auth:
|
||||
- entry:access:public
|
||||
- entry:register:authenticated
|
||||
- entry:logout:own
|
||||
|
||||
kb:read:
|
||||
- collections:read:all
|
||||
- documents:read:all
|
||||
- search:read:all
|
||||
- hits:read:all
|
||||
|
||||
kb:own:
|
||||
- collections:read:own
|
||||
- collections:write:own
|
||||
- collections:delete:own
|
||||
- documents:read:own
|
||||
- documents:write:own
|
||||
- documents:delete:own
|
||||
|
||||
kb:admin:
|
||||
- collections:read:all
|
||||
- collections:write:all
|
||||
- collections:delete:all
|
||||
- documents:read:all
|
||||
- documents:write:all
|
||||
- documents:delete:all
|
||||
- search:read:all
|
||||
- graphs:read:all
|
||||
|
||||
# System root permission - absolute highest privilege
|
||||
system:root:
|
||||
- "*:*:*"
|
||||
```
|
||||
|
||||
#### Alias Usage
|
||||
|
||||
**In Role Configuration**:
|
||||
|
||||
```go
|
||||
// Assign aliases to roles instead of individual scopes
|
||||
role := &Role{
|
||||
ID: "kb-viewer",
|
||||
AllowedScopes: []string{
|
||||
"kb:read", // Expands to all KB read scopes
|
||||
"user:auth", // Expands to all auth scopes
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
|
||||
- **Simplified Management**: Change multiple scopes by updating one alias
|
||||
- **Consistency**: Ensure users get consistent permission sets
|
||||
- **Readability**: Clear, semantic permission names
|
||||
- **Maintenance**: Easier to add/remove scopes from permission groups
|
||||
|
||||
**Best Practices**:
|
||||
|
||||
- Use hierarchical naming: `resource:level` (e.g., `kb:read`, `kb:own`, `kb:admin`)
|
||||
- Create aliases for common permission patterns
|
||||
- Document what each alias includes
|
||||
- Use wildcards (`*:*:*`) sparingly and only for system-level access
|
||||
|
||||
---
|
||||
|
||||
## Scope Naming Convention
|
||||
|
||||
Follow a consistent three-part naming convention for scopes:
|
||||
|
||||
```
|
||||
resource:action:level
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
1. **Resource** (noun): The resource being accessed
|
||||
|
||||
- Examples: `collections`, `documents`, `profile`, `jobs`, `files`
|
||||
- Should be plural for collections, singular for single resources
|
||||
|
||||
2. **Action** (verb): The operation being performed
|
||||
|
||||
- `read` - View/retrieve data (GET)
|
||||
- `write` - Create/update data (POST, PUT, PATCH)
|
||||
- `delete` - Remove data (DELETE)
|
||||
- `control` - Special operations (start, stop, pause)
|
||||
- `access` - Generic access without CRUD semantics
|
||||
|
||||
3. **Level** (scope): The access level or data visibility
|
||||
- `all` - Full access to all resources
|
||||
- `own` - Access only to user's own resources
|
||||
- `team` - Access to team resources
|
||||
- `public` - Public/unauthenticated access
|
||||
- `authenticated` - Basic authenticated access
|
||||
|
||||
### Examples
|
||||
|
||||
| Scope | Description |
|
||||
| ------------------------ | ----------------------------- |
|
||||
| `collections:read:all` | Read all collections |
|
||||
| `collections:read:own` | Read only own collections |
|
||||
| `collections:read:team` | Read team collections |
|
||||
| `collections:write:own` | Create/update own collections |
|
||||
| `collections:delete:own` | Delete own collections |
|
||||
| `documents:write:all` | Create/update any document |
|
||||
| `documents:delete:team` | Delete team documents |
|
||||
| `profile:read:own` | Read own profile |
|
||||
| `jobs:control:own` | Control (start/stop) own jobs |
|
||||
| `search:read:all` | Search across all resources |
|
||||
|
||||
---
|
||||
|
||||
## Data Access Constraints
|
||||
|
||||
Data access constraints control how API handlers should filter data based on ownership.
|
||||
|
||||
### Owner-Only Access (`owner: true`)
|
||||
|
||||
When `owner: true` is set, the scope grants access only to resources owned by the current user.
|
||||
|
||||
```yaml
|
||||
collections:read:own:
|
||||
owner: true
|
||||
description: "Read knowledge base for own collections"
|
||||
endpoints:
|
||||
- GET /kb/collections/own
|
||||
- GET /kb/collections/own/:collectionID
|
||||
```
|
||||
|
||||
**API Implementation**:
|
||||
|
||||
```go
|
||||
func GetCollections(c *gin.Context) {
|
||||
authInfo := authorized.GetInfo(c)
|
||||
|
||||
query := db.Query("SELECT * FROM collections")
|
||||
|
||||
// Apply owner constraint
|
||||
if authInfo.Constraints.OwnerOnly {
|
||||
query = query.Where("user_id = ?", authInfo.UserID)
|
||||
}
|
||||
|
||||
collections, _ := query.Get()
|
||||
c.JSON(200, collections)
|
||||
}
|
||||
```
|
||||
|
||||
### Team-Only Access (`team: true`)
|
||||
|
||||
When `team: true` is set, the scope grants access only to resources owned by the current team.
|
||||
|
||||
```yaml
|
||||
collections:read:team:
|
||||
team: true
|
||||
description: "Read knowledge base for team collections"
|
||||
endpoints:
|
||||
- GET /kb/collections/team
|
||||
- GET /kb/collections/team/:collectionID
|
||||
```
|
||||
|
||||
**API Implementation**:
|
||||
|
||||
```go
|
||||
func GetCollections(c *gin.Context) {
|
||||
authInfo := authorized.GetInfo(c)
|
||||
|
||||
query := db.Query("SELECT * FROM collections")
|
||||
|
||||
// Apply team constraint
|
||||
if authInfo.Constraints.TeamOnly {
|
||||
query = query.Where("team_id = ?", authInfo.TeamID)
|
||||
}
|
||||
|
||||
collections, _ := query.Get()
|
||||
c.JSON(200, collections)
|
||||
}
|
||||
```
|
||||
|
||||
### Combined Constraints
|
||||
|
||||
Both constraints can be applied:
|
||||
|
||||
```yaml
|
||||
documents:read:own:
|
||||
owner: true
|
||||
team: true # Can be used together
|
||||
description: "Read own documents within team context"
|
||||
endpoints:
|
||||
- GET /kb/documents/own
|
||||
```
|
||||
|
||||
**API Implementation**:
|
||||
|
||||
```go
|
||||
func GetDocuments(c *gin.Context) {
|
||||
authInfo := authorized.GetInfo(c)
|
||||
|
||||
query := db.Query("SELECT * FROM documents")
|
||||
|
||||
// Apply constraints (OwnerOnly is more restrictive)
|
||||
if authInfo.Constraints.OwnerOnly {
|
||||
query = query.Where("user_id = ?", authInfo.UserID)
|
||||
} else if authInfo.Constraints.TeamOnly {
|
||||
query = query.Where("team_id = ?", authInfo.TeamID)
|
||||
}
|
||||
|
||||
documents, _ := query.Get()
|
||||
c.JSON(200, documents)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Example
|
||||
|
||||
Let's create a complete scope configuration for a blog system.
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
openapi/scopes/
|
||||
├── scopes.yml
|
||||
├── alias.yml
|
||||
└── blog/
|
||||
├── posts.yml
|
||||
├── comments.yml
|
||||
└── categories.yml
|
||||
```
|
||||
|
||||
### `scopes.yml`
|
||||
|
||||
```yaml
|
||||
default: deny
|
||||
|
||||
public:
|
||||
- GET /blog/posts
|
||||
- GET /blog/posts/:postID
|
||||
- GET /blog/categories
|
||||
|
||||
endpoints:
|
||||
# Read operations allowed for authenticated users
|
||||
- GET /blog/* allow
|
||||
|
||||
# Write operations require specific scopes
|
||||
- POST /blog/* deny
|
||||
- PUT /blog/* deny
|
||||
- DELETE /blog/* deny
|
||||
```
|
||||
|
||||
### `blog/posts.yml`
|
||||
|
||||
```yaml
|
||||
posts:read:all:
|
||||
description: "Read all blog posts"
|
||||
endpoints:
|
||||
- GET /blog/posts
|
||||
- GET /blog/posts/:postID
|
||||
|
||||
posts:read:own:
|
||||
owner: true
|
||||
description: "Read own blog posts"
|
||||
endpoints:
|
||||
- GET /blog/posts/own
|
||||
- GET /blog/posts/own/:postID
|
||||
|
||||
posts:write:own:
|
||||
owner: true
|
||||
description: "Create and update own blog posts"
|
||||
endpoints:
|
||||
- POST /blog/posts
|
||||
- PUT /blog/posts/:postID
|
||||
- PATCH /blog/posts/:postID
|
||||
|
||||
posts:delete:own:
|
||||
owner: true
|
||||
description: "Delete own blog posts"
|
||||
endpoints:
|
||||
- DELETE /blog/posts/:postID
|
||||
|
||||
posts:write:all:
|
||||
description: "Create and update any blog post (admin)"
|
||||
endpoints:
|
||||
- POST /blog/posts/admin
|
||||
- PUT /blog/posts/admin/:postID
|
||||
|
||||
posts:delete:all:
|
||||
description: "Delete any blog post (admin)"
|
||||
endpoints:
|
||||
- DELETE /blog/posts/admin/:postID
|
||||
```
|
||||
|
||||
### `blog/comments.yml`
|
||||
|
||||
```yaml
|
||||
comments:read:all:
|
||||
description: "Read all comments"
|
||||
endpoints:
|
||||
- GET /blog/posts/:postID/comments
|
||||
- GET /blog/comments/:commentID
|
||||
|
||||
comments:write:own:
|
||||
owner: true
|
||||
description: "Write own comments"
|
||||
endpoints:
|
||||
- POST /blog/posts/:postID/comments
|
||||
- PUT /blog/comments/:commentID
|
||||
|
||||
comments:delete:own:
|
||||
owner: true
|
||||
description: "Delete own comments"
|
||||
endpoints:
|
||||
- DELETE /blog/comments/:commentID
|
||||
|
||||
comments:delete:all:
|
||||
description: "Delete any comment (moderator)"
|
||||
endpoints:
|
||||
- DELETE /blog/comments/admin/:commentID
|
||||
```
|
||||
|
||||
### `alias.yml`
|
||||
|
||||
```yaml
|
||||
# Blog reader - can read all posts and comments
|
||||
blog:reader:
|
||||
- posts:read:all
|
||||
- comments:read:all
|
||||
|
||||
# Blog author - can manage own posts and comments
|
||||
blog:author:
|
||||
- posts:read:all
|
||||
- posts:write:own
|
||||
- posts:delete:own
|
||||
- comments:read:all
|
||||
- comments:write:own
|
||||
- comments:delete:own
|
||||
|
||||
# Blog moderator - can manage all comments
|
||||
blog:moderator:
|
||||
- posts:read:all
|
||||
- comments:read:all
|
||||
- comments:delete:all
|
||||
|
||||
# Blog admin - full access to all blog features
|
||||
blog:admin:
|
||||
- posts:read:all
|
||||
- posts:write:all
|
||||
- posts:delete:all
|
||||
- comments:read:all
|
||||
- comments:write:own
|
||||
- comments:delete:all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wildcard Scopes
|
||||
|
||||
Wildcard scopes allow flexible permission matching using `*` as a placeholder.
|
||||
|
||||
### Syntax
|
||||
|
||||
```yaml
|
||||
system:root:
|
||||
- "*:*:*" # Matches everything
|
||||
|
||||
blog:admin:
|
||||
- "posts:*:*" # Matches all post operations at all levels
|
||||
- "comments:*:*" # Matches all comment operations at all levels
|
||||
|
||||
kb:read:
|
||||
- "collections:read:*" # Matches collections:read:all, collections:read:own, etc.
|
||||
- "documents:read:*" # Matches documents:read:all, documents:read:own, etc.
|
||||
```
|
||||
|
||||
### Matching Rules
|
||||
|
||||
1. **Full wildcard** (`*:*:*`): Matches any scope
|
||||
2. **Resource wildcard** (`posts:*:*`): Matches any action and level for the resource
|
||||
3. **Action wildcard** (`posts:read:*`): Matches any level for the resource and action
|
||||
4. **No partial wildcards**: `post*:read:all` is NOT supported
|
||||
|
||||
### Use Cases
|
||||
|
||||
- **System root access**: `*:*:*` for system administrators
|
||||
- **Resource administrators**: `resource:*:*` for resource-level admins
|
||||
- **Grouped permissions**: `resource:action:*` for action-level permissions
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- Use wildcards sparingly
|
||||
- Prefer explicit scope lists for most roles
|
||||
- Reserve `*:*:*` for system-level accounts only
|
||||
- Document wildcard usage clearly
|
||||
- Consider restricted scopes to block specific actions even with wildcards
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Scope Design
|
||||
|
||||
✅ **DO**:
|
||||
|
||||
- Use consistent naming conventions
|
||||
- Group related scopes in the same file
|
||||
- Provide clear descriptions for each scope
|
||||
- Design scopes around resources and actions, not UI features
|
||||
- Keep scopes granular but not too fine-grained
|
||||
|
||||
❌ **DON'T**:
|
||||
|
||||
- Mix different resources in one scope file
|
||||
- Create scopes for every single endpoint
|
||||
- Use vague or inconsistent naming
|
||||
- Duplicate endpoint definitions across scopes
|
||||
|
||||
### 2. Permission Levels
|
||||
|
||||
Create a clear hierarchy of permission levels:
|
||||
|
||||
1. **Public** (`public`): No authentication required
|
||||
2. **Authenticated** (`authenticated`): Basic logged-in access
|
||||
3. **Owner** (`own`): User's own resources
|
||||
4. **Team** (`team`): Team's resources
|
||||
5. **All** (`all`): All resources (admin level)
|
||||
|
||||
### 3. Aliases
|
||||
|
||||
✅ **DO**:
|
||||
|
||||
- Create aliases for common user roles (viewer, editor, admin)
|
||||
- Use aliases to group related scopes
|
||||
- Document what each alias grants
|
||||
- Keep alias names intuitive
|
||||
|
||||
❌ **DON'T**:
|
||||
|
||||
- Create single-scope aliases (use the scope directly)
|
||||
- Nest aliases (aliases should reference scopes, not other aliases)
|
||||
- Use ambiguous alias names
|
||||
|
||||
### 4. Data Constraints
|
||||
|
||||
✅ **DO**:
|
||||
|
||||
- Set `owner: true` for personal resource scopes
|
||||
- Set `team: true` for team resource scopes
|
||||
- Implement constraint checks in ALL relevant API handlers
|
||||
- Return appropriate errors when constraints are violated
|
||||
|
||||
❌ **DON'T**:
|
||||
|
||||
- Rely solely on URL paths (`/own`, `/team`) for access control
|
||||
- Skip constraint validation in database queries
|
||||
- Assume constraints are enforced automatically
|
||||
|
||||
### 5. Endpoint Definitions
|
||||
|
||||
✅ **DO**:
|
||||
|
||||
- List all related endpoints for a scope
|
||||
- Use consistent parameter naming (`:id`, `:userID`, `:collectionID`)
|
||||
- Include all HTTP methods the scope covers
|
||||
- Group similar endpoints together
|
||||
|
||||
❌ **DON'T**:
|
||||
|
||||
- Define the same endpoint in multiple scopes (unless intentional)
|
||||
- Use inconsistent path formats
|
||||
- Forget to include related endpoints
|
||||
|
||||
### 6. Testing
|
||||
|
||||
- Test each scope definition with real requests
|
||||
- Verify data constraints are enforced correctly
|
||||
- Test wildcard matching behavior
|
||||
- Ensure public endpoints are accessible without auth
|
||||
- Validate that denied endpoints return proper errors
|
||||
|
||||
### 7. Documentation
|
||||
|
||||
- Comment complex scope definitions
|
||||
- Document the purpose of each alias
|
||||
- Maintain a scope reference for developers
|
||||
- Update documentation when scopes change
|
||||
- Provide examples of scope usage in roles
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Issue**: Endpoint not accessible even with correct scope
|
||||
|
||||
**Solution**:
|
||||
|
||||
- Check if endpoint is in `scopes.yml` default deny list
|
||||
- Verify scope name matches exactly (case-sensitive)
|
||||
- Ensure endpoint path matches (check for typos, extra slashes)
|
||||
- Verify HTTP method matches
|
||||
|
||||
---
|
||||
|
||||
**Issue**: Data constraint not working
|
||||
|
||||
**Solution**:
|
||||
|
||||
- Confirm `owner: true` or `team: true` is set in scope definition
|
||||
- Check if API handler reads `authInfo.Constraints`
|
||||
- Verify database query applies constraint filters
|
||||
- Ensure `authInfo.UserID` or `authInfo.TeamID` is populated
|
||||
|
||||
---
|
||||
|
||||
**Issue**: Wildcard scope not matching
|
||||
|
||||
**Solution**:
|
||||
|
||||
- Verify wildcard syntax (`*` in correct position)
|
||||
- Check scope name format (must be `part1:part2:part3`)
|
||||
- Ensure no typos in scope name parts
|
||||
- Remember: wildcards only work with colon-separated scopes
|
||||
|
||||
---
|
||||
|
||||
**Issue**: Changes not taking effect
|
||||
|
||||
**Solution**:
|
||||
|
||||
- Restart the application to reload scope configurations
|
||||
- Clear role cache: `role.RoleManager.ClearCache()`
|
||||
- Verify YAML syntax is correct (use YAML validator)
|
||||
- Check file is in correct directory
|
||||
|
||||
---
|
||||
|
||||
## Reference
|
||||
|
||||
### Related Files
|
||||
|
||||
- **[types.go](./types.go)**: Scope configuration structures
|
||||
- **[scope.go](./scope.go)**: Scope matching and validation logic
|
||||
- **[README.md](./README.md)**: ACL enforcement logic
|
||||
- **[DESIGN.md](./DESIGN.md)**: Overall ACL system design
|
||||
|
||||
### Related Concepts
|
||||
|
||||
- **OAuth 2.1 Scopes**: Standard OAuth scope mechanism
|
||||
- **RBAC**: Role-Based Access Control
|
||||
- **Data Constraints**: Fine-grained data access control
|
||||
- **Endpoint Matching**: Path pattern matching algorithm
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Legacy Permissions
|
||||
|
||||
If migrating from a legacy permission system:
|
||||
|
||||
1. **Map old permissions to scopes**:
|
||||
|
||||
```
|
||||
can_read_posts → posts:read:all
|
||||
can_edit_own_posts → posts:write:own
|
||||
can_delete_any_post → posts:delete:all
|
||||
```
|
||||
|
||||
2. **Create scope definitions** for each permission
|
||||
|
||||
3. **Define aliases** for existing roles:
|
||||
|
||||
```yaml
|
||||
role:editor:
|
||||
- posts:read:all
|
||||
- posts:write:own
|
||||
- posts:delete:own
|
||||
```
|
||||
|
||||
4. **Update API handlers** to check constraints
|
||||
|
||||
5. **Migrate role assignments** to use new scopes/aliases
|
||||
|
||||
6. **Test thoroughly** before deploying
|
||||
|
||||
### Version Compatibility
|
||||
|
||||
- **v1.0**: Basic scope checking
|
||||
- **v1.1**: Data constraints (`owner`, `team`)
|
||||
- **v1.2**: Wildcard scopes, restricted scopes
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Key points to remember:
|
||||
|
||||
1. **Three main files**: `scopes.yml` (global), `alias.yml` (aliases), resource files (scopes)
|
||||
2. **Naming convention**: `resource:action:level`
|
||||
3. **Data constraints**: Use `owner: true` and `team: true` for data filtering
|
||||
4. **Aliases**: Group scopes for easier role management
|
||||
5. **Wildcards**: Use `*` for flexible matching, but sparingly
|
||||
6. **Testing**: Always test scope configurations thoroughly
|
||||
|
||||
For more details, refer to:
|
||||
|
||||
- [README.md](./README.md) - Enforcement logic
|
||||
- [DESIGN.md](./DESIGN.md) - System architecture
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
package acl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/yao/openapi/oauth/acl/role"
|
||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
|
@ -23,61 +26,452 @@ func (acl *ACL) Enforce(c *gin.Context) (bool, error) {
|
|||
// Get authorized info from context (set by OAuth guard middleware)
|
||||
authInfo := authorized.GetInfo(c)
|
||||
|
||||
// Resolve all scopes (client + user + team) from authorized info
|
||||
// Note: This should include scope expansion from roles, aliases, etc.
|
||||
scopes := getScopes(authInfo)
|
||||
|
||||
// Build access request (focused on scope-based access control)
|
||||
// Build access request
|
||||
request := &AccessRequest{
|
||||
Method: c.Request.Method,
|
||||
Path: c.Request.URL.Path,
|
||||
Scopes: scopes,
|
||||
}
|
||||
|
||||
// Check scopes
|
||||
decision := acl.Scope.Check(request)
|
||||
|
||||
if !decision.Allowed {
|
||||
// Return error with details, let the caller handle the response
|
||||
err := &Error{
|
||||
Type: ErrorTypePermissionDenied,
|
||||
Message: decision.Reason,
|
||||
Details: map[string]interface{}{
|
||||
"required_scopes": decision.RequiredScopes,
|
||||
"missing_scopes": decision.MissingScopes,
|
||||
},
|
||||
}
|
||||
// Execute enforcement chain and collect endpoint info
|
||||
allowed, endpointInfo, err := acl.enforce(c.Request.Context(), authInfo, request)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Update context with data access constraints from matched endpoint
|
||||
if endpointInfo != nil {
|
||||
constraints := endpointInfo.GetConstraints()
|
||||
authorized.UpdateConstraints(c, constraints)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// getScopes resolves all scopes from authorized info
|
||||
// This function is responsible for the complete scope resolution process:
|
||||
// 1. Get base scopes from token (authInfo.Scope)
|
||||
// 2. Get user role scopes from database (if authInfo.UserID exists)
|
||||
// 3. Get team role scopes from database (if authInfo.TeamID exists)
|
||||
// 4. Merge all scopes and return the complete list
|
||||
//
|
||||
// Scope resolution logic:
|
||||
// - Pure API call (no user_id): Returns client scopes from token
|
||||
// - User call: Returns merged scopes (client + user roles + team roles)
|
||||
//
|
||||
// This keeps the ACL layer focused on scope-based access control,
|
||||
// while relying on the authorized package for context extraction.
|
||||
func getScopes(authInfo *types.AuthorizedInfo) []string {
|
||||
// TODO: Implement scope resolution
|
||||
// 1. Parse base scopes from authInfo.Scope (space-separated)
|
||||
// 2. Query user roles and convert to scopes (if authInfo.UserID exists)
|
||||
// 3. Query team roles and convert to scopes (if authInfo.TeamID exists)
|
||||
// 4. Merge and deduplicate all scopes
|
||||
|
||||
// For now, just return scopes from token
|
||||
if authInfo.Scope == "" {
|
||||
return []string{}
|
||||
// enforce is the main enforcement chain that orchestrates all permission checks
|
||||
// Each step independently validates permissions against the endpoint
|
||||
// ALL checks must pass (AND logic) - if any check fails, access is denied
|
||||
// Returns: (allowed bool, endpointInfo *EndpointInfo, error)
|
||||
func (acl *ACL) enforce(ctx context.Context, authInfo *types.AuthorizedInfo, request *AccessRequest) (bool, *EndpointInfo, error) {
|
||||
// Step 1: Check client permission - MUST pass
|
||||
allowed, matchedEndpoint, err := acl.enforceClient(ctx, authInfo, request)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
if !allowed {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypePermissionDenied,
|
||||
Message: "access denied: client permission check failed",
|
||||
Stage: EnforcementStageClient,
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Check explicit scopes from token (if any) - MUST pass if present
|
||||
// If token scope is empty, skip this check
|
||||
if authInfo.Scope != "" {
|
||||
allowed, endpoint, err := acl.enforceScope(ctx, authInfo, request)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
if !allowed {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypePermissionDenied,
|
||||
Message: "access denied: token scope check failed",
|
||||
Stage: EnforcementStageScope,
|
||||
}
|
||||
}
|
||||
// Collect endpoint info
|
||||
if matchedEndpoint == nil && endpoint != nil {
|
||||
matchedEndpoint = endpoint
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Check team or user permissions
|
||||
// 3.1: If TeamID is present, this is a team login
|
||||
if authInfo.TeamID != "" {
|
||||
// 3.1.1: Check team permissions - MUST pass
|
||||
allowed, endpoint, err := acl.enforceTeam(ctx, authInfo, request)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
if !allowed {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypePermissionDenied,
|
||||
Message: "access denied: team permission check failed",
|
||||
Stage: EnforcementStageTeam,
|
||||
}
|
||||
}
|
||||
// Collect endpoint info
|
||||
if matchedEndpoint == nil && endpoint != nil {
|
||||
matchedEndpoint = endpoint
|
||||
}
|
||||
|
||||
// 3.1.2: Check member permissions (user's role in the team) - MUST pass
|
||||
allowed, endpoint, err = acl.enforceMember(ctx, authInfo, request)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
if !allowed {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypePermissionDenied,
|
||||
Message: "access denied: member permission check failed",
|
||||
Stage: EnforcementStageMember,
|
||||
}
|
||||
}
|
||||
// Collect endpoint info
|
||||
if matchedEndpoint == nil && endpoint != nil {
|
||||
matchedEndpoint = endpoint
|
||||
}
|
||||
|
||||
// All checks passed for team login
|
||||
return true, matchedEndpoint, nil
|
||||
}
|
||||
|
||||
// 3.2: This is a user login (no TeamID)
|
||||
if authInfo.UserID != "" {
|
||||
allowed, endpoint, err := acl.enforceUser(ctx, authInfo, request)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
if !allowed {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypePermissionDenied,
|
||||
Message: "access denied: user permission check failed",
|
||||
Stage: EnforcementStageUser,
|
||||
}
|
||||
}
|
||||
// Collect endpoint info
|
||||
if matchedEndpoint == nil && endpoint != nil {
|
||||
matchedEndpoint = endpoint
|
||||
}
|
||||
|
||||
// All checks passed for user login
|
||||
return true, matchedEndpoint, nil
|
||||
}
|
||||
|
||||
// All checks passed (pure API call - only client check required)
|
||||
return true, matchedEndpoint, nil
|
||||
}
|
||||
|
||||
// enforceClient checks client permissions independently
|
||||
// Returns: (allowed bool, endpointInfo *EndpointInfo, error)
|
||||
func (acl *ACL) enforceClient(ctx context.Context, authInfo *types.AuthorizedInfo, request *AccessRequest) (bool, *EndpointInfo, error) {
|
||||
// Get client role
|
||||
clientRole, err := role.RoleManager.GetClientRole(ctx, authInfo.ClientID)
|
||||
if err != nil {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypeInternal,
|
||||
Message: fmt.Sprintf("failed to get client role: %v", err),
|
||||
Stage: EnforcementStageClient,
|
||||
}
|
||||
}
|
||||
|
||||
// Get scopes for client role
|
||||
allowedScopes, restrictedScopes, err := role.RoleManager.GetScopes(ctx, clientRole)
|
||||
if err != nil {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypeInternal,
|
||||
Message: fmt.Sprintf("failed to get client scopes: %v", err),
|
||||
Stage: EnforcementStageClient,
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Check if allowed scopes grant access
|
||||
allowedRequest := &AccessRequest{
|
||||
Method: request.Method,
|
||||
Path: request.Path,
|
||||
Scopes: allowedScopes,
|
||||
}
|
||||
|
||||
decision := acl.Scope.Check(allowedRequest)
|
||||
if !decision.Allowed {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypePermissionDenied,
|
||||
Message: decision.Reason,
|
||||
Stage: EnforcementStageClient,
|
||||
Details: map[string]interface{}{
|
||||
"required_scopes": decision.RequiredScopes,
|
||||
"missing_scopes": decision.MissingScopes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Check if restricted scopes block access
|
||||
if len(restrictedScopes) > 0 {
|
||||
restrictedRequest := &AccessRequest{
|
||||
Method: request.Method,
|
||||
Path: request.Path,
|
||||
Scopes: restrictedScopes,
|
||||
}
|
||||
|
||||
restrictDecision := acl.Scope.CheckRestricted(restrictedRequest)
|
||||
if !restrictDecision.Allowed {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypePermissionDenied,
|
||||
Message: "access denied by restriction: " + restrictDecision.Reason,
|
||||
Stage: EnforcementStageClient,
|
||||
Details: map[string]interface{}{
|
||||
"restricted_scopes": restrictedScopes,
|
||||
"matched_pattern": restrictDecision.MatchedPattern,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return matched endpoint info (contains OwnerOnly, TeamOnly, and future constraints)
|
||||
return true, decision.MatchedEndpoint, nil
|
||||
}
|
||||
|
||||
// enforceScope checks the explicit scopes from token independently
|
||||
// Returns: (allowed bool, endpointInfo *EndpointInfo, error)
|
||||
func (acl *ACL) enforceScope(_ context.Context, authInfo *types.AuthorizedInfo, request *AccessRequest) (bool, *EndpointInfo, error) {
|
||||
// Parse scopes from token (space-separated)
|
||||
if authInfo.Scope == "" {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// Split space-separated scopes
|
||||
// e.g., "read:users write:users" -> ["read:users", "write:users"]
|
||||
return strings.Split(authInfo.Scope, " ")
|
||||
tokenScopes := strings.Split(authInfo.Scope, " ")
|
||||
|
||||
// Filter out empty strings
|
||||
var scopes []string
|
||||
for _, scope := range tokenScopes {
|
||||
if scope != "" {
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
}
|
||||
|
||||
// Build request with token scopes and check
|
||||
checkRequest := &AccessRequest{
|
||||
Method: request.Method,
|
||||
Path: request.Path,
|
||||
Scopes: scopes,
|
||||
}
|
||||
|
||||
decision := acl.Scope.Check(checkRequest)
|
||||
if !decision.Allowed {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypePermissionDenied,
|
||||
Message: decision.Reason,
|
||||
Stage: EnforcementStageScope,
|
||||
Details: map[string]interface{}{
|
||||
"required_scopes": decision.RequiredScopes,
|
||||
"missing_scopes": decision.MissingScopes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Return matched endpoint info
|
||||
return true, decision.MatchedEndpoint, nil
|
||||
}
|
||||
|
||||
// enforceUser checks user permissions independently
|
||||
// Returns: (allowed bool, endpointInfo *EndpointInfo, error)
|
||||
func (acl *ACL) enforceUser(ctx context.Context, authInfo *types.AuthorizedInfo, request *AccessRequest) (bool, *EndpointInfo, error) {
|
||||
// Get user role
|
||||
userRole, err := role.RoleManager.GetUserRole(ctx, authInfo.UserID)
|
||||
if err != nil {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypeInternal,
|
||||
Message: fmt.Sprintf("failed to get user role: %v", err),
|
||||
Stage: EnforcementStageUser,
|
||||
}
|
||||
}
|
||||
|
||||
// Get scopes for user role
|
||||
allowedScopes, restrictedScopes, err := role.RoleManager.GetScopes(ctx, userRole)
|
||||
if err != nil {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypeInternal,
|
||||
Message: fmt.Sprintf("failed to get user scopes: %v", err),
|
||||
Stage: EnforcementStageUser,
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Check if allowed scopes grant access
|
||||
allowedRequest := &AccessRequest{
|
||||
Method: request.Method,
|
||||
Path: request.Path,
|
||||
Scopes: allowedScopes,
|
||||
}
|
||||
|
||||
decision := acl.Scope.Check(allowedRequest)
|
||||
if !decision.Allowed {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypePermissionDenied,
|
||||
Message: decision.Reason,
|
||||
Stage: EnforcementStageUser,
|
||||
Details: map[string]interface{}{
|
||||
"required_scopes": decision.RequiredScopes,
|
||||
"missing_scopes": decision.MissingScopes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Check if restricted scopes block access
|
||||
if len(restrictedScopes) > 0 {
|
||||
restrictedRequest := &AccessRequest{
|
||||
Method: request.Method,
|
||||
Path: request.Path,
|
||||
Scopes: restrictedScopes,
|
||||
}
|
||||
|
||||
restrictDecision := acl.Scope.CheckRestricted(restrictedRequest)
|
||||
if !restrictDecision.Allowed {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypePermissionDenied,
|
||||
Message: "access denied by restriction: " + restrictDecision.Reason,
|
||||
Stage: EnforcementStageUser,
|
||||
Details: map[string]interface{}{
|
||||
"restricted_scopes": restrictedScopes,
|
||||
"matched_pattern": restrictDecision.MatchedPattern,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return matched endpoint info
|
||||
return true, decision.MatchedEndpoint, nil
|
||||
}
|
||||
|
||||
// enforceTeam checks team permissions independently
|
||||
// Returns: (allowed bool, endpointInfo *EndpointInfo, error)
|
||||
func (acl *ACL) enforceTeam(ctx context.Context, authInfo *types.AuthorizedInfo, request *AccessRequest) (bool, *EndpointInfo, error) {
|
||||
// Get team role
|
||||
teamRole, err := role.RoleManager.GetTeamRole(ctx, authInfo.TeamID)
|
||||
if err != nil {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypeInternal,
|
||||
Message: fmt.Sprintf("failed to get team role: %v", err),
|
||||
Stage: EnforcementStageTeam,
|
||||
}
|
||||
}
|
||||
|
||||
// Get scopes for team role
|
||||
allowedScopes, restrictedScopes, err := role.RoleManager.GetScopes(ctx, teamRole)
|
||||
if err != nil {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypeInternal,
|
||||
Message: fmt.Sprintf("failed to get team scopes: %v", err),
|
||||
Stage: EnforcementStageTeam,
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Check if allowed scopes grant access
|
||||
allowedRequest := &AccessRequest{
|
||||
Method: request.Method,
|
||||
Path: request.Path,
|
||||
Scopes: allowedScopes,
|
||||
}
|
||||
|
||||
decision := acl.Scope.Check(allowedRequest)
|
||||
if !decision.Allowed {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypePermissionDenied,
|
||||
Message: decision.Reason,
|
||||
Stage: EnforcementStageTeam,
|
||||
Details: map[string]interface{}{
|
||||
"required_scopes": decision.RequiredScopes,
|
||||
"missing_scopes": decision.MissingScopes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Check if restricted scopes block access
|
||||
if len(restrictedScopes) > 0 {
|
||||
restrictedRequest := &AccessRequest{
|
||||
Method: request.Method,
|
||||
Path: request.Path,
|
||||
Scopes: restrictedScopes,
|
||||
}
|
||||
|
||||
restrictDecision := acl.Scope.CheckRestricted(restrictedRequest)
|
||||
if !restrictDecision.Allowed {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypePermissionDenied,
|
||||
Message: "access denied by restriction: " + restrictDecision.Reason,
|
||||
Stage: EnforcementStageTeam,
|
||||
Details: map[string]interface{}{
|
||||
"restricted_scopes": restrictedScopes,
|
||||
"matched_pattern": restrictDecision.MatchedPattern,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return matched endpoint info
|
||||
return true, decision.MatchedEndpoint, nil
|
||||
}
|
||||
|
||||
// enforceMember checks member permissions independently
|
||||
// Returns: (allowed bool, endpointInfo *EndpointInfo, error)
|
||||
func (acl *ACL) enforceMember(ctx context.Context, authInfo *types.AuthorizedInfo, request *AccessRequest) (bool, *EndpointInfo, error) {
|
||||
// Get member role (user's role in the team)
|
||||
memberRole, err := role.RoleManager.GetMemberRole(ctx, authInfo.TeamID, authInfo.UserID)
|
||||
if err != nil {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypeInternal,
|
||||
Message: fmt.Sprintf("failed to get member role: %v", err),
|
||||
Stage: EnforcementStageMember,
|
||||
}
|
||||
}
|
||||
|
||||
// Get scopes for member role
|
||||
allowedScopes, restrictedScopes, err := role.RoleManager.GetScopes(ctx, memberRole)
|
||||
if err != nil {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypeInternal,
|
||||
Message: fmt.Sprintf("failed to get member scopes: %v", err),
|
||||
Stage: EnforcementStageMember,
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Check if allowed scopes grant access
|
||||
allowedRequest := &AccessRequest{
|
||||
Method: request.Method,
|
||||
Path: request.Path,
|
||||
Scopes: allowedScopes,
|
||||
}
|
||||
|
||||
decision := acl.Scope.Check(allowedRequest)
|
||||
if !decision.Allowed {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypePermissionDenied,
|
||||
Message: decision.Reason,
|
||||
Stage: EnforcementStageMember,
|
||||
Details: map[string]interface{}{
|
||||
"required_scopes": decision.RequiredScopes,
|
||||
"missing_scopes": decision.MissingScopes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Check if restricted scopes block access
|
||||
if len(restrictedScopes) > 0 {
|
||||
restrictedRequest := &AccessRequest{
|
||||
Method: request.Method,
|
||||
Path: request.Path,
|
||||
Scopes: restrictedScopes,
|
||||
}
|
||||
|
||||
restrictDecision := acl.Scope.CheckRestricted(restrictedRequest)
|
||||
if !restrictDecision.Allowed {
|
||||
return false, nil, &Error{
|
||||
Type: ErrorTypePermissionDenied,
|
||||
Message: "access denied by restriction: " + restrictDecision.Reason,
|
||||
Stage: EnforcementStageMember,
|
||||
Details: map[string]interface{}{
|
||||
"restricted_scopes": restrictedScopes,
|
||||
"matched_pattern": restrictDecision.MatchedPattern,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return matched endpoint info
|
||||
return true, decision.MatchedEndpoint, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ type Error struct {
|
|||
Type ErrorType
|
||||
Message string
|
||||
Details map[string]interface{}
|
||||
RetryAfter int // seconds to wait before retrying (for rate limit errors)
|
||||
RetryAfter int // seconds to wait before retrying (for rate limit errors)
|
||||
Stage EnforcementStage // stage where the permission check failed
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
|
|
|
|||
|
|
@ -400,7 +400,8 @@ func (m *ScopeManager) Check(req *AccessRequest) *AccessDecision {
|
|||
hasScope := false
|
||||
for _, required := range endpoint.RequiredScopes {
|
||||
for _, userScope := range expandedScopes {
|
||||
if userScope == required {
|
||||
// Check for exact match or wildcard match
|
||||
if userScope == required || m.matchesWildcardScope(userScope, required) {
|
||||
hasScope = true
|
||||
break
|
||||
}
|
||||
|
|
@ -427,6 +428,77 @@ func (m *ScopeManager) Check(req *AccessRequest) *AccessDecision {
|
|||
return decision
|
||||
}
|
||||
|
||||
// CheckRestricted checks if the endpoint is restricted by any of the given scopes
|
||||
// Returns true if the endpoint is restricted (should be denied)
|
||||
func (m *ScopeManager) CheckRestricted(req *AccessRequest) *AccessDecision {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
decision := &AccessDecision{
|
||||
Allowed: true, // Default to allowed (not restricted)
|
||||
UserScopes: req.Scopes,
|
||||
}
|
||||
|
||||
// 1. Check if it's a public endpoint - public endpoints cannot be restricted
|
||||
publicKey := req.Method + " " + req.Path
|
||||
if _, ok := m.publicPaths[publicKey]; ok {
|
||||
decision.Allowed = true
|
||||
decision.Reason = "public endpoint"
|
||||
return decision
|
||||
}
|
||||
|
||||
// 2. Find matching endpoint
|
||||
endpoint, pattern := m.matchEndpoint(req.Method, req.Path)
|
||||
if endpoint == nil {
|
||||
// No match found - not restricted
|
||||
decision.Allowed = true
|
||||
decision.Reason = "no restriction match"
|
||||
return decision
|
||||
}
|
||||
|
||||
decision.MatchedEndpoint = endpoint
|
||||
decision.MatchedPattern = pattern
|
||||
|
||||
// 3. Check if any user scope matches the endpoint's required scopes
|
||||
// If it matches, this endpoint IS restricted by these scopes
|
||||
switch endpoint.Policy {
|
||||
case PolicyDeny:
|
||||
// Explicit deny policy - this is restricted
|
||||
decision.Allowed = false
|
||||
decision.Reason = "policy: deny (restricted)"
|
||||
return decision
|
||||
|
||||
case PolicyRequireScopes:
|
||||
// Expand user scopes (include aliases)
|
||||
expandedScopes := m.expandUserScopes(req.Scopes)
|
||||
|
||||
// Check if this endpoint requires any of the user's scopes
|
||||
// If yes, this endpoint is restricted by these scopes
|
||||
for _, required := range endpoint.RequiredScopes {
|
||||
for _, userScope := range expandedScopes {
|
||||
// Check for exact match or wildcard match
|
||||
if userScope == required || m.matchesWildcardScope(userScope, required) {
|
||||
// This endpoint is restricted by this scope
|
||||
decision.Allowed = false
|
||||
decision.Reason = "endpoint restricted by scope: " + required
|
||||
decision.RequiredScopes = []string{required}
|
||||
return decision
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No restriction match
|
||||
decision.Allowed = true
|
||||
decision.Reason = "no restriction match"
|
||||
return decision
|
||||
}
|
||||
|
||||
// Default: not restricted
|
||||
decision.Allowed = true
|
||||
decision.Reason = "not restricted"
|
||||
return decision
|
||||
}
|
||||
|
||||
// matchEndpoint finds the matching endpoint for a request
|
||||
func (m *ScopeManager) matchEndpoint(method, path string) (*EndpointInfo, string) {
|
||||
matcher := m.endpointIndex[method]
|
||||
|
|
@ -505,6 +577,41 @@ func (m *ScopeManager) expandUserScopes(scopes []string) []string {
|
|||
return expanded
|
||||
}
|
||||
|
||||
// matchesWildcardScope checks if a user scope (potentially with wildcards) matches a required scope
|
||||
// Supports patterns like:
|
||||
// - *:*:* matches everything
|
||||
// - resource:*:* matches resource:action:level
|
||||
// - resource:action:* matches resource:action:level
|
||||
func (m *ScopeManager) matchesWildcardScope(userScope, requiredScope string) bool {
|
||||
// No wildcard, no match (exact match already checked)
|
||||
if !strings.Contains(userScope, "*") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Split both scopes into parts
|
||||
userParts := strings.Split(userScope, ":")
|
||||
requiredParts := strings.Split(requiredScope, ":")
|
||||
|
||||
// If lengths don't match and user scope isn't full wildcard, no match
|
||||
if len(userParts) != len(requiredParts) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check each part
|
||||
for i := range userParts {
|
||||
if userParts[i] == "*" {
|
||||
// Wildcard matches anything
|
||||
continue
|
||||
}
|
||||
if userParts[i] != requiredParts[i] {
|
||||
// Not a match
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// findMissingScopes finds which scopes are missing
|
||||
func (m *ScopeManager) findMissingScopes(userScopes, requiredScopes []string) []string {
|
||||
userScopeSet := make(map[string]bool)
|
||||
|
|
@ -514,7 +621,21 @@ func (m *ScopeManager) findMissingScopes(userScopes, requiredScopes []string) []
|
|||
|
||||
var missing []string
|
||||
for _, required := range requiredScopes {
|
||||
if !userScopeSet[required] {
|
||||
// Check exact match
|
||||
if userScopeSet[required] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check wildcard match
|
||||
matched := false
|
||||
for _, userScope := range userScopes {
|
||||
if m.matchesWildcardScope(userScope, required) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !matched {
|
||||
missing = append(missing, required)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,6 +148,32 @@ type EndpointInfo struct {
|
|||
TeamOnly bool // Team only
|
||||
}
|
||||
|
||||
// GetConstraints returns all data access constraints as a map
|
||||
// This allows flexible extension without changing method signatures
|
||||
func (e *EndpointInfo) GetConstraints() map[string]interface{} {
|
||||
if e == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
constraints := make(map[string]interface{})
|
||||
|
||||
if e.OwnerOnly {
|
||||
constraints["owner_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
|
||||
// }
|
||||
|
||||
return constraints
|
||||
}
|
||||
|
||||
// EndpointPolicy represents the endpoint policy
|
||||
type EndpointPolicy int
|
||||
|
||||
|
|
@ -193,3 +219,25 @@ type AccessDecision struct {
|
|||
UserScopes []string // User's scopes
|
||||
MissingScopes []string // Missing scopes
|
||||
}
|
||||
|
||||
// ============ Enforcement Stage (permission check stages) ============
|
||||
|
||||
// EnforcementStage represents the stage where permission check failed
|
||||
type EnforcementStage string
|
||||
|
||||
const (
|
||||
// EnforcementStageClient indicates client permission check failed
|
||||
EnforcementStageClient EnforcementStage = "client"
|
||||
|
||||
// EnforcementStageScope indicates scope permission check failed
|
||||
EnforcementStageScope EnforcementStage = "scope"
|
||||
|
||||
// EnforcementStageTeam indicates team permission check failed
|
||||
EnforcementStageTeam EnforcementStage = "team"
|
||||
|
||||
// EnforcementStageMember indicates member permission check failed
|
||||
EnforcementStageMember EnforcementStage = "member"
|
||||
|
||||
// EnforcementStageUser indicates user permission check failed
|
||||
EnforcementStageUser EnforcementStage = "user"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -44,9 +44,49 @@ func GetInfo(c *gin.Context) *types.AuthorizedInfo {
|
|||
}
|
||||
}
|
||||
|
||||
// Get data access constraints (set by ACL enforcement)
|
||||
info.Constraints = GetConstraints(c)
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// GetConstraints extracts data access constraints from the gin context
|
||||
// Returns a DataConstraints struct with all constraint flags
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Future constraints can be read here:
|
||||
// if departmentOnly, ok := c.Get("__department_only"); ok {
|
||||
// if deptBool, ok := departmentOnly.(bool); ok {
|
||||
// constraints.DepartmentOnly = deptBool
|
||||
// }
|
||||
// }
|
||||
|
||||
return constraints
|
||||
}
|
||||
|
||||
// UpdateConstraints updates data access constraints in the gin context
|
||||
// This should be called by ACL enforcement after successful permission check
|
||||
// Accepts a map of constraints for flexible extension
|
||||
func UpdateConstraints(c *gin.Context, constraints map[string]interface{}) {
|
||||
// Set each constraint in the context
|
||||
for key, value := range constraints {
|
||||
c.Set("__"+key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// SetInfo sets authorized information in the gin context
|
||||
// This function should be called by the OAuth guard middleware after token validation
|
||||
// userIDGetter is a function that resolves the user_id from clientID and subject
|
||||
|
|
|
|||
|
|
@ -596,6 +596,18 @@ type TokenClaims struct {
|
|||
Extra map[string]interface{} `json:"-"` // Additional custom claims (not serialized directly)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// AuthorizedInfo represents authorized information
|
||||
type AuthorizedInfo struct {
|
||||
Subject string `json:"sub,omitempty"` // Subject identifier
|
||||
|
|
@ -608,6 +620,9 @@ type AuthorizedInfo struct {
|
|||
TeamID string `json:"team_id,omitempty"` // Team identifier
|
||||
TenantID string `json:"tenant_id,omitempty"` // Tenant identifier
|
||||
RememberMe bool `json:"remember_me,omitempty"` // Remember Me flag preserved from login
|
||||
|
||||
// Data access constraints (set by ACL enforcement)
|
||||
Constraints DataConstraints `json:"constraints,omitempty"`
|
||||
}
|
||||
|
||||
// JWTClaims represents JWT-specific claims structure
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
package acl_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/openapi/oauth"
|
||||
"github.com/yaoapp/yao/openapi/oauth/acl"
|
||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
|
|
@ -76,6 +78,9 @@ func TestEnforce(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("EnforceWithEnabledACLNoScope", func(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
// Create enabled ACL
|
||||
config := &acl.Config{
|
||||
Enabled: true,
|
||||
|
|
@ -103,6 +108,9 @@ func TestEnforce(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("EnforceWithScopes", func(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
// Create enabled ACL
|
||||
config := &acl.Config{
|
||||
Enabled: true,
|
||||
|
|
@ -127,6 +135,9 @@ func TestEnforce(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("EnforceChecksContext", func(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
// Test that Enforce extracts info from context correctly
|
||||
config := &acl.Config{
|
||||
Enabled: true,
|
||||
|
|
@ -159,12 +170,125 @@ func TestEnforce(t *testing.T) {
|
|||
|
||||
t.Logf("Access correctly denied: %v", err)
|
||||
})
|
||||
|
||||
t.Run("EnforceUpdatesConstraints", func(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
// Get user provider and set up test data
|
||||
ctx := context.Background()
|
||||
provider, err := oauth.OAuth.GetUserProvider()
|
||||
if err != nil || provider == nil {
|
||||
t.Skip("Skipping: user provider not available")
|
||||
return
|
||||
}
|
||||
|
||||
// Set up test data
|
||||
testData := setupACLTestData(t, ctx, provider)
|
||||
defer cleanupACLTestData(t, ctx, provider, testData)
|
||||
|
||||
// Use global ACL instance
|
||||
aclEnforcer := acl.Global
|
||||
if aclEnforcer == nil || !aclEnforcer.Enabled() {
|
||||
t.Skip("Skipping: ACL not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
// Test case 1: OwnerOnly constraint (profile:read:own has owner: true)
|
||||
t.Run("OwnerOnlyConstraint", func(t *testing.T) {
|
||||
c, _ := setupGinContext("GET", "/user/profile", []string{"profile:read:own"})
|
||||
|
||||
allowed, err := aclEnforcer.Enforce(c)
|
||||
assert.NoError(t, err, "Should not return error")
|
||||
assert.True(t, allowed, "Should allow access with profile:read:own")
|
||||
|
||||
// Get updated authorized info from context
|
||||
authInfo := authorized.GetInfo(c)
|
||||
assert.NotNil(t, authInfo, "AuthInfo should not be nil")
|
||||
|
||||
// Verify OwnerOnly constraint was set
|
||||
assert.True(t, authInfo.Constraints.OwnerOnly,
|
||||
"OwnerOnly should be true for profile:read:own endpoint")
|
||||
assert.False(t, authInfo.Constraints.TeamOnly,
|
||||
"TeamOnly should be false for profile endpoint")
|
||||
|
||||
t.Logf("✓ OwnerOnly constraint correctly set: OwnerOnly=%v, TeamOnly=%v",
|
||||
authInfo.Constraints.OwnerOnly, authInfo.Constraints.TeamOnly)
|
||||
})
|
||||
|
||||
// Test case 2: TeamOnly constraint (collections:read:team has team: true)
|
||||
t.Run("TeamOnlyConstraint", func(t *testing.T) {
|
||||
c, _ := setupGinContext("GET", "/kb/collections/team", []string{"collections:read:team"})
|
||||
|
||||
allowed, err := aclEnforcer.Enforce(c)
|
||||
assert.NoError(t, err, "Should not return error")
|
||||
assert.True(t, allowed, "Should allow access with collections:read:team")
|
||||
|
||||
// Get updated authorized info from context
|
||||
authInfo := authorized.GetInfo(c)
|
||||
assert.NotNil(t, authInfo, "AuthInfo should not be nil")
|
||||
|
||||
// Verify TeamOnly constraint was set
|
||||
assert.True(t, authInfo.Constraints.TeamOnly,
|
||||
"TeamOnly should be true for collections:read:team endpoint")
|
||||
assert.False(t, authInfo.Constraints.OwnerOnly,
|
||||
"OwnerOnly should be false for team endpoint")
|
||||
|
||||
t.Logf("✓ TeamOnly constraint correctly set: OwnerOnly=%v, TeamOnly=%v",
|
||||
authInfo.Constraints.OwnerOnly, authInfo.Constraints.TeamOnly)
|
||||
})
|
||||
|
||||
// Test case 3: No constraints (collections:read:all has no owner/team flags)
|
||||
t.Run("NoConstraints", func(t *testing.T) {
|
||||
c, _ := setupGinContext("GET", "/kb/collections", []string{"collections:read:all"})
|
||||
|
||||
allowed, err := aclEnforcer.Enforce(c)
|
||||
assert.NoError(t, err, "Should not return error")
|
||||
assert.True(t, allowed, "Should allow access with collections:read:all")
|
||||
|
||||
// Get updated authorized info from context
|
||||
authInfo := authorized.GetInfo(c)
|
||||
assert.NotNil(t, authInfo, "AuthInfo should not be nil")
|
||||
|
||||
// Verify no constraints were set
|
||||
assert.False(t, authInfo.Constraints.OwnerOnly,
|
||||
"OwnerOnly should be false for unrestricted endpoint")
|
||||
assert.False(t, authInfo.Constraints.TeamOnly,
|
||||
"TeamOnly should be false for unrestricted endpoint")
|
||||
|
||||
t.Logf("✓ No constraints for unrestricted endpoint: OwnerOnly=%v, TeamOnly=%v",
|
||||
authInfo.Constraints.OwnerOnly, authInfo.Constraints.TeamOnly)
|
||||
})
|
||||
|
||||
// Test case 4: Both constraints (if such endpoint exists)
|
||||
t.Run("BothOwnerAndTeamConstraints", func(t *testing.T) {
|
||||
c, _ := setupGinContext("GET", "/kb/collections/own", []string{"collections:read:own"})
|
||||
|
||||
allowed, err := aclEnforcer.Enforce(c)
|
||||
assert.NoError(t, err, "Should not return error")
|
||||
assert.True(t, allowed, "Should allow access with collections:read:own")
|
||||
|
||||
// Get updated authorized info from context
|
||||
authInfo := authorized.GetInfo(c)
|
||||
assert.NotNil(t, authInfo, "AuthInfo should not be nil")
|
||||
|
||||
// Verify OwnerOnly constraint was set (collections:read:own has owner: true)
|
||||
assert.True(t, authInfo.Constraints.OwnerOnly,
|
||||
"OwnerOnly should be true for collections:read:own endpoint")
|
||||
|
||||
t.Logf("✓ Owner constraint for own collections: OwnerOnly=%v, TeamOnly=%v",
|
||||
authInfo.Constraints.OwnerOnly, authInfo.Constraints.TeamOnly)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestEnforceReturnValues tests Enforce return values when access is denied
|
||||
// Note: HTTP response format is handled by Guard middleware, not by Enforce
|
||||
func TestEnforceReturnValues(t *testing.T) {
|
||||
t.Run("DeniedAccessReturnValues", func(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
config := &acl.Config{
|
||||
Enabled: true,
|
||||
}
|
||||
|
|
@ -241,31 +365,257 @@ func TestGetScopes(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// setupACLTestRoles creates test roles with permissions for ACL testing
|
||||
func setupACLTestRoles(t *testing.T, ctx context.Context, provider types.UserProvider) {
|
||||
roles := []struct {
|
||||
roleID string
|
||||
name string
|
||||
description string
|
||||
permissions []string
|
||||
restricted []string
|
||||
}{
|
||||
{
|
||||
roleID: "system:root",
|
||||
name: "System Root",
|
||||
description: "System root role with full access",
|
||||
permissions: []string{"*:*:*"},
|
||||
restricted: []string{},
|
||||
},
|
||||
{
|
||||
roleID: "acl_test_user",
|
||||
name: "ACL Test User Role",
|
||||
description: "Role for ACL user testing",
|
||||
permissions: []string{
|
||||
"profile:read:own",
|
||||
"profile:write:own",
|
||||
"collections:read:all",
|
||||
"collections:write:all",
|
||||
},
|
||||
restricted: []string{},
|
||||
},
|
||||
{
|
||||
roleID: "acl_test_team",
|
||||
name: "ACL Test Team Role",
|
||||
description: "Role for ACL team testing",
|
||||
permissions: []string{
|
||||
"team:read:all",
|
||||
"team:write:all",
|
||||
"collections:read:team",
|
||||
},
|
||||
restricted: []string{},
|
||||
},
|
||||
{
|
||||
roleID: "acl_test_member",
|
||||
name: "ACL Test Member Role",
|
||||
description: "Role for ACL member testing",
|
||||
permissions: []string{
|
||||
"member:read:own",
|
||||
"member:write:own",
|
||||
"collections:read:own",
|
||||
},
|
||||
restricted: []string{
|
||||
"admin:access",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, role := range roles {
|
||||
// Create role
|
||||
roleData := map[string]interface{}{
|
||||
"role_id": role.roleID,
|
||||
"name": role.name,
|
||||
"description": role.description,
|
||||
"status": "active",
|
||||
}
|
||||
_, err := provider.CreateRole(ctx, roleData)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to create role %s (may already exist): %v", role.roleID, err)
|
||||
}
|
||||
|
||||
// Set role permissions
|
||||
permissions := map[string]interface{}{
|
||||
"permissions": role.permissions,
|
||||
"restricted_permissions": role.restricted,
|
||||
}
|
||||
err = provider.SetRolePermissions(ctx, role.roleID, permissions)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to set permissions for role %s: %v", role.roleID, err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("Set up ACL test roles and permissions")
|
||||
}
|
||||
|
||||
// cleanupACLTestRoles removes test roles created for ACL testing
|
||||
func cleanupACLTestRoles(t *testing.T, ctx context.Context, provider types.UserProvider) {
|
||||
roles := []string{"system:root", "acl_test_user", "acl_test_team", "acl_test_member"}
|
||||
for _, roleID := range roles {
|
||||
err := provider.DeleteRole(ctx, roleID)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to delete test role %s: %v", roleID, err)
|
||||
}
|
||||
}
|
||||
t.Log("Cleaned up ACL test roles")
|
||||
}
|
||||
|
||||
// setupACLTestData creates test users, teams, members and roles for ACL testing
|
||||
func setupACLTestData(t *testing.T, ctx context.Context, provider types.UserProvider) *ACLTestData {
|
||||
data := &ACLTestData{
|
||||
UserIDs: make([]string, 0),
|
||||
TeamIDs: make([]string, 0),
|
||||
MemberIDs: make([]int64, 0),
|
||||
}
|
||||
|
||||
// Set up roles first
|
||||
setupACLTestRoles(t, ctx, provider)
|
||||
|
||||
// Create test users with different roles
|
||||
users := []struct {
|
||||
userID string
|
||||
email string
|
||||
username string
|
||||
roleID string
|
||||
}{
|
||||
{"test-user", "testuser@acl.test", "testuser", "system:root"}, // For test-client in setupGinContext
|
||||
{"acl-user-1", "acluser1@acl.test", "acluser1", "acl_test_user"}, // Regular user
|
||||
{"acl-owner", "aclowner@acl.test", "aclowner", "acl_test_user"}, // Team owner
|
||||
{"acl-member-1", "aclmember1@acl.test", "aclmember1", "acl_test_user"}, // Team member
|
||||
}
|
||||
|
||||
for _, u := range users {
|
||||
userData := map[string]interface{}{
|
||||
"user_id": u.userID,
|
||||
"email": u.email,
|
||||
"preferred_username": u.username,
|
||||
"password_hash": "test_hash",
|
||||
"status": "active",
|
||||
"role_id": u.roleID,
|
||||
}
|
||||
userID, err := provider.CreateUser(ctx, userData)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to create user %s: %v", u.userID, err)
|
||||
} else {
|
||||
data.UserIDs = append(data.UserIDs, userID)
|
||||
}
|
||||
}
|
||||
|
||||
// Create test team
|
||||
teamData := map[string]interface{}{
|
||||
"name": "ACL Test Team",
|
||||
"description": "Team for ACL testing",
|
||||
"owner_id": "acl-owner",
|
||||
"status": "active",
|
||||
"role_id": "acl_test_team",
|
||||
}
|
||||
teamID, err := provider.CreateTeam(ctx, teamData)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to create team: %v", err)
|
||||
} else {
|
||||
data.TeamIDs = append(data.TeamIDs, teamID)
|
||||
|
||||
// Create team members
|
||||
members := []struct {
|
||||
userID string
|
||||
roleID string
|
||||
}{
|
||||
{"acl-owner", "acl_test_member"}, // Owner as member
|
||||
{"acl-member-1", "acl_test_member"}, // Regular member
|
||||
}
|
||||
|
||||
for _, m := range members {
|
||||
memberData := map[string]interface{}{
|
||||
"team_id": teamID,
|
||||
"user_id": m.userID,
|
||||
"role_id": m.roleID,
|
||||
"member_type": "user",
|
||||
"status": "active",
|
||||
}
|
||||
memberID, err := provider.CreateMember(ctx, memberData)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to create member %s: %v", m.userID, err)
|
||||
} else {
|
||||
data.MemberIDs = append(data.MemberIDs, memberID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Created ACL test data: %d users, %d teams, %d members",
|
||||
len(data.UserIDs), len(data.TeamIDs), len(data.MemberIDs))
|
||||
return data
|
||||
}
|
||||
|
||||
// cleanupACLTestData removes all test data created for ACL testing
|
||||
func cleanupACLTestData(t *testing.T, ctx context.Context, provider types.UserProvider, data *ACLTestData) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove teams (this will cascade remove members)
|
||||
for _, teamID := range data.TeamIDs {
|
||||
err := provider.DeleteTeam(ctx, teamID)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to delete test team %s: %v", teamID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove users
|
||||
for _, userID := range data.UserIDs {
|
||||
err := provider.DeleteUser(ctx, userID)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to delete test user %s: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove roles
|
||||
cleanupACLTestRoles(t, ctx, provider)
|
||||
|
||||
t.Logf("Cleaned up ACL test data: %d users, %d teams",
|
||||
len(data.UserIDs), len(data.TeamIDs))
|
||||
}
|
||||
|
||||
// ACLTestData holds test data created for ACL testing
|
||||
type ACLTestData struct {
|
||||
UserIDs []string
|
||||
TeamIDs []string
|
||||
MemberIDs []int64
|
||||
}
|
||||
|
||||
// TestEnforceIntegration tests the complete enforcement flow
|
||||
// Note: This test only validates scope-based ACL when RoleManager is not configured
|
||||
// For full role-based testing, see role package tests
|
||||
func TestEnforceIntegration(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
t.Run("CompleteFlow", func(t *testing.T) {
|
||||
// Create enabled ACL
|
||||
config := &acl.Config{
|
||||
Enabled: true,
|
||||
}
|
||||
t.Run("ScopeBasedFlow", func(t *testing.T) {
|
||||
// Use the global ACL instance created by testutils.Prepare
|
||||
// This tests the real configuration loaded from the application
|
||||
aclEnforcer := acl.Global
|
||||
|
||||
aclEnforcer, err := acl.New(config)
|
||||
|
||||
if err != nil {
|
||||
t.Skipf("Skipping integration test: ACL initialization failed: %v", err)
|
||||
if aclEnforcer == nil || !aclEnforcer.Enabled() {
|
||||
t.Skip("Skipping integration test: ACL is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
// Get user provider and set up complete test data
|
||||
ctx := context.Background()
|
||||
provider, err := oauth.OAuth.GetUserProvider()
|
||||
if err != nil || provider == nil {
|
||||
t.Skip("Skipping: user provider not available")
|
||||
return
|
||||
}
|
||||
|
||||
// Set up test data and ensure cleanup
|
||||
testData := setupACLTestData(t, ctx, provider)
|
||||
defer cleanupACLTestData(t, ctx, provider, testData)
|
||||
|
||||
// Test cases with real endpoints from yao-dev-app scopes configuration
|
||||
testCases := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
scopes []string
|
||||
expected string // "allow" or "deny" or "unknown"
|
||||
expected string // "allow" or "deny"
|
||||
}{
|
||||
{
|
||||
name: "PublicEndpoint",
|
||||
|
|
@ -314,7 +664,7 @@ func TestEnforceIntegration(t *testing.T) {
|
|||
method: "GET",
|
||||
path: "/kb/some-other-resource/item-123",
|
||||
scopes: []string{},
|
||||
expected: "allow", // GET /kb/* allow from scopes.yml (wildcard match, no specific scope defined)
|
||||
expected: "allow", // GET /kb/* allow from scopes.yml
|
||||
},
|
||||
{
|
||||
name: "UnmatchedEndpoint",
|
||||
|
|
@ -334,7 +684,7 @@ func TestEnforceIntegration(t *testing.T) {
|
|||
t.Logf("%s %s with scopes %v: allowed=%v",
|
||||
tc.method, tc.path, tc.scopes, allowed)
|
||||
|
||||
// Verify Enforce return values (not HTTP response format)
|
||||
// Verify Enforce return values
|
||||
if tc.expected == "allow" {
|
||||
assert.True(t, allowed, "Should allow access")
|
||||
assert.NoError(t, err, "Should not return error when access is allowed")
|
||||
|
|
@ -387,6 +737,9 @@ func TestEnforceEdgeCases(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("SpecialCharactersInPath", func(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
config := &acl.Config{
|
||||
Enabled: true,
|
||||
}
|
||||
|
|
@ -409,6 +762,9 @@ func TestEnforceEdgeCases(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("VeryLongScope", func(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
config := &acl.Config{
|
||||
Enabled: true,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -509,3 +509,97 @@ func TestScopeAtomic_ComplexScenarios(t *testing.T) {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestScopeAtomic_DataConstraints tests data access constraints (OwnerOnly, TeamOnly)
|
||||
func TestScopeAtomic_DataConstraints(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
manager, err := acl.LoadScopes()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, manager)
|
||||
|
||||
t.Run("Constraints_OwnerOnly", func(t *testing.T) {
|
||||
// Test endpoints with owner: true constraint
|
||||
// According to collections.yml: collections:read:own has owner: true
|
||||
request := &acl.AccessRequest{
|
||||
Method: "GET",
|
||||
Path: "/kb/collections/own",
|
||||
Scopes: []string{"collections:read:own"},
|
||||
}
|
||||
|
||||
decision := manager.Check(request)
|
||||
assert.NotNil(t, decision)
|
||||
t.Logf("✓ OwnerOnly endpoint: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason)
|
||||
|
||||
if decision.Allowed && decision.MatchedEndpoint != nil {
|
||||
assert.True(t, decision.MatchedEndpoint.OwnerOnly,
|
||||
"Endpoint with owner:true should set OwnerOnly flag")
|
||||
t.Logf(" OwnerOnly=%v", decision.MatchedEndpoint.OwnerOnly)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Constraints_TeamOnly", func(t *testing.T) {
|
||||
// Test endpoints with team: true constraint
|
||||
// According to collections.yml: collections:read:team has team: true
|
||||
request := &acl.AccessRequest{
|
||||
Method: "GET",
|
||||
Path: "/kb/collections/team",
|
||||
Scopes: []string{"collections:read:team"},
|
||||
}
|
||||
|
||||
decision := manager.Check(request)
|
||||
assert.NotNil(t, decision)
|
||||
t.Logf("✓ TeamOnly endpoint: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason)
|
||||
|
||||
if decision.Allowed && decision.MatchedEndpoint != nil {
|
||||
assert.True(t, decision.MatchedEndpoint.TeamOnly,
|
||||
"Endpoint with team:true should set TeamOnly flag")
|
||||
t.Logf(" TeamOnly=%v", decision.MatchedEndpoint.TeamOnly)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Constraints_NoRestrictions", func(t *testing.T) {
|
||||
// Test endpoints without constraints
|
||||
// collections:read:all has no owner/team flags
|
||||
request := &acl.AccessRequest{
|
||||
Method: "GET",
|
||||
Path: "/kb/collections",
|
||||
Scopes: []string{"collections:read:all"},
|
||||
}
|
||||
|
||||
decision := manager.Check(request)
|
||||
assert.NotNil(t, decision)
|
||||
t.Logf("✓ No constraints endpoint: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason)
|
||||
|
||||
if decision.Allowed && decision.MatchedEndpoint != nil {
|
||||
assert.False(t, decision.MatchedEndpoint.OwnerOnly,
|
||||
"Endpoint without owner flag should have OwnerOnly=false")
|
||||
assert.False(t, decision.MatchedEndpoint.TeamOnly,
|
||||
"Endpoint without team flag should have TeamOnly=false")
|
||||
t.Logf(" OwnerOnly=%v, TeamOnly=%v",
|
||||
decision.MatchedEndpoint.OwnerOnly,
|
||||
decision.MatchedEndpoint.TeamOnly)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Constraints_ProfileOwner", func(t *testing.T) {
|
||||
// Test user profile endpoint with owner constraint
|
||||
// According to profile.yml: profile:read:own has owner: true
|
||||
request := &acl.AccessRequest{
|
||||
Method: "GET",
|
||||
Path: "/user/profile",
|
||||
Scopes: []string{"profile:read:own"},
|
||||
}
|
||||
|
||||
decision := manager.Check(request)
|
||||
assert.NotNil(t, decision)
|
||||
t.Logf("✓ Profile endpoint: Allowed=%v, Reason=%s", decision.Allowed, decision.Reason)
|
||||
|
||||
if decision.Allowed && decision.MatchedEndpoint != nil {
|
||||
assert.True(t, decision.MatchedEndpoint.OwnerOnly,
|
||||
"Profile endpoint should have OwnerOnly constraint")
|
||||
t.Logf(" OwnerOnly=%v", decision.MatchedEndpoint.OwnerOnly)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue