From 47934f87a7de4498c9c375eaa0c3471d3f054ee4 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 29 Oct 2025 12:07:27 +0800 Subject: [PATCH] Add feature manager support in ACL initialization - Enhanced the ACL structure to include a new FeatureManager field, allowing for better management of feature-related permissions. - Updated the New function to load the feature manager during ACL initialization, improving the overall functionality and logging success messages for better traceability. --- openapi/oauth/acl/FEATURES_CONFIGURATION.md | 1049 +++++++++++++++++ openapi/oauth/acl/acl.go | 8 + openapi/oauth/acl/feature.go | 585 +++++++++ openapi/oauth/acl/feature_integration_test.go | 416 +++++++ openapi/oauth/acl/feature_test.go | 410 +++++++ openapi/oauth/acl/types.go | 5 +- 6 files changed, 2471 insertions(+), 2 deletions(-) create mode 100644 openapi/oauth/acl/FEATURES_CONFIGURATION.md create mode 100644 openapi/oauth/acl/feature.go create mode 100644 openapi/oauth/acl/feature_integration_test.go create mode 100644 openapi/oauth/acl/feature_test.go diff --git a/openapi/oauth/acl/FEATURES_CONFIGURATION.md b/openapi/oauth/acl/FEATURES_CONFIGURATION.md new file mode 100644 index 00000000..3c5985e3 --- /dev/null +++ b/openapi/oauth/acl/FEATURES_CONFIGURATION.md @@ -0,0 +1,1049 @@ +# ACL Features Configuration Guide + +## Overview + +This guide explains how to configure and manage feature definitions for your application. Features define what functionality is available to different roles, providing a feature flag system that allows the frontend to dynamically show or hide UI elements based on role permissions. + +--- + +## Directory Structure + +All feature configurations should be placed in the `openapi/features/` directory with the following structure: + +``` +openapi/features/ +├── features.yml # Role to features mapping +├── alias.yml # Feature aliases (groups of features) +└── / # Domain-specific feature definitions + ├── profile.yml # User profile features + ├── team.yml # Team management features + ├── / # Nested subdomain (supports unlimited depth) + │ ├── members.yml + │ └── / + │ └── settings.yml + └── ... +``` + +**Organization Guidelines**: + +- Group related features by domain (e.g., `user/`, `team/`, `kb/`) +- Use descriptive filenames matching the domain name +- Keep each file focused on a single domain or logical grouping +- Each directory represents a domain that can be queried separately +- **Supports nested directories** for hierarchical organization (e.g., `user/team/members.yml` → domain `user/team`) +- Nested domain names use forward slashes as separators (e.g., `user/team`, `kb/collections/advanced`) + +--- + +## Configuration Files + +### 1. Role Features Mapping (`features.yml`) + +The `features.yml` file defines which features are available to each role. + +#### Structure + +```yaml +# Role ID to features mapping +# Features can be aliases or actual feature names + +# ============ System Roles ============ + +# System Root - Super administrator with all features +system:root: + - "*:*:*" + +# System Admin - Platform administrator +system:admin: + - "*:*:*" + +# ============ Owner Roles (User Login) ============ + +# Owner Free - Free tier account owner +owner:free: + - profile:manage + - team:manage + - collections:create + +# Owner Pro - Professional tier account owner +owner:pro: + - user:full + +# Owner Enterprise - Enterprise tier account owner +owner:ent: + - user:full + +# ============ Team Roles (Team Login) ============ + +# Team Admin - Team administrator with full team management +team:admin: + - profile:manage + - team:manage + - collections:create + +# Team Member - Standard team member with basic features +team:member: + - profile:manage + - collections:create +``` + +#### Fields + +| Field | Type | Required | Description | +| ------- | ----- | -------- | --------------------------------------------------------------- | +| Role ID | array | Yes | List of features (can include aliases and actual feature names) | + +#### Wildcard Support + +- `*:*:*` - Grants all features (use for system administrators only) +- Future support for partial wildcards may be added + +**Best Practices**: + +- Use aliases for common feature groups +- Use wildcards only for system-level roles +- Keep role definitions organized by category (system, owner, team) +- Document each role's purpose with comments + +--- + +### 2. Feature Definitions (Domain Files) + +Feature definition files define specific features within a domain. Each file contains multiple feature definitions. + +#### Structure + +```yaml +# user/profile.yml +profile:read: + description: "Read own profile" + +profile:edit: + description: "Edit own profile" +``` + +```yaml +# user/team.yml +team:edit: + description: "Edit team information" + +team:member:invite: + description: "Invite team members" + +team:member:robot:create: + description: "Create robot team members" + +team:member:robot:edit: + description: "Edit robot team members" + +team:member:remove: + description: "Remove team members" +``` + +```yaml +# kb/collections.yml +collections:create: + description: "Create knowledge base collections" +``` + +#### Feature Definition Fields + +| Field | Type | Required | Default | Description | +| ------------- | ------ | -------- | ------- | ----------------------------------------- | +| `description` | string | Yes | "" | Human-readable description of the feature | + +#### Feature Naming Convention + +Use descriptive, colon-separated names that indicate the feature's purpose: + +``` +resource:action +``` + +or + +``` +resource:subresource:action +``` + +**Examples**: + +- `profile:read` - View profile +- `profile:edit` - Edit profile +- `team:edit` - Edit team +- `team:member:invite` - Invite team members +- `collections:create` - Create collections + +**Important**: Feature names are independent of domain names. The domain is determined by the **file path** (relative to `openapi/features/`, without `.yml` extension), not by the feature names defined within the file. + +--- + +### 3. Feature Aliases (`alias.yml`) + +Aliases allow you to group multiple features under a single name for simplified role assignment. + +#### Structure + +```yaml +# Feature Aliases - Groups of related features + +# ============ Profile Feature Aliases ============ + +profile:manage: + - profile:read + - profile:edit + +# ============ Team Feature Aliases ============ + +team:manage: + - team:edit + - team:member:invite + - team:member:robot:create + - team:member:robot:edit + - team:member:remove + +team:member:manage: + - team:member:invite + - team:member:robot:create + - team:member:robot:edit + - team:member:remove + +# ============ Knowledge Base Feature Aliases ============ + +kb:manage: + - collections:create + +# ============ Combined Feature Bundles ============ + +user:basic: + - profile:read + - profile:edit + +user:full: + - profile:manage + - team:manage + - kb:manage + +admin:full: + - profile:manage + - team:manage + - kb:manage +``` + +#### Alias Usage + +**In Role Configuration**: + +```yaml +# Use aliases instead of listing individual features +owner:pro: + - user:full # Expands to all features in user:full alias + +team:admin: + - profile:manage # Expands to profile:read + profile:edit + - team:manage # Expands to all team management features +``` + +**Benefits**: + +- **Simplified Management**: Change multiple features by updating one alias +- **Consistency**: Ensure roles get consistent feature sets +- **Readability**: Clear, semantic feature group names +- **Maintenance**: Easier to add/remove features from groups + +**Best Practices**: + +- Use hierarchical naming: `domain:level` (e.g., `user:basic`, `user:full`) +- Create aliases for common feature patterns +- Document what each alias includes +- Aliases can reference other aliases (they will be recursively expanded) + +--- + +## Domain-Based Querying + +The feature system is designed to support efficient domain-based queries, allowing the frontend to request only the features relevant to a specific section of the application. + +### Available Domains + +Based on directory structure: + +``` +openapi/features/ +├── user/ +│ ├── profile.yml → domain: "user/profile" +│ └── team/ +│ ├── settings.yml → domain: "user/team/settings" +│ └── members.yml → domain: "user/team/members" +└── kb/ + ├── collections.yml → domain: "kb/collections" + └── collections/ + ├── basic.yml → domain: "kb/collections/basic" + └── document/ + ├── meta.yml → domain: "kb/collections/document/meta" + └── content.yml → domain: "kb/collections/document/content" +``` + +**Query examples**: + +| Query Domain | Returns Features From | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `"user"` | All files: `user/profile`, `user/team/settings`, `user/team/members` | +| `"user/team"` | All files: `user/team/settings`, `user/team/members` | +| `"user/team/members"` | Only file: `user/team/members` (specific file) | +| `"user/profile"` | Only file: `user/profile` (specific file) | +| `"kb"` | All files: `kb/collections`, `kb/collections/basic`, `kb/collections/document/meta`, `kb/collections/document/content` | +| `"kb/collections"` | All files: `kb/collections`, `kb/collections/basic`, `kb/collections/document/meta`, `kb/collections/document/content` | +| `"kb/collections/document"` | All files: `kb/collections/document/meta`, `kb/collections/document/content` | +| `"kb/collections/document/meta"` | Only file: `kb/collections/document/meta` (specific file) | + +### Query Methods + +```go +// Get all features for a role +features := featureManager.Features("owner:free") +// Returns: map[string]bool{ +// "profile:read": true, +// "profile:edit": true, +// "team:edit": true, +// "team:member:invite": true, +// ... +// } + +// Get features by domain (includes nested subdomains) +userFeatures := featureManager.FeaturesByDomain("owner:free", "user") +// Returns features from "user" AND all nested domains like "user/profile", "user/team/settings", etc. +// Returns: map[string]bool{ +// "profile:read": true, // from user/profile.yml (domain: user/profile) +// "profile:edit": true, // from user/profile.yml (domain: user/profile) +// "team:view": true, // from user/team/settings.yml (domain: user/team/settings) +// "team:edit": true, // from user/team/settings.yml (domain: user/team/settings) +// "team:member:invite": true, // from user/team/members.yml (domain: user/team/members) +// ... +// } + +// Get features by specific file/domain +profileFeatures := featureManager.FeaturesByDomain("owner:free", "user/profile") +// Returns only features from "user/profile.yml" +// Returns: map[string]bool{ +// "profile:read": true, +// "profile:edit": true, +// ... +// } + +// Get features by nested domain prefix +teamFeatures := featureManager.FeaturesByDomain("owner:free", "user/team") +// Returns features from all "user/team/*" domains (hierarchical match) +// Returns: map[string]bool{ +// "team:view": true, // from user/team/settings.yml (domain: user/team/settings) +// "team:edit": true, // from user/team/settings.yml (domain: user/team/settings) +// "team:member:invite": true, // from user/team/members.yml (domain: user/team/members) +// ... +// } + +// Get all features in a specific domain (exact match only) +allProfileFeatures := featureManager.DomainFeatures("user/profile") +// Returns only features in the exact "user/profile" domain (from user/profile.yml) +// Does NOT include nested subdomains +// Returns: map[string]bool{ +// "profile:read": true, +// "profile:edit": true, +// ... +// } + +// Get all domains +domains := featureManager.Domains() +// Returns: []string{"user/profile", "user/team/settings", "user/team/members", "kb/collections", "kb/collections/basic", "kb/collections/document/meta", "kb/collections/document/content"} +``` + +### Backend API Integration + +Create API endpoints that use the package-level functions to return features: + +```go +// In your API router setup +func SetupFeatureRoutes(router *gin.Engine) { + api := router.Group("/api/v1/features") + { + // Get all features for current user + api.GET("/", func(c *gin.Context) { + features, err := acl.GetFeatures(c) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"features": features}) + }) + + // Get features by domain + api.GET("/:domain", func(c *gin.Context) { + domain := c.Param("domain") + features, err := acl.GetFeaturesByDomain(c, domain) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"features": features}) + }) + } +} +``` + +### Frontend Usage + +The frontend can query features from the API and dynamically show/hide UI elements: + +```javascript +// Fetch all features for current user (from API endpoint using acl.GetFeatures) +const response = await fetch("/api/v1/features", { + headers: { + Authorization: `Bearer ${token}`, + }, +}); +const { features } = await response.json(); + +// Check if feature is available (O(1) lookup) +if (features["profile:edit"]) { + // Show edit profile button + showEditButton(); +} + +if (features["team:member:invite"]) { + // Show invite members button + showInviteButton(); +} + +// Query features by domain for specific page +// This will include all nested subdomains automatically +const userResponse = await fetch("/api/v1/features/user", { + headers: { + Authorization: `Bearer ${token}`, + }, +}); +const { features: userFeatures } = await userResponse.json(); +// userFeatures includes: user/profile, user/team/settings, user/team/members, etc. + +// Query specific file domain +const profileResponse = await fetch("/api/v1/features/user/profile", { + headers: { + Authorization: `Bearer ${token}`, + }, +}); +const { features: profileFeatures } = await profileResponse.json(); +// profileFeatures only includes features from user/profile.yml + +// Query nested domain prefix +const teamResponse = await fetch("/api/v1/features/user/team", { + headers: { + Authorization: `Bearer ${token}`, + }, +}); +const { features: teamFeatures } = await teamResponse.json(); +// teamFeatures includes: user/team/settings, user/team/members, etc. + +// Render UI based on available features +renderTeamManagementUI(teamFeatures); + +// React example with hooks +function UserProfilePage() { + const [features, setFeatures] = useState({}); + + useEffect(() => { + async function loadFeatures() { + const response = await fetch("/api/v1/features/user/profile"); + const { features } = await response.json(); + setFeatures(features); + } + loadFeatures(); + }, []); + + return ( +
+ {features["profile:edit"] && ( + + )} + {features["profile:delete"] && ( + + )} +
+ ); +} +``` + +--- + +## Complete Example + +Let's create a complete feature configuration for a collaboration platform. + +### Directory Structure + +``` +openapi/features/ +├── features.yml +├── alias.yml +├── user/ +│ ├── profile.yml # domain: user/profile +│ └── team/ +│ ├── settings.yml # domain: user/team/settings +│ └── members.yml # domain: user/team/members +├── kb/ +│ ├── collections.yml # domain: kb/collections +│ └── collections/ +│ ├── basic.yml # domain: kb/collections/basic +│ ├── advanced.yml # domain: kb/collections/advanced +│ └── document/ +│ ├── meta.yml # domain: kb/collections/document/meta +│ └── content.yml # domain: kb/collections/document/content +└── project/ + ├── boards.yml # domain: project/boards + └── tasks.yml # domain: project/tasks +``` + +### `features.yml` + +```yaml +# System Roles +system:root: + - "*:*:*" + +system:admin: + - "*:*:*" + +# Free Tier +owner:free: + - user:basic + - project:viewer + +# Pro Tier +owner:pro: + - user:full + - project:editor + +# Enterprise Tier +owner:ent: + - user:full + - project:admin + +# Team Roles +team:admin: + - user:full + - project:admin + +team:member: + - user:basic + - project:editor +``` + +### `user/profile.yml` + +```yaml +profile:read: + description: "View own profile information" + +profile:edit: + description: "Edit own profile information" + +profile:export: + description: "Export profile data" + +profile:delete: + description: "Delete own account" +``` + +### `user/team/settings.yml` (domain: `user/team/settings`) + +```yaml +team:view: + description: "View team information" + +team:edit: + description: "Edit team settings" + +team:billing:view: + description: "View team billing information" + +team:billing:edit: + description: "Manage team billing and subscriptions" +``` + +### `user/team/members.yml` (domain: `user/team/members`) + +```yaml +team:member:invite: + description: "Invite new team members" + +team:member:remove: + description: "Remove team members" +``` + +### `project/boards.yml` + +```yaml +boards:view: + description: "View project boards" + +boards:create: + description: "Create new project boards" + +boards:edit: + description: "Edit project boards" + +boards:delete: + description: "Delete project boards" + +boards:share: + description: "Share boards with others" +``` + +### `project/tasks.yml` + +```yaml +tasks:view: + description: "View tasks" + +tasks:create: + description: "Create new tasks" + +tasks:edit: + description: "Edit tasks" + +tasks:delete: + description: "Delete tasks" + +tasks:assign: + description: "Assign tasks to team members" + +tasks:comment: + description: "Comment on tasks" +``` + +### `alias.yml` + +```yaml +# User Aliases +user:basic: + - profile:read + - profile:edit + - team:view + +user:full: + - profile:read + - profile:edit + - profile:export + - team:view + - team:edit + - team:member:invite + - team:member:remove + +user:admin: + - user:full + - profile:delete + - team:billing:view + - team:billing:edit + +# Project Aliases +project:viewer: + - boards:view + - tasks:view + +project:editor: + - boards:view + - boards:create + - boards:edit + - tasks:view + - tasks:create + - tasks:edit + - tasks:comment + +project:admin: + - boards:view + - boards:create + - boards:edit + - boards:delete + - boards:share + - tasks:view + - tasks:create + - tasks:edit + - tasks:delete + - tasks:assign + - tasks:comment +``` + +--- + +## Best Practices + +### 1. Feature Design + +✅ **DO**: + +- Use consistent naming conventions across domains +- Group related features in the same domain/file +- Provide clear descriptions for each feature +- Design features around UI functionality, not just API endpoints +- Keep features granular but not too fine-grained + +❌ **DON'T**: + +- Mix different domains in one feature file +- Create features for every single button (unless the feature genuinely represents a single, critical action) +- Use vague or inconsistent naming +- Duplicate feature definitions across files + +### 2. Domain Organization + +✅ **DO**: + +- Create domains based on application sections (user, team, project, etc.) +- Keep domain names short and meaningful +- Organize features hierarchically within domains +- Use nested directories for logical grouping (e.g., `user/team/`, `kb/collections/document/`) +- Use domains to enable lazy-loading of features +- Leverage hierarchical querying: query parent domain to get all child features +- Unlimited nesting depth is supported for complex structures + +❌ **DON'T**: + +- Create too many small domains (consolidate related features) +- Use generic domain names like "misc" or "other" +- Mix unrelated features in one domain +- Create unnecessarily deep nesting (keep it reasonable, typically 2-4 levels) + +### 3. Aliases + +✅ **DO**: + +- Create aliases for user tiers (basic, pro, enterprise) +- Create aliases for common roles (viewer, editor, admin) +- Use aliases to group related features +- Document what each alias grants + +❌ **DON'T**: + +- Create single-feature aliases (use the feature directly) +- Create aliases that are too broad +- Use ambiguous alias names + +### 4. Role Assignment + +✅ **DO**: + +- Use aliases for most role definitions +- Use wildcards (`*:*:*`) only for system roles +- Organize roles by category (system, owner, team) +- Document the purpose of each role + +❌ **DON'T**: + +- List dozens of individual features per role +- Grant `*:*:*` to non-system roles +- Create too many role variations + +### 5. Backend API Integration + +✅ **DO**: + +- Use `acl.GetFeatures(c)` and `acl.GetFeaturesByDomain(c, domain)` in your API handlers +- Let the context functions automatically determine user vs team member context +- Return features as JSON with `map[string]bool` format +- Handle errors gracefully and return appropriate HTTP status codes +- Use domain-based queries to reduce payload size + +❌ **DON'T**: + +- Manually extract `__user_id` and `__team_id` from context (use the helper functions) +- Return features as arrays (use map for O(1) lookups on frontend) +- Expose internal role IDs to the frontend +- Query all features when only one domain is needed + +### 6. Frontend Integration + +✅ **DO**: + +- Query features by domain for better performance +- Cache feature results on the frontend +- Use feature flags to show/hide UI elements +- Provide fallbacks for missing features +- Use the API endpoints that leverage `acl.GetFeatures` and `acl.GetFeaturesByDomain` + +❌ **DON'T**: + +- Query all features when only one domain is needed +- Make feature queries for every component render +- Assume a feature exists without checking +- Store features in insecure locations (use memory/session storage) + +### 7. Testing + +- Test each role's feature set +- Verify alias expansion works correctly +- Test wildcard matching behavior +- Ensure features query by domain returns correct results +- Validate frontend properly hides/shows UI based on features +- Test `GetFeatures` and `GetFeaturesByDomain` with mock gin.Context +- Verify user vs team member context detection works correctly + +### 8. Documentation + +- Comment complex feature groupings +- Document the purpose of each alias +- Maintain a feature reference for developers +- Update documentation when features change +- Provide examples of feature usage in frontend + +--- + +## Troubleshooting + +### Common Issues + +**Issue**: Role has no features + +**Solution**: + +- Verify role ID matches exactly in `features.yml` (case-sensitive) +- Check if aliases are defined in `alias.yml` +- Ensure feature files exist in domain directories +- Restart application to reload configurations + +--- + +**Issue**: Alias not expanding + +**Solution**: + +- Check alias definition in `alias.yml` +- Verify feature names in alias are correct +- Check for circular alias references +- Ensure YAML syntax is valid + +--- + +**Issue**: Domain query returns empty + +**Solution**: + +- Verify domain directory exists +- Check feature files in domain directory are valid YAML +- Ensure features are properly defined with descriptions +- Check that role includes features from that domain + +--- + +**Issue**: Changes not taking effect + +**Solution**: + +- Restart the application to reload feature configurations +- Verify YAML syntax is correct (use YAML validator) +- Check file is in correct directory +- Clear frontend cache + +--- + +## API Reference + +### Package-Level Functions (Gin Context Integration) + +These functions automatically extract user/team information from `gin.Context` and return features for the current user or team member: + +```go +// GetFeatures returns all features for the current user/team member from gin context +// Automatically determines whether to use user or team member lookup based on context +// Returns a map for O(1) feature lookup: feature_name -> true +// +// Usage in gin handler: +// func MyHandler(c *gin.Context) { +// features, err := acl.GetFeatures(c) +// if err != nil { +// c.JSON(500, gin.H{"error": err.Error()}) +// return +// } +// c.JSON(200, features) +// } +GetFeatures(c *gin.Context) (map[string]bool, error) + +// GetFeaturesByDomain returns features filtered by domain from gin context +// Automatically determines whether to use user or team member lookup based on context +// Supports hierarchical matching: "user" includes "user/profile", "user/team", etc. +// Returns a map for O(1) feature lookup: feature_name -> true +// +// Usage in gin handler: +// func MyUserPageHandler(c *gin.Context) { +// features, err := acl.GetFeaturesByDomain(c, "user") +// if err != nil { +// c.JSON(500, gin.H{"error": err.Error()}) +// return +// } +// c.JSON(200, features) +// } +GetFeaturesByDomain(c *gin.Context, domain string) (map[string]bool, error) +``` + +**Context Requirements**: + +- `__user_id` (string): Required - The current user's ID +- `__team_id` (string): Optional - If present, queries team member role; otherwise queries user role + +**Behavior**: + +1. If `__team_id` is present in context → queries member role using `RoleManager.GetMemberRole(teamID, userID)` +2. If `__team_id` is not present → queries user role using `RoleManager.GetUserRole(userID)` +3. Returns empty map if ACL is disabled or role manager is not initialized +4. Returns error if context values are invalid + +**Example Integration**: + +```go +package api + +import ( + "github.com/gin-gonic/gin" + "github.com/yaoapp/yao/openapi/oauth/acl" +) + +// GetUserFeatures returns all features for current user +func GetUserFeatures(c *gin.Context) { + features, err := acl.GetFeatures(c) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"features": features}) +} + +// GetUserPageFeatures returns features for user management page +func GetUserPageFeatures(c *gin.Context) { + features, err := acl.GetFeaturesByDomain(c, "user") + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"features": features}) +} + +// GetKBPageFeatures returns features for knowledge base page +func GetKBPageFeatures(c *gin.Context) { + features, err := acl.GetFeaturesByDomain(c, "kb") + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"features": features}) +} +``` + +--- + +### FeatureManager Methods + +These methods require explicit role ID and are used internally or for advanced use cases: + +```go +// Features returns all features for a role (expanded) +Features(roleID string) map[string]bool + +// FeaturesByDomain returns features filtered by domain +// Supports hierarchical matching: "user" includes "user/team", "user/profile", etc. +FeaturesByDomain(roleID, domain string) map[string]bool + +// DomainFeatures returns all features in a specific domain (exact match only) +// Does NOT include nested subdomains +DomainFeatures(domain string) map[string]bool + +// Domains returns all available domains (including nested domains) +Domains() []string + +// Definition returns detailed info about a feature +Definition(featureName string) *FeatureDefinition + +// Reload reloads feature configurations +Reload() error +``` + +**Convenience Methods (with role resolution)**: + +```go +// FeaturesForUser returns all features for a user by user ID +FeaturesForUser(ctx context.Context, userID string) (map[string]bool, error) + +// FeaturesForUserByDomain returns features for a user filtered by domain +FeaturesForUserByDomain(ctx context.Context, userID, domain string) (map[string]bool, error) + +// FeaturesForTeamUser returns all features for a team member +FeaturesForTeamUser(ctx context.Context, teamID, userID string) (map[string]bool, error) + +// FeaturesForTeamUserByDomain returns features for a team member filtered by domain +FeaturesForTeamUserByDomain(ctx context.Context, teamID, userID, domain string) (map[string]bool, error) +``` + +--- + +## Integration with Scopes + +Features and Scopes work together but serve different purposes: + +- **Features**: Control UI visibility and functionality (frontend) +- **Scopes**: Control API access and permissions (backend) + +A user might have the feature `team:member:invite` (showing the invite button) and also need the scope `teams:invitations:write` (actually sending invitations). + +**Example**: + +```yaml +# features.yml +owner:free: + - team:member:invite # Shows invite button + +# scopes/alias.yml +role:owner:free: + - teams:invitations:write # Allows API calls +``` + +--- + +## Summary + +Key points to remember: + +1. **Three main files**: `features.yml` (roles), `alias.yml` (aliases), domain files (features) +2. **Naming convention**: Use descriptive, colon-separated names +3. **Domain organization**: Group features by application section, supports multi-level nesting +4. **Aliases**: Group features for easier role management +5. **Wildcards**: Use `*:*:*` for system roles only +6. **Return type**: All queries return `map[string]bool` for efficient lookups +7. **Backend API**: Use `acl.GetFeatures(c)` and `acl.GetFeaturesByDomain(c, domain)` in handlers +8. **Context detection**: Automatically handles user vs team member based on `__team_id` presence +9. **Frontend**: Query by domain for better performance, cache results +10. **Hierarchical queries**: Querying parent domain includes all nested subdomains + +**Quick Start for Backend Integration**: + +```go +// In your API handler +func GetFeatures(c *gin.Context) { + features, err := acl.GetFeatures(c) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"features": features}) +} + +func GetDomainFeatures(c *gin.Context) { + domain := c.Param("domain") + features, err := acl.GetFeaturesByDomain(c, domain) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{"features": features}) +} +``` + +For more details, refer to: + +- [SCOPES_CONFIGURATION.md](./SCOPES_CONFIGURATION.md) - Scope permissions +- [README.md](./README.md) - ACL enforcement logic +- [DESIGN.md](./DESIGN.md) - System architecture diff --git a/openapi/oauth/acl/acl.go b/openapi/oauth/acl/acl.go index a3c7a367..1403a1eb 100644 --- a/openapi/oauth/acl/acl.go +++ b/openapi/oauth/acl/acl.go @@ -30,6 +30,14 @@ func New(config *Config) (Enforcer, error) { acl.Scope = manager log.Info("[ACL] Scope manager loaded successfully") + // Load feature manager + featureManager, err := LoadFeatures() + if err != nil { + return nil, err + } + acl.Feature = featureManager + log.Info("[ACL] Feature manager loaded successfully") + // Init Role Manager role.RoleManager = role.NewManager(config.Cache, config.Provider) log.Info("[ACL] Role manager loaded successfully") diff --git a/openapi/oauth/acl/feature.go b/openapi/oauth/acl/feature.go new file mode 100644 index 00000000..aa684eeb --- /dev/null +++ b/openapi/oauth/acl/feature.go @@ -0,0 +1,585 @@ +package acl + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "sync" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/gou/application" + "github.com/yaoapp/kun/log" + "github.com/yaoapp/yao/openapi/oauth/acl/role" + "gopkg.in/yaml.v3" +) + +// ============ Gin Context Integration (Package-Level Functions) ============ + +// GetFeatures returns all features for the current user/team member from gin context +// Automatically determines whether to use user or team member lookup based on context +// Returns a map for O(1) feature lookup: feature_name -> true +func GetFeatures(c *gin.Context) (map[string]bool, error) { + // Get ACL instance + if Global == nil || !Global.Enabled() { + return make(map[string]bool), nil + } + + acl, ok := Global.(*ACL) + if !ok || acl.Feature == nil { + return make(map[string]bool), nil + } + + // Get role ID from context + roleID, err := getRoleFromContext(c) + if err != nil || roleID == "" { + return make(map[string]bool), err + } + + // Get features for this role + return acl.Feature.Features(roleID), nil +} + +// GetFeaturesByDomain returns features filtered by domain from gin context +// Automatically determines whether to use user or team member lookup based on context +// Supports hierarchical matching: "user" includes "user/profile", "user/team", etc. +// Returns a map for O(1) feature lookup: feature_name -> true +func GetFeaturesByDomain(c *gin.Context, domain string) (map[string]bool, error) { + // Get ACL instance + if Global == nil || !Global.Enabled() { + return make(map[string]bool), nil + } + + acl, ok := Global.(*ACL) + if !ok || acl.Feature == nil { + return make(map[string]bool), nil + } + + // Get role ID from context + roleID, err := getRoleFromContext(c) + if err != nil || roleID == "" { + return make(map[string]bool), err + } + + // Get features by domain for this role + return acl.Feature.FeaturesByDomain(roleID, domain), nil +} + +// ============ Public API (exported query methods) ============ + +// Features returns all features for a given role (expands aliases and wildcards) +// Returns a map for O(1) lookup: feature_name -> true +func (m *FeatureManager) Features(roleID string) map[string]bool { + m.mu.RLock() + defer m.mu.RUnlock() + + features := m.roleFeatures[roleID] + if features == nil { + return make(map[string]bool) + } + + return m.expandFeaturesAsMap(features) +} + +// FeaturesForUser returns all features for a user by looking up their role +// Returns a map for O(1) lookup: feature_name -> true +func (m *FeatureManager) FeaturesForUser(ctx context.Context, userID string) (map[string]bool, error) { + roleID, err := m.getRoleForUser(ctx, userID) + if err != nil { + return make(map[string]bool), err + } + return m.Features(roleID), nil +} + +// FeaturesForUserByDomain returns features for a user filtered by domain +// Returns a map for O(1) lookup: feature_name -> true +func (m *FeatureManager) FeaturesForUserByDomain(ctx context.Context, userID, domain string) (map[string]bool, error) { + roleID, err := m.getRoleForUser(ctx, userID) + if err != nil { + return make(map[string]bool), err + } + return m.FeaturesByDomain(roleID, domain), nil +} + +// FeaturesForTeamUser returns all features for a team user by looking up their member role +// Returns a map for O(1) lookup: feature_name -> true +func (m *FeatureManager) FeaturesForTeamUser(ctx context.Context, teamID, userID string) (map[string]bool, error) { + roleID, err := m.getRoleForMember(ctx, teamID, userID) + if err != nil { + return make(map[string]bool), err + } + return m.Features(roleID), nil +} + +// FeaturesForTeamUserByDomain returns features for a team user filtered by domain +// Returns a map for O(1) lookup: feature_name -> true +func (m *FeatureManager) FeaturesForTeamUserByDomain(ctx context.Context, teamID, userID, domain string) (map[string]bool, error) { + roleID, err := m.getRoleForMember(ctx, teamID, userID) + if err != nil { + return make(map[string]bool), err + } + return m.FeaturesByDomain(roleID, domain), nil +} + +// FeaturesByDomain returns features for a role filtered by domain +// Supports hierarchical matching: querying "user" will include "user/team", "user/profile", etc. +// Returns a map for O(1) lookup: feature_name -> true +func (m *FeatureManager) FeaturesByDomain(roleID, domain string) map[string]bool { + m.mu.RLock() + defer m.mu.RUnlock() + + features := m.roleFeatures[roleID] + if features == nil { + return make(map[string]bool) + } + + // Expand all features + expanded := m.expandFeaturesAsMap(features) + + // Filter by domain (supports hierarchical matching) + result := make(map[string]bool) + for feature := range expanded { + featureDomain := m.featureDomain[feature] + // Exact match OR prefix match (for nested domains) + // e.g., domain="user" matches "user", "user/team", "user/profile", etc. + if featureDomain == domain || strings.HasPrefix(featureDomain, domain+"/") { + result[feature] = true + } + } + + return result +} + +// DomainFeatures returns all features in a specific domain +// Returns a map for O(1) lookup: feature_name -> true +func (m *FeatureManager) DomainFeatures(domain string) map[string]bool { + m.mu.RLock() + defer m.mu.RUnlock() + + features := m.domainFeatures[domain] + if features == nil { + return make(map[string]bool) + } + + result := make(map[string]bool, len(features)) + for name := range features { + result[name] = true + } + + return result +} + +// Domains returns all available domains +func (m *FeatureManager) Domains() []string { + m.mu.RLock() + defer m.mu.RUnlock() + + domains := make([]string, 0, len(m.domainFeatures)) + for domain := range m.domainFeatures { + domains = append(domains, domain) + } + + return domains +} + +// Definition returns the definition of a specific feature +func (m *FeatureManager) Definition(featureName string) *FeatureDefinition { + m.mu.RLock() + defer m.mu.RUnlock() + + domain := m.featureDomain[featureName] + if domain == "" { + return nil + } + + return m.domainFeatures[domain][featureName] +} + +// ============ Internal Structures ============ + +// FeatureManager manages feature definitions and role-to-feature mappings +type FeatureManager struct { + mu sync.RWMutex + + // Feature definitions by domain + // domain -> feature_name -> FeatureDefinition + // Example: "user" -> "profile:read" -> FeatureDefinition + domainFeatures map[string]map[string]*FeatureDefinition + + // Feature aliases (groups of features) + // alias_name -> []feature_names + aliasIndex map[string][]string + + // Role to features mapping + // role_id -> []feature_names (can include aliases and actual features) + roleFeatures map[string][]string + + // Domain index for quick lookup + // feature_name -> domain + featureDomain map[string]string +} + +// FeatureDefinition defines a single feature +type FeatureDefinition struct { + Name string `yaml:"-"` + Description string `yaml:"description"` +} + +// FeatureAliasConfig stores feature aliases (alias_name -> feature_names) +type FeatureAliasConfig map[string][]string + +// RoleFeatureConfig stores role-to-features mapping (role_id -> feature_names) +type RoleFeatureConfig map[string][]string + +// ============ Loading and Configuration ============ + +// LoadFeatures loads the feature configuration from the openapi/features directory +func LoadFeatures() (*FeatureManager, error) { + manager := &FeatureManager{ + domainFeatures: make(map[string]map[string]*FeatureDefinition), + aliasIndex: make(map[string][]string), + roleFeatures: make(map[string][]string), + featureDomain: make(map[string]string), + } + + // Check if features directory exists + featuresDir := filepath.Join("openapi", "features") + exists, err := application.App.Exists(featuresDir) + if err != nil { + return nil, err + } + if !exists { + log.Warn("[Feature] Features directory not found") + return manager, nil + } + + // Step 1: Load feature aliases (alias.yml) + if err := manager.loadAliasConfig(); err != nil { + return nil, fmt.Errorf("failed to load alias config: %w", err) + } + + // Step 2: Load feature definitions from subdirectories (domains) + if err := manager.loadFeatureDefinitions(); err != nil { + return nil, fmt.Errorf("failed to load feature definitions: %w", err) + } + + // Step 3: Load role-to-features mapping (features.yml) + if err := manager.loadRoleFeaturesConfig(); err != nil { + return nil, fmt.Errorf("failed to load role features config: %w", err) + } + + // Step 4: Build indexes + if err := manager.buildIndexes(); err != nil { + return nil, fmt.Errorf("failed to build indexes: %w", err) + } + + log.Info("[Feature] Loaded %d features across %d domains, %d aliases, %d roles", + len(manager.featureDomain), len(manager.domainFeatures), len(manager.aliasIndex), len(manager.roleFeatures)) + return manager, nil +} + +// loadAliasConfig loads the feature aliases from alias.yml +func (m *FeatureManager) loadAliasConfig() error { + configPath := filepath.Join("openapi", "features", "alias.yml") + exists, err := application.App.Exists(configPath) + if err != nil { + return err + } + if !exists { + log.Warn("[Feature] alias.yml not found") + return nil + } + + raw, err := application.App.Read(configPath) + if err != nil { + return err + } + + var config FeatureAliasConfig + if err := yaml.Unmarshal(raw, &config); err != nil { + return err + } + + // Expand aliases (resolve nested aliases) + for alias := range config { + expanded, err := m.expandAlias(alias, config, make(map[string]bool)) + if err != nil { + return fmt.Errorf("failed to expand alias %s: %w", alias, err) + } + m.aliasIndex[alias] = expanded + } + + return nil +} + +// expandAlias recursively expands an alias to its features, detecting circular references +func (m *FeatureManager) expandAlias(alias string, config FeatureAliasConfig, visited map[string]bool) ([]string, error) { + // Check for circular reference + if visited[alias] { + return nil, fmt.Errorf("circular alias reference detected: %s", alias) + } + visited[alias] = true + + features := config[alias] + if features == nil { + // Not an alias, return as is + return []string{alias}, nil + } + + var expanded []string + seen := make(map[string]bool) + + for _, feature := range features { + // Check if this is another alias + if config[feature] != nil { + // Recursively expand + subFeatures, err := m.expandAlias(feature, config, visited) + if err != nil { + return nil, err + } + for _, sf := range subFeatures { + if !seen[sf] { + expanded = append(expanded, sf) + seen[sf] = true + } + } + } else { + if !seen[feature] { + expanded = append(expanded, feature) + seen[feature] = true + } + } + } + + delete(visited, alias) + return expanded, nil +} + +// loadFeatureDefinitions loads feature definitions from subdirectories (domains) +// Supports nested directories for hierarchical domain organization +func (m *FeatureManager) loadFeatureDefinitions() error { + featuresDir := filepath.Join("openapi", "features") + + // Walk through all subdirectories + err := application.App.Walk(featuresDir, func(root, path string, isdir bool) error { + // Skip root directory files (alias.yml, features.yml) + if filepath.Dir(path) == featuresDir { + return nil + } + + // Only process .yml files in subdirectories + if isdir || !strings.HasSuffix(path, ".yml") { + return nil + } + + // Extract domain from path (include filename without extension) + // Example: openapi/features/user/profile.yml -> domain = "user/profile" + // Example: openapi/features/user/team/members.yml -> domain = "user/team/members" + relPath, err := filepath.Rel(featuresDir, path) + if err != nil { + return err + } + + // Remove .yml extension to get domain path + domainPath := strings.TrimSuffix(relPath, ".yml") + // Convert to forward slashes for consistent domain names + domain := filepath.ToSlash(domainPath) + + // Load feature definitions from this file + if err := m.loadFeatureFile(path, domain); err != nil { + log.Warn("[Feature] Failed to load %s: %v", path, err) + } + + return nil + }, "*.yml") + + return err +} + +// loadFeatureFile loads feature definitions from a single YAML file +func (m *FeatureManager) loadFeatureFile(filePath, domain string) error { + raw, err := application.App.Read(filePath) + if err != nil { + return err + } + + // Parse as map of feature definitions + var featureMap map[string]*FeatureDefinition + if err := yaml.Unmarshal(raw, &featureMap); err != nil { + return err + } + + // Initialize domain map if needed + if m.domainFeatures[domain] == nil { + m.domainFeatures[domain] = make(map[string]*FeatureDefinition) + } + + // Store each feature definition + for name, def := range featureMap { + def.Name = name + m.domainFeatures[domain][name] = def + } + + return nil +} + +// loadRoleFeaturesConfig loads the role-to-features mapping from features.yml +func (m *FeatureManager) loadRoleFeaturesConfig() error { + configPath := filepath.Join("openapi", "features", "features.yml") + exists, err := application.App.Exists(configPath) + if err != nil { + return err + } + if !exists { + log.Warn("[Feature] features.yml not found") + return nil + } + + raw, err := application.App.Read(configPath) + if err != nil { + return err + } + + var config RoleFeatureConfig + if err := yaml.Unmarshal(raw, &config); err != nil { + return err + } + + m.roleFeatures = config + return nil +} + +// buildIndexes builds runtime indexes for efficient querying +func (m *FeatureManager) buildIndexes() error { + // Build feature-to-domain index + for domain, features := range m.domainFeatures { + for featureName := range features { + m.featureDomain[featureName] = domain + } + } + + return nil +} + +// expandFeaturesAsMap expands feature list by resolving aliases and wildcards +// Returns a map for efficient lookup +func (m *FeatureManager) expandFeaturesAsMap(features []string) map[string]bool { + result := make(map[string]bool) + + for _, feature := range features { + // Check for wildcard + if m.matchesWildcard(feature) { + // Add all features + for f := range m.featureDomain { + result[f] = true + } + continue + } + + // Check if it's an alias + if aliasFeatures := m.aliasIndex[feature]; aliasFeatures != nil { + for _, f := range aliasFeatures { + result[f] = true + } + } else { + // Regular feature + result[feature] = true + } + } + + return result +} + +// matchesWildcard checks if a feature string is a wildcard pattern +func (m *FeatureManager) matchesWildcard(feature string) bool { + // Full wildcard: *:*:* + if feature == "*:*:*" { + return true + } + + // Could extend to support partial wildcards in the future + // For now, only support full wildcard + return false +} + +// Reload reloads the feature configuration +func (m *FeatureManager) Reload() error { + m.mu.Lock() + defer m.mu.Unlock() + + // Create a new manager + newManager, err := LoadFeatures() + if err != nil { + return err + } + + // Replace current data with new data + m.domainFeatures = newManager.domainFeatures + m.aliasIndex = newManager.aliasIndex + m.roleFeatures = newManager.roleFeatures + m.featureDomain = newManager.featureDomain + + return nil +} + +// ============================================================================ +// Role Resolution Helper Methods +// ============================================================================ + +// getRoleForUser gets the role ID for a user from role manager +func (m *FeatureManager) getRoleForUser(ctx context.Context, userID string) (string, error) { + if role.RoleManager == nil { + return "", fmt.Errorf("role manager is not initialized") + } + return role.RoleManager.GetUserRole(ctx, userID) +} + +// getRoleForMember gets the role ID for a team member from role manager +func (m *FeatureManager) getRoleForMember(ctx context.Context, teamID, userID string) (string, error) { + if role.RoleManager == nil { + return "", fmt.Errorf("role manager is not initialized") + } + return role.RoleManager.GetMemberRole(ctx, teamID, userID) +} + +// ============================================================================ +// Internal Helper Functions +// ============================================================================ + +// getRoleFromContext extracts role ID from gin context +// Automatically determines whether to use user or team member role lookup +func getRoleFromContext(c *gin.Context) (string, error) { + // Get context for queries + ctx := c.Request.Context() + + // Check if this is a team context (has team_id) + teamID, hasTeam := c.Get("__team_id") + userID, hasUser := c.Get("__user_id") + + if !hasUser { + // No user_id, cannot get role + return "", nil + } + + userIDStr, ok := userID.(string) + if !ok { + return "", fmt.Errorf("invalid user_id type") + } + + // If team_id exists, get member role + if hasTeam && teamID != nil { + if teamIDStr, ok := teamID.(string); ok && teamIDStr != "" { + // Get member role from role manager + if role.RoleManager != nil { + return role.RoleManager.GetMemberRole(ctx, teamIDStr, userIDStr) + } + } + } + + // No team_id, get user role + if role.RoleManager != nil { + return role.RoleManager.GetUserRole(ctx, userIDStr) + } + + return "", fmt.Errorf("role manager is not initialized") +} diff --git a/openapi/oauth/acl/feature_integration_test.go b/openapi/oauth/acl/feature_integration_test.go new file mode 100644 index 00000000..cb35ca0d --- /dev/null +++ b/openapi/oauth/acl/feature_integration_test.go @@ -0,0 +1,416 @@ +package acl + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/gou/application" + "github.com/yaoapp/gou/model" + "github.com/yaoapp/gou/store" + "github.com/yaoapp/kun/maps" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/openapi/oauth/acl/role" + "github.com/yaoapp/yao/openapi/oauth/providers/user" + "github.com/yaoapp/yao/test" +) + +var ( + integrationTestProvider *user.DefaultUser + integrationTestCache store.Store +) + +// createMockGinContext creates a mock gin.Context for testing +// If teamID is empty, creates a user context; otherwise creates a team member context +func createMockGinContext(userID, teamID string) *gin.Context { + // Create a test HTTP request + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + + // Create gin context + c, _ := gin.CreateTestContext(w) + c.Request = req + + // Set user_id in context + c.Set("__user_id", userID) + + // Set team_id if provided + if teamID != "" { + c.Set("__team_id", teamID) + } + + return c +} + +// prepareIntegrationTest initializes the integration test environment +func prepareIntegrationTest(t *testing.T) (*FeatureManager, string, string, string, string) { + // Initialize test environment + test.Prepare(t, config.Conf) + + // Set test application path + testApp := os.Getenv("YAO_TEST_APPLICATION") + if testApp == "" { + t.Skip("YAO_TEST_APPLICATION not set, skipping integration tests") + } + + // Initialize application + app, err := application.OpenFromDisk(testApp) + require.NoError(t, err) + application.Load(app) + + // Initialize provider + integrationTestProvider = user.NewDefaultUser(&user.DefaultUserOptions{ + Prefix: "test:", + IDStrategy: user.NanoIDStrategy, + IDPrefix: "test_", + }) + + // Initialize cache for role manager (use system store if available, nil otherwise) + integrationTestCache, _ = store.Get("system") + + // Initialize role manager (cache can be nil, role manager handles it gracefully) + role.RoleManager = role.NewManager(integrationTestCache, integrationTestProvider) + + // Load features + manager, err := LoadFeatures() + require.NoError(t, err) + + ctx := context.Background() + testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8] + + // Note: We assume roles owner:free and team:admin already exist in features.yml + // If they don't exist in the database, create them (but ignore duplicate errors) + roleOwnerFree := maps.MapStrAny{ + "role_id": "owner:free", + "name": "Owner Free", + "description": "Free tier owner", + "is_active": true, + "level": 10, + } + integrationTestProvider.CreateRole(ctx, roleOwnerFree) // Ignore error if exists + + roleMemberAdmin := maps.MapStrAny{ + "role_id": "team:admin", + "name": "Team Admin", + "description": "Team administrator", + "is_active": true, + "level": 50, + } + integrationTestProvider.CreateRole(ctx, roleMemberAdmin) // Ignore error if exists + + // Create test user + userMap := maps.MapStrAny{ + "preferred_username": "featureuser" + testUUID, + "email": "featureuser" + testUUID + "@example.com", + "password": "TestPass123!", + "name": "Feature Test User", + "status": "active", + "role_id": "owner:free", + "type_id": "regular", + "email_verified": true, + } + _, err = integrationTestProvider.CreateUser(ctx, userMap) + require.NoError(t, err) + userID := userMap["user_id"].(string) + + // Create test team + teamMap := maps.MapStrAny{ + "name": "Feature Test Team " + testUUID, + "display_name": "Feature Test Team", + "description": "Test team for feature integration", + "owner_id": userID, + "status": "active", + "type": "corporation", + "type_id": "business", + } + teamID, err := integrationTestProvider.CreateTeam(ctx, teamMap) + require.NoError(t, err) + + // Create another user as team member + memberUserMap := maps.MapStrAny{ + "preferred_username": "featuremember" + testUUID, + "email": "featuremember" + testUUID + "@example.com", + "password": "TestPass123!", + "name": "Feature Member User", + "status": "active", + "role_id": "owner:free", + "type_id": "regular", + "email_verified": true, + } + _, err = integrationTestProvider.CreateUser(ctx, memberUserMap) + require.NoError(t, err) + memberUserID := memberUserMap["user_id"].(string) + + // Add member to team + memberData := maps.MapStrAny{ + "team_id": teamID, + "user_id": memberUserID, + "member_type": "user", + "role_id": "team:admin", + "status": "active", + } + _, err = integrationTestProvider.CreateMember(ctx, memberData) + require.NoError(t, err) + + return manager, testUUID, userID, teamID, memberUserID +} + +// cleanIntegrationTest cleans up integration test data +func cleanIntegrationTest(testUUID string) { + if integrationTestProvider == nil { + return + } + + // Clean users + userModel := model.Select("__yao.user") + userModel.DestroyWhere(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "preferred_username", OP: "like", Value: "%featureuser" + testUUID + "%"}, + }, + }) + userModel.DestroyWhere(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "preferred_username", OP: "like", Value: "%featuremember" + testUUID + "%"}, + }, + }) + + // Clean teams + teamModel := model.Select("__yao.team") + teamModel.DestroyWhere(model.QueryParam{ + Wheres: []model.QueryWhere{ + {Column: "name", OP: "like", Value: "Feature Test Team " + testUUID}, + }, + }) + + // Reset globals + integrationTestProvider = nil + integrationTestCache = nil + role.RoleManager = nil + + // Clean base test environment + test.Clean() +} + +func TestFeaturesForUser_Integration(t *testing.T) { + manager, testUUID, userID, _, _ := prepareIntegrationTest(t) + defer cleanIntegrationTest(testUUID) + + ctx := context.Background() + + // Test FeaturesForUser + t.Run("FeaturesForUser", func(t *testing.T) { + features, err := manager.FeaturesForUser(ctx, userID) + require.NoError(t, err) + assert.NotEmpty(t, features, "user should have features") + + // owner:free should have profile:manage expanded + assert.True(t, features["profile:read"], "should have profile:read from profile:manage alias") + assert.True(t, features["profile:edit"], "should have profile:edit from profile:manage alias") + + // Should have team:manage expanded + assert.True(t, features["team:edit"], "should have team:edit from team:manage alias") + assert.True(t, features["team:member:invite"], "should have team:member:invite") + + // Should have collections:create + assert.True(t, features["collections:create"], "should have collections:create") + }) + + // Test FeaturesForUserByDomain + t.Run("FeaturesForUserByDomain", func(t *testing.T) { + // Query user domain + userFeatures, err := manager.FeaturesForUserByDomain(ctx, userID, "user") + require.NoError(t, err) + assert.NotEmpty(t, userFeatures, "user domain should have features") + + // Should include profile features (from user/profile.yml) + assert.True(t, userFeatures["profile:read"], "should have profile:read in user domain") + assert.True(t, userFeatures["profile:edit"], "should have profile:edit in user domain") + + // Should include team features (from user/team.yml via team:manage alias) + assert.True(t, userFeatures["team:edit"], "should have team:edit in user domain") + assert.True(t, userFeatures["team:member:invite"], "should have team:member:invite in user domain") + + // Note: team:settings:view is NOT in owner:free role + // It's in user/team/settings.yml but not included in team:manage alias + // If owner:free role had access to all user/* features, we would see it + + // Should NOT include kb features + assert.False(t, userFeatures["collections:create"], "should not have kb features in user domain") + + // Query specific subdomain + profileFeatures, err := manager.FeaturesForUserByDomain(ctx, userID, "user/profile") + require.NoError(t, err) + assert.True(t, profileFeatures["profile:read"], "should have profile:read in user/profile domain") + assert.False(t, profileFeatures["team:edit"], "should not have team features in user/profile domain") + }) +} + +func TestFeaturesForTeamUser_Integration(t *testing.T) { + manager, testUUID, _, teamID, memberUserID := prepareIntegrationTest(t) + defer cleanIntegrationTest(testUUID) + + ctx := context.Background() + + // Test FeaturesForTeamUser + t.Run("FeaturesForTeamUser", func(t *testing.T) { + features, err := manager.FeaturesForTeamUser(ctx, teamID, memberUserID) + require.NoError(t, err) + assert.NotEmpty(t, features, "team member should have features") + + // team:admin should have profile:manage, team:manage, collections:create + assert.True(t, features["profile:read"], "should have profile:read") + assert.True(t, features["profile:edit"], "should have profile:edit") + assert.True(t, features["team:edit"], "should have team:edit") + assert.True(t, features["team:member:invite"], "should have team:member:invite") + assert.True(t, features["collections:create"], "should have collections:create") + }) + + // Test FeaturesForTeamUserByDomain + t.Run("FeaturesForTeamUserByDomain", func(t *testing.T) { + // Query user domain for team member + userFeatures, err := manager.FeaturesForTeamUserByDomain(ctx, teamID, memberUserID, "user") + require.NoError(t, err) + assert.NotEmpty(t, userFeatures, "team member should have user domain features") + + // Should include user/* features that are in team:admin role + assert.True(t, userFeatures["profile:read"], "should have profile:read") + assert.True(t, userFeatures["team:edit"], "should have team:edit") + assert.True(t, userFeatures["team:member:invite"], "should have team:member:invite") + + // Note: team:settings:view and team:members:list are NOT in team:admin role + // because they're not included in the aliases that team:admin has + + // Should NOT include kb features + assert.False(t, userFeatures["collections:create"], "should not have kb features in user domain") + + // Query kb domain + kbFeatures, err := manager.FeaturesForTeamUserByDomain(ctx, teamID, memberUserID, "kb") + require.NoError(t, err) + assert.True(t, kbFeatures["collections:create"], "should have collections:create in kb domain") + assert.False(t, kbFeatures["profile:read"], "should not have user features in kb domain") + }) +} + +func TestConvenienceMethodsWithCache_Integration(t *testing.T) { + manager, testUUID, userID, teamID, memberUserID := prepareIntegrationTest(t) + defer cleanIntegrationTest(testUUID) + + ctx := context.Background() + + // First call should query from database + features1, err := manager.FeaturesForUser(ctx, userID) + require.NoError(t, err) + assert.NotEmpty(t, features1) + + // Second call should use cache + features2, err := manager.FeaturesForUser(ctx, userID) + require.NoError(t, err) + assert.NotEmpty(t, features2) + assert.Equal(t, features1, features2, "cached results should match") + + // Test team member cache + teamFeatures1, err := manager.FeaturesForTeamUser(ctx, teamID, memberUserID) + require.NoError(t, err) + assert.NotEmpty(t, teamFeatures1) + + teamFeatures2, err := manager.FeaturesForTeamUser(ctx, teamID, memberUserID) + require.NoError(t, err) + assert.Equal(t, teamFeatures1, teamFeatures2, "cached team member results should match") +} + +func TestGetFeaturesFromGinContext_Integration(t *testing.T) { + manager, testUUID, userID, teamID, memberUserID := prepareIntegrationTest(t) + defer cleanIntegrationTest(testUUID) + + // Load ACL with feature manager + config := &Config{ + Enabled: true, + } + acl := &ACL{ + Config: config, + Feature: manager, + } + Global = acl + + // Test GetFeatures for user (no team_id) + t.Run("GetFeatures_User", func(t *testing.T) { + // Create mock gin.Context + c := createMockGinContext(userID, "") + + // Call GetFeatures + features, err := GetFeatures(c) + require.NoError(t, err) + assert.NotEmpty(t, features, "user should have features") + + // Verify features + assert.True(t, features["profile:read"], "should have profile:read") + assert.True(t, features["profile:edit"], "should have profile:edit") + assert.True(t, features["team:edit"], "should have team:edit") + assert.True(t, features["collections:create"], "should have collections:create") + }) + + // Test GetFeaturesByDomain for user + t.Run("GetFeaturesByDomain_User", func(t *testing.T) { + // Create mock gin.Context + c := createMockGinContext(userID, "") + + // Call GetFeaturesByDomain with "user" domain + userFeatures, err := GetFeaturesByDomain(c, "user") + require.NoError(t, err) + assert.NotEmpty(t, userFeatures, "user domain should have features") + + // Should include user domain features + assert.True(t, userFeatures["profile:read"], "should have profile:read") + assert.True(t, userFeatures["team:edit"], "should have team:edit") + + // Should NOT include kb features + assert.False(t, userFeatures["collections:create"], "should not have kb features in user domain") + + // Call GetFeaturesByDomain with "kb" domain + kbFeatures, err := GetFeaturesByDomain(c, "kb") + require.NoError(t, err) + assert.True(t, kbFeatures["collections:create"], "should have collections:create in kb domain") + assert.False(t, kbFeatures["profile:read"], "should not have user features in kb domain") + }) + + // Test GetFeatures for team member (with team_id) + t.Run("GetFeatures_TeamMember", func(t *testing.T) { + // Create mock gin.Context with team_id + c := createMockGinContext(memberUserID, teamID) + + // Call GetFeatures + features, err := GetFeatures(c) + require.NoError(t, err) + assert.NotEmpty(t, features, "team member should have features") + + // Verify team:admin features + assert.True(t, features["profile:read"], "should have profile:read") + assert.True(t, features["team:edit"], "should have team:edit") + assert.True(t, features["collections:create"], "should have collections:create") + }) + + // Test GetFeaturesByDomain for team member + t.Run("GetFeaturesByDomain_TeamMember", func(t *testing.T) { + // Create mock gin.Context with team_id + c := createMockGinContext(memberUserID, teamID) + + // Call GetFeaturesByDomain with "user" domain + userFeatures, err := GetFeaturesByDomain(c, "user") + require.NoError(t, err) + assert.NotEmpty(t, userFeatures, "team member should have user domain features") + + // Should include user domain features + assert.True(t, userFeatures["profile:read"], "should have profile:read") + assert.True(t, userFeatures["team:edit"], "should have team:edit") + + // Should NOT include kb features + assert.False(t, userFeatures["collections:create"], "should not have kb features in user domain") + }) +} diff --git a/openapi/oauth/acl/feature_test.go b/openapi/oauth/acl/feature_test.go new file mode 100644 index 00000000..9f351e46 --- /dev/null +++ b/openapi/oauth/acl/feature_test.go @@ -0,0 +1,410 @@ +package acl + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/gou/application" +) + +// setupFeatureTest initializes test environment +func setupFeatureTest(t *testing.T) *FeatureManager { + // Set test application path + testApp := os.Getenv("YAO_TEST_APPLICATION") + if testApp == "" { + t.Skip("YAO_TEST_APPLICATION not set, skipping feature tests") + } + + // Initialize application + app, err := application.OpenFromDisk(testApp) + if err != nil { + t.Fatalf("Failed to open application: %v", err) + } + application.Load(app) + + // Load features + manager, err := LoadFeatures() + if err != nil { + t.Fatalf("Failed to load features: %v", err) + } + + return manager +} + +func TestLoadFeatures(t *testing.T) { + manager := setupFeatureTest(t) + assert.NotNil(t, manager) +} + +func TestFeatures_SystemRoot(t *testing.T) { + manager := setupFeatureTest(t) + + // Test system:root role with wildcard *:*:* + features := manager.Features("system:root") + assert.NotEmpty(t, features, "system:root should have features") + + // Verify it has all features from all domains + assert.True(t, features["profile:read"], "should have profile:read") + assert.True(t, features["profile:edit"], "should have profile:edit") + assert.True(t, features["team:edit"], "should have team:edit") + assert.True(t, features["collections:create"], "should have collections:create") + assert.True(t, features["document:create"], "should have document:create") + assert.True(t, features["meta:edit"], "should have meta:edit") +} + +func TestFeatures_OwnerFree(t *testing.T) { + manager := setupFeatureTest(t) + + // Test owner:free role + features := manager.Features("owner:free") + assert.NotEmpty(t, features, "owner:free should have features") + + // Should have profile:manage alias expanded + assert.True(t, features["profile:read"], "should have profile:read from profile:manage alias") + assert.True(t, features["profile:edit"], "should have profile:edit from profile:manage alias") + + // Should have team:manage alias expanded + assert.True(t, features["team:edit"], "should have team:edit from team:manage alias") + assert.True(t, features["team:member:invite"], "should have team:member:invite from team:manage alias") + assert.True(t, features["team:member:remove"], "should have team:member:remove from team:manage alias") + + // Should have collections:create + assert.True(t, features["collections:create"], "should have collections:create") +} + +func TestFeatures_OwnerPro(t *testing.T) { + manager := setupFeatureTest(t) + + // Test owner:pro role with user:full alias + features := manager.Features("owner:pro") + assert.NotEmpty(t, features, "owner:pro should have features") + + // user:full includes profile:manage, team:manage, kb:manage + assert.True(t, features["profile:read"], "should have profile:read") + assert.True(t, features["profile:edit"], "should have profile:edit") + assert.True(t, features["team:edit"], "should have team:edit") + assert.True(t, features["collections:create"], "should have collections:create from kb:manage") +} + +func TestFeaturesByDomain_User(t *testing.T) { + manager := setupFeatureTest(t) + + // Query "user" domain - should include all user/* files + features := manager.FeaturesByDomain("system:root", "user") + assert.NotEmpty(t, features, "user domain should have features") + + // Should include features from user/profile.yml + assert.True(t, features["profile:read"], "should have profile:read from user/profile") + assert.True(t, features["profile:edit"], "should have profile:edit from user/profile") + + // Should include features from user/team.yml + assert.True(t, features["team:edit"], "should have team:edit from user/team") + + // Should include features from user/team/settings.yml (nested) + assert.True(t, features["team:settings:view"], "should have team:settings:view from user/team/settings") + assert.True(t, features["team:settings:edit"], "should have team:settings:edit from user/team/settings") + + // Should include features from user/team/members.yml (nested) + assert.True(t, features["team:members:list"], "should have team:members:list from user/team/members") + assert.True(t, features["team:members:add"], "should have team:members:add from user/team/members") + + // Should NOT include kb features + assert.False(t, features["collections:create"], "should not have kb features") +} + +func TestFeaturesByDomain_UserTeam(t *testing.T) { + manager := setupFeatureTest(t) + + // Query "user/team" domain - should include user/team.yml (exact match) AND user/team/* files (prefix match) + features := manager.FeaturesByDomain("system:root", "user/team") + assert.NotEmpty(t, features, "user/team domain should have features") + + // Should NOT include user/profile features + assert.False(t, features["profile:read"], "should not have profile:read") + + // Should include user/team.yml features (exact match on domain="user/team") + assert.True(t, features["team:edit"], "should have team:edit from user/team.yml (exact match)") + assert.True(t, features["team:member:invite"], "should have team:member:invite from user/team.yml") + + // Should include features from user/team/settings.yml (prefix match) + assert.True(t, features["team:settings:view"], "should have team:settings:view from user/team/settings") + assert.True(t, features["team:settings:edit"], "should have team:settings:edit from user/team/settings") + + // Should include features from user/team/members.yml (prefix match) + assert.True(t, features["team:members:list"], "should have team:members:list from user/team/members") + assert.True(t, features["team:members:add"], "should have team:members:add from user/team/members") +} + +func TestFeaturesByDomain_UserProfile(t *testing.T) { + manager := setupFeatureTest(t) + + // Query "user/profile" domain - should only include user/profile.yml + features := manager.FeaturesByDomain("system:root", "user/profile") + assert.NotEmpty(t, features, "user/profile domain should have features") + + // Should include features from user/profile.yml + assert.True(t, features["profile:read"], "should have profile:read") + assert.True(t, features["profile:edit"], "should have profile:edit") + + // Should NOT include team features + assert.False(t, features["team:edit"], "should not have team:edit") + assert.False(t, features["team:settings:view"], "should not have team:settings:view") +} + +func TestFeaturesByDomain_KB(t *testing.T) { + manager := setupFeatureTest(t) + + // Query "kb" domain - should include all kb/* files + features := manager.FeaturesByDomain("system:root", "kb") + assert.NotEmpty(t, features, "kb domain should have features") + + // Should include features from kb/collections.yml + assert.True(t, features["collections:create"], "should have collections:create") + + // Should include features from kb/collections/document.yml + assert.True(t, features["document:create"], "should have document:create") + assert.True(t, features["document:edit"], "should have document:edit") + assert.True(t, features["document:delete"], "should have document:delete") + + // Should include features from kb/collections/advanced/meta.yml (deep nested) + assert.True(t, features["meta:edit"], "should have meta:edit from kb/collections/advanced/meta") + assert.True(t, features["meta:view"], "should have meta:view from kb/collections/advanced/meta") + assert.True(t, features["meta:export"], "should have meta:export from kb/collections/advanced/meta") + + // Should NOT include user features + assert.False(t, features["profile:read"], "should not have user features") +} + +func TestFeaturesByDomain_KBCollections(t *testing.T) { + manager := setupFeatureTest(t) + + // Query "kb/collections" domain - should include kb/collections.yml and kb/collections/* files + features := manager.FeaturesByDomain("system:root", "kb/collections") + assert.NotEmpty(t, features, "kb/collections domain should have features") + + // Should include features from kb/collections.yml (exact match) + assert.True(t, features["collections:create"], "should have collections:create from kb/collections") + + // Should include features from kb/collections/document.yml (nested) + assert.True(t, features["document:create"], "should have document:create") + assert.True(t, features["document:edit"], "should have document:edit") + + // Should include features from kb/collections/advanced/meta.yml (deep nested) + assert.True(t, features["meta:edit"], "should have meta:edit") + assert.True(t, features["meta:view"], "should have meta:view") +} + +func TestFeaturesByDomain_KBCollectionsAdvanced(t *testing.T) { + manager := setupFeatureTest(t) + + // Query "kb/collections/advanced" domain - should include kb/collections/advanced/* files + features := manager.FeaturesByDomain("system:root", "kb/collections/advanced") + assert.NotEmpty(t, features, "kb/collections/advanced domain should have features") + + // Should include features from kb/collections/advanced/meta.yml + assert.True(t, features["meta:edit"], "should have meta:edit") + assert.True(t, features["meta:view"], "should have meta:view") + assert.True(t, features["meta:export"], "should have meta:export") + + // Should NOT include kb/collections.yml features + assert.False(t, features["collections:create"], "should not have collections:create") + + // Should NOT include kb/collections/document.yml features + assert.False(t, features["document:create"], "should not have document:create") +} + +func TestDomainFeatures(t *testing.T) { + manager := setupFeatureTest(t) + + // Test exact domain match - user/profile + features := manager.DomainFeatures("user/profile") + assert.NotEmpty(t, features, "user/profile domain should have features") + assert.True(t, features["profile:read"], "should have profile:read") + assert.True(t, features["profile:edit"], "should have profile:edit") + + // Should NOT include nested domains + assert.False(t, features["team:edit"], "should not include user/team features") + assert.False(t, features["team:settings:view"], "should not include user/team/settings features") +} + +func TestDomains(t *testing.T) { + manager := setupFeatureTest(t) + + domains := manager.Domains() + assert.NotEmpty(t, domains, "should have domains") + + // Check expected domains exist + expectedDomains := []string{ + "user/profile", + "user/team", + "user/team/settings", + "user/team/members", + "kb/collections", + "kb/collections/document", + "kb/collections/advanced/meta", + } + + domainMap := make(map[string]bool) + for _, d := range domains { + domainMap[d] = true + } + + for _, expected := range expectedDomains { + assert.True(t, domainMap[expected], "should have domain: %s", expected) + } +} + +func TestDefinition(t *testing.T) { + manager := setupFeatureTest(t) + + // Test feature definition lookup + def := manager.Definition("profile:read") + assert.NotNil(t, def, "should find profile:read definition") + assert.Equal(t, "Read own profile", def.Description, "description should match") + + // Test nested domain feature + def = manager.Definition("team:settings:view") + assert.NotNil(t, def, "should find team:settings:view definition") + assert.Equal(t, "View team settings", def.Description, "description should match") + + // Test deep nested feature + def = manager.Definition("meta:edit") + assert.NotNil(t, def, "should find meta:edit definition") + assert.Equal(t, "Edit document metadata", def.Description, "description should match") + + // Test non-existent feature + def = manager.Definition("nonexistent:feature") + assert.Nil(t, def, "should return nil for non-existent feature") +} + +func TestAliasExpansion(t *testing.T) { + manager := setupFeatureTest(t) + + // Test that aliases are properly expanded + features := manager.Features("owner:free") + + // profile:manage should expand to profile:read + profile:edit + assert.True(t, features["profile:read"], "profile:manage alias should include profile:read") + assert.True(t, features["profile:edit"], "profile:manage alias should include profile:edit") + + // team:manage should expand to multiple team features + assert.True(t, features["team:edit"], "team:manage alias should include team:edit") + assert.True(t, features["team:member:invite"], "team:manage alias should include team:member:invite") + assert.True(t, features["team:member:robot:create"], "team:manage alias should include team:member:robot:create") + assert.True(t, features["team:member:robot:edit"], "team:manage alias should include team:member:robot:edit") + assert.True(t, features["team:member:remove"], "team:manage alias should include team:member:remove") +} + +func TestNestedAliasExpansion(t *testing.T) { + manager := setupFeatureTest(t) + + // Test nested alias: user:full -> profile:manage, team:manage, kb:manage + features := manager.Features("owner:pro") + + // Should expand all nested aliases + assert.True(t, features["profile:read"], "should have profile:read from profile:manage") + assert.True(t, features["profile:edit"], "should have profile:edit from profile:manage") + assert.True(t, features["team:edit"], "should have team:edit from team:manage") + assert.True(t, features["team:member:invite"], "should have team:member:invite from team:manage") + assert.True(t, features["collections:create"], "should have collections:create from kb:manage") +} + +func TestEmptyRole(t *testing.T) { + manager := setupFeatureTest(t) + + // Test non-existent role + features := manager.Features("nonexistent:role") + assert.Empty(t, features, "non-existent role should return empty map") + + // Test by domain with non-existent role + features = manager.FeaturesByDomain("nonexistent:role", "user") + assert.Empty(t, features, "non-existent role should return empty map for domain query") +} + +func TestEmptyDomain(t *testing.T) { + manager := setupFeatureTest(t) + + // Test non-existent domain + features := manager.FeaturesByDomain("system:root", "nonexistent") + assert.Empty(t, features, "non-existent domain should return empty map") + + // Test DomainFeatures with non-existent domain + features = manager.DomainFeatures("nonexistent") + assert.Empty(t, features, "non-existent domain should return empty map") +} + +func TestFeatureMapReturnType(t *testing.T) { + manager := setupFeatureTest(t) + + // Verify return type is map[string]bool for O(1) lookups + features := manager.Features("owner:free") + + // Should be able to check existence with simple map lookup + if features["profile:read"] { + // Feature exists + assert.True(t, true) + } else { + t.Error("profile:read should exist for owner:free") + } + + // Check non-existent feature + if features["nonexistent:feature"] { + t.Error("nonexistent:feature should not exist") + } +} + +func TestHierarchicalQueryBehavior(t *testing.T) { + manager := setupFeatureTest(t) + + // Verify hierarchical behavior: querying parent includes children + userFeatures := manager.FeaturesByDomain("system:root", "user") + userTeamFeatures := manager.FeaturesByDomain("system:root", "user/team") + userProfileFeatures := manager.FeaturesByDomain("system:root", "user/profile") + + // user should include more features than user/team and user/profile + assert.Greater(t, len(userFeatures), len(userTeamFeatures), "user should have more features than user/team") + assert.Greater(t, len(userFeatures), len(userProfileFeatures), "user should have more features than user/profile") + + // user/team should NOT include user/profile features + assert.True(t, userProfileFeatures["profile:read"], "user/profile should have profile:read") + assert.False(t, userTeamFeatures["profile:read"], "user/team should NOT have profile:read") + + // user should include both + assert.True(t, userFeatures["profile:read"], "user should have profile:read") + assert.True(t, userFeatures["team:settings:view"], "user should have team:settings:view") +} + +func TestConvenienceMethods(t *testing.T) { + manager := setupFeatureTest(t) + ctx := context.Background() + + // Note: These tests will skip if role manager is not properly initialized + // In production, role manager would be initialized with a real provider + + // Test FeaturesForUser (will fail if role manager not initialized, which is expected in test) + _, err := manager.FeaturesForUser(ctx, "test-user-123") + // We expect an error because role manager might not be initialized + if err != nil { + assert.Contains(t, err.Error(), "role manager", "should indicate role manager issue") + } + + // Test FeaturesForUserByDomain + _, err = manager.FeaturesForUserByDomain(ctx, "test-user-123", "user") + if err != nil { + assert.Contains(t, err.Error(), "role manager", "should indicate role manager issue") + } + + // Test FeaturesForTeamUser + _, err = manager.FeaturesForTeamUser(ctx, "test-team-456", "test-user-123") + if err != nil { + assert.Contains(t, err.Error(), "role manager", "should indicate role manager issue") + } + + // Test FeaturesForTeamUserByDomain + _, err = manager.FeaturesForTeamUserByDomain(ctx, "test-team-456", "test-user-123", "user") + if err != nil { + assert.Contains(t, err.Error(), "role manager", "should indicate role manager issue") + } +} diff --git a/openapi/oauth/acl/types.go b/openapi/oauth/acl/types.go index a05da5fc..4063d69f 100644 --- a/openapi/oauth/acl/types.go +++ b/openapi/oauth/acl/types.go @@ -24,8 +24,9 @@ type Config struct { // ACL is the ACL checker type ACL struct { - Config *Config - Scope *ScopeManager + Config *Config + Scope *ScopeManager + Feature *FeatureManager } // ============ Configuration Structures (loaded from config files) ============