- Enhanced the ACL structure to include a new FeatureManager field, allowing for better management of feature-related permissions. - Updated the New function to load the feature manager during ACL initialization, improving the overall functionality and logging success messages for better traceability.
585 lines
16 KiB
Go
585 lines
16 KiB
Go
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")
|
|
}
|