From c7eff70026ee233a5163fde42f5082134e66b795 Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Tue, 4 Aug 2026 09:59:48 -0400 Subject: [PATCH] ABAC: plugin-keyed resource types, trusted plugin PAP/CEL APIs, and AuthZEN-style decision API (#37509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * MM: add v0.5 plugin access control policy model, registry, and decision outcomes Co-authored-by: nick.misasi * MM: add plugin access control PDP/PAP app-layer methods with fail-closed semantics Co-authored-by: nick.misasi * MM: expose plugin access control API surface (EvaluateAccessControl + PAP/CEL methods) Co-authored-by: nick.misasi * MM: add store-layer round-trip tests for v0.5 plugin access control policies Co-authored-by: nick.misasi * MM: extract plugin access control app code into plugin_access_control.go Co-authored-by: nick.misasi * MM: add atomic type-guarded AccessControlPolicyStore.DeleteIfType Co-authored-by: nick.misasi * MM: plugin PAP hardening — atomic typed delete, indistinguishable 404s, audit every attempt Co-authored-by: nick.misasi * MM: close plugin Get-by-ID TOCTOU via GetPolicyOfType; stamp save audit operation at entry Co-authored-by: nick.misasi * 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 * 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 * 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 * 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 * Tighten ABAC comments Co-authored-by: nick.misasi * 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 * Retrigger CI to rebuild enterprise image with updated enterprise branch Co-authored-by: nick.misasi * Document that only the evaluator's plugin lane sets AccessDecision.Outcome Co-authored-by: nick.misasi * ABAC: key plugin resource types as plugin_id:type and drop the static registry Co-authored-by: nick.misasi * ABAC: replace AccessDecision.Outcome with the AuthZEN decision context reason Co-authored-by: nick.misasi * ABAC: drop type-scoped policy get/delete and check the type in the app layer Co-authored-by: nick.misasi * ABAC: compare plugin policy type ownership exactly instead of case-insensitively Co-authored-by: nick.misasi * ABAC: bound the whole policy type to the Type column width Co-authored-by: nick.misasi * ABAC: require an allow before treating a decision as the no-policy fallback Co-authored-by: nick.misasi * ABAC: treat a colliding foreign-type policy row as no_policy, not a deny Co-authored-by: nick.misasi * ABAC: gate plugin policy reads on a raw store read before normalization Co-authored-by: nick.misasi * ABAC: pin policy type immutability on save in the store tests Co-authored-by: nick.misasi * ABAC: reuse a single unavailable-error constructor in the existence fallback Co-authored-by: nick.misasi * ABAC: re-check plugin policy ownership on the normalized get read Co-authored-by: nick.misasi * ABAC: confirm plugin policy ownership before surfacing a normalization error Co-authored-by: nick.misasi --------- Co-authored-by: Cursor Agent --- server/channels/app/plugin_access_control.go | 444 +++++++ .../app/plugin_access_control_gob_test.go | 95 ++ .../app/plugin_access_control_test.go | 1153 +++++++++++++++++ server/channels/app/plugin_api.go | 32 + .../storetest/access_control_policy_store.go | 116 ++ server/i18n/en.json | 36 + server/public/model/access_policy.go | 139 ++ server/public/model/access_policy_test.go | 247 ++++ server/public/model/access_request.go | 42 +- server/public/model/access_request_test.go | 58 + server/public/model/audit_events.go | 3 + server/public/model/native_attributes.go | 11 +- server/public/model/native_attributes_test.go | 9 +- server/public/plugin/api.go | 68 + .../plugin/api_timer_layer_generated.go | 56 + server/public/plugin/client_rpc_generated.go | 248 ++++ server/public/plugin/plugintest/api.go | 244 ++++ 17 files changed, 2995 insertions(+), 6 deletions(-) create mode 100644 server/channels/app/plugin_access_control.go create mode 100644 server/channels/app/plugin_access_control_gob_test.go create mode 100644 server/channels/app/plugin_access_control_test.go create mode 100644 server/public/model/access_request_test.go diff --git a/server/channels/app/plugin_access_control.go b/server/channels/app/plugin_access_control.go new file mode 100644 index 00000000000..aa95718fe42 --- /dev/null +++ b/server/channels/app/plugin_access_control.go @@ -0,0 +1,444 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "errors" + "net/http" + "strings" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/store" +) + +// Plugin-owned access control (PDP + PAP proxies for the plugin API). Policy +// types are keyed ":", so verifying that the type's +// plugin-ID prefix matches the calling plugin is the entire ownership check. + +// Bounds for plugin-supplied paging; max mirrors the api4 autocomplete cap. +const ( + pluginAccessControlQueryLimitDefault = 50 + pluginAccessControlQueryLimitMax = 100 +) + +// pluginAccessControlScopeCheck validates resourceType's format and that its +// plugin-ID prefix matches pluginID. +func (a *App) pluginAccessControlScopeCheck(where, pluginID, resourceType string) *model.AppError { + if !model.IsPluginAccessControlPolicyType(resourceType) { + return model.NewAppError(where, "app.access_control.plugin.invalid_resource_type.app_error", nil, resourceType, http.StatusBadRequest) + } + if !model.PluginOwnsAccessControlPolicyType(pluginID, resourceType) { + return model.NewAppError(where, "app.access_control.plugin.resource_type_forbidden.app_error", nil, "plugin_id="+pluginID, http.StatusForbidden) + } + return nil +} + +// pluginAccessControlAvailable reports whether the enterprise ABAC service is +// registered, licensed, and enabled. Checked before calling enterprise so its +// readiness AppErrors never leak into plugin decision calls. +func (a *App) pluginAccessControlAvailable() bool { + return a.Srv().ch.AccessControl != nil && + model.MinimumEnterpriseAdvancedLicense(a.License()) && + *a.Config().AccessControlSettings.EnableAttributeBasedAccessControl +} + +// validatePluginActingUser validates actingUserID is a well-formed ID of an +// existing user. Permission checks are the calling plugin's responsibility. +func (a *App) validatePluginActingUser(where, actingUserID string) *model.AppError { + if !model.IsValidId(actingUserID) { + return model.NewAppError(where, "app.access_control.plugin.invalid_acting_user.app_error", nil, "", http.StatusBadRequest) + } + if _, appErr := a.GetUser(actingUserID); appErr != nil { + return model.NewAppError(where, "app.access_control.plugin.invalid_acting_user.app_error", nil, "", http.StatusBadRequest).Wrap(appErr) + } + return nil +} + +// resolvePluginPolicyExistence resolves policy existence via a raw open-core +// store read when evaluation is impossible. A row of the requested type makes +// the resource policy-gated, so the caller gets an error and must fail closed. +// Absent, or a row of any other type, yields the vacuous no-policy allow: a +// foreign row can never be the caller's policy, and the caller's own policy can +// never become foreign (Type is save-immutable and plugins may only save their +// own types). Treating a colliding foreign row as a deny would instead let any +// plugin grief another's resource IDs. +func (a *App) resolvePluginPolicyExistence(rctx request.CTX, where, pluginID, resourceType, resourceID, reason string) (*model.AccessDecision, *model.AppError) { + noPolicy := func() (*model.AccessDecision, *model.AppError) { + decision := model.NewNoPolicyAccessDecision() + return &decision, nil + } + unavailable := func() *model.AppError { + return model.NewAppError(where, "app.access_control.plugin.evaluation_unavailable.app_error", nil, "reason="+reason, http.StatusServiceUnavailable) + } + + policy, err := a.Srv().Store().AccessControlPolicy().Get(rctx, resourceID) + if err != nil { + var nfErr *store.ErrNotFound + if errors.As(err, &nfErr) { + return noPolicy() + } + // Existence is genuinely unknown; fail closed. + rctx.Logger().Warn("Plugin access evaluation: existence fallback store read failed", + mlog.String("plugin_id", pluginID), mlog.String("resource_id", resourceID), + mlog.String("reason", reason), mlog.Err(err)) + return nil, unavailable() + } + if policy.Type != resourceType { + rctx.Logger().Debug("Plugin access evaluation: existence fallback found only a foreign-type policy under the resource ID", + mlog.String("plugin_id", pluginID), mlog.String("resource_id", resourceID), + mlog.String("requested_type", resourceType), mlog.String("stored_type", policy.Type), + mlog.String("reason", reason)) + return noPolicy() + } + return nil, unavailable() +} + +// EvaluatePluginAccessRequest evaluates whether userID may perform action on +// the plugin-owned resource (resourceType, resourceID). +// +// When evaluation is impossible, policy existence is still resolved via a raw +// store read: a no-policy decision means no policy exists for the resource (the +// caller can safely apply legacy behavior). Every other failure — including a +// policy existing under the resource ID whose rules could not be evaluated — is +// an AppError the caller must treat as a deny. +func (a *App) EvaluatePluginAccessRequest(rctx request.CTX, pluginID, userID, resourceType, resourceID, action string) (*model.AccessDecision, *model.AppError) { + if appErr := a.pluginAccessControlScopeCheck("EvaluatePluginAccessRequest", pluginID, resourceType); appErr != nil { + return nil, appErr + } + if !model.IsValidPolicyAction(action) { + return nil, model.NewAppError("EvaluatePluginAccessRequest", "app.access_control.plugin.invalid_action.app_error", nil, "action="+action, http.StatusBadRequest) + } + if !model.IsValidId(userID) || !model.IsValidId(resourceID) { + return nil, model.NewAppError("EvaluatePluginAccessRequest", "app.access_control.plugin.invalid_id.app_error", nil, "", http.StatusBadRequest) + } + + const where = "EvaluatePluginAccessRequest" + + if !a.pluginAccessControlAvailable() { + return a.resolvePluginPolicyExistence(rctx, where, pluginID, resourceType, resourceID, "abac_unavailable") + } + + user, appErr := a.GetUser(userID) + if appErr != nil { + rctx.Logger().Warn("Plugin access evaluation: failed to load user; resolving policy existence", + mlog.String("plugin_id", pluginID), + mlog.String("user_id", userID), + mlog.Err(appErr)) + return a.resolvePluginPolicyExistence(rctx, where, pluginID, resourceType, resourceID, "user_load_failed") + } + subject, appErr := a.BuildAccessControlSubject(rctx, userID, user.Roles, "") + if appErr != nil { + rctx.Logger().Warn("Plugin access evaluation: failed to build subject; resolving policy existence", + mlog.String("plugin_id", pluginID), + mlog.String("user_id", userID), + mlog.Err(appErr)) + return a.resolvePluginPolicyExistence(rctx, where, pluginID, resourceType, resourceID, "subject_build_failed") + } + + decision, evalErr := a.Srv().ch.AccessControl.AccessEvaluation(rctx, model.AccessRequest{ + Subject: *subject, + Resource: model.Resource{ID: resourceID, Type: resourceType}, + Action: action, + }) + if evalErr != nil { + // The evaluator converts CEL errors to deny; an error here is an + // infra failure before policy resolution. + rctx.Logger().Warn("Plugin access evaluation: evaluator error; resolving policy existence", + mlog.String("plugin_id", pluginID), + mlog.String("resource_id", resourceID), + mlog.Err(evalErr)) + return a.resolvePluginPolicyExistence(rctx, where, pluginID, resourceType, resourceID, "evaluator_error") + } + + return &decision, nil +} + +// SavePluginAccessControlPolicy creates or updates a plugin-owned access +// control policy. Version is forced to v0.5 and Active to true (plugin types +// have no separate activation lifecycle); policy.ID must be the resource's +// stable ID. +func (a *App) SavePluginAccessControlPolicy(rctx request.CTX, pluginID, actingUserID string, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) { + // Audit every attempt, including precondition failures. + auditRec := a.MakeAuditRecord(rctx, model.AuditEventSavePluginAccessControlPolicy, model.AuditStatusFail) + defer a.LogAuditRec(rctx, auditRec, nil) + model.AddEventParameterToAuditRec(auditRec, "actor", actingUserID) + model.AddEventParameterToAuditRec(auditRec, "plugin_id", pluginID) + // Refined to create/update once the existence probe resolves. + model.AddEventParameterToAuditRec(auditRec, "operation", "create_or_update") + if policy != nil { + model.AddEventParameterToAuditRec(auditRec, "resource_type", policy.Type) + model.AddEventParameterAuditableToAuditRec(auditRec, "policy", policy) + } + + acs := a.Srv().ch.AccessControl + if acs == nil { + return nil, model.NewAppError("SavePluginAccessControlPolicy", "app.pap.create_access_control_policy.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented) + } + + if policy == nil || policy.ID == "" { + return nil, model.NewAppError("SavePluginAccessControlPolicy", "app.access_control.plugin.invalid_id.app_error", nil, "policy ID is required", http.StatusBadRequest) + } + + if appErr := a.pluginAccessControlScopeCheck("SavePluginAccessControlPolicy", pluginID, policy.Type); appErr != nil { + return nil, appErr + } + if appErr := a.validatePluginActingUser("SavePluginAccessControlPolicy", actingUserID); appErr != nil { + return nil, appErr + } + + policy.Version = model.AccessControlPolicyVersionV0_5 + policy.Active = true + + // Existence probe: create-vs-update for the audit trail, plus a guard + // against overwriting a policy of a different type sharing the ID. + // + // Rows are keyed by ID alone, so a foreign type already holding this ID + // wins first-writer-wins and this save is rejected. That is accepted under + // the trusted-plugin model: the squatting row is attributable by its Type, + // and evaluation reports the resource as no_policy rather than denying it. + operation := "update" + existing, getErr := acs.GetPolicy(rctx, policy.ID) + if getErr != nil { + if getErr.StatusCode != http.StatusNotFound { + return nil, getErr + } + operation = "create" + } else if existing != nil && existing.Type != policy.Type { + return nil, model.NewAppError("SavePluginAccessControlPolicy", "app.access_control.plugin.type_conflict.app_error", nil, "stored_type="+existing.Type, http.StatusBadRequest) + } + model.AddEventParameterToAuditRec(auditRec, "operation", operation) + + if appErr := policy.IsValid(); appErr != nil { + return nil, appErr + } + + // Enterprise SavePolicy derives the caller ID from the session, so + // synthesize one for the acting user. + saveCtx := rctx.WithSession(&model.Session{UserId: actingUserID}) + saved, appErr := acs.SavePolicy(saveCtx, policy) + if appErr != nil { + return nil, appErr + } + + auditRec.Success() + auditRec.AddEventResultState(saved) + auditRec.AddEventObjectType("access_control_policy") + + return saved, nil +} + +// pluginPolicyNotFoundError is the single 404 for every "not visible to this +// plugin" condition, so a plugin cannot distinguish "does not exist" from +// "exists but is not yours". +func pluginPolicyNotFoundError(where string) *model.AppError { + return model.NewAppError(where, "app.access_control.plugin.policy_not_found.app_error", nil, "", http.StatusNotFound) +} + +// readStoredPolicyType returns the Type of the row stored under id, read +// straight from the store rather than through the enterprise PAP. Reading raw +// keeps the ownership gate ahead of normalization: a foreign policy that failed +// to normalize would otherwise surface a 400/500 and become distinguishable +// from the uniform 404. Absent rows get that same 404. +// +// Gating on the stored type is sound because Type is immutable: the store +// rejects type changes on an existing policy. +func (a *App) readStoredPolicyType(rctx request.CTX, where, id string) (string, *model.AppError) { + policy, err := a.Srv().Store().AccessControlPolicy().Get(rctx, id) + if err != nil { + var nfErr *store.ErrNotFound + if errors.As(err, &nfErr) { + return "", pluginPolicyNotFoundError(where) + } + return "", model.NewAppError(where, "app.pap.get_policy.app_error", nil, err.Error(), http.StatusInternalServerError) + } + return policy.Type, nil +} + +// GetPluginAccessControlPolicy returns the policy stored under id when its +// type is owned by the calling plugin. Absent and foreign-type collapse into +// one byte-identical 404. +func (a *App) GetPluginAccessControlPolicy(rctx request.CTX, pluginID, id string) (*model.AccessControlPolicy, *model.AppError) { + const where = "GetPluginAccessControlPolicy" + + acs := a.Srv().ch.AccessControl + if acs == nil { + return nil, model.NewAppError(where, "app.pap.get_policy.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented) + } + + if !model.IsValidId(id) { + return nil, model.NewAppError(where, "app.access_control.plugin.invalid_id.app_error", nil, "", http.StatusBadRequest) + } + + storedType, appErr := a.readStoredPolicyType(rctx, where, id) + if appErr != nil { + return nil, appErr + } + if !model.PluginOwnsAccessControlPolicyType(pluginID, storedType) { + return nil, pluginPolicyNotFoundError(where) + } + + // This read is independent of the gate above, so neither its result nor its + // failure is trusted without re-confirming ownership. In the + // delete/recreate window the delete path documents, the row could have + // turned foreign in between, and returning that policy — or surfacing its + // normalization error — would disclose the collision. Reaching the window + // needs a cross-plugin resource-ID collision plus an owner or admin + // recreating mid-flight, so it is not adversary-reachable by the caller. + policy, appErr := acs.GetPolicy(rctx, id) + if appErr != nil { + if appErr.StatusCode == http.StatusNotFound { + return nil, pluginPolicyNotFoundError(where) + } + // Confirm-read, on this cold path only. Whatever cannot be confirmed as + // still owned — absent, foreign, or unreadable — collapses into the + // uniform 404; the legitimate owner still sees the real error. + confirmedType, confirmErr := a.readStoredPolicyType(rctx, where, id) + if confirmErr != nil || !model.PluginOwnsAccessControlPolicyType(pluginID, confirmedType) { + return nil, pluginPolicyNotFoundError(where) + } + return nil, appErr + } + if !model.PluginOwnsAccessControlPolicyType(pluginID, policy.Type) { + return nil, pluginPolicyNotFoundError(where) + } + + return policy, nil +} + +// DeletePluginAccessControlPolicy deletes the policy stored under id when its +// stored type equals resourceType; absent and type-mismatch fail closed with +// the same 404. +func (a *App) DeletePluginAccessControlPolicy(rctx request.CTX, pluginID, actingUserID, resourceType, id string) *model.AppError { + // Audit every attempt, including precondition failures. + auditRec := a.MakeAuditRecord(rctx, model.AuditEventDeletePluginAccessControlPolicy, model.AuditStatusFail) + defer a.LogAuditRec(rctx, auditRec, nil) + model.AddEventParameterToAuditRec(auditRec, "actor", actingUserID) + model.AddEventParameterToAuditRec(auditRec, "plugin_id", pluginID) + model.AddEventParameterToAuditRec(auditRec, "resource_type", resourceType) + model.AddEventParameterToAuditRec(auditRec, "policy_id", id) + model.AddEventParameterToAuditRec(auditRec, "operation", "delete") + + const where = "DeletePluginAccessControlPolicy" + + acs := a.Srv().ch.AccessControl + if acs == nil { + return model.NewAppError(where, "app.pap.delete_policy.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented) + } + + if appErr := a.pluginAccessControlScopeCheck(where, pluginID, resourceType); appErr != nil { + return appErr + } + if appErr := a.validatePluginActingUser(where, actingUserID); appErr != nil { + return appErr + } + if !model.IsValidId(id) { + return model.NewAppError(where, "app.access_control.plugin.invalid_id.app_error", nil, "", http.StatusBadRequest) + } + + storedType, appErr := a.readStoredPolicyType(rctx, where, id) + if appErr != nil { + return appErr + } + if storedType != resourceType { + return pluginPolicyNotFoundError(where) + } + + // The check and the delete are separate statements, so a row deleted and + // re-created under the same ID with a foreign type in between would be + // deleted on the caller's behalf. Reaching that needs a cross-plugin + // resource-ID collision plus an owner or admin recreating mid-flight, so it + // is not adversary-reachable by the calling plugin. + if appErr := acs.DeletePolicy(rctx, id); appErr != nil { + return appErr + } + + auditRec.Success() + + return nil +} + +// CheckPluginAccessControlExpression compiles and lints a CEL expression for +// a plugin-owned resource type; an empty slice means the expression is valid. +func (a *App) CheckPluginAccessControlExpression(rctx request.CTX, pluginID, actingUserID, resourceType, expression string) ([]model.CELExpressionError, *model.AppError) { + if appErr := a.pluginAccessControlScopeCheck("CheckPluginAccessControlExpression", pluginID, resourceType); appErr != nil { + return nil, appErr + } + if appErr := a.validatePluginActingUser("CheckPluginAccessControlExpression", actingUserID); appErr != nil { + return nil, appErr + } + + return a.CheckExpression(rctx.WithSession(&model.Session{UserId: actingUserID}), expression) +} + +// QueryUsersForPluginAccessControlExpression returns the users matching the +// expression (test-modal support for plugin policy editors). limit is clamped +// to (0, pluginAccessControlQueryLimitMax]. +func (a *App) QueryUsersForPluginAccessControlExpression(rctx request.CTX, pluginID, actingUserID, resourceType, expression, term, cursorID string, limit int) (*model.AccessControlPolicyTestResponse, *model.AppError) { + if appErr := a.pluginAccessControlScopeCheck("QueryUsersForPluginAccessControlExpression", pluginID, resourceType); appErr != nil { + return nil, appErr + } + if appErr := a.validatePluginActingUser("QueryUsersForPluginAccessControlExpression", actingUserID); appErr != nil { + return nil, appErr + } + + if limit <= 0 { + limit = pluginAccessControlQueryLimitDefault + } + if limit > pluginAccessControlQueryLimitMax { + limit = pluginAccessControlQueryLimitMax + } + + users, total, appErr := a.TestExpression(rctx.WithSession(&model.Session{UserId: actingUserID}), expression, model.SubjectSearchOptions{ + Term: term, + Limit: limit, + Cursor: model.SubjectCursor{TargetID: cursorID}, + }) + if appErr != nil { + return nil, appErr + } + + return &model.AccessControlPolicyTestResponse{Users: users, Total: total}, nil +} + +// GetPluginAccessControlFieldsAutocomplete returns CPA fields for plugin +// policy editor autocomplete. No resourceType scope check — field visibility +// is attribute-level, enforced by the underlying method via the acting user. +func (a *App) GetPluginAccessControlFieldsAutocomplete(rctx request.CTX, pluginID, actingUserID, after string, limit int) ([]*model.PropertyField, *model.AppError) { + // The underlying method does not gate on the service itself. + if a.Srv().ch.AccessControl == nil { + return nil, model.NewAppError("GetPluginAccessControlFieldsAutocomplete", "app.pap.get_access_control_auto_complete.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented) + } + if appErr := a.validatePluginActingUser("GetPluginAccessControlFieldsAutocomplete", actingUserID); appErr != nil { + return nil, appErr + } + + if limit <= 0 { + limit = pluginAccessControlQueryLimitDefault + } + if limit > pluginAccessControlQueryLimitMax { + limit = pluginAccessControlQueryLimitMax + } + + // Empty cursor means first page; map to the lowest sentinel like api4 does. + if after == "" { + after = strings.Repeat("0", 26) + } + + return a.GetAccessControlFieldsAutocomplete(rctx, after, limit, actingUserID) +} + +// GetPluginAccessControlVisualAST converts a CEL expression to the visual +// (table) AST for plugin policy editors. +func (a *App) GetPluginAccessControlVisualAST(rctx request.CTX, pluginID, actingUserID, resourceType, expression string) (*model.VisualExpression, *model.AppError) { + if appErr := a.pluginAccessControlScopeCheck("GetPluginAccessControlVisualAST", pluginID, resourceType); appErr != nil { + return nil, appErr + } + if appErr := a.validatePluginActingUser("GetPluginAccessControlVisualAST", actingUserID); appErr != nil { + return nil, appErr + } + + return a.ExpressionToVisualAST(rctx.WithSession(&model.Session{UserId: actingUserID}), expression) +} diff --git a/server/channels/app/plugin_access_control_gob_test.go b/server/channels/app/plugin_access_control_gob_test.go new file mode 100644 index 00000000000..ddc22c299e0 --- /dev/null +++ b/server/channels/app/plugin_access_control_gob_test.go @@ -0,0 +1,95 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "bytes" + "encoding/gob" + "encoding/json" + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost/server/v8/einterfaces/mocks" +) + +// requirePluginRPCGobSafe gob-encodes v the way the plugin RPC layer encodes +// an API reply (public/plugin's client_rpc gob registrations are active via +// the import). An unregistered concrete type inside an interface-typed field +// fails encoding, which shuts down the shared plugin RPC connection. +func requirePluginRPCGobSafe(t *testing.T, v any) { + t.Helper() + var buf bytes.Buffer + require.NoError(t, gob.NewEncoder(&buf).Encode(v), + "reply payload must gob-encode with client_rpc registrations; an unregistered concrete type inside `any` poisons the plugin RPC connection") +} + +// TestPluginAccessControlGobSafety pins that every payload the plugin access +// control API can return over net/rpc survives gob encoding. The autocomplete +// subtest is the regression test for the native-attribute options bug +// (bool-select options were []map[string]string, which gob rejects). +func TestPluginAccessControlGobSafety(t *testing.T) { + th := Setup(t).InitBasic(t) + actingUserID := th.BasicUser.Id + + t.Run("fields autocomplete response including native attribute fields", func(t *testing.T) { + th.App.Srv().ch.AccessControl = &mocks.AccessControlServiceInterface{} + + fields, appErr := th.App.GetPluginAccessControlFieldsAutocomplete(th.Context, testAgentsPluginID, actingUserID, "", 100) + require.Nil(t, appErr) + require.NotEmpty(t, fields, "native attribute fields expected on the first page") + + // Prove the payload contains a native bool-select field carrying + // options inside Attrs. + hasBoolSelect := false + for _, f := range fields { + if f.Attrs[model.PropertyFieldAttributeOptions] != nil { + hasBoolSelect = true + } + } + require.True(t, hasBoolSelect, "expected at least one native field with select options in Attrs") + + requirePluginRPCGobSafe(t, fields) + }) + + t.Run("policy with JSON-decoded Props", func(t *testing.T) { + // Stored policies hydrate Props via json.Unmarshal, so the concrete + // types inside are exactly the ones gob accepts; pin that. + var props map[string]any + require.NoError(t, json.Unmarshal( + []byte(`{"nested":{"k":"v"},"list":[1,"two",true],"s":"x","n":1.5,"b":true,"z":null}`), &props)) + + p := validPluginPolicy(model.NewId()) + p.Props = props + requirePluginRPCGobSafe(t, p) + }) + + t.Run("visual AST with every runtime value shape", func(t *testing.T) { + // The enterprise AST→visual conversion produces these value shapes. + visual := &model.VisualExpression{Conditions: []model.Condition{ + {Attribute: "user.attributes.team", Operator: "==", Value: "eng"}, + {Attribute: "user.attributes.admin", Operator: "==", Value: true}, + {Attribute: "user.attributes.age", Operator: ">", Value: int64(30)}, + {Attribute: "user.attributes.count", Operator: "<", Value: uint64(10)}, + {Attribute: "user.attributes.score", Operator: ">=", Value: 1.5}, + {Attribute: "user.attributes.missing", Operator: "==", Value: nil}, + {Attribute: "user.attributes.role", Operator: "in", Value: []any{"a", "b"}}, + }} + requirePluginRPCGobSafe(t, visual) + }) + + t.Run("expression check errors", func(t *testing.T) { + requirePluginRPCGobSafe(t, []model.CELExpressionError{{Line: 1, Column: 2, Message: "boom"}}) + }) + + t.Run("query users response with a real user", func(t *testing.T) { + requirePluginRPCGobSafe(t, &model.AccessControlPolicyTestResponse{Users: []*model.User{th.BasicUser}, Total: 1}) + }) + + t.Run("evaluation decision carrying a context reason", func(t *testing.T) { + decision := model.NewNoPolicyAccessDecision() + requirePluginRPCGobSafe(t, &decision) + }) +} diff --git a/server/channels/app/plugin_access_control_test.go b/server/channels/app/plugin_access_control_test.go new file mode 100644 index 00000000000..75ae5e73c88 --- /dev/null +++ b/server/channels/app/plugin_access_control_test.go @@ -0,0 +1,1153 @@ +// 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 ":". + 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) + }) +} diff --git a/server/channels/app/plugin_api.go b/server/channels/app/plugin_api.go index 6a59a740f61..62ce1f17c31 100644 --- a/server/channels/app/plugin_api.go +++ b/server/channels/app/plugin_api.go @@ -1907,6 +1907,38 @@ func (api *PluginAPI) DeletePropertyValuesForField(groupID, fieldID string) erro return nil } +func (api *PluginAPI) EvaluateAccessControl(userID, resourceType, resourceID, action string) (*model.AccessDecision, *model.AppError) { + return api.app.EvaluatePluginAccessRequest(api.ctx, api.id, userID, resourceType, resourceID, action) +} + +func (api *PluginAPI) SaveAccessControlPolicy(actingUserID string, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) { + return api.app.SavePluginAccessControlPolicy(api.ctx, api.id, actingUserID, policy) +} + +func (api *PluginAPI) GetAccessControlPolicy(id string) (*model.AccessControlPolicy, *model.AppError) { + return api.app.GetPluginAccessControlPolicy(api.ctx, api.id, id) +} + +func (api *PluginAPI) DeleteAccessControlPolicy(actingUserID, resourceType, id string) *model.AppError { + return api.app.DeletePluginAccessControlPolicy(api.ctx, api.id, actingUserID, resourceType, id) +} + +func (api *PluginAPI) CheckAccessControlExpression(actingUserID, resourceType, expression string) ([]model.CELExpressionError, *model.AppError) { + return api.app.CheckPluginAccessControlExpression(api.ctx, api.id, actingUserID, resourceType, expression) +} + +func (api *PluginAPI) QueryUsersForAccessControlExpression(actingUserID, resourceType, expression, term, cursorID string, limit int) (*model.AccessControlPolicyTestResponse, *model.AppError) { + return api.app.QueryUsersForPluginAccessControlExpression(api.ctx, api.id, actingUserID, resourceType, expression, term, cursorID, limit) +} + +func (api *PluginAPI) GetAccessControlFieldsAutocomplete(actingUserID, after string, limit int) ([]*model.PropertyField, *model.AppError) { + return api.app.GetPluginAccessControlFieldsAutocomplete(api.ctx, api.id, actingUserID, after, limit) +} + +func (api *PluginAPI) GetAccessControlVisualAST(actingUserID, resourceType, expression string) (*model.VisualExpression, *model.AppError) { + return api.app.GetPluginAccessControlVisualAST(api.ctx, api.id, actingUserID, resourceType, expression) +} + func (api *PluginAPI) UpsertPropertyValuesWithOptions(values []*model.PropertyValue, options model.PropertyRequestOptions) ([]*model.PropertyValue, error) { upsertedValues, appErr := api.app.UpsertPropertyValues(api.psaPluginContextWithOptions(options), values, "", "", "") if appErr != nil { diff --git a/server/channels/store/storetest/access_control_policy_store.go b/server/channels/store/storetest/access_control_policy_store.go index 675ca229a0e..bf2c0028e69 100644 --- a/server/channels/store/storetest/access_control_policy_store.go +++ b/server/channels/store/storetest/access_control_policy_store.go @@ -14,6 +14,12 @@ import ( "github.com/stretchr/testify/require" ) +// A plugin-owned policy type and one of its plugin-defined actions. +const ( + testPluginPolicyType = "mattermost-ai:agent" + testPluginPolicyAction = "use" +) + func TestAccessControlPolicyStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) { t.Run("Save", func(t *testing.T) { testAccessControlPolicyStoreSaveAndGet(t, rctx, ss) }) t.Run("SaveDuplicateName", func(t *testing.T) { testAccessControlPolicyStoreSaveDuplicateName(t, rctx, ss) }) @@ -29,6 +35,116 @@ func TestAccessControlPolicyStore(t *testing.T, rctx request.CTX, ss store.Store t.Run("SearchByTeamIDWithScope", func(t *testing.T) { testAccessControlPolicyStoreSearchByTeamIDWithScope(t, rctx, ss) }) t.Run("GetActionsForPolicy", func(t *testing.T) { testAccessControlPolicyStoreGetActionsForPolicy(t, rctx, ss) }) t.Run("GetActionsForPolicies", func(t *testing.T) { testAccessControlPolicyStoreGetActionsForPolicies(t, rctx, ss) }) + t.Run("PluginPolicy", func(t *testing.T) { testAccessControlPolicyStorePluginPolicy(t, rctx, ss) }) + t.Run("TypeImmutableOnSave", func(t *testing.T) { testAccessControlPolicyStoreTypeImmutableOnSave(t, rctx, ss) }) +} + +// testAccessControlPolicyStoreTypeImmutableOnSave pins the invariant the plugin +// app layer depends on: a stored policy's Type never changes, so an ownership +// decision taken from an earlier read cannot be invalidated by a later save. +func testAccessControlPolicyStoreTypeImmutableOnSave(t *testing.T, rctx request.CTX, ss store.Store) { + newPolicy := func(policyType, version, action string) *model.AccessControlPolicy { + return &model.AccessControlPolicy{ + ID: model.NewId(), + Name: "Type Immutability " + model.NewId(), + Type: policyType, + Active: true, + Revision: 1, + Version: version, + Rules: []model.AccessControlPolicyRule{{ + Actions: []string{action}, + Expression: "true", + }}, + } + } + + tests := []struct { + name string + policy *model.AccessControlPolicy + newType string + }{ + { + name: "plugin type cannot become a core type", + policy: newPolicy(testPluginPolicyType, model.AccessControlPolicyVersionV0_5, testPluginPolicyAction), + newType: model.AccessControlPolicyTypeChannel, + }, + { + name: "core type cannot become a plugin type", + policy: newPolicy(model.AccessControlPolicyTypeChannel, model.AccessControlPolicyVersionV0_2, model.AccessControlPolicyActionMembership), + newType: testPluginPolicyType, + }, + { + name: "plugin type cannot be taken over by another plugin", + policy: newPolicy(testPluginPolicyType, model.AccessControlPolicyVersionV0_5, testPluginPolicyAction), + newType: "other-plugin:agent", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + saved, err := ss.AccessControlPolicy().Save(rctx, tc.policy) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, ss.AccessControlPolicy().Delete(rctx, saved.ID)) + }) + + retyped := *saved + retyped.Type = tc.newType + _, err = ss.AccessControlPolicy().Save(rctx, &retyped) + require.Error(t, err, "the store must reject a type change on an existing policy") + + got, err := ss.AccessControlPolicy().Get(rctx, saved.ID) + require.NoError(t, err) + require.Equal(t, tc.policy.Type, got.Type, "the stored type must survive the rejected save") + }) + } +} + +func testAccessControlPolicyStorePluginPolicy(t *testing.T, rctx request.CTX, ss store.Store) { + policy := &model.AccessControlPolicy{ + ID: model.NewId(), + Name: "Agent Gate " + model.NewId(), + Type: testPluginPolicyType, + Active: true, + Revision: 1, + Version: model.AccessControlPolicyVersionV0_5, + Rules: []model.AccessControlPolicyRule{{ + Actions: []string{testPluginPolicyAction}, + Expression: `user.attributes.department == "eng"`, + }}, + } + + saved, err := ss.AccessControlPolicy().Save(rctx, policy) + require.NoError(t, err) + require.NotNil(t, saved) + require.Equal(t, testPluginPolicyType, saved.Type) + + t.Run("Get round-trips type and rules", func(t *testing.T) { + got, err := ss.AccessControlPolicy().Get(rctx, policy.ID) + require.NoError(t, err) + require.Equal(t, testPluginPolicyType, got.Type) + require.Equal(t, model.AccessControlPolicyVersionV0_5, got.Version) + require.Equal(t, policy.Rules, got.Rules) + }) + + t.Run("SearchPolicies by plugin type finds it", func(t *testing.T) { + results, total, err := ss.AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{ + Type: testPluginPolicyType, + Limit: 10, + }) + require.NoError(t, err) + require.EqualValues(t, 1, total) + require.Len(t, results, 1) + require.Equal(t, policy.ID, results[0].ID) + }) + + t.Run("Delete removes it", func(t *testing.T) { + require.NoError(t, ss.AccessControlPolicy().Delete(rctx, policy.ID)) + + _, err := ss.AccessControlPolicy().Get(rctx, policy.ID) + var nfErr *store.ErrNotFound + require.True(t, errors.As(err, &nfErr)) + }) } func testAccessControlPolicyStoreSaveAndGet(t *testing.T, rctx request.CTX, ss store.Store) { diff --git a/server/i18n/en.json b/server/i18n/en.json index 1408e381ce8..d2c0db608ac 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -5430,6 +5430,38 @@ "id": "app.access_control.insufficient_permissions", "translation": "You do not have permission to manage this access control policy." }, + { + "id": "app.access_control.plugin.evaluation_unavailable.app_error", + "translation": "Access control could not be evaluated for this resource." + }, + { + "id": "app.access_control.plugin.invalid_acting_user.app_error", + "translation": "The acting user is invalid or does not exist." + }, + { + "id": "app.access_control.plugin.invalid_action.app_error", + "translation": "The action is not a valid access control action." + }, + { + "id": "app.access_control.plugin.invalid_id.app_error", + "translation": "Invalid identifier supplied to the access control API." + }, + { + "id": "app.access_control.plugin.invalid_resource_type.app_error", + "translation": "The resource type is not a valid plugin access control resource type." + }, + { + "id": "app.access_control.plugin.policy_not_found.app_error", + "translation": "Access control policy not found." + }, + { + "id": "app.access_control.plugin.resource_type_forbidden.app_error", + "translation": "The calling plugin does not own this resource type." + }, + { + "id": "app.access_control.plugin.type_conflict.app_error", + "translation": "An access control policy of a different type already exists for this ID." + }, { "id": "app.acknowledgement.batch_save.app_error", "translation": "Failed to save the batch of acknowledgement objects" @@ -11174,6 +11206,10 @@ "id": "model.access_policy.is_valid.name.app_error", "translation": "Invalid name for the policy." }, + { + "id": "model.access_policy.is_valid.plugin_type.app_error", + "translation": "Invalid access control policy: type is not a valid plugin resource type." + }, { "id": "model.access_policy.is_valid.revision.app_error", "translation": "Invalid policy revision." diff --git a/server/public/model/access_policy.go b/server/public/model/access_policy.go index 576ef7222be..23c43108cf5 100644 --- a/server/public/model/access_policy.go +++ b/server/public/model/access_policy.go @@ -5,6 +5,7 @@ package model import ( "fmt" + "regexp" "slices" "strings" @@ -20,10 +21,30 @@ const ( MaxPolicyNameLength = 128 + // PluginAccessControlPolicyTypeSeparator separates the owning plugin ID + // from the resource-type segment of a plugin-owned policy type, e.g. + // "mattermost-ai:agent". Ownership is encoded in the key itself, so core + // treats these types opaquely. + PluginAccessControlPolicyTypeSeparator = ":" + + // MaxPluginResourceTypeLength bounds the resource-type segment of a + // plugin-owned policy type. + MaxPluginResourceTypeLength = 64 + + // MaxPolicyTypeLength bounds a whole policy type to the width of the + // AccessControlPolicies.Type column. A valid plugin ID alone can exceed + // it, so the combined key must be bounded here or an otherwise-valid type + // would fail at insert time instead of validation. + MaxPolicyTypeLength = 128 + + // MaxPolicyActionLength bounds an action name. + MaxPolicyActionLength = 64 + AccessControlPolicyVersionV0_1 = "v0.1" AccessControlPolicyVersionV0_2 = "v0.2" AccessControlPolicyVersionV0_3 = "v0.3" AccessControlPolicyVersionV0_4 = "v0.4" + AccessControlPolicyVersionV0_5 = "v0.5" AccessControlPolicyActionMembership = "membership" AccessControlPolicyActionUploadFileAttachment = "upload_file_attachment" @@ -60,6 +81,57 @@ func IsPermissionAction(action string) bool { return allowedPermissionActionsV0_4[action] } +// pluginResourceTypeRe constrains the resource-type segment of a plugin-owned +// policy type to the same charset as a plugin ID. +var pluginResourceTypeRe = regexp.MustCompile(ValidIdRegex) + +// policyActionRe constrains action names to lowercase alphanumeric segments +// joined by single underscores, matching the shape of the built-in actions +// (membership, upload_file_attachment). It rejects the "*" wildcard. +var policyActionRe = regexp.MustCompile(`^[a-z0-9]+(_[a-z0-9]+)*$`) + +// SplitPluginAccessControlPolicyType splits a plugin-owned policy type into +// its owning plugin ID and resource-type segment. ok is false unless both +// segments are well formed. +func SplitPluginAccessControlPolicyType(policyType string) (pluginID string, resourceType string, ok bool) { + if len(policyType) > MaxPolicyTypeLength { + return "", "", false + } + pluginID, resourceType, found := strings.Cut(policyType, PluginAccessControlPolicyTypeSeparator) + if !found || !IsValidPluginId(pluginID) { + return "", "", false + } + if resourceType == "" || len(resourceType) > MaxPluginResourceTypeLength || !pluginResourceTypeRe.MatchString(resourceType) { + return "", "", false + } + return pluginID, resourceType, true +} + +// IsPluginAccessControlPolicyType reports whether policyType is a well-formed +// plugin-owned resource-policy type (":"). +func IsPluginAccessControlPolicyType(policyType string) bool { + _, _, ok := SplitPluginAccessControlPolicyType(policyType) + return ok +} + +// PluginOwnsAccessControlPolicyType reports whether policyType is a +// well-formed plugin policy type whose plugin-ID prefix is pluginID. The +// prefix is the entire ownership check. The comparison is exact: the stored +// type is matched byte-for-byte everywhere (delete, evaluation), so accepting +// case variants here would let a mixed-case type be read but never deleted or +// evaluated. +func PluginOwnsAccessControlPolicyType(pluginID, policyType string) bool { + owner, _, ok := SplitPluginAccessControlPolicyType(policyType) + return ok && owner == pluginID +} + +// IsValidPolicyAction reports whether action is a well-formed action name. +// Plugin policies choose their own action names, so core validates only the +// format. +func IsValidPolicyAction(action string) bool { + return len(action) <= MaxPolicyActionLength && policyActionRe.MatchString(action) +} + // HasPermissionRuleAction reports whether ANY rule on this policy // carries a non-membership permission action (file upload/download). // Used by the API4 layer to gate channel-scope policies behind the @@ -208,6 +280,8 @@ func (p *AccessControlPolicy) IsValid() *AppError { return p.accessPolicyVersionV0_3() case AccessControlPolicyVersionV0_4: return p.accessPolicyVersionV0_4() + case AccessControlPolicyVersionV0_5: + return p.accessPolicyVersionV0_5() default: return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.version.app_error", nil, "", 400) } @@ -515,6 +589,71 @@ func (p *AccessControlPolicy) accessPolicyVersionV0_4() *AppError { return nil } +// accessPolicyVersionV0_5 validates a v0.5 policy. v0.5 is the lane for +// plugin-owned resource policies, which differ from the core versions in ways +// that are not expressible there: the type is a ":" +// key rather than one of the core type constants, and actions are +// plugin-defined rather than drawn from the core action set. On top of that it +// pins resource-scoped policies to system scope with no imports or roles. +func (p *AccessControlPolicy) accessPolicyVersionV0_5() *AppError { + if !IsPluginAccessControlPolicyType(p.Type) { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.plugin_type.app_error", nil, "", 400) + } + + if !IsValidId(p.ID) { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.id.app_error", nil, "", 400) + } + + // Name is required — no backing entity supplies one for plugin types. + if p.Name == "" || len(p.Name) > MaxPolicyNameLength { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.name.app_error", nil, "", 400) + } + + if p.Revision < 0 { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.revision.app_error", nil, "", 400) + } + + if !semver.IsValid(p.Version) { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.version.app_error", nil, "", 400) + } + + if len(p.Rules) == 0 { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rules.app_error", nil, "", 400) + } + + if len(p.Imports) > 0 { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.imports.app_error", nil, "", 400) + } + + if len(p.Roles) > 0 { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.roles.app_error", nil, "", 400) + } + + // validateScope allows team scopes; plugin policies are system scope only. + if p.Scope != "" || p.ScopeID != "" { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.scope.app_error", nil, "", 400) + } + + for _, rule := range p.Rules { + if len(rule.Actions) == 0 { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.app_error", nil, "actions must not be empty", 400) + } + for _, action := range rule.Actions { + if !IsValidPolicyAction(action) { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.actions.app_error", nil, fmt.Sprintf("malformed action: %s", action), 400) + } + } + if rule.Role != "" { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rule_role.app_error", nil, "plugin policy rules must not have a role", 400) + } + if len(rule.Name) > MaxPolicyNameLength { + return NewAppError("AccessControlPolicy.IsValid", "model.access_policy.is_valid.rule_name.app_error", nil, "rule name exceeds the policy max length", 400) + } + } + + return nil +} + func (p *AccessControlPolicy) Inherit(parent *AccessControlPolicy) *AppError { rules := make([]AccessControlPolicyRule, len(p.Rules)) diff --git a/server/public/model/access_policy_test.go b/server/public/model/access_policy_test.go index 2d320450327..f9c9841e56e 100644 --- a/server/public/model/access_policy_test.go +++ b/server/public/model/access_policy_test.go @@ -1300,3 +1300,250 @@ func TestInheritTeamType(t *testing.T) { require.Equal(t, 400, err.StatusCode) }) } + +// testPluginPolicyType is a well-formed plugin-owned policy type: the owning +// plugin ID, a colon, then the plugin's own resource-type name. +const testPluginPolicyType = "mattermost-ai:agent" + +func TestAccessPolicyVersionV0_5(t *testing.T) { + validPolicy := func(mutate func(p *AccessControlPolicy)) *AccessControlPolicy { + p := &AccessControlPolicy{ + ID: NewId(), + Type: testPluginPolicyType, + Name: "Agent policy", + Revision: 0, + Version: AccessControlPolicyVersionV0_5, + Rules: []AccessControlPolicyRule{{ + Actions: []string{"use"}, + Expression: "user.attributes.dept == \"eng\"", + }}, + } + if mutate != nil { + mutate(p) + } + return p + } + + for _, pluginType := range []string{ + "mattermost-ai:agent", + "mattermost-ai:service", + "mattermost-ai:mcp", + "com.example.plugin:widget", + } { + t.Run("valid policy for "+pluginType, func(t *testing.T) { + p := validPolicy(func(p *AccessControlPolicy) { p.Type = pluginType }) + require.Nil(t, p.IsValid()) + }) + } + + t.Run("plugin-defined action accepted", func(t *testing.T) { + p := validPolicy(func(p *AccessControlPolicy) { p.Rules[0].Actions = []string{"invoke_tool"} }) + require.Nil(t, p.IsValid()) + }) + + t.Run("optional rule name accepted", func(t *testing.T) { + p := validPolicy(func(p *AccessControlPolicy) { p.Rules[0].Name = "Named rule" }) + require.Nil(t, p.IsValid()) + }) + + tests := []struct { + name string + mutate func(p *AccessControlPolicy) + expectedID string + }{ + {"legacy channel type rejected", func(p *AccessControlPolicy) { p.Type = AccessControlPolicyTypeChannel }, "model.access_policy.is_valid.plugin_type.app_error"}, + {"legacy parent type rejected", func(p *AccessControlPolicy) { p.Type = AccessControlPolicyTypeParent }, "model.access_policy.is_valid.plugin_type.app_error"}, + {"legacy permission type rejected", func(p *AccessControlPolicy) { p.Type = AccessControlPolicyTypePermission }, "model.access_policy.is_valid.plugin_type.app_error"}, + {"legacy team type rejected", func(p *AccessControlPolicy) { p.Type = AccessControlPolicyTypeTeam }, "model.access_policy.is_valid.plugin_type.app_error"}, + {"unprefixed type rejected", func(p *AccessControlPolicy) { p.Type = "some-plugin.widget" }, "model.access_policy.is_valid.plugin_type.app_error"}, + {"empty resource-type segment rejected", func(p *AccessControlPolicy) { p.Type = "mattermost-ai:" }, "model.access_policy.is_valid.plugin_type.app_error"}, + {"empty plugin-ID segment rejected", func(p *AccessControlPolicy) { p.Type = ":agent" }, "model.access_policy.is_valid.plugin_type.app_error"}, + {"resource-type segment with a separator rejected", func(p *AccessControlPolicy) { p.Type = "mattermost-ai:agent:v2" }, "model.access_policy.is_valid.plugin_type.app_error"}, + {"oversized resource-type segment rejected", func(p *AccessControlPolicy) { + p.Type = "mattermost-ai:" + strings.Repeat("a", MaxPluginResourceTypeLength+1) + }, "model.access_policy.is_valid.plugin_type.app_error"}, + {"invalid id", func(p *AccessControlPolicy) { p.ID = "short" }, "model.access_policy.is_valid.id.app_error"}, + {"empty name", func(p *AccessControlPolicy) { p.Name = "" }, "model.access_policy.is_valid.name.app_error"}, + {"oversized name", func(p *AccessControlPolicy) { p.Name = strings.Repeat("a", MaxPolicyNameLength+1) }, "model.access_policy.is_valid.name.app_error"}, + {"negative revision", func(p *AccessControlPolicy) { p.Revision = -1 }, "model.access_policy.is_valid.revision.app_error"}, + {"zero rules", func(p *AccessControlPolicy) { p.Rules = nil }, "model.access_policy.is_valid.rules.app_error"}, + {"imports present", func(p *AccessControlPolicy) { p.Imports = []string{NewId()} }, "model.access_policy.is_valid.imports.app_error"}, + {"roles present", func(p *AccessControlPolicy) { p.Roles = []string{SystemUserRoleId} }, "model.access_policy.is_valid.roles.app_error"}, + {"empty actions", func(p *AccessControlPolicy) { p.Rules[0].Actions = nil }, "model.access_policy.is_valid.actions.app_error"}, + {"wildcard action rejected", func(p *AccessControlPolicy) { p.Rules[0].Actions = []string{"*"} }, "model.access_policy.is_valid.actions.app_error"}, + {"malformed action rejected", func(p *AccessControlPolicy) { p.Rules[0].Actions = []string{"Use It"} }, "model.access_policy.is_valid.actions.app_error"}, + {"rule role rejected", func(p *AccessControlPolicy) { p.Rules[0].Role = ChannelUserRoleId }, "model.access_policy.is_valid.rule_role.app_error"}, + {"oversized rule name", func(p *AccessControlPolicy) { p.Rules[0].Name = strings.Repeat("a", MaxPolicyNameLength+1) }, "model.access_policy.is_valid.rule_name.app_error"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := validPolicy(tc.mutate) + err := p.IsValid() + require.NotNil(t, err) + require.Equal(t, tc.expectedID, err.Id) + }) + } + + // Scope rejections go through the top-level IsValid because + // validateScope runs first for non-empty scopes. + t.Run("team scope rejected", func(t *testing.T) { + p := validPolicy(func(p *AccessControlPolicy) { + p.Scope = AccessControlPolicyScopeTeam + p.ScopeID = NewId() + }) + err := p.IsValid() + require.NotNil(t, err) + require.Equal(t, "model.access_policy.is_valid.scope.app_error", err.Id) + }) + + t.Run("scope id without scope rejected", func(t *testing.T) { + p := validPolicy(func(p *AccessControlPolicy) { p.ScopeID = NewId() }) + err := p.IsValid() + require.NotNil(t, err) + require.Equal(t, "model.access_policy.is_valid.scope_id_without_scope.app_error", err.Id) + }) + + t.Run("invalid semver rejected", func(t *testing.T) { + // A bad semver can't reach the v0.5 validator through the IsValid + // switch, so exercise the validator directly. + p := validPolicy(nil) + p.Version = "not-semver" + err := p.accessPolicyVersionV0_5() + require.NotNil(t, err) + require.Equal(t, "model.access_policy.is_valid.version.app_error", err.Id) + }) +} + +func TestSplitPluginAccessControlPolicyType(t *testing.T) { + valid := []struct { + policyType string + pluginID string + resourceType string + }{ + {"mattermost-ai:agent", "mattermost-ai", "agent"}, + {"com.example.plugin:widget", "com.example.plugin", "widget"}, + {"my_plugin:some.nested-type", "my_plugin", "some.nested-type"}, + {"mattermost-ai:" + strings.Repeat("a", MaxPluginResourceTypeLength), "mattermost-ai", strings.Repeat("a", MaxPluginResourceTypeLength)}, + // Exactly the column width: a 63-char plugin ID + ":" + 64-char type. + { + strings.Repeat("p", MaxPolicyTypeLength-MaxPluginResourceTypeLength-1) + ":" + strings.Repeat("a", MaxPluginResourceTypeLength), + strings.Repeat("p", MaxPolicyTypeLength-MaxPluginResourceTypeLength-1), + strings.Repeat("a", MaxPluginResourceTypeLength), + }, + } + for _, tc := range valid { + t.Run("valid "+tc.policyType, func(t *testing.T) { + pluginID, resourceType, ok := SplitPluginAccessControlPolicyType(tc.policyType) + require.True(t, ok) + require.Equal(t, tc.pluginID, pluginID) + require.Equal(t, tc.resourceType, resourceType) + require.True(t, IsPluginAccessControlPolicyType(tc.policyType)) + }) + } + + invalid := []string{ + AccessControlPolicyTypeParent, + AccessControlPolicyTypeChannel, + AccessControlPolicyTypePermission, + AccessControlPolicyTypeTeam, + "mattermost-ai.agent", + "mattermost-ai:", + ":agent", + "ab:agent", // plugin ID shorter than MinIdLength + "mattermost ai:agent", // space is not a valid plugin-ID character + "mattermost-ai:agent:v2", // separator inside the resource type + "mattermost-ai:agent type", // space inside the resource type + "mattermost-ai:" + strings.Repeat("a", MaxPluginResourceTypeLength+1), + // One over the column width, with both segments individually valid. + strings.Repeat("p", MaxPolicyTypeLength-MaxPluginResourceTypeLength) + ":" + strings.Repeat("a", MaxPluginResourceTypeLength), + "", + } + for _, policyType := range invalid { + t.Run("invalid "+policyType, func(t *testing.T) { + _, _, ok := SplitPluginAccessControlPolicyType(policyType) + require.False(t, ok) + require.False(t, IsPluginAccessControlPolicyType(policyType)) + require.False(t, PluginOwnsAccessControlPolicyType("mattermost-ai", policyType)) + }) + } +} + +func TestPluginOwnsAccessControlPolicyType(t *testing.T) { + tests := []struct { + name string + pluginID string + policyType string + want bool + }{ + {"owner matches", "mattermost-ai", "mattermost-ai:agent", true}, + {"foreign plugin", "other-plugin", "mattermost-ai:agent", false}, + {"empty plugin ID", "", "mattermost-ai:agent", false}, + {"core policy type", "mattermost-ai", AccessControlPolicyTypeChannel, false}, + // The stored type is matched byte-for-byte on delete and during + // evaluation, so a case variant that could Get but never Delete would + // be a trap. + {"caller ID case mismatch", "Mattermost-AI", "mattermost-ai:agent", false}, + {"stored prefix case mismatch", "mattermost-ai", "Mattermost-AI:agent", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, PluginOwnsAccessControlPolicyType(tc.pluginID, tc.policyType)) + }) + } +} + +func TestIsValidPolicyAction(t *testing.T) { + for _, action := range []string{ + AccessControlPolicyActionMembership, + AccessControlPolicyActionUploadFileAttachment, + "use", + "invoke_tool", + "read2", + } { + require.True(t, IsValidPolicyAction(action), action) + } + + for _, action := range []string{ + "", + "*", + "Use", + "use it", + "use-it", + "_use", + "use_", + "use__it", + strings.Repeat("a", MaxPolicyActionLength+1), + } { + require.False(t, IsValidPolicyAction(action), action) + } +} + +func TestInheritV0_5Rejected(t *testing.T) { + parent := &AccessControlPolicy{ + ID: NewId(), + Type: AccessControlPolicyTypeParent, + Name: "Parent", + Version: AccessControlPolicyVersionV0_3, + Revision: 0, + Rules: []AccessControlPolicyRule{{ + Actions: []string{AccessControlPolicyActionMembership}, + Expression: "true", + }}, + } + child := &AccessControlPolicy{ + ID: NewId(), + Type: testPluginPolicyType, + Name: "Agent policy", + Version: AccessControlPolicyVersionV0_5, + Revision: 0, + Rules: []AccessControlPolicyRule{{ + Actions: []string{"use"}, + Expression: "true", + }}, + } + err := child.Inherit(parent) + require.NotNil(t, err) + require.Equal(t, "model.access_policy.inherit.version.app_error", err.Id) + require.Empty(t, child.Imports) +} diff --git a/server/public/model/access_request.go b/server/public/model/access_request.go index a14271ace73..c09a787e15f 100644 --- a/server/public/model/access_request.go +++ b/server/public/model/access_request.go @@ -215,13 +215,51 @@ type AccessRequest struct { Context map[string]any `json:"context,omitempty"` } -// The PDP evaluates the request and returns an AccessDecision. -// The Decision field is a boolean indicating whether the request is allowed or not. +// AccessDecisionContextKeyReason is the AuthZEN decision-context key under +// which the PDP reports an AccessDecisionReason. +const AccessDecisionContextKeyReason = "reason" + +// AccessDecisionReason enumerates the well-known reasons the PDP reports in +// the decision context. +type AccessDecisionReason string + +// AccessDecisionReasonNoPolicy marks an allow as vacuous: no policy governs +// the request, so callers may apply their own defaults instead of treating the +// allow as an explicit grant. +const AccessDecisionReasonNoPolicy AccessDecisionReason = "no_policy" + +// AccessDecision is the PDP's answer to an AccessRequest. It follows the +// OpenID AuthZEN evaluation response: a boolean Decision plus an optional +// Context carrying additional detail. type AccessDecision struct { Decision bool `json:"decision"` Context map[string]any `json:"context,omitempty"` } +// NewNoPolicyAccessDecision returns the vacuous allow for a request no policy +// governs. +func NewNoPolicyAccessDecision() AccessDecision { + return AccessDecision{ + Decision: true, + Context: map[string]any{AccessDecisionContextKeyReason: string(AccessDecisionReasonNoPolicy)}, + } +} + +// Reason returns the well-known reason recorded in the decision context, or +// the empty reason when the context carries none. +func (d AccessDecision) Reason() AccessDecisionReason { + reason, _ := d.Context[AccessDecisionContextKeyReason].(string) + return AccessDecisionReason(reason) +} + +// IsNoPolicy reports whether the request is unregulated: no policy governs it, +// so the caller may apply its own defaults. A denial is never treated as a +// no-policy fallback, however it is labelled, so a contradictory response +// (decision false carrying the no_policy reason) stays a deny. +func (d AccessDecision) IsNoPolicy() bool { + return d.Decision && d.Reason() == AccessDecisionReasonNoPolicy +} + type QueryExpressionParams struct { Expression string `json:"expression"` Term string `json:"term"` diff --git a/server/public/model/access_request_test.go b/server/public/model/access_request_test.go new file mode 100644 index 00000000000..84c55de2dcd --- /dev/null +++ b/server/public/model/access_request_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAccessDecisionJSON(t *testing.T) { + t.Run("bare decision omits the context", func(t *testing.T) { + data, err := json.Marshal(AccessDecision{Decision: true}) + require.NoError(t, err) + require.JSONEq(t, `{"decision":true}`, string(data)) + }) + + t.Run("no-policy decision round-trips", func(t *testing.T) { + in := NewNoPolicyAccessDecision() + data, err := json.Marshal(in) + require.NoError(t, err) + require.JSONEq(t, `{"decision":true,"context":{"reason":"no_policy"}}`, string(data)) + + var out AccessDecision + require.NoError(t, json.Unmarshal(data, &out)) + require.Equal(t, in, out) + require.True(t, out.IsNoPolicy()) + }) +} + +func TestAccessDecisionIsNoPolicy(t *testing.T) { + tests := []struct { + name string + decision AccessDecision + want bool + }{ + {"no context", AccessDecision{Decision: true}, false}, + {"no reason key", AccessDecision{Decision: true, Context: map[string]any{"other": "x"}}, false}, + {"non-string reason", AccessDecision{Decision: true, Context: map[string]any{AccessDecisionContextKeyReason: 1}}, false}, + {"unrelated reason", AccessDecision{Decision: true, Context: map[string]any{AccessDecisionContextKeyReason: "whatever"}}, false}, + {"no_policy reason", NewNoPolicyAccessDecision(), true}, + // A deny must never be classified as an unregulated request, however + // the context labels it. + { + "deny contradicting the no_policy reason", + AccessDecision{Decision: false, Context: map[string]any{AccessDecisionContextKeyReason: string(AccessDecisionReasonNoPolicy)}}, + false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, tc.decision.IsNoPolicy()) + }) + } +} diff --git a/server/public/model/audit_events.go b/server/public/model/audit_events.go index d2ac5f6afc3..d0c0bed1d4e 100644 --- a/server/public/model/audit_events.go +++ b/server/public/model/audit_events.go @@ -13,6 +13,9 @@ const ( AuditEventUpdateActiveStatus = "updateActiveStatus" // update active/inactive status of access control policy AuditEventSetActiveStatus = "setActiveStatus" // set active/inactive status of multiple access control policies + AuditEventSavePluginAccessControlPolicy = "savePluginAccessControlPolicy" // create/update plugin-owned access control policy (activation implicit) + AuditEventDeletePluginAccessControlPolicy = "deletePluginAccessControlPolicy" // delete plugin-owned access control policy + AuditEventCreateTeamAccessPolicy = "createTeamAccessPolicy" // create team-scoped access control policy AuditEventUpdateTeamAccessPolicy = "updateTeamAccessPolicy" // update team-scoped access control policy AuditEventDeleteTeamAccessPolicy = "deleteTeamAccessPolicy" // delete team-scoped access control policy diff --git a/server/public/model/native_attributes.go b/server/public/model/native_attributes.go index 7b27ad0b0fa..d1776c9a402 100644 --- a/server/public/model/native_attributes.go +++ b/server/public/model/native_attributes.go @@ -73,10 +73,15 @@ func nativeAttributeField(groupID, name, displayName string, fieldType PropertyF // access-control autocomplete so the table/text editors can list them alongside // custom profile attributes. func NativeUserAttributeFields(groupID string) []*PropertyField { + // The options must be the gob-registered []any / map[string]any + // containers, not a concrete slice type: these fields cross the plugin + // RPC boundary inside PropertyField.Attrs, and an unregistered type + // (e.g. []map[string]string) fails gob encoding and shuts down the + // shared plugin API connection. The JSON output is identical either way. boolSelectOptions := StringInterface{ - PropertyFieldAttributeOptions: []map[string]string{ - {"name": "true"}, - {"name": "false"}, + PropertyFieldAttributeOptions: []any{ + map[string]any{"name": "true"}, + map[string]any{"name": "false"}, }, } diff --git a/server/public/model/native_attributes_test.go b/server/public/model/native_attributes_test.go index a1e6d5fb3ac..fa2d9f9a077 100644 --- a/server/public/model/native_attributes_test.go +++ b/server/public/model/native_attributes_test.go @@ -4,6 +4,7 @@ package model import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -47,7 +48,13 @@ func TestNativeUserAttributeFields(t *testing.T) { require.NotNil(t, f) assert.Equal(t, PropertyFieldTypeSelect, f.Type) assert.Equal(t, []string{"==", "!="}, f.Attrs[NativeAttributeAttrOperators]) - assert.Equal(t, []map[string]string{{"name": "true"}, {"name": "false"}}, f.Attrs[PropertyFieldAttributeOptions]) + // []any/map[string]any so the field survives the plugin RPC + // boundary; the JSON shape is pinned below. + assert.Equal(t, []any{map[string]any{"name": "true"}, map[string]any{"name": "false"}}, f.Attrs[PropertyFieldAttributeOptions]) + + data, err := json.Marshal(f.Attrs[PropertyFieldAttributeOptions]) + require.NoError(t, err) + assert.JSONEq(t, `[{"name":"true"},{"name":"false"}]`, string(data)) }) } diff --git a/server/public/plugin/api.go b/server/public/plugin/api.go index 63bb0fab3fe..b5bbb9a9641 100644 --- a/server/public/plugin/api.go +++ b/server/public/plugin/api.go @@ -1706,6 +1706,74 @@ type API interface { // @tag Audit // Minimum server version: 10.10 LogAuditRecWithLevel(rec *model.AuditRecord, level mlog.Level) + + // EvaluateAccessControl evaluates whether userID may perform action on the + // plugin-owned resource (resourceType, resourceID). resourceType must be + // ":". The reply follows the OpenID AuthZEN + // evaluation response: Decision plus an optional Context. + // + // AccessDecision.IsNoPolicy() reports that the server positively determined + // no policy governs the resource — resolved even when the access control + // engine is unavailable — so the caller can safely apply its own defaults + // instead of treating the allow as an explicit grant. Any returned error + // means the decision could not be computed and the plugin MUST fail closed + // (deny). + // + // @tag AccessControl + // Minimum server version: 11.10 + EvaluateAccessControl(userID, resourceType, resourceID, action string) (*model.AccessDecision, *model.AppError) + + // SaveAccessControlPolicy creates or updates a policy whose Type is + // ":". Version is forced to v0.5 and Active to + // true. policy.ID must be the resource's stable 26-char ID. + // + // @tag AccessControl + // Minimum server version: 11.10 + SaveAccessControlPolicy(actingUserID string, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) + + // GetAccessControlPolicy returns the policy stored under id. Returns a + // not-found error if no policy exists OR the stored policy's type is not + // owned by the calling plugin (fail closed, no existence leak). + // + // @tag AccessControl + // Minimum server version: 11.10 + GetAccessControlPolicy(id string) (*model.AccessControlPolicy, *model.AppError) + + // DeleteAccessControlPolicy deletes the policy stored under id after + // verifying the stored policy's type equals resourceType and is owned by + // the calling plugin. Type mismatches return a not-found error (fail closed). + // + // @tag AccessControl + // Minimum server version: 11.10 + DeleteAccessControlPolicy(actingUserID, resourceType, id string) *model.AppError + + // CheckAccessControlExpression compiles and lints a CEL expression; an + // empty slice means the expression is valid. + // + // @tag AccessControl + // Minimum server version: 11.10 + CheckAccessControlExpression(actingUserID, resourceType, expression string) ([]model.CELExpressionError, *model.AppError) + + // QueryUsersForAccessControlExpression returns users matching the + // expression (test modal support for policy editors). + // + // @tag AccessControl + // Minimum server version: 11.10 + QueryUsersForAccessControlExpression(actingUserID, resourceType, expression, term, cursorID string, limit int) (*model.AccessControlPolicyTestResponse, *model.AppError) + + // GetAccessControlFieldsAutocomplete returns CPA fields for editor + // autocomplete, filtered by the acting user's attribute visibility. + // + // @tag AccessControl + // Minimum server version: 11.10 + GetAccessControlFieldsAutocomplete(actingUserID, after string, limit int) ([]*model.PropertyField, *model.AppError) + + // GetAccessControlVisualAST converts a CEL expression to the visual + // (table) AST. + // + // @tag AccessControl + // Minimum server version: 11.10 + GetAccessControlVisualAST(actingUserID, resourceType, expression string) (*model.VisualExpression, *model.AppError) } var handshake = plugin.HandshakeConfig{ diff --git a/server/public/plugin/api_timer_layer_generated.go b/server/public/plugin/api_timer_layer_generated.go index 4906c1f7be3..0204fa52826 100644 --- a/server/public/plugin/api_timer_layer_generated.go +++ b/server/public/plugin/api_timer_layer_generated.go @@ -1769,3 +1769,59 @@ func (api *apiTimerLayer) LogAuditRecWithLevel(rec *model.AuditRecord, level mlo api.apiImpl.LogAuditRecWithLevel(rec, level) api.recordTime(startTime, "LogAuditRecWithLevel", true) } + +func (api *apiTimerLayer) EvaluateAccessControl(userID, resourceType, resourceID, action string) (*model.AccessDecision, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.EvaluateAccessControl(userID, resourceType, resourceID, action) + api.recordTime(startTime, "EvaluateAccessControl", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) SaveAccessControlPolicy(actingUserID string, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.SaveAccessControlPolicy(actingUserID, policy) + api.recordTime(startTime, "SaveAccessControlPolicy", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) GetAccessControlPolicy(id string) (*model.AccessControlPolicy, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.GetAccessControlPolicy(id) + api.recordTime(startTime, "GetAccessControlPolicy", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) DeleteAccessControlPolicy(actingUserID, resourceType, id string) *model.AppError { + startTime := timePkg.Now() + _returnsA := api.apiImpl.DeleteAccessControlPolicy(actingUserID, resourceType, id) + api.recordTime(startTime, "DeleteAccessControlPolicy", _returnsA == nil) + return _returnsA +} + +func (api *apiTimerLayer) CheckAccessControlExpression(actingUserID, resourceType, expression string) ([]model.CELExpressionError, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.CheckAccessControlExpression(actingUserID, resourceType, expression) + api.recordTime(startTime, "CheckAccessControlExpression", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) QueryUsersForAccessControlExpression(actingUserID, resourceType, expression, term, cursorID string, limit int) (*model.AccessControlPolicyTestResponse, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.QueryUsersForAccessControlExpression(actingUserID, resourceType, expression, term, cursorID, limit) + api.recordTime(startTime, "QueryUsersForAccessControlExpression", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) GetAccessControlFieldsAutocomplete(actingUserID, after string, limit int) ([]*model.PropertyField, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.GetAccessControlFieldsAutocomplete(actingUserID, after, limit) + api.recordTime(startTime, "GetAccessControlFieldsAutocomplete", _returnsB == nil) + return _returnsA, _returnsB +} + +func (api *apiTimerLayer) GetAccessControlVisualAST(actingUserID, resourceType, expression string) (*model.VisualExpression, *model.AppError) { + startTime := timePkg.Now() + _returnsA, _returnsB := api.apiImpl.GetAccessControlVisualAST(actingUserID, resourceType, expression) + api.recordTime(startTime, "GetAccessControlVisualAST", _returnsB == nil) + return _returnsA, _returnsB +} diff --git a/server/public/plugin/client_rpc_generated.go b/server/public/plugin/client_rpc_generated.go index 71411dbf5d9..3314d2edb93 100644 --- a/server/public/plugin/client_rpc_generated.go +++ b/server/public/plugin/client_rpc_generated.go @@ -9350,3 +9350,251 @@ func (s *apiRPCServer) DeletePropertyValuesForFieldWithOptions(args *Z_DeletePro } return nil } + +type Z_EvaluateAccessControlArgs struct { + A string + B string + C string + D string +} + +type Z_EvaluateAccessControlReturns struct { + A *model.AccessDecision + B *model.AppError +} + +func (g *apiRPCClient) EvaluateAccessControl(userID, resourceType, resourceID, action string) (*model.AccessDecision, *model.AppError) { + _args := &Z_EvaluateAccessControlArgs{userID, resourceType, resourceID, action} + _returns := &Z_EvaluateAccessControlReturns{} + if err := g.client.Call("Plugin.EvaluateAccessControl", _args, _returns); err != nil { + log.Printf("RPC call to EvaluateAccessControl API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) EvaluateAccessControl(args *Z_EvaluateAccessControlArgs, returns *Z_EvaluateAccessControlReturns) error { + if hook, ok := s.impl.(interface { + EvaluateAccessControl(userID, resourceType, resourceID, action string) (*model.AccessDecision, *model.AppError) + }); ok { + returns.A, returns.B = hook.EvaluateAccessControl(args.A, args.B, args.C, args.D) + } else { + return encodableError(fmt.Errorf("API EvaluateAccessControl called but not implemented.")) + } + return nil +} + +type Z_SaveAccessControlPolicyArgs struct { + A string + B *model.AccessControlPolicy +} + +type Z_SaveAccessControlPolicyReturns struct { + A *model.AccessControlPolicy + B *model.AppError +} + +func (g *apiRPCClient) SaveAccessControlPolicy(actingUserID string, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) { + _args := &Z_SaveAccessControlPolicyArgs{actingUserID, policy} + _returns := &Z_SaveAccessControlPolicyReturns{} + if err := g.client.Call("Plugin.SaveAccessControlPolicy", _args, _returns); err != nil { + log.Printf("RPC call to SaveAccessControlPolicy API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) SaveAccessControlPolicy(args *Z_SaveAccessControlPolicyArgs, returns *Z_SaveAccessControlPolicyReturns) error { + if hook, ok := s.impl.(interface { + SaveAccessControlPolicy(actingUserID string, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) + }); ok { + returns.A, returns.B = hook.SaveAccessControlPolicy(args.A, args.B) + } else { + return encodableError(fmt.Errorf("API SaveAccessControlPolicy called but not implemented.")) + } + return nil +} + +type Z_GetAccessControlPolicyArgs struct { + A string +} + +type Z_GetAccessControlPolicyReturns struct { + A *model.AccessControlPolicy + B *model.AppError +} + +func (g *apiRPCClient) GetAccessControlPolicy(id string) (*model.AccessControlPolicy, *model.AppError) { + _args := &Z_GetAccessControlPolicyArgs{id} + _returns := &Z_GetAccessControlPolicyReturns{} + if err := g.client.Call("Plugin.GetAccessControlPolicy", _args, _returns); err != nil { + log.Printf("RPC call to GetAccessControlPolicy API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) GetAccessControlPolicy(args *Z_GetAccessControlPolicyArgs, returns *Z_GetAccessControlPolicyReturns) error { + if hook, ok := s.impl.(interface { + GetAccessControlPolicy(id string) (*model.AccessControlPolicy, *model.AppError) + }); ok { + returns.A, returns.B = hook.GetAccessControlPolicy(args.A) + } else { + return encodableError(fmt.Errorf("API GetAccessControlPolicy called but not implemented.")) + } + return nil +} + +type Z_DeleteAccessControlPolicyArgs struct { + A string + B string + C string +} + +type Z_DeleteAccessControlPolicyReturns struct { + A *model.AppError +} + +func (g *apiRPCClient) DeleteAccessControlPolicy(actingUserID, resourceType, id string) *model.AppError { + _args := &Z_DeleteAccessControlPolicyArgs{actingUserID, resourceType, id} + _returns := &Z_DeleteAccessControlPolicyReturns{} + if err := g.client.Call("Plugin.DeleteAccessControlPolicy", _args, _returns); err != nil { + log.Printf("RPC call to DeleteAccessControlPolicy API failed: %s", err.Error()) + } + return _returns.A +} + +func (s *apiRPCServer) DeleteAccessControlPolicy(args *Z_DeleteAccessControlPolicyArgs, returns *Z_DeleteAccessControlPolicyReturns) error { + if hook, ok := s.impl.(interface { + DeleteAccessControlPolicy(actingUserID, resourceType, id string) *model.AppError + }); ok { + returns.A = hook.DeleteAccessControlPolicy(args.A, args.B, args.C) + } else { + return encodableError(fmt.Errorf("API DeleteAccessControlPolicy called but not implemented.")) + } + return nil +} + +type Z_CheckAccessControlExpressionArgs struct { + A string + B string + C string +} + +type Z_CheckAccessControlExpressionReturns struct { + A []model.CELExpressionError + B *model.AppError +} + +func (g *apiRPCClient) CheckAccessControlExpression(actingUserID, resourceType, expression string) ([]model.CELExpressionError, *model.AppError) { + _args := &Z_CheckAccessControlExpressionArgs{actingUserID, resourceType, expression} + _returns := &Z_CheckAccessControlExpressionReturns{} + if err := g.client.Call("Plugin.CheckAccessControlExpression", _args, _returns); err != nil { + log.Printf("RPC call to CheckAccessControlExpression API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) CheckAccessControlExpression(args *Z_CheckAccessControlExpressionArgs, returns *Z_CheckAccessControlExpressionReturns) error { + if hook, ok := s.impl.(interface { + CheckAccessControlExpression(actingUserID, resourceType, expression string) ([]model.CELExpressionError, *model.AppError) + }); ok { + returns.A, returns.B = hook.CheckAccessControlExpression(args.A, args.B, args.C) + } else { + return encodableError(fmt.Errorf("API CheckAccessControlExpression called but not implemented.")) + } + return nil +} + +type Z_QueryUsersForAccessControlExpressionArgs struct { + A string + B string + C string + D string + E string + F int +} + +type Z_QueryUsersForAccessControlExpressionReturns struct { + A *model.AccessControlPolicyTestResponse + B *model.AppError +} + +func (g *apiRPCClient) QueryUsersForAccessControlExpression(actingUserID, resourceType, expression, term, cursorID string, limit int) (*model.AccessControlPolicyTestResponse, *model.AppError) { + _args := &Z_QueryUsersForAccessControlExpressionArgs{actingUserID, resourceType, expression, term, cursorID, limit} + _returns := &Z_QueryUsersForAccessControlExpressionReturns{} + if err := g.client.Call("Plugin.QueryUsersForAccessControlExpression", _args, _returns); err != nil { + log.Printf("RPC call to QueryUsersForAccessControlExpression API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) QueryUsersForAccessControlExpression(args *Z_QueryUsersForAccessControlExpressionArgs, returns *Z_QueryUsersForAccessControlExpressionReturns) error { + if hook, ok := s.impl.(interface { + QueryUsersForAccessControlExpression(actingUserID, resourceType, expression, term, cursorID string, limit int) (*model.AccessControlPolicyTestResponse, *model.AppError) + }); ok { + returns.A, returns.B = hook.QueryUsersForAccessControlExpression(args.A, args.B, args.C, args.D, args.E, args.F) + } else { + return encodableError(fmt.Errorf("API QueryUsersForAccessControlExpression called but not implemented.")) + } + return nil +} + +type Z_GetAccessControlFieldsAutocompleteArgs struct { + A string + B string + C int +} + +type Z_GetAccessControlFieldsAutocompleteReturns struct { + A []*model.PropertyField + B *model.AppError +} + +func (g *apiRPCClient) GetAccessControlFieldsAutocomplete(actingUserID, after string, limit int) ([]*model.PropertyField, *model.AppError) { + _args := &Z_GetAccessControlFieldsAutocompleteArgs{actingUserID, after, limit} + _returns := &Z_GetAccessControlFieldsAutocompleteReturns{} + if err := g.client.Call("Plugin.GetAccessControlFieldsAutocomplete", _args, _returns); err != nil { + log.Printf("RPC call to GetAccessControlFieldsAutocomplete API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) GetAccessControlFieldsAutocomplete(args *Z_GetAccessControlFieldsAutocompleteArgs, returns *Z_GetAccessControlFieldsAutocompleteReturns) error { + if hook, ok := s.impl.(interface { + GetAccessControlFieldsAutocomplete(actingUserID, after string, limit int) ([]*model.PropertyField, *model.AppError) + }); ok { + returns.A, returns.B = hook.GetAccessControlFieldsAutocomplete(args.A, args.B, args.C) + } else { + return encodableError(fmt.Errorf("API GetAccessControlFieldsAutocomplete called but not implemented.")) + } + return nil +} + +type Z_GetAccessControlVisualASTArgs struct { + A string + B string + C string +} + +type Z_GetAccessControlVisualASTReturns struct { + A *model.VisualExpression + B *model.AppError +} + +func (g *apiRPCClient) GetAccessControlVisualAST(actingUserID, resourceType, expression string) (*model.VisualExpression, *model.AppError) { + _args := &Z_GetAccessControlVisualASTArgs{actingUserID, resourceType, expression} + _returns := &Z_GetAccessControlVisualASTReturns{} + if err := g.client.Call("Plugin.GetAccessControlVisualAST", _args, _returns); err != nil { + log.Printf("RPC call to GetAccessControlVisualAST API failed: %s", err.Error()) + } + return _returns.A, _returns.B +} + +func (s *apiRPCServer) GetAccessControlVisualAST(args *Z_GetAccessControlVisualASTArgs, returns *Z_GetAccessControlVisualASTReturns) error { + if hook, ok := s.impl.(interface { + GetAccessControlVisualAST(actingUserID, resourceType, expression string) (*model.VisualExpression, *model.AppError) + }); ok { + returns.A, returns.B = hook.GetAccessControlVisualAST(args.A, args.B, args.C) + } else { + return encodableError(fmt.Errorf("API GetAccessControlVisualAST called but not implemented.")) + } + return nil +} diff --git a/server/public/plugin/plugintest/api.go b/server/public/plugin/plugintest/api.go index 42cd9e3f07e..e853ffef467 100644 --- a/server/public/plugin/plugintest/api.go +++ b/server/public/plugin/plugintest/api.go @@ -114,6 +114,38 @@ func (_m *API) AddUserToChannel(channelId string, userID string, asUserId string return r0, r1 } +// CheckAccessControlExpression provides a mock function with given fields: actingUserID, resourceType, expression +func (_m *API) CheckAccessControlExpression(actingUserID string, resourceType string, expression string) ([]model.CELExpressionError, *model.AppError) { + ret := _m.Called(actingUserID, resourceType, expression) + + if len(ret) == 0 { + panic("no return value specified for CheckAccessControlExpression") + } + + var r0 []model.CELExpressionError + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(string, string, string) ([]model.CELExpressionError, *model.AppError)); ok { + return rf(actingUserID, resourceType, expression) + } + if rf, ok := ret.Get(0).(func(string, string, string) []model.CELExpressionError); ok { + r0 = rf(actingUserID, resourceType, expression) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]model.CELExpressionError) + } + } + + if rf, ok := ret.Get(1).(func(string, string, string) *model.AppError); ok { + r1 = rf(actingUserID, resourceType, expression) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // CopyFileInfos provides a mock function with given fields: userID, fileIds func (_m *API) CopyFileInfos(userID string, fileIds []string) ([]string, *model.AppError) { ret := _m.Called(userID, fileIds) @@ -758,6 +790,26 @@ func (_m *API) CreateUserAccessToken(token *model.UserAccessToken) (*model.UserA return r0, r1 } +// DeleteAccessControlPolicy provides a mock function with given fields: actingUserID, resourceType, id +func (_m *API) DeleteAccessControlPolicy(actingUserID string, resourceType string, id string) *model.AppError { + ret := _m.Called(actingUserID, resourceType, id) + + if len(ret) == 0 { + panic("no return value specified for DeleteAccessControlPolicy") + } + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(string, string, string) *model.AppError); ok { + r0 = rf(actingUserID, resourceType, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + // DeleteChannel provides a mock function with given fields: channelId func (_m *API) DeleteChannel(channelId string) *model.AppError { ret := _m.Called(channelId) @@ -1251,6 +1303,38 @@ func (_m *API) EnsureBotUser(bot *model.Bot) (string, error) { return r0, r1 } +// EvaluateAccessControl provides a mock function with given fields: userID, resourceType, resourceID, action +func (_m *API) EvaluateAccessControl(userID string, resourceType string, resourceID string, action string) (*model.AccessDecision, *model.AppError) { + ret := _m.Called(userID, resourceType, resourceID, action) + + if len(ret) == 0 { + panic("no return value specified for EvaluateAccessControl") + } + + var r0 *model.AccessDecision + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(string, string, string, string) (*model.AccessDecision, *model.AppError)); ok { + return rf(userID, resourceType, resourceID, action) + } + if rf, ok := ret.Get(0).(func(string, string, string, string) *model.AccessDecision); ok { + r0 = rf(userID, resourceType, resourceID, action) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AccessDecision) + } + } + + if rf, ok := ret.Get(1).(func(string, string, string, string) *model.AppError); ok { + r1 = rf(userID, resourceType, resourceID, action) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // ExecuteSlashCommand provides a mock function with given fields: commandArgs func (_m *API) ExecuteSlashCommand(commandArgs *model.CommandArgs) (*model.CommandResponse, error) { ret := _m.Called(commandArgs) @@ -1301,6 +1385,102 @@ func (_m *API) ExtendSessionExpiry(sessionID string, newExpiry int64) *model.App return r0 } +// GetAccessControlFieldsAutocomplete provides a mock function with given fields: actingUserID, after, limit +func (_m *API) GetAccessControlFieldsAutocomplete(actingUserID string, after string, limit int) ([]*model.PropertyField, *model.AppError) { + ret := _m.Called(actingUserID, after, limit) + + if len(ret) == 0 { + panic("no return value specified for GetAccessControlFieldsAutocomplete") + } + + var r0 []*model.PropertyField + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(string, string, int) ([]*model.PropertyField, *model.AppError)); ok { + return rf(actingUserID, after, limit) + } + if rf, ok := ret.Get(0).(func(string, string, int) []*model.PropertyField); ok { + r0 = rf(actingUserID, after, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.PropertyField) + } + } + + if rf, ok := ret.Get(1).(func(string, string, int) *model.AppError); ok { + r1 = rf(actingUserID, after, limit) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// GetAccessControlPolicy provides a mock function with given fields: id +func (_m *API) GetAccessControlPolicy(id string) (*model.AccessControlPolicy, *model.AppError) { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for GetAccessControlPolicy") + } + + var r0 *model.AccessControlPolicy + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(string) (*model.AccessControlPolicy, *model.AppError)); ok { + return rf(id) + } + if rf, ok := ret.Get(0).(func(string) *model.AccessControlPolicy); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AccessControlPolicy) + } + } + + if rf, ok := ret.Get(1).(func(string) *model.AppError); ok { + r1 = rf(id) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// GetAccessControlVisualAST provides a mock function with given fields: actingUserID, resourceType, expression +func (_m *API) GetAccessControlVisualAST(actingUserID string, resourceType string, expression string) (*model.VisualExpression, *model.AppError) { + ret := _m.Called(actingUserID, resourceType, expression) + + if len(ret) == 0 { + panic("no return value specified for GetAccessControlVisualAST") + } + + var r0 *model.VisualExpression + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(string, string, string) (*model.VisualExpression, *model.AppError)); ok { + return rf(actingUserID, resourceType, expression) + } + if rf, ok := ret.Get(0).(func(string, string, string) *model.VisualExpression); ok { + r0 = rf(actingUserID, resourceType, expression) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.VisualExpression) + } + } + + if rf, ok := ret.Get(1).(func(string, string, string) *model.AppError); ok { + r1 = rf(actingUserID, resourceType, expression) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // GetBot provides a mock function with given fields: botUserId, includeDeleted func (_m *API) GetBot(botUserId string, includeDeleted bool) (*model.Bot, *model.AppError) { ret := _m.Called(botUserId, includeDeleted) @@ -4651,6 +4831,38 @@ func (_m *API) PublishWebSocketEvent(event string, payload map[string]interface{ _m.Called(event, payload, broadcast) } +// QueryUsersForAccessControlExpression provides a mock function with given fields: actingUserID, resourceType, expression, term, cursorID, limit +func (_m *API) QueryUsersForAccessControlExpression(actingUserID string, resourceType string, expression string, term string, cursorID string, limit int) (*model.AccessControlPolicyTestResponse, *model.AppError) { + ret := _m.Called(actingUserID, resourceType, expression, term, cursorID, limit) + + if len(ret) == 0 { + panic("no return value specified for QueryUsersForAccessControlExpression") + } + + var r0 *model.AccessControlPolicyTestResponse + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(string, string, string, string, string, int) (*model.AccessControlPolicyTestResponse, *model.AppError)); ok { + return rf(actingUserID, resourceType, expression, term, cursorID, limit) + } + if rf, ok := ret.Get(0).(func(string, string, string, string, string, int) *model.AccessControlPolicyTestResponse); ok { + r0 = rf(actingUserID, resourceType, expression, term, cursorID, limit) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AccessControlPolicyTestResponse) + } + } + + if rf, ok := ret.Get(1).(func(string, string, string, string, string, int) *model.AppError); ok { + r1 = rf(actingUserID, resourceType, expression, term, cursorID, limit) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // ReadFile provides a mock function with given fields: path func (_m *API) ReadFile(path string) ([]byte, *model.AppError) { ret := _m.Called(path) @@ -5083,6 +5295,38 @@ func (_m *API) RolesGrantPermission(roleNames []string, permissionId string) boo return r0 } +// SaveAccessControlPolicy provides a mock function with given fields: actingUserID, policy +func (_m *API) SaveAccessControlPolicy(actingUserID string, policy *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError) { + ret := _m.Called(actingUserID, policy) + + if len(ret) == 0 { + panic("no return value specified for SaveAccessControlPolicy") + } + + var r0 *model.AccessControlPolicy + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(string, *model.AccessControlPolicy) (*model.AccessControlPolicy, *model.AppError)); ok { + return rf(actingUserID, policy) + } + if rf, ok := ret.Get(0).(func(string, *model.AccessControlPolicy) *model.AccessControlPolicy); ok { + r0 = rf(actingUserID, policy) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AccessControlPolicy) + } + } + + if rf, ok := ret.Get(1).(func(string, *model.AccessControlPolicy) *model.AppError); ok { + r1 = rf(actingUserID, policy) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // SaveConfig provides a mock function with given fields: config func (_m *API) SaveConfig(config *model.Config) *model.AppError { ret := _m.Called(config)