Files
mattermost/server/channels/app/plugin_access_control_test.go
T
Nick MisasiandCursor Agent c7eff70026 ABAC: plugin-keyed resource types, trusted plugin PAP/CEL APIs, and AuthZEN-style decision API (#37509)
* 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>
2026-08-04 09:59:48 -04:00

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)
})
}