// Copyright (c) 2024 Mattermost Community Enterprise // Access Control Implementation (PAP + PDP) package access_control import ( "net/http" "strings" "sync" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/v8/channels/store" ) // AccessControlConfig holds configuration for the access control service type AccessControlConfig struct { Store store.Store Config func() *model.Config Logger mlog.LoggerIFace } // AccessControlImpl implements the AccessControlServiceInterface (PAP + PDP) type AccessControlImpl struct { store store.Store config func() *model.Config logger mlog.LoggerIFace // In-memory storage for policies policies map[string]*model.AccessControlPolicy mutex sync.RWMutex // CEL engine initialized flag initialized bool } // NewAccessControlInterface creates a new access control interface func NewAccessControlInterface(cfg *AccessControlConfig) *AccessControlImpl { return &AccessControlImpl{ store: cfg.Store, config: cfg.Config, logger: cfg.Logger, policies: make(map[string]*model.AccessControlPolicy), } } // PAP Methods (Policy Administration Point) // Init initializes the policy administration point func (ac *AccessControlImpl) Init(rctx request.CTX) *model.AppError { ac.mutex.Lock() defer ac.mutex.Unlock() if ac.initialized { return nil } // Initialize CEL engine (in production, this would set up the CEL environment) ac.logger.Info("Initializing Access Control Policy Administration Point") ac.initialized = true return nil } // GetPolicyRuleAttributes retrieves the attributes of the given policy for a specific action func (ac *AccessControlImpl) GetPolicyRuleAttributes(rctx request.CTX, policyID string, action string) (map[string][]string, *model.AppError) { ac.mutex.RLock() defer ac.mutex.RUnlock() policy, ok := ac.policies[policyID] if !ok { return nil, model.NewAppError("GetPolicyRuleAttributes", "access_control.policy_not_found", map[string]any{"PolicyId": policyID}, "", http.StatusNotFound) } attributes := make(map[string][]string) for _, rule := range policy.Rules { // Check if this rule applies to the requested action appliesToAction := false for _, ruleAction := range rule.Actions { if ruleAction == action || ruleAction == "*" { appliesToAction = true break } } if appliesToAction { // Extract attributes from expression (simplified implementation) // In production, this would use the CEL parser extractedAttrs := extractAttributesFromExpression(rule.Expression) for attrName, attrValues := range extractedAttrs { attributes[attrName] = append(attributes[attrName], attrValues...) } } } return attributes, nil } // CheckExpression checks the validity of the given CEL expression func (ac *AccessControlImpl) CheckExpression(rctx request.CTX, expression string) ([]model.CELExpressionError, *model.AppError) { var errors []model.CELExpressionError if expression == "" { errors = append(errors, model.CELExpressionError{ Line: 1, Column: 1, Message: "Expression cannot be empty", }) return errors, nil } // Basic syntax validation (simplified - in production would use CEL parser) // Check for balanced parentheses parenCount := 0 for i, c := range expression { if c == '(' { parenCount++ } else if c == ')' { parenCount-- } if parenCount < 0 { errors = append(errors, model.CELExpressionError{ Line: 1, Column: i + 1, Message: "Unbalanced parentheses: unexpected ')'", }) } } if parenCount > 0 { errors = append(errors, model.CELExpressionError{ Line: 1, Column: len(expression), Message: "Unbalanced parentheses: missing ')'", }) } // Check for valid operators validOperators := []string{"&&", "||", "==", "!=", "in", "contains", "startsWith", "endsWith"} hasValidOperator := false for _, op := range validOperators { if strings.Contains(expression, op) { hasValidOperator = true break } } // If expression has no operators, it might be a simple attribute reference which is valid if !hasValidOperator && !strings.Contains(expression, ".") && !strings.Contains(expression, "subject") && !strings.Contains(expression, "resource") { errors = append(errors, model.CELExpressionError{ Line: 1, Column: 1, Message: "Expression should reference subject or resource attributes", }) } return errors, nil } // ExpressionToVisualAST converts the given expression to a visual AST func (ac *AccessControlImpl) ExpressionToVisualAST(rctx request.CTX, expression string) (*model.VisualExpression, *model.AppError) { // Simplified implementation - in production would parse CEL expression properly visual := &model.VisualExpression{ Conditions: []model.Condition{}, } // Split by && for AND conditions parts := strings.Split(expression, "&&") for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } condition := model.Condition{ Attribute: part, Operator: "exists", Value: true, } visual.Conditions = append(visual.Conditions, condition) } return visual, nil } // NormalizePolicy normalizes the given policy by restoring ids back to names func (ac *AccessControlImpl) NormalizePolicy(rctx request.CTX, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) { // In production, this would resolve IDs to names in expressions // For now, return the policy as-is return policy, nil } // QueryUsersForExpression evaluates the given expression and returns matching users func (ac *AccessControlImpl) QueryUsersForExpression(rctx request.CTX, expression string, opts model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError) { if ac.store == nil { return nil, 0, model.NewAppError("QueryUsersForExpression", "access_control.store_not_available", nil, "", http.StatusInternalServerError) } // Simplified implementation - in production would evaluate CEL expression // For now, return users based on basic search users, err := ac.store.User().Search(rctx, "", opts.Term, &model.UserSearchOptions{ Limit: opts.Limit, }) if err != nil { return nil, 0, model.NewAppError("QueryUsersForExpression", "access_control.query_failed", nil, err.Error(), http.StatusInternalServerError) } return users, int64(len(users)), nil } // QueryUsersForResource finds users that match to the resource func (ac *AccessControlImpl) QueryUsersForResource(rctx request.CTX, resourceID, action string, opts model.SubjectSearchOptions) ([]*model.User, int64, *model.AppError) { ac.mutex.RLock() defer ac.mutex.RUnlock() // Find policies that apply to this resource var applicableExpressions []string for _, policy := range ac.policies { if !policy.Active { continue } // Check if policy type matches resource type // In production, would check policy.Type against resource type for _, rule := range policy.Rules { for _, ruleAction := range rule.Actions { if ruleAction == action || ruleAction == "*" { applicableExpressions = append(applicableExpressions, rule.Expression) } } } } if len(applicableExpressions) == 0 { return nil, 0, nil } // Combine expressions and query combinedExpression := strings.Join(applicableExpressions, " || ") return ac.QueryUsersForExpression(rctx, combinedExpression, opts) } // GetChannelMembersToRemove retrieves channel members that need to be removed func (ac *AccessControlImpl) GetChannelMembersToRemove(rctx request.CTX, channelID string) ([]*model.ChannelMember, *model.AppError) { // In production, this would evaluate access control policies against channel members // and return those who no longer have access return nil, nil } // SavePolicy saves the given access control policy func (ac *AccessControlImpl) SavePolicy(rctx request.CTX, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) { ac.mutex.Lock() defer ac.mutex.Unlock() if policy.ID == "" { policy.ID = model.NewId() } // Validate policy if appErr := policy.IsValid(); appErr != nil { return nil, appErr } // Validate expressions for _, rule := range policy.Rules { errors, appErr := ac.CheckExpression(rctx, rule.Expression) if appErr != nil { return nil, appErr } if len(errors) > 0 { return nil, model.NewAppError("SavePolicy", "access_control.invalid_expression", map[string]any{"Errors": errors}, "", http.StatusBadRequest) } } now := model.GetMillis() if policy.CreateAt == 0 { policy.CreateAt = now } policy.Revision++ ac.policies[policy.ID] = policy ac.logger.Info("Saved access control policy", mlog.String("policy_id", policy.ID), mlog.String("name", policy.Name), ) return policy, nil } // GetPolicy retrieves the access control policy with the given ID func (ac *AccessControlImpl) GetPolicy(rctx request.CTX, id string) (*model.AccessControlPolicy, *model.AppError) { ac.mutex.RLock() defer ac.mutex.RUnlock() policy, ok := ac.policies[id] if !ok { return nil, model.NewAppError("GetPolicy", "access_control.policy_not_found", map[string]any{"PolicyId": id}, "", http.StatusNotFound) } return policy, nil } // DeletePolicy deletes the access control policy with the given ID func (ac *AccessControlImpl) DeletePolicy(rctx request.CTX, id string) *model.AppError { ac.mutex.Lock() defer ac.mutex.Unlock() if _, ok := ac.policies[id]; !ok { return model.NewAppError("DeletePolicy", "access_control.policy_not_found", map[string]any{"PolicyId": id}, "", http.StatusNotFound) } delete(ac.policies, id) ac.logger.Info("Deleted access control policy", mlog.String("policy_id", id), ) return nil } // PDP Methods (Policy Decision Point) // AccessEvaluation evaluates an access request and returns a decision func (ac *AccessControlImpl) AccessEvaluation(rctx request.CTX, accessRequest model.AccessRequest) (model.AccessDecision, *model.AppError) { ac.mutex.RLock() defer ac.mutex.RUnlock() decision := model.AccessDecision{ Decision: false, Context: make(map[string]any), } // Find applicable policies for _, policy := range ac.policies { if !policy.Active { continue } // Check if policy type matches resource type if policy.Type != "" && policy.Type != accessRequest.Resource.Type { continue } // Evaluate rules for _, rule := range policy.Rules { // Check if action matches actionMatches := false for _, ruleAction := range rule.Actions { if ruleAction == accessRequest.Action || ruleAction == "*" { actionMatches = true break } } if !actionMatches { continue } // Evaluate expression (simplified) allowed := ac.evaluateExpression(rule.Expression, accessRequest) if allowed { decision.Decision = true decision.Context["matched_policy"] = policy.ID decision.Context["matched_rule"] = rule.Expression return decision, nil } } } return decision, nil } // Helper functions func extractAttributesFromExpression(expression string) map[string][]string { attributes := make(map[string][]string) // Simple extraction of attribute references like "subject.attr" or "resource.attr" parts := strings.Fields(expression) for _, part := range parts { part = strings.Trim(part, "()") if strings.HasPrefix(part, "subject.") { attrName := strings.TrimPrefix(part, "subject.") attrName = strings.Split(attrName, "==")[0] attrName = strings.Split(attrName, "!=")[0] attrName = strings.TrimSpace(attrName) if attrName != "" { attributes["subject."+attrName] = []string{} } } else if strings.HasPrefix(part, "resource.") { attrName := strings.TrimPrefix(part, "resource.") attrName = strings.Split(attrName, "==")[0] attrName = strings.Split(attrName, "!=")[0] attrName = strings.TrimSpace(attrName) if attrName != "" { attributes["resource."+attrName] = []string{} } } } return attributes } func (ac *AccessControlImpl) evaluateExpression(expression string, request model.AccessRequest) bool { // Simplified expression evaluation // In production, this would use the CEL engine // Default: if expression references subject.id, check if it matches if strings.Contains(expression, "subject.id") { if strings.Contains(expression, "==") { parts := strings.Split(expression, "==") if len(parts) == 2 { expected := strings.TrimSpace(parts[1]) expected = strings.Trim(expected, "\"'") if request.Subject.ID == expected { return true } } } } // Check for "true" expression (always allow) if strings.TrimSpace(expression) == "true" { return true } // Check for subject type matching if strings.Contains(expression, "subject.type") && strings.Contains(expression, request.Subject.Type) { return true } // Default deny return false }