Merge pull request #1253 from trheyi/main
Add feature manager support in ACL initialization
This commit is contained in:
commit
04f70ce444
6 changed files with 2471 additions and 2 deletions
1049
openapi/oauth/acl/FEATURES_CONFIGURATION.md
Normal file
1049
openapi/oauth/acl/FEATURES_CONFIGURATION.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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")
|
||||
|
|
|
|||
585
openapi/oauth/acl/feature.go
Normal file
585
openapi/oauth/acl/feature.go
Normal 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")
|
||||
}
|
||||
416
openapi/oauth/acl/feature_integration_test.go
Normal file
416
openapi/oauth/acl/feature_integration_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
410
openapi/oauth/acl/feature_test.go
Normal file
410
openapi/oauth/acl/feature_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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) ============
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue