Merge pull request #1253 from trheyi/main

Add feature manager support in ACL initialization
This commit is contained in:
Max 2025-10-29 12:08:00 +08:00 committed by GitHub
commit 04f70ce444
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 2471 additions and 2 deletions

File diff suppressed because it is too large Load diff

View file

@ -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")

View file

@ -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")
}

View file

@ -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")
})
}

View file

@ -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")
}
}

View file

@ -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) ============