diff --git a/server/i18n/en.json b/server/i18n/en.json index 19b0dd29e6..2c4350c506 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -8468,6 +8468,38 @@ "id": "model.access.is_valid.user_id.app_error", "translation": "Invalid user id." }, + { + "id": "model.access_policy.is_valid.id.app_error", + "translation": "Invalid policyy id." + }, + { + "id": "model.access_policy.is_valid.imports.app_error", + "translation": "Invalid imports for the policy." + }, + { + "id": "model.access_policy.is_valid.name.app_error", + "translation": "Invalid name for the policy." + }, + { + "id": "model.access_policy.is_valid.revision.app_error", + "translation": "Invalid policy revision." + }, + { + "id": "model.access_policy.is_valid.rules.app_error", + "translation": "Rule(s) is not valid." + }, + { + "id": "model.access_policy.is_valid.rules_imports.app_error", + "translation": "Policy must either import or define rules." + }, + { + "id": "model.access_policy.is_valid.type.app_error", + "translation": "Invalid policy type." + }, + { + "id": "model.access_policy.is_valid.version.app_error", + "translation": "Version is not valid for this access control policy." + }, { "id": "model.acknowledgement.is_valid.post_id.app_error", "translation": "Invalid post id." diff --git a/server/public/go.mod b/server/public/go.mod index 11232c02db..94aa90d50a 100644 --- a/server/public/go.mod +++ b/server/public/go.mod @@ -29,6 +29,7 @@ require ( github.com/tinylib/msgp v1.2.5 github.com/vmihailenco/msgpack/v5 v5.4.1 golang.org/x/crypto v0.32.0 + golang.org/x/mod v0.22.0 golang.org/x/net v0.34.0 golang.org/x/oauth2 v0.25.0 golang.org/x/text v0.21.0 @@ -66,7 +67,6 @@ require ( github.com/wiggin77/merror v1.0.5 // indirect github.com/wiggin77/srslog v1.0.1 // indirect github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect - golang.org/x/mod v0.22.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.29.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 // indirect diff --git a/server/public/model/access_policy.go b/server/public/model/access_policy.go new file mode 100644 index 0000000000..77eea09971 --- /dev/null +++ b/server/public/model/access_policy.go @@ -0,0 +1,105 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "slices" + + "golang.org/x/mod/semver" +) + +const ( + AccessControlPolicyTypeParent = "parent" + AccessControlPolicyTypeChannel = "channel" + + MaxPolicyNameLength = 128 + + AccessControlPolicyVersionV0_1 = "v0.1" +) + +// ParentPolicy is a augmented version of AccessPolicy to be used in +// system console and API responses. +type ParentPolicy struct { + ID string `json:"id"` + Name string `json:"name"` + Attributes map[string]string `json:"attributes"` + Children []*AccessControlPolicy `json:"children"` +} + +type AccessControlPolicy struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Active bool `json:"active"` + CreateAt int64 `json:"create_at"` + + Revision int `json:"revision"` + Version string `json:"version"` + + Imports []string `json:"imports"` + Rules []AccessControlPolicyRule `json:"rules"` + + Props map[string]any `json:"props"` // add auto-sync property here, also maybe the attributes being used in the expression +} + +type AccessControlPolicyRule struct { + Actions []string `json:"actions"` + Expression string `json:"expression"` +} + +func (p *AccessControlPolicy) IsValid() *AppError { + switch p.Version { + case AccessControlPolicyVersionV0_1: + return p.accessPolicyVersionV0_1() + default: + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.version.app_error", nil, "", 400) + } +} + +func (p *AccessControlPolicy) accessPolicyVersionV0_1() *AppError { + if !slices.Contains([]string{AccessControlPolicyTypeParent, AccessControlPolicyTypeChannel}, p.Type) { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.type.app_error", nil, "", 400) + } + + if !IsValidId(p.ID) { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.id.app_error", nil, "", 400) + } + + if p.Type == AccessControlPolicyTypeParent && (p.Name == "" || len(p.Name) > MaxPolicyNameLength) { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.name.app_error", nil, "", 400) + } + + if p.Revision < 0 { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.revision.app_error", nil, "", 400) + } + + if !semver.IsValid(p.Version) { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.version.app_error", nil, "", 400) + } + + switch p.Type { + case AccessControlPolicyTypeParent: + if len(p.Rules) == 0 { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules.app_error", nil, "", 400) + } + + if len(p.Imports) > 0 { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400) + } + case AccessControlPolicyTypeChannel: + if len(p.Rules) == 0 && len(p.Imports) == 0 { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules_imports.app_error", nil, "", 400) + } + + if len(p.Rules) == 0 { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules.app_error", nil, "", 400) + } + + if len(p.Imports) > 1 { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400) + } + } + + return nil +} diff --git a/server/public/model/access_policy_test.go b/server/public/model/access_policy_test.go new file mode 100644 index 0000000000..06827f4177 --- /dev/null +++ b/server/public/model/access_policy_test.go @@ -0,0 +1,214 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAccessPolicyVersionV0_1(t *testing.T) { + t.Run("invalid type", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: "policy_id", + Type: "invalid_type", + Name: "Test Policy", + Revision: 1, + Version: AccessControlPolicyVersionV0_1, + Rules: []AccessControlPolicyRule{{Actions: []string{"read"}, Expression: "user.role == 'admin'"}}, + } + + err := policy.accessPolicyVersionV0_1() + require.NotNil(t, err, "Should return error for invalid type") + require.Equal(t, "model.access_policy.is_valid.type.app_error", err.Id) + }) + + t.Run("invalid ID", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: "", + Type: AccessControlPolicyTypeParent, + Name: "Test Policy", + Revision: 1, + Version: AccessControlPolicyVersionV0_1, + Rules: []AccessControlPolicyRule{{Actions: []string{"read"}, Expression: "user.role == 'admin'"}}, + } + + err := policy.accessPolicyVersionV0_1() + require.NotNil(t, err, "Should return error for invalid ID") + require.Equal(t, "model.access_policy.is_valid.id.app_error", err.Id) + }) + + t.Run("parent policy with empty name", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeParent, + Name: "", + Revision: 1, + Version: AccessControlPolicyVersionV0_1, + Rules: []AccessControlPolicyRule{{Actions: []string{"read"}, Expression: "user.role == 'admin'"}}, + } + + err := policy.accessPolicyVersionV0_1() + require.NotNil(t, err, "Should return error for empty name in parent policy") + require.Equal(t, "model.access_policy.is_valid.name.app_error", err.Id) + }) + + t.Run("parent policy with too long name", func(t *testing.T) { + longName := "" + for i := 0; i <= MaxPolicyNameLength; i++ { + longName += "a" + } + + policy := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeParent, + Name: longName, + Revision: 1, + Version: AccessControlPolicyVersionV0_1, + Rules: []AccessControlPolicyRule{{Actions: []string{"read"}, Expression: "user.role == 'admin'"}}, + } + + err := policy.accessPolicyVersionV0_1() + require.NotNil(t, err, "Should return error for too long name in parent policy") + require.Equal(t, "model.access_policy.is_valid.name.app_error", err.Id) + }) + + t.Run("negative revision", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeParent, + Name: "Test Policy", + Revision: -1, + Version: AccessControlPolicyVersionV0_1, + Rules: []AccessControlPolicyRule{{Actions: []string{"read"}, Expression: "user.role == 'admin'"}}, + } + + err := policy.accessPolicyVersionV0_1() + require.NotNil(t, err, "Should return error for negative revision") + require.Equal(t, "model.access_policy.is_valid.revision.app_error", err.Id) + }) + + t.Run("invalid version", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeParent, + Name: "Test Policy", + Revision: 1, + Version: "invalid-version", + Rules: []AccessControlPolicyRule{{Actions: []string{"read"}, Expression: "user.role == 'admin'"}}, + } + + err := policy.accessPolicyVersionV0_1() + require.NotNil(t, err, "Should return error for invalid version") + require.Equal(t, "model.access_policy.is_valid.version.app_error", err.Id) + }) + + t.Run("parent policy with no rules", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeParent, + Name: "Test Policy", + Revision: 1, + Version: AccessControlPolicyVersionV0_1, + Rules: []AccessControlPolicyRule{}, + } + + err := policy.accessPolicyVersionV0_1() + require.NotNil(t, err, "Should return error for parent policy with no rules") + require.Equal(t, "model.access_policy.is_valid.rules.app_error", err.Id) + }) + + t.Run("parent policy with imports", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeParent, + Name: "Test Policy", + Revision: 1, + Version: AccessControlPolicyVersionV0_1, + Rules: []AccessControlPolicyRule{{Actions: []string{"read"}, Expression: "user.role == 'admin'"}}, + Imports: []string{"some_import"}, + } + + err := policy.accessPolicyVersionV0_1() + require.NotNil(t, err, "Should return error for parent policy with imports") + require.Equal(t, "model.access_policy.is_valid.imports.app_error", err.Id) + }) + + t.Run("channel policy with no rules", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeChannel, + Name: "Test Policy", + Revision: 1, + Version: AccessControlPolicyVersionV0_1, + Rules: []AccessControlPolicyRule{}, + Imports: []string{"parent_policy_id"}, + } + + err := policy.accessPolicyVersionV0_1() + require.NotNil(t, err, "Should return error for channel policy with no rules") + require.Equal(t, "model.access_policy.is_valid.rules.app_error", err.Id) + }) + + t.Run("channel policy with no imports", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeChannel, + Name: "Test Policy", + Revision: 1, + Version: AccessControlPolicyVersionV0_1, + Rules: []AccessControlPolicyRule{{Actions: []string{"read"}, Expression: "user.role == 'admin'"}}, + Imports: []string{}, + } + + err := policy.accessPolicyVersionV0_1() + require.Nil(t, err, "Should not return error for channel policy with no imports") + }) + + t.Run("channel policy with multiple imports", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeChannel, + Name: "Test Policy", + Revision: 1, + Version: AccessControlPolicyVersionV0_1, + Rules: []AccessControlPolicyRule{{Actions: []string{"read"}, Expression: "user.role == 'admin'"}}, + Imports: []string{"parent_policy_id1", "parent_policy_id2"}, + } + + err := policy.accessPolicyVersionV0_1() + require.NotNil(t, err, "Should return error for channel policy with multiple imports") + require.Equal(t, "model.access_policy.is_valid.imports.app_error", err.Id) + }) + + t.Run("valid parent policy", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeParent, + Name: "Test Policy", + Revision: 1, + Version: "v0.1", + Rules: []AccessControlPolicyRule{{Actions: []string{"read"}, Expression: "user.role == 'admin'"}}, + } + + err := policy.accessPolicyVersionV0_1() + require.Nil(t, err, "Should not return error for valid parent policy") + }) + + t.Run("valid channel policy", func(t *testing.T) { + policy := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeChannel, + Name: "Test Policy", + Revision: 1, + Version: "v0.1", + Rules: []AccessControlPolicyRule{{Actions: []string{"read"}, Expression: "user.role == 'admin'"}}, + Imports: []string{"parent_policy_id"}, + } + + err := policy.accessPolicyVersionV0_1() + require.Nil(t, err, "Should not return error for valid channel policy") + }) +}