mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-27 05:37:15 -05:00
* MM: add v0.5 plugin access control policy model, registry, and decision outcomes Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * MM: add plugin access control PDP/PAP app-layer methods with fail-closed semantics Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * MM: expose plugin access control API surface (EvaluateAccessControl + PAP/CEL methods) Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * MM: add store-layer round-trip tests for v0.5 plugin access control policies Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * MM: extract plugin access control app code into plugin_access_control.go Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * MM: add atomic type-guarded AccessControlPolicyStore.DeleteIfType Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * MM: plugin PAP hardening — atomic typed delete, indistinguishable 404s, audit every attempt Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * MM: close plugin Get-by-ID TOCTOU via GetPolicyOfType; stamp save audit operation at entry Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * Fix gob RPC poisoning from native attribute select options NativeUserAttributeFields stored bool-select options as []map[string]string inside PropertyField.Attrs (map[string]any). gob requires concrete types inside interface values to be registered, and []map[string]string is not registered in client_rpc.go, so encoding the GetAccessControlFieldsAutocomplete reply failed and net/rpc shut down the shared plugin API connection, breaking every subsequent plugin API call. Build the options from gob-registered containers ([]any/map[string]any) instead; JSON output is byte-identical. Add gob round-trip regression tests covering every plugin access control API reply payload: the autocomplete response including native attribute fields (fails against the old code), policies with JSON-decoded Props, visual AST condition values of every runtime shape, expression check errors, query users responses, and evaluation decisions. Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * Move plugin access control gob-safety tests into their own file Pure move: plugin_access_control_test.go crossed 1000 lines; the gob-safety helper and TestPluginAccessControlGobSafety now live in plugin_access_control_gob_test.go, unchanged. Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * MM: resolve plugin policy existence when ABAC is unavailable (Option B) Every evaluation-impossible branch of EvaluatePluginAccessRequest (service nil / unlicensed / flag off / user load or subject build failure / evaluator infra error / unknown outcome) now performs a raw open-core store read on the already-validated resource ID: no stored row returns no_policy so the caller can safely apply legacy behavior; any stored row (with a Warn on a foreign-type anomaly) or a failed read returns unavailable so the caller must fail closed. This lets the plugin drop its local policy index entirely. Strengthens the EvaluateAccessControl doc contract accordingly and reworks the fail-closed test matrix with with/without-row splits per branch, a foreign-type-row case, a store-read-error case on the store mock, and passthrough rows pinning that the fallback read never runs when the evaluator answers. Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * Clarify EvaluateAccessControl failure-mapping doc A failure with a definitive store miss maps to no_policy under the Option B semantics, so 'failures never map to allow or no_policy' was inaccurate. State precisely: never allow; no_policy only on positively determined non-existence; deny for defensive failures on a resolved policy; unavailable otherwise. Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * Tighten ABAC comments Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * Document AccessDecision.Outcome as plugin-API-only The evaluator populates Outcome on every lane, but the only production reader is the app layer's EvaluatePluginAccessRequest, which maps it into PluginAccessControlDecision; core channel/team enforcement reads the collapsed Decision bool alone. State that on the field. Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * Retrigger CI to rebuild enterprise image with updated enterprise branch Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * Document that only the evaluator's plugin lane sets AccessDecision.Outcome Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * ABAC: key plugin resource types as plugin_id:type and drop the static registry Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * ABAC: replace AccessDecision.Outcome with the AuthZEN decision context reason Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * ABAC: drop type-scoped policy get/delete and check the type in the app layer Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * ABAC: compare plugin policy type ownership exactly instead of case-insensitively Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * ABAC: bound the whole policy type to the Type column width Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * ABAC: require an allow before treating a decision as the no-policy fallback Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * ABAC: treat a colliding foreign-type policy row as no_policy, not a deny Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * ABAC: gate plugin policy reads on a raw store read before normalization Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * ABAC: pin policy type immutability on save in the store tests Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * ABAC: reuse a single unavailable-error constructor in the existence fallback Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * ABAC: re-check plugin policy ownership on the normalized get read Co-authored-by: nick.misasi <nick.misasi@mattermost.com> * ABAC: confirm plugin policy ownership before surfacing a normalization error Co-authored-by: nick.misasi <nick.misasi@mattermost.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1154 lines
48 KiB
Go
1154 lines
48 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/public/shared/request"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/mattermost/mattermost/server/v8/channels/app/properties"
|
|
storemocks "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
|
|
"github.com/mattermost/mattermost/server/v8/config"
|
|
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
|
|
)
|
|
|
|
const (
|
|
testAgentsPluginID = "mattermost-ai"
|
|
// Plugin-owned policy types are keyed "<pluginID>:<resourceType>".
|
|
testAgentResourceType = testAgentsPluginID + ":agent"
|
|
testAgentAction = "use"
|
|
)
|
|
|
|
// enablePluginAccessControl licenses and configures the server so
|
|
// pluginAccessControlAvailable() is true.
|
|
func enablePluginAccessControl(t *testing.T, th *TestHelper) {
|
|
t.Helper()
|
|
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
|
|
require.True(t, ok)
|
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
|
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
|
|
})
|
|
}
|
|
|
|
func validPluginPolicy(id string) *model.AccessControlPolicy {
|
|
return &model.AccessControlPolicy{
|
|
ID: id,
|
|
Type: testAgentResourceType,
|
|
Name: "Agent policy",
|
|
Version: model.AccessControlPolicyVersionV0_5,
|
|
Rules: []model.AccessControlPolicyRule{{
|
|
Actions: []string{testAgentAction},
|
|
Expression: `user.attributes.dept == "eng"`,
|
|
}},
|
|
}
|
|
}
|
|
|
|
// savePluginPolicyRow plants a row directly in the open-core store, which is
|
|
// what the app layer's raw ownership read consults.
|
|
func savePluginPolicyRow(t *testing.T, th *TestHelper, policy *model.AccessControlPolicy) {
|
|
t.Helper()
|
|
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, policy)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
require.NoError(t, th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, policy.ID))
|
|
})
|
|
}
|
|
|
|
// validForeignTypePolicy is a valid v0.3 channel policy used to plant a
|
|
// foreign-type row under a plugin resource ID.
|
|
func validForeignTypePolicy(id string) *model.AccessControlPolicy {
|
|
return &model.AccessControlPolicy{
|
|
ID: id,
|
|
Name: "Channel policy " + id,
|
|
Type: model.AccessControlPolicyTypeChannel,
|
|
Active: true,
|
|
Revision: 1,
|
|
Version: model.AccessControlPolicyVersionV0_3,
|
|
Rules: []model.AccessControlPolicyRule{{
|
|
Actions: []string{model.AccessControlPolicyActionMembership},
|
|
Expression: "true",
|
|
}},
|
|
}
|
|
}
|
|
|
|
// TestEvaluatePluginAccessRequest pins the fail-closed contract: a request is
|
|
// reported as unregulated (no_policy) only when no policy of the requested type
|
|
// exists, and any condition that leaves a governing policy unevaluated is an
|
|
// AppError the caller must treat as a deny — never an allow.
|
|
func TestEvaluatePluginAccessRequest(t *testing.T) {
|
|
th := Setup(t).InitBasic(t)
|
|
|
|
userID := th.BasicUser.Id
|
|
resourceType := testAgentResourceType
|
|
action := testAgentAction
|
|
|
|
savePolicyRow := func(t *testing.T, policy *model.AccessControlPolicy) {
|
|
t.Helper()
|
|
savePluginPolicyRow(t, th, policy)
|
|
}
|
|
|
|
t.Run("programming errors return AppErrors", func(t *testing.T) {
|
|
enablePluginAccessControl(t, th)
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
resourceID := model.NewId()
|
|
tests := []struct {
|
|
name string
|
|
pluginID string
|
|
userID string
|
|
resourceType string
|
|
resourceID string
|
|
action string
|
|
expectedStatus int
|
|
expectedID string
|
|
}{
|
|
{"unprefixed resource type", testAgentsPluginID, userID, "bogus.type", resourceID, action, http.StatusBadRequest, "app.access_control.plugin.invalid_resource_type.app_error"},
|
|
{"foreign plugin", "other-plugin", userID, resourceType, resourceID, action, http.StatusForbidden, "app.access_control.plugin.resource_type_forbidden.app_error"},
|
|
{"malformed action", testAgentsPluginID, userID, resourceType, resourceID, "*", http.StatusBadRequest, "app.access_control.plugin.invalid_action.app_error"},
|
|
{"invalid user id", testAgentsPluginID, "short", resourceType, resourceID, action, http.StatusBadRequest, "app.access_control.plugin.invalid_id.app_error"},
|
|
{"invalid resource id", testAgentsPluginID, userID, resourceType, "short", action, http.StatusBadRequest, "app.access_control.plugin.invalid_id.app_error"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
decision, appErr := th.App.EvaluatePluginAccessRequest(th.Context, tc.pluginID, tc.userID, tc.resourceType, tc.resourceID, tc.action)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, tc.expectedStatus, appErr.StatusCode)
|
|
assert.Equal(t, tc.expectedID, appErr.Id)
|
|
assert.Nil(t, decision)
|
|
})
|
|
}
|
|
mockACS.AssertNotCalled(t, "AccessEvaluation", mock.Anything, mock.Anything)
|
|
})
|
|
|
|
// Each branch is split: no stored row → no_policy; matching-type row → AppError.
|
|
t.Run("evaluation-impossible branches resolve policy existence", func(t *testing.T) {
|
|
branches := []struct {
|
|
name string
|
|
// arrange returns the userID to evaluate with and the mock ACS
|
|
// (nil when the service is nil).
|
|
arrange func(t *testing.T) (string, *mocks.AccessControlServiceInterface)
|
|
}{
|
|
{"service nil", func(t *testing.T) (string, *mocks.AccessControlServiceInterface) {
|
|
enablePluginAccessControl(t, th)
|
|
th.App.Srv().ch.AccessControl = nil
|
|
return userID, nil
|
|
}},
|
|
{"insufficient license", func(t *testing.T) (string, *mocks.AccessControlServiceInterface) {
|
|
enablePluginAccessControl(t, th)
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional))
|
|
require.True(t, ok)
|
|
return userID, mockACS
|
|
}},
|
|
{"disabled config flag", func(t *testing.T) (string, *mocks.AccessControlServiceInterface) {
|
|
enablePluginAccessControl(t, th)
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
|
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = false
|
|
})
|
|
return userID, mockACS
|
|
}},
|
|
{"unknown user", func(t *testing.T) (string, *mocks.AccessControlServiceInterface) {
|
|
enablePluginAccessControl(t, th)
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
return model.NewId(), mockACS
|
|
}},
|
|
{"subject build failure", func(t *testing.T) (string, *mocks.AccessControlServiceInterface) {
|
|
enablePluginAccessControl(t, th)
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
// Break the CPA property-group lookup so
|
|
// BuildAccessControlSubject fails; the fallback existence
|
|
// read uses a different store and is unaffected.
|
|
mockGroupStore := &storemocks.PropertyGroupStore{}
|
|
mockGroupStore.
|
|
On("Get", model.AccessControlPropertyGroupName).
|
|
Return((*model.PropertyGroup)(nil), errors.New("simulated store failure"))
|
|
ps, err := properties.New(properties.ServiceConfig{
|
|
PropertyGroupStore: mockGroupStore,
|
|
PropertyFieldStore: &storemocks.PropertyFieldStore{},
|
|
PropertyValueStore: &storemocks.PropertyValueStore{},
|
|
CallerIDExtractor: func(rctx request.CTX) string { return "" },
|
|
})
|
|
require.NoError(t, err)
|
|
originalPS := th.App.Srv().propertyService
|
|
th.App.Srv().propertyService = ps
|
|
t.Cleanup(func() { th.App.Srv().propertyService = originalPS })
|
|
return userID, mockACS
|
|
}},
|
|
{"evaluator AppError", func(t *testing.T) (string, *mocks.AccessControlServiceInterface) {
|
|
enablePluginAccessControl(t, th)
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
mockACS.On("AccessEvaluation", mock.Anything, mock.Anything).
|
|
Return(model.AccessDecision{}, model.NewAppError("AccessEvaluation", "app.pdp.access_evaluation.app_error", nil, "boom", http.StatusInternalServerError)).Once()
|
|
return userID, mockACS
|
|
}},
|
|
}
|
|
|
|
// Branches that must never reach the evaluator.
|
|
evaluatorNeverCalled := map[string]bool{
|
|
"insufficient license": true,
|
|
"disabled config flag": true,
|
|
"unknown user": true,
|
|
"subject build failure": true,
|
|
}
|
|
|
|
for _, br := range branches {
|
|
t.Run(br.name+", no row → no_policy", func(t *testing.T) {
|
|
evalUserID, mockACS := br.arrange(t)
|
|
decision, appErr := th.App.EvaluatePluginAccessRequest(th.Context, testAgentsPluginID, evalUserID, resourceType, model.NewId(), action)
|
|
require.Nil(t, appErr)
|
|
require.True(t, decision.Decision)
|
|
require.True(t, decision.IsNoPolicy())
|
|
if mockACS != nil {
|
|
if evaluatorNeverCalled[br.name] {
|
|
mockACS.AssertNotCalled(t, "AccessEvaluation", mock.Anything, mock.Anything)
|
|
} else {
|
|
mockACS.AssertExpectations(t)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run(br.name+", matching-type row → AppError", func(t *testing.T) {
|
|
evalUserID, mockACS := br.arrange(t)
|
|
resourceID := model.NewId()
|
|
savePolicyRow(t, validPluginPolicy(resourceID))
|
|
decision, appErr := th.App.EvaluatePluginAccessRequest(th.Context, testAgentsPluginID, evalUserID, resourceType, resourceID, action)
|
|
require.Nil(t, decision)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.evaluation_unavailable.app_error", appErr.Id)
|
|
assert.Equal(t, http.StatusServiceUnavailable, appErr.StatusCode)
|
|
if mockACS != nil {
|
|
if evaluatorNeverCalled[br.name] {
|
|
mockACS.AssertNotCalled(t, "AccessEvaluation", mock.Anything, mock.Anything)
|
|
} else {
|
|
mockACS.AssertExpectations(t)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run(br.name+", foreign-type row → no_policy", func(t *testing.T) {
|
|
evalUserID, mockACS := br.arrange(t)
|
|
resourceID := model.NewId()
|
|
savePolicyRow(t, validForeignTypePolicy(resourceID))
|
|
decision, appErr := th.App.EvaluatePluginAccessRequest(th.Context, testAgentsPluginID, evalUserID, resourceType, resourceID, action)
|
|
require.Nil(t, appErr)
|
|
require.True(t, decision.Decision)
|
|
require.True(t, decision.IsNoPolicy())
|
|
if mockACS != nil {
|
|
if evaluatorNeverCalled[br.name] {
|
|
mockACS.AssertNotCalled(t, "AccessEvaluation", mock.Anything, mock.Anything)
|
|
} else {
|
|
mockACS.AssertExpectations(t)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("foreign-type row with ABAC off returns no_policy", func(t *testing.T) {
|
|
// A colliding foreign row can never be this plugin's policy, so the
|
|
// resource is unregulated and the caller may apply its own default.
|
|
// Denying instead would let any plugin grief another's resource IDs.
|
|
enablePluginAccessControl(t, th)
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
th.App.UpdateConfig(func(cfg *model.Config) {
|
|
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = false
|
|
})
|
|
|
|
resourceID := model.NewId()
|
|
savePolicyRow(t, validForeignTypePolicy(resourceID))
|
|
|
|
decision, appErr := th.App.EvaluatePluginAccessRequest(th.Context, testAgentsPluginID, userID, resourceType, resourceID, action)
|
|
require.Nil(t, appErr)
|
|
require.True(t, decision.Decision)
|
|
require.True(t, decision.IsNoPolicy())
|
|
mockACS.AssertNotCalled(t, "AccessEvaluation", mock.Anything, mock.Anything)
|
|
})
|
|
|
|
t.Run("evaluator decisions pass through without a fallback read", func(t *testing.T) {
|
|
enablePluginAccessControl(t, th)
|
|
|
|
tests := []struct {
|
|
name string
|
|
decision model.AccessDecision
|
|
// saveRow plants a matching-type row to pin that the fallback
|
|
// existence read never runs on the happy path.
|
|
saveRow bool
|
|
wantAllow bool
|
|
wantNoPolicy bool
|
|
}{
|
|
{"no_policy (with a stored row — fallback must not run)", model.NewNoPolicyAccessDecision(), true, true, true},
|
|
{"deny", model.AccessDecision{Decision: false}, false, false, false},
|
|
{"allow", model.AccessDecision{Decision: true}, false, true, false},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
resourceID := model.NewId()
|
|
if tc.saveRow {
|
|
savePolicyRow(t, validPluginPolicy(resourceID))
|
|
}
|
|
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
|
|
return req.Resource.ID == resourceID &&
|
|
req.Resource.Type == resourceType &&
|
|
req.Action == action &&
|
|
req.Subject.ID == userID &&
|
|
req.Subject.RoleForScope(model.AccessControlSubjectScopeChannel) == ""
|
|
})).Return(tc.decision, nil).Once()
|
|
|
|
decision, appErr := th.App.EvaluatePluginAccessRequest(th.Context, testAgentsPluginID, userID, resourceType, resourceID, action)
|
|
require.Nil(t, appErr)
|
|
require.Equal(t, tc.wantAllow, decision.Decision)
|
|
require.Equal(t, tc.wantNoPolicy, decision.IsNoPolicy())
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestEvaluatePluginAccessRequestStoreError pins that when the fallback store
|
|
// read itself fails, the caller gets an error rather than a no-policy allow.
|
|
// Uses the store-mock helper because a read error cannot be arranged on the
|
|
// real store.
|
|
func TestEvaluatePluginAccessRequestStoreError(t *testing.T) {
|
|
th := SetupWithStoreMock(t)
|
|
|
|
mockStore := th.App.Srv().Store().(*storemocks.Store)
|
|
mockACPStore := &storemocks.AccessControlPolicyStore{}
|
|
mockACPStore.On("Get", mock.Anything, mock.AnythingOfType("string")).
|
|
Return(nil, errors.New("simulated store failure"))
|
|
mockStore.On("AccessControlPolicy").Return(mockACPStore)
|
|
|
|
// No license here, so pluginAccessControlAvailable() is false and the
|
|
// fallback read runs.
|
|
require.Nil(t, th.App.Srv().ch.AccessControl)
|
|
|
|
// Programming errors return before the fallback read.
|
|
_, appErr := th.App.EvaluatePluginAccessRequest(th.Context, testAgentsPluginID, model.NewId(), testAgentResourceType, "short", testAgentAction)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.invalid_id.app_error", appErr.Id)
|
|
mockACPStore.AssertNotCalled(t, "Get", mock.Anything, mock.Anything)
|
|
|
|
decision, appErr := th.App.EvaluatePluginAccessRequest(th.Context, testAgentsPluginID, model.NewId(), testAgentResourceType, model.NewId(), testAgentAction)
|
|
require.Nil(t, decision)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.evaluation_unavailable.app_error", appErr.Id)
|
|
mockACPStore.AssertExpectations(t)
|
|
}
|
|
|
|
func TestSavePluginAccessControlPolicy(t *testing.T) {
|
|
th := Setup(t).InitBasic(t)
|
|
actingUserID := th.BasicUser.Id
|
|
|
|
notFoundErr := model.NewAppError("GetPolicy", "app.pap.get_policy.app_error", nil, "", http.StatusNotFound)
|
|
|
|
t.Run("service nil returns 501", func(t *testing.T) {
|
|
th.App.Srv().ch.AccessControl = nil
|
|
_, appErr := th.App.SavePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, validPluginPolicy(model.NewId()))
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, http.StatusNotImplemented, appErr.StatusCode)
|
|
})
|
|
|
|
t.Run("nil policy and missing ID rejected", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
_, appErr := th.App.SavePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, nil)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.invalid_id.app_error", appErr.Id)
|
|
|
|
p := validPluginPolicy("")
|
|
_, appErr = th.App.SavePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, p)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.invalid_id.app_error", appErr.Id)
|
|
mockACS.AssertNotCalled(t, "SavePolicy", mock.Anything, mock.Anything)
|
|
})
|
|
|
|
t.Run("scope check failures", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
p := validPluginPolicy(model.NewId())
|
|
p.Type = model.AccessControlPolicyTypeChannel
|
|
_, appErr := th.App.SavePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, p)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.invalid_resource_type.app_error", appErr.Id)
|
|
|
|
_, appErr = th.App.SavePluginAccessControlPolicy(th.Context, "other-plugin", actingUserID, validPluginPolicy(model.NewId()))
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.resource_type_forbidden.app_error", appErr.Id)
|
|
assert.Equal(t, http.StatusForbidden, appErr.StatusCode)
|
|
mockACS.AssertNotCalled(t, "SavePolicy", mock.Anything, mock.Anything)
|
|
})
|
|
|
|
t.Run("acting user validation", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
_, appErr := th.App.SavePluginAccessControlPolicy(th.Context, testAgentsPluginID, "short", validPluginPolicy(model.NewId()))
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.invalid_acting_user.app_error", appErr.Id)
|
|
|
|
_, appErr = th.App.SavePluginAccessControlPolicy(th.Context, testAgentsPluginID, model.NewId(), validPluginPolicy(model.NewId()))
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.invalid_acting_user.app_error", appErr.Id)
|
|
mockACS.AssertNotCalled(t, "SavePolicy", mock.Anything, mock.Anything)
|
|
})
|
|
|
|
t.Run("create path forces v0.5 and Active, threads acting user session, passes rules verbatim", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
p := validPluginPolicy(model.NewId())
|
|
p.Version = "v0.3" // caller-supplied version must be overridden
|
|
p.Active = false
|
|
expression := p.Rules[0].Expression
|
|
|
|
mockACS.On("GetPolicy", mock.Anything, p.ID).Return(nil, notFoundErr).Once()
|
|
mockACS.On("SavePolicy", mock.MatchedBy(func(c request.CTX) bool {
|
|
return c.Session() != nil && c.Session().UserId == actingUserID
|
|
}), mock.MatchedBy(func(saved *model.AccessControlPolicy) bool {
|
|
return saved.Version == model.AccessControlPolicyVersionV0_5 &&
|
|
saved.Active &&
|
|
len(saved.Rules) == 1 &&
|
|
len(saved.Rules[0].Actions) == 1 &&
|
|
saved.Rules[0].Actions[0] == testAgentAction &&
|
|
saved.Rules[0].Expression == expression
|
|
})).Return(p, nil).Once()
|
|
|
|
saved, appErr := th.App.SavePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, p)
|
|
require.Nil(t, appErr)
|
|
require.NotNil(t, saved)
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
t.Run("update path saves when stored type matches", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
p := validPluginPolicy(model.NewId())
|
|
existing := validPluginPolicy(p.ID)
|
|
mockACS.On("GetPolicy", mock.Anything, p.ID).Return(existing, nil).Once()
|
|
mockACS.On("SavePolicy", mock.Anything, mock.Anything).Return(p, nil).Once()
|
|
|
|
_, appErr := th.App.SavePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, p)
|
|
require.Nil(t, appErr)
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
t.Run("cross-type ID collision rejected", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
p := validPluginPolicy(model.NewId())
|
|
channelPolicy := &model.AccessControlPolicy{ID: p.ID, Type: model.AccessControlPolicyTypeChannel}
|
|
mockACS.On("GetPolicy", mock.Anything, p.ID).Return(channelPolicy, nil).Once()
|
|
|
|
_, appErr := th.App.SavePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, p)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.type_conflict.app_error", appErr.Id)
|
|
mockACS.AssertNotCalled(t, "SavePolicy", mock.Anything, mock.Anything)
|
|
})
|
|
|
|
t.Run("validator failures surface and skip SavePolicy", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
p := validPluginPolicy(model.NewId())
|
|
p.Imports = []string{model.NewId()}
|
|
mockACS.On("GetPolicy", mock.Anything, p.ID).Return(nil, notFoundErr).Once()
|
|
|
|
_, appErr := th.App.SavePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, p)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "model.access_policy.is_valid.imports.app_error", appErr.Id)
|
|
mockACS.AssertNotCalled(t, "SavePolicy", mock.Anything, mock.Anything)
|
|
})
|
|
|
|
t.Run("wildcard action rejected, never rewritten", func(t *testing.T) {
|
|
// The legacy "*"→membership rewrite must not apply to the plugin path.
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
p := validPluginPolicy(model.NewId())
|
|
p.Rules[0].Actions = []string{"*"}
|
|
mockACS.On("GetPolicy", mock.Anything, p.ID).Return(nil, notFoundErr).Once()
|
|
|
|
_, appErr := th.App.SavePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, p)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "model.access_policy.is_valid.actions.app_error", appErr.Id)
|
|
mockACS.AssertNotCalled(t, "SavePolicy", mock.Anything, mock.Anything)
|
|
})
|
|
|
|
t.Run("existence probe infra error propagates", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
p := validPluginPolicy(model.NewId())
|
|
infraErr := model.NewAppError("GetPolicy", "app.pap.get_policy.app_error", nil, "", http.StatusInternalServerError)
|
|
mockACS.On("GetPolicy", mock.Anything, p.ID).Return(nil, infraErr).Once()
|
|
|
|
_, appErr := th.App.SavePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, p)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, http.StatusInternalServerError, appErr.StatusCode)
|
|
mockACS.AssertNotCalled(t, "SavePolicy", mock.Anything, mock.Anything)
|
|
})
|
|
}
|
|
|
|
func TestGetPluginAccessControlPolicy(t *testing.T) {
|
|
th := Setup(t).InitBasic(t)
|
|
|
|
t.Run("service nil returns 501", func(t *testing.T) {
|
|
th.App.Srv().ch.AccessControl = nil
|
|
_, appErr := th.App.GetPluginAccessControlPolicy(th.Context, testAgentsPluginID, model.NewId())
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, http.StatusNotImplemented, appErr.StatusCode)
|
|
})
|
|
|
|
t.Run("invalid id returns 400", func(t *testing.T) {
|
|
th.App.Srv().ch.AccessControl = &mocks.AccessControlServiceInterface{}
|
|
_, appErr := th.App.GetPluginAccessControlPolicy(th.Context, testAgentsPluginID, "short")
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.invalid_id.app_error", appErr.Id)
|
|
})
|
|
|
|
t.Run("type owned by the caller returns the normalized policy", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
p := validPluginPolicy(model.NewId())
|
|
savePluginPolicyRow(t, th, p)
|
|
|
|
// Ownership comes from the raw row; the returned policy comes from the
|
|
// enterprise read, which is the only one that normalizes.
|
|
normalized := validPluginPolicy(p.ID)
|
|
normalized.Rules[0].Expression = `user.attributes.dept == "eng" /* normalized */`
|
|
mockACS.On("GetPolicy", mock.Anything, p.ID).Return(normalized, nil).Once()
|
|
|
|
policy, appErr := th.App.GetPluginAccessControlPolicy(th.Context, testAgentsPluginID, p.ID)
|
|
require.Nil(t, appErr)
|
|
require.Equal(t, normalized, policy)
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
t.Run("a policy that turned foreign between the two reads is not disclosed", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
// The raw gate sees the caller's own type, so ownership passes.
|
|
p := validPluginPolicy(model.NewId())
|
|
savePluginPolicyRow(t, th, p)
|
|
|
|
// The normalized read is independent: simulate the row having been
|
|
// deleted and re-created under a foreign type in between.
|
|
recreated := validPluginPolicy(p.ID)
|
|
recreated.Type = "other-plugin:agent"
|
|
mockACS.On("GetPolicy", mock.Anything, p.ID).Return(recreated, nil).Once()
|
|
|
|
policy, appErr := th.App.GetPluginAccessControlPolicy(th.Context, testAgentsPluginID, p.ID)
|
|
require.Nil(t, policy, "a foreign policy must never be returned, even if the raw gate passed")
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, http.StatusNotFound, appErr.StatusCode)
|
|
assert.Equal(t, "app.access_control.plugin.policy_not_found.app_error", appErr.Id)
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
t.Run("absent and foreign-type policy return the same byte-identical 404", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
absentID := model.NewId()
|
|
|
|
// A channel policy and another plugin's policy both sit outside the
|
|
// caller's type namespace.
|
|
channelPolicy := validForeignTypePolicy(model.NewId())
|
|
savePluginPolicyRow(t, th, channelPolicy)
|
|
|
|
foreignPlugin := validPluginPolicy(model.NewId())
|
|
foreignPlugin.Type = "other-plugin:agent"
|
|
savePluginPolicyRow(t, th, foreignPlugin)
|
|
|
|
var errs []*model.AppError
|
|
for _, id := range []string{absentID, channelPolicy.ID, foreignPlugin.ID} {
|
|
policy, appErr := th.App.GetPluginAccessControlPolicy(th.Context, testAgentsPluginID, id)
|
|
require.Nil(t, policy, "a policy outside the caller's namespace must never be returned")
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, http.StatusNotFound, appErr.StatusCode)
|
|
assert.Equal(t, "app.access_control.plugin.policy_not_found.app_error", appErr.Id)
|
|
errs = append(errs, appErr)
|
|
}
|
|
assert.Equal(t, errs[0].Error(), errs[1].Error())
|
|
assert.Equal(t, errs[0].Error(), errs[2].Error())
|
|
// The ownership gate runs on the raw row, so a foreign policy is never
|
|
// handed to the normalizer — whose failures would otherwise be
|
|
// distinguishable from this 404.
|
|
mockACS.AssertNotCalled(t, "GetPolicy", mock.Anything, mock.Anything)
|
|
})
|
|
|
|
t.Run("foreign policy that would fail to normalize still returns the uniform 404", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
foreign := validPluginPolicy(model.NewId())
|
|
foreign.Type = "other-plugin:agent"
|
|
savePluginPolicyRow(t, th, foreign)
|
|
|
|
// Any call into the normalizer would leak that the row exists.
|
|
mockACS.On("GetPolicy", mock.Anything, foreign.ID).
|
|
Return(nil, model.NewAppError("GetPolicy", "app.pap.rehydrate_rank.app_error", nil, "cannot normalize", http.StatusInternalServerError)).Maybe()
|
|
|
|
policy, appErr := th.App.GetPluginAccessControlPolicy(th.Context, testAgentsPluginID, foreign.ID)
|
|
require.Nil(t, policy)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, http.StatusNotFound, appErr.StatusCode)
|
|
assert.Equal(t, "app.access_control.plugin.policy_not_found.app_error", appErr.Id)
|
|
mockACS.AssertNotCalled(t, "GetPolicy", mock.Anything, mock.Anything)
|
|
})
|
|
|
|
t.Run("non-owner plugin cannot read an owned policy", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
p := validPluginPolicy(model.NewId())
|
|
savePluginPolicyRow(t, th, p)
|
|
|
|
policy, appErr := th.App.GetPluginAccessControlPolicy(th.Context, "other-plugin", p.ID)
|
|
require.Nil(t, policy)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.policy_not_found.app_error", appErr.Id)
|
|
mockACS.AssertNotCalled(t, "GetPolicy", mock.Anything, mock.Anything)
|
|
})
|
|
|
|
// A normalization failure is a real error for the owner but would leak the
|
|
// collision if the row raced to a foreign type, so it is only surfaced when
|
|
// the row is confirmed still owned. The mocked normalized read runs between
|
|
// the gate read and the confirm read, so mutating the row inside it
|
|
// reproduces the window exactly.
|
|
t.Run("a normalization error is surfaced only to the still-owning caller", func(t *testing.T) {
|
|
normalizeErr := model.NewAppError("GetPolicy", "app.pap.rehydrate_rank.app_error", nil, "cannot normalize", http.StatusInternalServerError)
|
|
|
|
tests := []struct {
|
|
name string
|
|
race func(t *testing.T, id string)
|
|
wantErrID string
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "row raced to a foreign type",
|
|
race: func(t *testing.T, id string) {
|
|
require.NoError(t, th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, id))
|
|
foreign := validPluginPolicy(id)
|
|
foreign.Type = "other-plugin:agent"
|
|
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, foreign)
|
|
require.NoError(t, err)
|
|
},
|
|
wantErrID: "app.access_control.plugin.policy_not_found.app_error",
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "row raced to absent",
|
|
race: func(t *testing.T, id string) {
|
|
require.NoError(t, th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, id))
|
|
},
|
|
wantErrID: "app.access_control.plugin.policy_not_found.app_error",
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "row still owned",
|
|
race: func(*testing.T, string) {},
|
|
wantErrID: normalizeErr.Id,
|
|
wantStatus: http.StatusInternalServerError,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
p := validPluginPolicy(model.NewId())
|
|
_, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, p)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
// The race may already have swapped or removed the row.
|
|
_ = th.App.Srv().Store().AccessControlPolicy().Delete(th.Context, p.ID)
|
|
})
|
|
|
|
mockACS.On("GetPolicy", mock.Anything, p.ID).
|
|
Run(func(mock.Arguments) { tc.race(t, p.ID) }).
|
|
Return(nil, normalizeErr).Once()
|
|
|
|
policy, appErr := th.App.GetPluginAccessControlPolicy(th.Context, testAgentsPluginID, p.ID)
|
|
require.Nil(t, policy)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, tc.wantErrID, appErr.Id)
|
|
assert.Equal(t, tc.wantStatus, appErr.StatusCode)
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDeletePluginAccessControlPolicy(t *testing.T) {
|
|
th := Setup(t).InitBasic(t)
|
|
actingUserID := th.BasicUser.Id
|
|
resourceType := testAgentResourceType
|
|
|
|
t.Run("service nil returns 501", func(t *testing.T) {
|
|
th.App.Srv().ch.AccessControl = nil
|
|
appErr := th.App.DeletePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, resourceType, model.NewId())
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, http.StatusNotImplemented, appErr.StatusCode)
|
|
})
|
|
|
|
t.Run("programming errors", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
appErr := th.App.DeletePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, "bogus.type", model.NewId())
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.invalid_resource_type.app_error", appErr.Id)
|
|
|
|
appErr = th.App.DeletePluginAccessControlPolicy(th.Context, "other-plugin", actingUserID, resourceType, model.NewId())
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.resource_type_forbidden.app_error", appErr.Id)
|
|
|
|
appErr = th.App.DeletePluginAccessControlPolicy(th.Context, testAgentsPluginID, "short", resourceType, model.NewId())
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.invalid_acting_user.app_error", appErr.Id)
|
|
|
|
appErr = th.App.DeletePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, resourceType, "short")
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.invalid_id.app_error", appErr.Id)
|
|
|
|
mockACS.AssertNotCalled(t, "GetPolicy", mock.Anything, mock.Anything)
|
|
mockACS.AssertNotCalled(t, "DeletePolicy", mock.Anything, mock.Anything)
|
|
})
|
|
|
|
t.Run("absent and stored-type mismatch return the same byte-identical 404", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
absentID := model.NewId()
|
|
mismatch := validForeignTypePolicy(model.NewId())
|
|
savePluginPolicyRow(t, th, mismatch)
|
|
|
|
absentErr := th.App.DeletePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, resourceType, absentID)
|
|
mismatchErr := th.App.DeletePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, resourceType, mismatch.ID)
|
|
|
|
require.NotNil(t, absentErr)
|
|
require.NotNil(t, mismatchErr)
|
|
assert.Equal(t, "app.access_control.plugin.policy_not_found.app_error", absentErr.Id)
|
|
assert.Equal(t, absentErr.Error(), mismatchErr.Error())
|
|
// A foreign-type policy must never reach the delete.
|
|
mockACS.AssertNotCalled(t, "DeletePolicy", mock.Anything, mock.Anything)
|
|
})
|
|
|
|
t.Run("happy path deletes after the stored-type check", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
p := validPluginPolicy(model.NewId())
|
|
savePluginPolicyRow(t, th, p)
|
|
mockACS.On("DeletePolicy", mock.Anything, p.ID).Return(nil).Once()
|
|
|
|
appErr := th.App.DeletePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, resourceType, p.ID)
|
|
require.Nil(t, appErr)
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
t.Run("delete error propagates", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
p := validPluginPolicy(model.NewId())
|
|
savePluginPolicyRow(t, th, p)
|
|
mockACS.On("DeletePolicy", mock.Anything, p.ID).
|
|
Return(model.NewAppError("DeletePolicy", "app.pap.delete_policy.app_error", nil, "db down", http.StatusInternalServerError)).Once()
|
|
|
|
appErr := th.App.DeletePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, resourceType, p.ID)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, http.StatusInternalServerError, appErr.StatusCode)
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
}
|
|
|
|
// TestPluginAccessControlPolicyReadStoreError pins that a failing ownership
|
|
// read propagates as a 500 and never reaches the normalizer. Uses the
|
|
// store-mock helper because a read error cannot be arranged on the real store.
|
|
// The delete path resolves ownership through the same readStoredPolicyType
|
|
// helper, so it inherits this behaviour.
|
|
func TestPluginAccessControlPolicyReadStoreError(t *testing.T) {
|
|
th := SetupWithStoreMock(t)
|
|
|
|
mockStore := th.App.Srv().Store().(*storemocks.Store)
|
|
mockACPStore := &storemocks.AccessControlPolicyStore{}
|
|
mockACPStore.On("Get", mock.Anything, mock.AnythingOfType("string")).
|
|
Return(nil, errors.New("simulated store failure"))
|
|
mockStore.On("AccessControlPolicy").Return(mockACPStore)
|
|
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
_, appErr := th.App.GetPluginAccessControlPolicy(th.Context, testAgentsPluginID, model.NewId())
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, http.StatusInternalServerError, appErr.StatusCode)
|
|
|
|
mockACS.AssertNotCalled(t, "GetPolicy", mock.Anything, mock.Anything)
|
|
mockACPStore.AssertExpectations(t)
|
|
}
|
|
|
|
func TestPluginAccessControlCELProxies(t *testing.T) {
|
|
th := Setup(t).InitBasic(t)
|
|
actingUserID := th.BasicUser.Id
|
|
resourceType := testAgentResourceType
|
|
expression := `user.attributes.dept == "eng"`
|
|
|
|
t.Run("scope check enforced on check/test/visual_ast", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
_, appErr := th.App.CheckPluginAccessControlExpression(th.Context, "other-plugin", actingUserID, resourceType, expression)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.resource_type_forbidden.app_error", appErr.Id)
|
|
|
|
_, appErr = th.App.QueryUsersForPluginAccessControlExpression(th.Context, testAgentsPluginID, actingUserID, "bogus.type", expression, "", "", 10)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.invalid_resource_type.app_error", appErr.Id)
|
|
|
|
_, appErr = th.App.GetPluginAccessControlVisualAST(th.Context, "other-plugin", actingUserID, resourceType, expression)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.resource_type_forbidden.app_error", appErr.Id)
|
|
|
|
mockACS.AssertNotCalled(t, "CheckExpression", mock.Anything, mock.Anything)
|
|
mockACS.AssertNotCalled(t, "QueryUsersForExpression", mock.Anything, mock.Anything, mock.Anything)
|
|
mockACS.AssertNotCalled(t, "ExpressionToVisualAST", mock.Anything, mock.Anything)
|
|
})
|
|
|
|
t.Run("check expression threads acting user session", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
mockACS.On("CheckExpression", mock.MatchedBy(func(c request.CTX) bool {
|
|
return c.Session() != nil && c.Session().UserId == actingUserID
|
|
}), expression).Return([]model.CELExpressionError{}, nil).Once()
|
|
|
|
errs, appErr := th.App.CheckPluginAccessControlExpression(th.Context, testAgentsPluginID, actingUserID, resourceType, expression)
|
|
require.Nil(t, appErr)
|
|
require.Empty(t, errs)
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
t.Run("query users wraps response and clamps limit", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
cursorID := model.NewId()
|
|
users := []*model.User{th.BasicUser}
|
|
mockACS.On("QueryUsersForExpression", mock.MatchedBy(func(c request.CTX) bool {
|
|
return c.Session() != nil && c.Session().UserId == actingUserID
|
|
}), expression, mock.MatchedBy(func(opts model.SubjectSearchOptions) bool {
|
|
return opts.Term == "ali" &&
|
|
opts.Limit == pluginAccessControlQueryLimitMax &&
|
|
opts.Cursor.TargetID == cursorID
|
|
})).Return(users, int64(1), nil).Once()
|
|
|
|
resp, appErr := th.App.QueryUsersForPluginAccessControlExpression(th.Context, testAgentsPluginID, actingUserID, resourceType, expression, "ali", cursorID, 10000)
|
|
require.Nil(t, appErr)
|
|
require.Equal(t, users, resp.Users)
|
|
require.Equal(t, int64(1), resp.Total)
|
|
mockACS.AssertExpectations(t)
|
|
|
|
mockACS.On("QueryUsersForExpression", mock.Anything, expression, mock.MatchedBy(func(opts model.SubjectSearchOptions) bool {
|
|
return opts.Limit == pluginAccessControlQueryLimitDefault
|
|
})).Return(users, int64(1), nil).Once()
|
|
_, appErr = th.App.QueryUsersForPluginAccessControlExpression(th.Context, testAgentsPluginID, actingUserID, resourceType, expression, "", "", 0)
|
|
require.Nil(t, appErr)
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
t.Run("autocomplete requires only a valid acting user", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
_, appErr := th.App.GetPluginAccessControlFieldsAutocomplete(th.Context, testAgentsPluginID, model.NewId(), "", 10)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, "app.access_control.plugin.invalid_acting_user.app_error", appErr.Id)
|
|
|
|
fields, appErr := th.App.GetPluginAccessControlFieldsAutocomplete(th.Context, testAgentsPluginID, actingUserID, "", 10)
|
|
require.Nil(t, appErr)
|
|
require.NotEmpty(t, fields, "native attribute fields expected on the first page")
|
|
})
|
|
|
|
t.Run("autocomplete gates on missing service", func(t *testing.T) {
|
|
th.App.Srv().ch.AccessControl = nil
|
|
_, appErr := th.App.GetPluginAccessControlFieldsAutocomplete(th.Context, testAgentsPluginID, actingUserID, "", 10)
|
|
require.NotNil(t, appErr)
|
|
assert.Equal(t, http.StatusNotImplemented, appErr.StatusCode)
|
|
})
|
|
|
|
t.Run("visual AST proxies to the service", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
visual := &model.VisualExpression{}
|
|
mockACS.On("ExpressionToVisualAST", mock.Anything, expression).Return(visual, nil).Once()
|
|
|
|
ast, appErr := th.App.GetPluginAccessControlVisualAST(th.Context, testAgentsPluginID, actingUserID, resourceType, expression)
|
|
require.Nil(t, appErr)
|
|
require.Equal(t, visual, ast)
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
}
|
|
|
|
// pluginAuditCapture routes the test server's audit logger to a temp file so
|
|
// tests can assert on emitted audit records.
|
|
type pluginAuditCapture struct {
|
|
t *testing.T
|
|
th *TestHelper
|
|
path string
|
|
}
|
|
|
|
func startPluginAuditCapture(t *testing.T, th *TestHelper) *pluginAuditCapture {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "audit.log")
|
|
cfg, err := config.MloggerConfigFromAuditConfig(model.ExperimentalAuditSettings{
|
|
FileEnabled: model.NewPointer(true),
|
|
FileName: model.NewPointer(path),
|
|
}, nil)
|
|
require.NoError(t, err)
|
|
require.NoError(t, th.App.Srv().Audit.Configure(cfg))
|
|
return &pluginAuditCapture{t: t, th: th, path: path}
|
|
}
|
|
|
|
// recordsFor returns all audit records logged so far for the given event.
|
|
func (c *pluginAuditCapture) recordsFor(event string) []map[string]any {
|
|
c.t.Helper()
|
|
require.NoError(c.t, c.th.App.Srv().Audit.Flush())
|
|
data, err := os.ReadFile(c.path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
// The file target creates the log lazily on first write.
|
|
return nil
|
|
}
|
|
require.NoError(c.t, err)
|
|
|
|
var out []map[string]any
|
|
for line := range bytesLines(data) {
|
|
var rec map[string]any
|
|
require.NoError(c.t, json.Unmarshal(line, &rec))
|
|
if rec[model.AuditKeyEventName] == event {
|
|
out = append(out, rec)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// bytesLines yields non-empty newline-separated chunks of data.
|
|
func bytesLines(data []byte) func(func([]byte) bool) {
|
|
return func(yield func([]byte) bool) {
|
|
start := 0
|
|
for i := range data {
|
|
if data[i] != '\n' {
|
|
continue
|
|
}
|
|
if i > start && !yield(data[start:i]) {
|
|
return
|
|
}
|
|
start = i + 1
|
|
}
|
|
if start < len(data) {
|
|
yield(data[start:])
|
|
}
|
|
}
|
|
}
|
|
|
|
func auditParam(t *testing.T, rec map[string]any, key string) any {
|
|
t.Helper()
|
|
event, ok := rec[model.AuditKeyEvent].(map[string]any)
|
|
require.True(t, ok, "audit record has no event data")
|
|
params, ok := event["parameters"].(map[string]any)
|
|
require.True(t, ok, "audit record has no event parameters")
|
|
return params[key]
|
|
}
|
|
|
|
// TestPluginAccessControlAudit pins that Save/Delete audit every attempt,
|
|
// including precondition failures. Save stamps operation="create_or_update"
|
|
// from entry and refines it to "create"/"update" once the existence probe
|
|
// resolves; Delete stamps "delete".
|
|
func TestPluginAccessControlAudit(t *testing.T) {
|
|
th := Setup(t).InitBasic(t)
|
|
capture := startPluginAuditCapture(t, th)
|
|
|
|
actingUserID := th.BasicUser.Id
|
|
resourceType := testAgentResourceType
|
|
|
|
// assertNextRecord runs fn and returns the single new audit record for event.
|
|
assertNextRecord := func(t *testing.T, event, wantStatus string, fn func()) map[string]any {
|
|
t.Helper()
|
|
before := len(capture.recordsFor(event))
|
|
fn()
|
|
records := capture.recordsFor(event)
|
|
require.Len(t, records, before+1, "expected exactly one new %s audit record", event)
|
|
rec := records[len(records)-1]
|
|
assert.Equal(t, wantStatus, rec[model.AuditKeyStatus])
|
|
return rec
|
|
}
|
|
|
|
t.Run("save: service unavailable still audits as fail", func(t *testing.T) {
|
|
th.App.Srv().ch.AccessControl = nil
|
|
rec := assertNextRecord(t, model.AuditEventSavePluginAccessControlPolicy, model.AuditStatusFail, func() {
|
|
_, appErr := th.App.SavePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, validPluginPolicy(model.NewId()))
|
|
require.NotNil(t, appErr)
|
|
})
|
|
assert.Equal(t, testAgentsPluginID, auditParam(t, rec, "plugin_id"))
|
|
assert.Equal(t, actingUserID, auditParam(t, rec, "actor"))
|
|
assert.Equal(t, resourceType, auditParam(t, rec, "resource_type"))
|
|
assert.Equal(t, "create_or_update", auditParam(t, rec, "operation"))
|
|
})
|
|
|
|
t.Run("save: invalid (nil) policy audits as fail", func(t *testing.T) {
|
|
th.App.Srv().ch.AccessControl = &mocks.AccessControlServiceInterface{}
|
|
rec := assertNextRecord(t, model.AuditEventSavePluginAccessControlPolicy, model.AuditStatusFail, func() {
|
|
_, appErr := th.App.SavePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, nil)
|
|
require.NotNil(t, appErr)
|
|
})
|
|
assert.Equal(t, testAgentsPluginID, auditParam(t, rec, "plugin_id"))
|
|
assert.Equal(t, "create_or_update", auditParam(t, rec, "operation"))
|
|
})
|
|
|
|
t.Run("save: ownership failure audits as fail", func(t *testing.T) {
|
|
th.App.Srv().ch.AccessControl = &mocks.AccessControlServiceInterface{}
|
|
rec := assertNextRecord(t, model.AuditEventSavePluginAccessControlPolicy, model.AuditStatusFail, func() {
|
|
_, appErr := th.App.SavePluginAccessControlPolicy(th.Context, "other-plugin", actingUserID, validPluginPolicy(model.NewId()))
|
|
require.NotNil(t, appErr)
|
|
})
|
|
assert.Equal(t, "other-plugin", auditParam(t, rec, "plugin_id"))
|
|
assert.Equal(t, "create_or_update", auditParam(t, rec, "operation"))
|
|
})
|
|
|
|
t.Run("save: invalid acting user audits as fail with operation", func(t *testing.T) {
|
|
th.App.Srv().ch.AccessControl = &mocks.AccessControlServiceInterface{}
|
|
rec := assertNextRecord(t, model.AuditEventSavePluginAccessControlPolicy, model.AuditStatusFail, func() {
|
|
_, appErr := th.App.SavePluginAccessControlPolicy(th.Context, testAgentsPluginID, "short", validPluginPolicy(model.NewId()))
|
|
require.NotNil(t, appErr)
|
|
})
|
|
assert.Equal(t, "create_or_update", auditParam(t, rec, "operation"))
|
|
})
|
|
|
|
t.Run("save: existence-probe error audits as fail with operation", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
p := validPluginPolicy(model.NewId())
|
|
mockACS.On("GetPolicy", mock.Anything, p.ID).
|
|
Return(nil, model.NewAppError("GetPolicy", "app.pap.get_policy.app_error", nil, "db down", http.StatusInternalServerError)).Once()
|
|
|
|
rec := assertNextRecord(t, model.AuditEventSavePluginAccessControlPolicy, model.AuditStatusFail, func() {
|
|
_, appErr := th.App.SavePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, p)
|
|
require.NotNil(t, appErr)
|
|
})
|
|
assert.Equal(t, "create_or_update", auditParam(t, rec, "operation"))
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
t.Run("save: cross-type conflict audits as fail with operation", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
p := validPluginPolicy(model.NewId())
|
|
stored := validPluginPolicy(p.ID)
|
|
stored.Type = model.AccessControlPolicyTypeChannel
|
|
mockACS.On("GetPolicy", mock.Anything, p.ID).Return(stored, nil).Once()
|
|
|
|
rec := assertNextRecord(t, model.AuditEventSavePluginAccessControlPolicy, model.AuditStatusFail, func() {
|
|
_, appErr := th.App.SavePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, p)
|
|
require.NotNil(t, appErr)
|
|
})
|
|
assert.Equal(t, "create_or_update", auditParam(t, rec, "operation"))
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
t.Run("save: success audits as success with operation", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
p := validPluginPolicy(model.NewId())
|
|
mockACS.On("GetPolicy", mock.Anything, p.ID).
|
|
Return(nil, model.NewAppError("GetPolicy", "app.pap.get_policy.app_error", nil, "", http.StatusNotFound)).Once()
|
|
mockACS.On("SavePolicy", mock.Anything, mock.Anything).Return(p, nil).Once()
|
|
|
|
rec := assertNextRecord(t, model.AuditEventSavePluginAccessControlPolicy, model.AuditStatusSuccess, func() {
|
|
_, appErr := th.App.SavePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, p)
|
|
require.Nil(t, appErr)
|
|
})
|
|
assert.Equal(t, "create", auditParam(t, rec, "operation"))
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
|
|
t.Run("delete: service unavailable still audits as fail", func(t *testing.T) {
|
|
th.App.Srv().ch.AccessControl = nil
|
|
rec := assertNextRecord(t, model.AuditEventDeletePluginAccessControlPolicy, model.AuditStatusFail, func() {
|
|
appErr := th.App.DeletePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, resourceType, model.NewId())
|
|
require.NotNil(t, appErr)
|
|
})
|
|
assert.Equal(t, "delete", auditParam(t, rec, "operation"))
|
|
assert.Equal(t, actingUserID, auditParam(t, rec, "actor"))
|
|
})
|
|
|
|
t.Run("delete: malformed policy ID audits as fail", func(t *testing.T) {
|
|
th.App.Srv().ch.AccessControl = &mocks.AccessControlServiceInterface{}
|
|
rec := assertNextRecord(t, model.AuditEventDeletePluginAccessControlPolicy, model.AuditStatusFail, func() {
|
|
appErr := th.App.DeletePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, resourceType, "short")
|
|
require.NotNil(t, appErr)
|
|
})
|
|
assert.Equal(t, "short", auditParam(t, rec, "policy_id"))
|
|
assert.Equal(t, "delete", auditParam(t, rec, "operation"))
|
|
})
|
|
|
|
t.Run("delete: ownership failure audits as fail", func(t *testing.T) {
|
|
th.App.Srv().ch.AccessControl = &mocks.AccessControlServiceInterface{}
|
|
rec := assertNextRecord(t, model.AuditEventDeletePluginAccessControlPolicy, model.AuditStatusFail, func() {
|
|
appErr := th.App.DeletePluginAccessControlPolicy(th.Context, "other-plugin", actingUserID, resourceType, model.NewId())
|
|
require.NotNil(t, appErr)
|
|
})
|
|
assert.Equal(t, "other-plugin", auditParam(t, rec, "plugin_id"))
|
|
assert.Equal(t, "delete", auditParam(t, rec, "operation"))
|
|
})
|
|
|
|
t.Run("delete: success audits as success", func(t *testing.T) {
|
|
mockACS := &mocks.AccessControlServiceInterface{}
|
|
th.App.Srv().ch.AccessControl = mockACS
|
|
|
|
p := validPluginPolicy(model.NewId())
|
|
savePluginPolicyRow(t, th, p)
|
|
mockACS.On("DeletePolicy", mock.Anything, p.ID).Return(nil).Once()
|
|
|
|
rec := assertNextRecord(t, model.AuditEventDeletePluginAccessControlPolicy, model.AuditStatusSuccess, func() {
|
|
appErr := th.App.DeletePluginAccessControlPolicy(th.Context, testAgentsPluginID, actingUserID, resourceType, p.ID)
|
|
require.Nil(t, appErr)
|
|
})
|
|
assert.Equal(t, p.ID, auditParam(t, rec, "policy_id"))
|
|
assert.Equal(t, "delete", auditParam(t, rec, "operation"))
|
|
mockACS.AssertExpectations(t)
|
|
})
|
|
}
|