// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package model import ( "strings" "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) { var longName strings.Builder for i := 0; i <= MaxPolicyNameLength; i++ { longName.WriteString("a") } policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeParent, Name: longName.String(), 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") }) } func TestAccessControlPolicyValidateScope(t *testing.T) { validPolicy := func() *AccessControlPolicy { return &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeParent, Name: "Test Policy", Revision: 1, Version: AccessControlPolicyVersionV0_1, Rules: []AccessControlPolicyRule{{Actions: []string{"*"}, Expression: "true"}}, } } t.Run("no scope fields set — valid", func(t *testing.T) { p := validPolicy() require.Nil(t, p.IsValid()) }) t.Run("scope=team with valid scope_id — valid", func(t *testing.T) { p := validPolicy() p.Scope = AccessControlPolicyScopeTeam p.ScopeID = NewId() require.Nil(t, p.IsValid()) }) t.Run("scope_id set without scope — invalid", func(t *testing.T) { p := validPolicy() p.ScopeID = NewId() err := p.IsValid() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.scope_id_without_scope.app_error", err.Id) }) t.Run("scope=team with empty scope_id — invalid", func(t *testing.T) { p := validPolicy() p.Scope = AccessControlPolicyScopeTeam p.ScopeID = "" err := p.IsValid() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.scope_id.app_error", err.Id) }) t.Run("unknown scope value — invalid", func(t *testing.T) { p := validPolicy() p.Scope = "unknown" p.ScopeID = NewId() err := p.IsValid() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.scope.app_error", err.Id) }) } func TestAccessPolicyVersionV0_3(t *testing.T) { validRule := AccessControlPolicyRule{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "user.properties.dept == \"eng\"", } t.Run("valid parent type", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeParent, Name: "Parent", Revision: 0, Version: AccessControlPolicyVersionV0_3, Rules: []AccessControlPolicyRule{{ Actions: []string{ AccessControlPolicyActionMembership, AccessControlPolicyActionUploadFileAttachment, AccessControlPolicyActionDownloadFileAttachment, }, Expression: "user.properties.dept == \"eng\"", }}, } require.Nil(t, policy.accessPolicyVersionV0_3()) }) t.Run("valid channel type", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeChannel, Revision: 0, Version: AccessControlPolicyVersionV0_3, Imports: []string{NewId()}, Rules: []AccessControlPolicyRule{validRule}, } require.Nil(t, policy.accessPolicyVersionV0_3()) }) t.Run("valid permission type", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypePermission, Name: "Permission", Revision: 0, Version: AccessControlPolicyVersionV0_3, Roles: []string{"system_admin"}, Rules: []AccessControlPolicyRule{validRule}, } require.Nil(t, policy.accessPolicyVersionV0_3()) }) t.Run("invalid type", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: "unknown", Revision: 0, Version: AccessControlPolicyVersionV0_3, Rules: []AccessControlPolicyRule{validRule}, } err := policy.accessPolicyVersionV0_3() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.type.app_error", err.Id) }) t.Run("parent with no rules", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeParent, Name: "Parent", Revision: 0, Version: AccessControlPolicyVersionV0_3, Rules: []AccessControlPolicyRule{}, } err := policy.accessPolicyVersionV0_3() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.rules.app_error", err.Id) }) t.Run("parent with non-empty imports", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeParent, Name: "Parent", Revision: 0, Version: AccessControlPolicyVersionV0_3, Rules: []AccessControlPolicyRule{validRule}, Imports: []string{NewId()}, } err := policy.accessPolicyVersionV0_3() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.imports.app_error", err.Id) }) t.Run("permission with empty roles", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypePermission, Name: "Permission", Revision: 0, Version: AccessControlPolicyVersionV0_3, Roles: []string{}, Rules: []AccessControlPolicyRule{validRule}, } err := policy.accessPolicyVersionV0_3() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.roles.app_error", err.Id) }) t.Run("permission with blank role string", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypePermission, Name: "Permission", Revision: 0, Version: AccessControlPolicyVersionV0_3, Roles: []string{""}, Rules: []AccessControlPolicyRule{validRule}, } err := policy.accessPolicyVersionV0_3() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.roles.app_error", err.Id) }) t.Run("permission with multiple roles", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypePermission, Name: "Permission", Revision: 0, Version: AccessControlPolicyVersionV0_3, Roles: []string{"system_admin", "system_user"}, Rules: []AccessControlPolicyRule{validRule}, } err := policy.accessPolicyVersionV0_3() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.roles.app_error", err.Id) }) t.Run("permission with imports", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypePermission, Name: "Permission", Revision: 0, Version: AccessControlPolicyVersionV0_3, Roles: []string{"system_admin"}, Rules: []AccessControlPolicyRule{validRule}, Imports: []string{NewId()}, } err := policy.accessPolicyVersionV0_3() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.imports.app_error", err.Id) }) t.Run("permission with empty name", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypePermission, Name: "", Revision: 0, Version: AccessControlPolicyVersionV0_3, Roles: []string{"system_admin"}, Rules: []AccessControlPolicyRule{validRule}, } err := policy.accessPolicyVersionV0_3() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.name.app_error", err.Id) }) t.Run("permission with name exceeding max length", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypePermission, Name: strings.Repeat("a", MaxPolicyNameLength+1), Revision: 0, Version: AccessControlPolicyVersionV0_3, Roles: []string{"system_admin"}, Rules: []AccessControlPolicyRule{validRule}, } err := policy.accessPolicyVersionV0_3() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.name.app_error", err.Id) }) t.Run("unrecognized action", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeParent, Name: "Parent", Revision: 0, Version: AccessControlPolicyVersionV0_3, Rules: []AccessControlPolicyRule{{ Actions: []string{"not_a_real_action"}, Expression: "true", }}, } err := policy.accessPolicyVersionV0_3() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.actions.app_error", err.Id) require.Contains(t, err.DetailedError, "not_a_real_action") }) t.Run("empty actions slice", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeParent, Name: "Parent", Revision: 0, Version: AccessControlPolicyVersionV0_3, Rules: []AccessControlPolicyRule{{ Actions: []string{}, Expression: "true", }}, } err := policy.accessPolicyVersionV0_3() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.actions.app_error", err.Id) }) t.Run("channel with no rules and no imports", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeChannel, Revision: 0, Version: AccessControlPolicyVersionV0_3, } err := policy.accessPolicyVersionV0_3() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.rules_imports.app_error", err.Id) }) t.Run("membership rule rejects session attribute reference", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeParent, Name: "Parent", Revision: 0, Version: AccessControlPolicyVersionV0_3, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "user.session.ip_address == \"10.0.0.1\"", }}, } err := policy.accessPolicyVersionV0_3() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.session_attribute_on_membership.app_error", err.Id) }) t.Run("permission rule allows session attribute reference", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypePermission, Name: "Permission", Revision: 0, Version: AccessControlPolicyVersionV0_3, Roles: []string{"system_user"}, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionUploadFileAttachment}, Expression: "user.session.ip_address == \"10.0.0.1\"", }}, } require.Nil(t, policy.accessPolicyVersionV0_3()) }) t.Run("mixed-action rule rejects session attribute reference", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeParent, Name: "Parent", Revision: 0, Version: AccessControlPolicyVersionV0_3, Rules: []AccessControlPolicyRule{{ Actions: []string{ AccessControlPolicyActionMembership, AccessControlPolicyActionUploadFileAttachment, }, Expression: "user.session.ip_address == \"10.0.0.1\"", }}, } err := policy.accessPolicyVersionV0_3() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.session_attribute_on_membership.app_error", err.Id) }) t.Run("membership rule without session reference is accepted", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeParent, Name: "Parent", Revision: 0, Version: AccessControlPolicyVersionV0_3, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "user.attributes.team == \"eng\"", }}, } require.Nil(t, policy.accessPolicyVersionV0_3()) }) t.Run("membership rule rejects user.session inside string literal (lexical check)", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeParent, Name: "Parent", Revision: 0, Version: AccessControlPolicyVersionV0_3, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "user.attributes.note == \"see user.session for context\"", }}, } err := policy.accessPolicyVersionV0_3() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.session_attribute_on_membership.app_error", err.Id) }) } func TestAccessPolicyVersionV0_4(t *testing.T) { validMembership := AccessControlPolicyRule{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "user.attributes.dept == \"eng\"", } validPermission := func(name, role, action string) AccessControlPolicyRule { return AccessControlPolicyRule{ Name: name, Role: role, Actions: []string{action}, Expression: "user.attributes.dept == \"eng\"", } } t.Run("valid channel policy with membership and permission rules", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeChannel, Revision: 0, Version: AccessControlPolicyVersionV0_4, Rules: []AccessControlPolicyRule{ validMembership, validPermission("Block external uploads", ChannelUserRoleId, AccessControlPolicyActionUploadFileAttachment), validPermission("Admin overrides", ChannelAdminRoleId, AccessControlPolicyActionDownloadFileAttachment), }, } require.Nil(t, policy.accessPolicyVersionV0_4()) }) t.Run("permission rule missing role rejected", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeChannel, Revision: 0, Version: AccessControlPolicyVersionV0_4, Rules: []AccessControlPolicyRule{{ Name: "Block external uploads", Actions: []string{AccessControlPolicyActionUploadFileAttachment}, Expression: "true", }}, } err := policy.accessPolicyVersionV0_4() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.rule_role.app_error", err.Id) }) t.Run("permission rule with invalid role rejected", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeChannel, Revision: 0, Version: AccessControlPolicyVersionV0_4, Rules: []AccessControlPolicyRule{{ Name: "Block external uploads", Role: SystemUserRoleId, // wrong scope: must be channel role Actions: []string{AccessControlPolicyActionUploadFileAttachment}, Expression: "true", }}, } err := policy.accessPolicyVersionV0_4() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.rule_role.app_error", err.Id) }) t.Run("permission rule missing name rejected", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeChannel, Revision: 0, Version: AccessControlPolicyVersionV0_4, Rules: []AccessControlPolicyRule{{ Role: ChannelUserRoleId, Actions: []string{AccessControlPolicyActionUploadFileAttachment}, Expression: "true", }}, } err := policy.accessPolicyVersionV0_4() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.rule_name.app_error", err.Id) }) t.Run("duplicate permission rule names rejected", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeChannel, Revision: 0, Version: AccessControlPolicyVersionV0_4, Rules: []AccessControlPolicyRule{ validPermission("Block uploads", ChannelUserRoleId, AccessControlPolicyActionUploadFileAttachment), validPermission("Block uploads", ChannelAdminRoleId, AccessControlPolicyActionDownloadFileAttachment), }, } err := policy.accessPolicyVersionV0_4() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.rule_name_unique.app_error", err.Id) }) t.Run("membership combined with permission action rejected", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeChannel, Revision: 0, Version: AccessControlPolicyVersionV0_4, Rules: []AccessControlPolicyRule{{ Name: "Combined", Role: ChannelUserRoleId, Actions: []string{AccessControlPolicyActionMembership, AccessControlPolicyActionUploadFileAttachment}, Expression: "true", }}, } err := policy.accessPolicyVersionV0_4() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.actions.membership_combined.app_error", err.Id) }) t.Run("membership rule with role rejected", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeChannel, Revision: 0, Version: AccessControlPolicyVersionV0_4, Rules: []AccessControlPolicyRule{{ Role: ChannelUserRoleId, Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } err := policy.accessPolicyVersionV0_4() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.rule_role.app_error", err.Id) }) t.Run("permission rule on parent policy rejected", func(t *testing.T) { policy := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeParent, Name: "Parent", Revision: 0, Version: AccessControlPolicyVersionV0_4, Rules: []AccessControlPolicyRule{ validPermission("Block uploads", ChannelUserRoleId, AccessControlPolicyActionUploadFileAttachment), }, } err := policy.accessPolicyVersionV0_4() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.actions.permission_type.app_error", err.Id) }) } func TestInheritV0_4(t *testing.T) { t.Run("v0.4 child can import v0.4 parent", func(t *testing.T) { // Same-version happy path: a v0.4 channel policy importing // another v0.4 parent should be accepted (Inherit only blocks // v0.4 children importing pre-v0.3 parents). parentID := NewId() parent := &AccessControlPolicy{ ID: parentID, Type: AccessControlPolicyTypeParent, Name: "Parent V04", Revision: 0, Version: AccessControlPolicyVersionV0_4, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } child := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeChannel, Revision: 0, Version: AccessControlPolicyVersionV0_4, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } err := child.Inherit(parent) require.Nil(t, err) require.Contains(t, child.Imports, parentID) }) t.Run("v0.4 child can import v0.3 parent", func(t *testing.T) { parentID := NewId() parent := &AccessControlPolicy{ ID: parentID, Type: AccessControlPolicyTypeParent, Name: "Parent", Revision: 0, Version: AccessControlPolicyVersionV0_3, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } child := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeChannel, Revision: 0, Version: AccessControlPolicyVersionV0_4, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } err := child.Inherit(parent) require.Nil(t, err) require.Contains(t, child.Imports, parentID) }) t.Run("v0.4 child cannot import v0.1 parent", func(t *testing.T) { parent := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeParent, Name: "V01 Parent", Revision: 0, Version: AccessControlPolicyVersionV0_1, Rules: []AccessControlPolicyRule{{ Actions: []string{"read"}, Expression: "true", }}, } child := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeChannel, Revision: 0, Version: AccessControlPolicyVersionV0_4, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } err := child.Inherit(parent) require.NotNil(t, err) require.Equal(t, "model.access_policy.inherit.version.app_error", err.Id) }) t.Run("v0.4 child rejects permission-type parent", func(t *testing.T) { parent := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypePermission, Name: "Permission", Revision: 0, Version: AccessControlPolicyVersionV0_3, Roles: []string{"system_admin"}, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } child := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeChannel, Revision: 0, Version: AccessControlPolicyVersionV0_4, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } err := child.Inherit(parent) require.NotNil(t, err) require.Equal(t, "model.access_policy.inherit.permission.app_error", err.Id) }) // v0.4 imports are strictly child-channel → parent-membership. // A channel→channel import would write a peer channel policy's ID // into Imports where the loader expects a membership parent — the // resulting evaluation would silently misroute. Reject up front. t.Run("v0.4 child rejects channel-type parent", func(t *testing.T) { parent := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeChannel, Revision: 0, Version: AccessControlPolicyVersionV0_4, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } child := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeChannel, Revision: 0, Version: AccessControlPolicyVersionV0_4, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } err := child.Inherit(parent) require.NotNil(t, err) require.Equal(t, "model.access_policy.inherit.parent_type.app_error", err.Id) require.Empty(t, child.Imports, "rejected imports must not leak into the child's Imports slice") }) } func TestSubjectRoleForScope(t *testing.T) { t.Run("scoped roles take precedence", func(t *testing.T) { s := &Subject{ Role: SystemUserRoleId, // legacy field ScopedRoles: []ScopedRole{ {Scope: AccessControlSubjectScopeSystem, Role: SystemAdminRoleId}, {Scope: AccessControlSubjectScopeChannel, Role: ChannelAdminRoleId}, }, } require.Equal(t, SystemAdminRoleId, s.RoleForScope(AccessControlSubjectScopeSystem)) require.Equal(t, ChannelAdminRoleId, s.RoleForScope(AccessControlSubjectScopeChannel)) }) t.Run("falls back to legacy Role for system scope when ScopedRoles empty", func(t *testing.T) { s := &Subject{Role: SystemAdminRoleId} require.Equal(t, SystemAdminRoleId, s.RoleForScope(AccessControlSubjectScopeSystem)) require.Equal(t, "", s.RoleForScope(AccessControlSubjectScopeChannel)) }) t.Run("returns empty for unknown scope", func(t *testing.T) { s := &Subject{} require.Equal(t, "", s.RoleForScope("unknown")) }) } func TestSubjectRolesForScope(t *testing.T) { t.Run("returns every entry matching the scope in order", func(t *testing.T) { s := &Subject{ ScopedRoles: []ScopedRole{ {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, {Scope: AccessControlSubjectScopeChannel, Role: ChannelAdminRoleId}, {Scope: AccessControlSubjectScopeSystem, Role: SystemAdminRoleId}, }, } require.Equal(t, []string{SystemUserRoleId, SystemAdminRoleId}, s.RolesForScope(AccessControlSubjectScopeSystem)) require.Equal(t, []string{ChannelAdminRoleId}, s.RolesForScope(AccessControlSubjectScopeChannel)) }) t.Run("returns nil when no entry matches", func(t *testing.T) { s := &Subject{ ScopedRoles: []ScopedRole{ {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, }, } require.Nil(t, s.RolesForScope(AccessControlSubjectScopeChannel)) }) t.Run("does NOT fall back to legacy Role for system scope", func(t *testing.T) { s := &Subject{Role: SystemAdminRoleId} require.Nil(t, s.RolesForScope(AccessControlSubjectScopeSystem)) }) } func TestSubjectSetScopedRole(t *testing.T) { t.Run("appends when scope is absent", func(t *testing.T) { s := &Subject{} s.SetScopedRole(AccessControlSubjectScopeSystem, SystemUserRoleId) require.Equal(t, []ScopedRole{ {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, }, s.ScopedRoles) }) t.Run("replaces in place when scope already exists", func(t *testing.T) { s := &Subject{ ScopedRoles: []ScopedRole{ {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, {Scope: AccessControlSubjectScopeChannel, Role: ChannelUserRoleId}, }, } s.SetScopedRole(AccessControlSubjectScopeSystem, SystemAdminRoleId) require.Equal(t, []ScopedRole{ {Scope: AccessControlSubjectScopeSystem, Role: SystemAdminRoleId}, {Scope: AccessControlSubjectScopeChannel, Role: ChannelUserRoleId}, }, s.ScopedRoles) }) t.Run("collapses duplicate scope entries to one", func(t *testing.T) { s := &Subject{ ScopedRoles: []ScopedRole{ {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, {Scope: AccessControlSubjectScopeChannel, Role: ChannelUserRoleId}, {Scope: AccessControlSubjectScopeSystem, Role: SystemGuestRoleId}, }, } s.SetScopedRole(AccessControlSubjectScopeSystem, SystemAdminRoleId) require.Equal(t, []ScopedRole{ {Scope: AccessControlSubjectScopeSystem, Role: SystemAdminRoleId}, {Scope: AccessControlSubjectScopeChannel, Role: ChannelUserRoleId}, }, s.ScopedRoles) }) t.Run("empty role removes every entry for the scope", func(t *testing.T) { s := &Subject{ ScopedRoles: []ScopedRole{ {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, {Scope: AccessControlSubjectScopeChannel, Role: ChannelUserRoleId}, {Scope: AccessControlSubjectScopeSystem, Role: SystemGuestRoleId}, }, } s.SetScopedRole(AccessControlSubjectScopeSystem, "") require.Equal(t, []ScopedRole{ {Scope: AccessControlSubjectScopeChannel, Role: ChannelUserRoleId}, }, s.ScopedRoles) }) t.Run("empty role on absent scope is a no-op", func(t *testing.T) { s := &Subject{ ScopedRoles: []ScopedRole{ {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, }, } s.SetScopedRole(AccessControlSubjectScopeChannel, "") require.Equal(t, []ScopedRole{ {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, }, s.ScopedRoles) }) t.Run("empty scope is a no-op", func(t *testing.T) { original := []ScopedRole{ {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, } s := &Subject{ScopedRoles: original} s.SetScopedRole("", SystemAdminRoleId) require.Equal(t, original, s.ScopedRoles) }) t.Run("does not mutate aliased backing array", func(t *testing.T) { // Mirrors the attachChannelScopedRole hot path: a cached Subject is // passed by value, its ScopedRoles slice header is copied but the // backing array is shared. SetScopedRole must allocate a fresh array // so the cached Subject's ScopedRoles is not corrupted. cached := Subject{ ScopedRoles: []ScopedRole{ {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, }, } copyOfCached := cached copyOfCached.SetScopedRole(AccessControlSubjectScopeChannel, ChannelAdminRoleId) require.Equal(t, []ScopedRole{ {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, }, cached.ScopedRoles, "cached Subject's ScopedRoles must not be mutated") require.Equal(t, []ScopedRole{ {Scope: AccessControlSubjectScopeSystem, Role: SystemUserRoleId}, {Scope: AccessControlSubjectScopeChannel, Role: ChannelAdminRoleId}, }, copyOfCached.ScopedRoles) }) } func TestInheritV0_3(t *testing.T) { t.Run("successful inherit", func(t *testing.T) { parentID := NewId() parent := &AccessControlPolicy{ ID: parentID, Type: AccessControlPolicyTypeParent, Name: "Parent", Revision: 0, Version: AccessControlPolicyVersionV0_3, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } child := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeChannel, Revision: 0, Version: AccessControlPolicyVersionV0_3, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } err := child.Inherit(parent) require.Nil(t, err) require.Contains(t, child.Imports, parentID) }) t.Run("duplicate import guard", func(t *testing.T) { parentID := NewId() parent := &AccessControlPolicy{ ID: parentID, Type: AccessControlPolicyTypeParent, Name: "Parent", Revision: 0, Version: AccessControlPolicyVersionV0_3, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } child := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeChannel, Revision: 0, Version: AccessControlPolicyVersionV0_3, Imports: []string{parentID}, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } err := child.Inherit(parent) require.NotNil(t, err) require.Equal(t, "model.access_policy.inherit.already_imported.app_error", err.Id) }) t.Run("permission type child rejected", func(t *testing.T) { parent := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeParent, Name: "Parent", Revision: 0, Version: AccessControlPolicyVersionV0_3, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } child := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypePermission, Revision: 0, Version: AccessControlPolicyVersionV0_3, Roles: []string{"system_admin"}, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } err := child.Inherit(parent) require.NotNil(t, err) require.Equal(t, "model.access_policy.inherit.permission.app_error", err.Id) }) t.Run("permission type parent rejected", func(t *testing.T) { parent := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypePermission, Revision: 0, Version: AccessControlPolicyVersionV0_3, Roles: []string{"system_admin"}, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } child := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeChannel, Revision: 0, Version: AccessControlPolicyVersionV0_3, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } err := child.Inherit(parent) require.NotNil(t, err) require.Equal(t, "model.access_policy.inherit.permission.app_error", err.Id) }) t.Run("non-v0.3 parent version rejected", func(t *testing.T) { parent := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeParent, Name: "V01 Parent", Revision: 0, Version: AccessControlPolicyVersionV0_1, Rules: []AccessControlPolicyRule{{ Actions: []string{"read"}, Expression: "true", }}, } child := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeChannel, Revision: 0, Version: AccessControlPolicyVersionV0_3, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } err := child.Inherit(parent) require.NotNil(t, err) require.Equal(t, "model.access_policy.inherit.version.app_error", err.Id) }) } func TestIsValidTeamType(t *testing.T) { validID := NewId() t.Run("team type with rules only is valid", func(t *testing.T) { p := &AccessControlPolicy{ ID: validID, Type: AccessControlPolicyTypeTeam, Version: AccessControlPolicyVersionV0_3, Revision: 0, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } require.Nil(t, p.IsValid()) }) t.Run("team type with imports only is valid", func(t *testing.T) { p := &AccessControlPolicy{ ID: validID, Type: AccessControlPolicyTypeTeam, Version: AccessControlPolicyVersionV0_3, Revision: 0, Imports: []string{NewId()}, } require.Nil(t, p.IsValid()) }) t.Run("team type with neither rules nor imports is invalid", func(t *testing.T) { p := &AccessControlPolicy{ ID: validID, Type: AccessControlPolicyTypeTeam, Version: AccessControlPolicyVersionV0_3, Revision: 0, } appErr := p.IsValid() require.NotNil(t, appErr) require.Equal(t, "model.access_policy.is_valid.rules_imports.app_error", appErr.Id) }) } func TestInheritTeamType(t *testing.T) { makeParent := func() *AccessControlPolicy { return &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeParent, Name: "TestParent", Version: AccessControlPolicyVersionV0_3, Revision: 0, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } } t.Run("team child inherits from parent is ok", func(t *testing.T) { parent := makeParent() child := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeTeam, Version: AccessControlPolicyVersionV0_3, Revision: 0, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } err := child.Inherit(parent) require.Nil(t, err) require.Contains(t, child.Imports, parent.ID) }) t.Run("team child inheriting from team policy returns 400", func(t *testing.T) { teamParent := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeTeam, Version: AccessControlPolicyVersionV0_3, Revision: 0, Imports: []string{NewId()}, } child := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeTeam, Version: AccessControlPolicyVersionV0_3, Revision: 0, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } err := child.Inherit(teamParent) require.NotNil(t, err) require.Equal(t, 400, err.StatusCode) }) t.Run("team child inheriting from permission policy returns 400", func(t *testing.T) { permParent := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypePermission, Name: "PermParent", Version: AccessControlPolicyVersionV0_3, Revision: 0, Roles: []string{"system_admin"}, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } child := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeTeam, Version: AccessControlPolicyVersionV0_3, Revision: 0, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } err := child.Inherit(permParent) require.NotNil(t, err) require.Equal(t, 400, err.StatusCode) }) } // testPluginPolicyType is a well-formed plugin-owned policy type: the owning // plugin ID, a colon, then the plugin's own resource-type name. const testPluginPolicyType = "mattermost-ai:agent" func TestAccessPolicyVersionV0_5(t *testing.T) { validPolicy := func(mutate func(p *AccessControlPolicy)) *AccessControlPolicy { p := &AccessControlPolicy{ ID: NewId(), Type: testPluginPolicyType, Name: "Agent policy", Revision: 0, Version: AccessControlPolicyVersionV0_5, Rules: []AccessControlPolicyRule{{ Actions: []string{"use"}, Expression: "user.attributes.dept == \"eng\"", }}, } if mutate != nil { mutate(p) } return p } for _, pluginType := range []string{ "mattermost-ai:agent", "mattermost-ai:service", "mattermost-ai:mcp", "com.example.plugin:widget", } { t.Run("valid policy for "+pluginType, func(t *testing.T) { p := validPolicy(func(p *AccessControlPolicy) { p.Type = pluginType }) require.Nil(t, p.IsValid()) }) } t.Run("plugin-defined action accepted", func(t *testing.T) { p := validPolicy(func(p *AccessControlPolicy) { p.Rules[0].Actions = []string{"invoke_tool"} }) require.Nil(t, p.IsValid()) }) t.Run("optional rule name accepted", func(t *testing.T) { p := validPolicy(func(p *AccessControlPolicy) { p.Rules[0].Name = "Named rule" }) require.Nil(t, p.IsValid()) }) tests := []struct { name string mutate func(p *AccessControlPolicy) expectedID string }{ {"legacy channel type rejected", func(p *AccessControlPolicy) { p.Type = AccessControlPolicyTypeChannel }, "model.access_policy.is_valid.plugin_type.app_error"}, {"legacy parent type rejected", func(p *AccessControlPolicy) { p.Type = AccessControlPolicyTypeParent }, "model.access_policy.is_valid.plugin_type.app_error"}, {"legacy permission type rejected", func(p *AccessControlPolicy) { p.Type = AccessControlPolicyTypePermission }, "model.access_policy.is_valid.plugin_type.app_error"}, {"legacy team type rejected", func(p *AccessControlPolicy) { p.Type = AccessControlPolicyTypeTeam }, "model.access_policy.is_valid.plugin_type.app_error"}, {"unprefixed type rejected", func(p *AccessControlPolicy) { p.Type = "some-plugin.widget" }, "model.access_policy.is_valid.plugin_type.app_error"}, {"empty resource-type segment rejected", func(p *AccessControlPolicy) { p.Type = "mattermost-ai:" }, "model.access_policy.is_valid.plugin_type.app_error"}, {"empty plugin-ID segment rejected", func(p *AccessControlPolicy) { p.Type = ":agent" }, "model.access_policy.is_valid.plugin_type.app_error"}, {"resource-type segment with a separator rejected", func(p *AccessControlPolicy) { p.Type = "mattermost-ai:agent:v2" }, "model.access_policy.is_valid.plugin_type.app_error"}, {"oversized resource-type segment rejected", func(p *AccessControlPolicy) { p.Type = "mattermost-ai:" + strings.Repeat("a", MaxPluginResourceTypeLength+1) }, "model.access_policy.is_valid.plugin_type.app_error"}, {"invalid id", func(p *AccessControlPolicy) { p.ID = "short" }, "model.access_policy.is_valid.id.app_error"}, {"empty name", func(p *AccessControlPolicy) { p.Name = "" }, "model.access_policy.is_valid.name.app_error"}, {"oversized name", func(p *AccessControlPolicy) { p.Name = strings.Repeat("a", MaxPolicyNameLength+1) }, "model.access_policy.is_valid.name.app_error"}, {"negative revision", func(p *AccessControlPolicy) { p.Revision = -1 }, "model.access_policy.is_valid.revision.app_error"}, {"zero rules", func(p *AccessControlPolicy) { p.Rules = nil }, "model.access_policy.is_valid.rules.app_error"}, {"imports present", func(p *AccessControlPolicy) { p.Imports = []string{NewId()} }, "model.access_policy.is_valid.imports.app_error"}, {"roles present", func(p *AccessControlPolicy) { p.Roles = []string{SystemUserRoleId} }, "model.access_policy.is_valid.roles.app_error"}, {"empty actions", func(p *AccessControlPolicy) { p.Rules[0].Actions = nil }, "model.access_policy.is_valid.actions.app_error"}, {"wildcard action rejected", func(p *AccessControlPolicy) { p.Rules[0].Actions = []string{"*"} }, "model.access_policy.is_valid.actions.app_error"}, {"malformed action rejected", func(p *AccessControlPolicy) { p.Rules[0].Actions = []string{"Use It"} }, "model.access_policy.is_valid.actions.app_error"}, {"rule role rejected", func(p *AccessControlPolicy) { p.Rules[0].Role = ChannelUserRoleId }, "model.access_policy.is_valid.rule_role.app_error"}, {"oversized rule name", func(p *AccessControlPolicy) { p.Rules[0].Name = strings.Repeat("a", MaxPolicyNameLength+1) }, "model.access_policy.is_valid.rule_name.app_error"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { p := validPolicy(tc.mutate) err := p.IsValid() require.NotNil(t, err) require.Equal(t, tc.expectedID, err.Id) }) } // Scope rejections go through the top-level IsValid because // validateScope runs first for non-empty scopes. t.Run("team scope rejected", func(t *testing.T) { p := validPolicy(func(p *AccessControlPolicy) { p.Scope = AccessControlPolicyScopeTeam p.ScopeID = NewId() }) err := p.IsValid() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.scope.app_error", err.Id) }) t.Run("scope id without scope rejected", func(t *testing.T) { p := validPolicy(func(p *AccessControlPolicy) { p.ScopeID = NewId() }) err := p.IsValid() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.scope_id_without_scope.app_error", err.Id) }) t.Run("invalid semver rejected", func(t *testing.T) { // A bad semver can't reach the v0.5 validator through the IsValid // switch, so exercise the validator directly. p := validPolicy(nil) p.Version = "not-semver" err := p.accessPolicyVersionV0_5() require.NotNil(t, err) require.Equal(t, "model.access_policy.is_valid.version.app_error", err.Id) }) } func TestSplitPluginAccessControlPolicyType(t *testing.T) { valid := []struct { policyType string pluginID string resourceType string }{ {"mattermost-ai:agent", "mattermost-ai", "agent"}, {"com.example.plugin:widget", "com.example.plugin", "widget"}, {"my_plugin:some.nested-type", "my_plugin", "some.nested-type"}, {"mattermost-ai:" + strings.Repeat("a", MaxPluginResourceTypeLength), "mattermost-ai", strings.Repeat("a", MaxPluginResourceTypeLength)}, // Exactly the column width: a 63-char plugin ID + ":" + 64-char type. { strings.Repeat("p", MaxPolicyTypeLength-MaxPluginResourceTypeLength-1) + ":" + strings.Repeat("a", MaxPluginResourceTypeLength), strings.Repeat("p", MaxPolicyTypeLength-MaxPluginResourceTypeLength-1), strings.Repeat("a", MaxPluginResourceTypeLength), }, } for _, tc := range valid { t.Run("valid "+tc.policyType, func(t *testing.T) { pluginID, resourceType, ok := SplitPluginAccessControlPolicyType(tc.policyType) require.True(t, ok) require.Equal(t, tc.pluginID, pluginID) require.Equal(t, tc.resourceType, resourceType) require.True(t, IsPluginAccessControlPolicyType(tc.policyType)) }) } invalid := []string{ AccessControlPolicyTypeParent, AccessControlPolicyTypeChannel, AccessControlPolicyTypePermission, AccessControlPolicyTypeTeam, "mattermost-ai.agent", "mattermost-ai:", ":agent", "ab:agent", // plugin ID shorter than MinIdLength "mattermost ai:agent", // space is not a valid plugin-ID character "mattermost-ai:agent:v2", // separator inside the resource type "mattermost-ai:agent type", // space inside the resource type "mattermost-ai:" + strings.Repeat("a", MaxPluginResourceTypeLength+1), // One over the column width, with both segments individually valid. strings.Repeat("p", MaxPolicyTypeLength-MaxPluginResourceTypeLength) + ":" + strings.Repeat("a", MaxPluginResourceTypeLength), "", } for _, policyType := range invalid { t.Run("invalid "+policyType, func(t *testing.T) { _, _, ok := SplitPluginAccessControlPolicyType(policyType) require.False(t, ok) require.False(t, IsPluginAccessControlPolicyType(policyType)) require.False(t, PluginOwnsAccessControlPolicyType("mattermost-ai", policyType)) }) } } func TestPluginOwnsAccessControlPolicyType(t *testing.T) { tests := []struct { name string pluginID string policyType string want bool }{ {"owner matches", "mattermost-ai", "mattermost-ai:agent", true}, {"foreign plugin", "other-plugin", "mattermost-ai:agent", false}, {"empty plugin ID", "", "mattermost-ai:agent", false}, {"core policy type", "mattermost-ai", AccessControlPolicyTypeChannel, false}, // The stored type is matched byte-for-byte on delete and during // evaluation, so a case variant that could Get but never Delete would // be a trap. {"caller ID case mismatch", "Mattermost-AI", "mattermost-ai:agent", false}, {"stored prefix case mismatch", "mattermost-ai", "Mattermost-AI:agent", false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.want, PluginOwnsAccessControlPolicyType(tc.pluginID, tc.policyType)) }) } } func TestIsValidPolicyAction(t *testing.T) { for _, action := range []string{ AccessControlPolicyActionMembership, AccessControlPolicyActionUploadFileAttachment, "use", "invoke_tool", "read2", } { require.True(t, IsValidPolicyAction(action), action) } for _, action := range []string{ "", "*", "Use", "use it", "use-it", "_use", "use_", "use__it", strings.Repeat("a", MaxPolicyActionLength+1), } { require.False(t, IsValidPolicyAction(action), action) } } func TestInheritV0_5Rejected(t *testing.T) { parent := &AccessControlPolicy{ ID: NewId(), Type: AccessControlPolicyTypeParent, Name: "Parent", Version: AccessControlPolicyVersionV0_3, Revision: 0, Rules: []AccessControlPolicyRule{{ Actions: []string{AccessControlPolicyActionMembership}, Expression: "true", }}, } child := &AccessControlPolicy{ ID: NewId(), Type: testPluginPolicyType, Name: "Agent policy", Version: AccessControlPolicyVersionV0_5, Revision: 0, Rules: []AccessControlPolicyRule{{ Actions: []string{"use"}, Expression: "true", }}, } err := child.Inherit(parent) require.NotNil(t, err) require.Equal(t, "model.access_policy.inherit.version.app_error", err.Id) require.Empty(t, child.Imports) }