Full Mattermost server source with integrated Community Enterprise features. Includes vendor directory for offline/air-gapped builds. Structure: - enterprise-impl/: Enterprise feature implementations - enterprise-community/: Init files that register implementations - enterprise/: Bridge imports (community_imports.go) - vendor/: All dependencies for offline builds Build (online): go build ./cmd/mattermost Build (offline/air-gapped): go build -mod=vendor ./cmd/mattermost 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
438 lines
13 KiB
Go
438 lines
13 KiB
Go
// 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
|
|
}
|