ABAC: plugin-keyed resource types, trusted plugin PAP/CEL APIs, and AuthZEN-style decision API (#37509)

* MM: add v0.5 plugin access control policy model, registry, and decision outcomes

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* MM: add plugin access control PDP/PAP app-layer methods with fail-closed semantics

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* MM: expose plugin access control API surface (EvaluateAccessControl + PAP/CEL methods)

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* MM: add store-layer round-trip tests for v0.5 plugin access control policies

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* MM: extract plugin access control app code into plugin_access_control.go

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* MM: add atomic type-guarded AccessControlPolicyStore.DeleteIfType

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* MM: plugin PAP hardening — atomic typed delete, indistinguishable 404s, audit every attempt

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* MM: close plugin Get-by-ID TOCTOU via GetPolicyOfType; stamp save audit operation at entry

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Fix gob RPC poisoning from native attribute select options

NativeUserAttributeFields stored bool-select options as
[]map[string]string inside PropertyField.Attrs (map[string]any). gob
requires concrete types inside interface values to be registered, and
[]map[string]string is not registered in client_rpc.go, so encoding the
GetAccessControlFieldsAutocomplete reply failed and net/rpc shut down
the shared plugin API connection, breaking every subsequent plugin API
call.

Build the options from gob-registered containers ([]any/map[string]any)
instead; JSON output is byte-identical. Add gob round-trip regression
tests covering every plugin access control API reply payload: the
autocomplete response including native attribute fields (fails against
the old code), policies with JSON-decoded Props, visual AST condition
values of every runtime shape, expression check errors, query users
responses, and evaluation decisions.

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Move plugin access control gob-safety tests into their own file

Pure move: plugin_access_control_test.go crossed 1000 lines; the gob-safety
helper and TestPluginAccessControlGobSafety now live in
plugin_access_control_gob_test.go, unchanged.

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* MM: resolve plugin policy existence when ABAC is unavailable (Option B)

Every evaluation-impossible branch of EvaluatePluginAccessRequest
(service nil / unlicensed / flag off / user load or subject build
failure / evaluator infra error / unknown outcome) now performs a raw
open-core store read on the already-validated resource ID: no stored
row returns no_policy so the caller can safely apply legacy behavior;
any stored row (with a Warn on a foreign-type anomaly) or a failed
read returns unavailable so the caller must fail closed. This lets the
plugin drop its local policy index entirely.

Strengthens the EvaluateAccessControl doc contract accordingly and
reworks the fail-closed test matrix with with/without-row splits per
branch, a foreign-type-row case, a store-read-error case on the store
mock, and passthrough rows pinning that the fallback read never runs
when the evaluator answers.

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Clarify EvaluateAccessControl failure-mapping doc

A failure with a definitive store miss maps to no_policy under the
Option B semantics, so 'failures never map to allow or no_policy' was
inaccurate. State precisely: never allow; no_policy only on positively
determined non-existence; deny for defensive failures on a resolved
policy; unavailable otherwise.

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Tighten ABAC comments

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Document AccessDecision.Outcome as plugin-API-only

The evaluator populates Outcome on every lane, but the only production
reader is the app layer's EvaluatePluginAccessRequest, which maps it
into PluginAccessControlDecision; core channel/team enforcement reads
the collapsed Decision bool alone. State that on the field.

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Retrigger CI to rebuild enterprise image with updated enterprise branch

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Document that only the evaluator's plugin lane sets AccessDecision.Outcome

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* ABAC: key plugin resource types as plugin_id:type and drop the static registry

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* ABAC: replace AccessDecision.Outcome with the AuthZEN decision context reason

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* ABAC: drop type-scoped policy get/delete and check the type in the app layer

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* ABAC: compare plugin policy type ownership exactly instead of case-insensitively

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* ABAC: bound the whole policy type to the Type column width

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* ABAC: require an allow before treating a decision as the no-policy fallback

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* ABAC: treat a colliding foreign-type policy row as no_policy, not a deny

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* ABAC: gate plugin policy reads on a raw store read before normalization

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* ABAC: pin policy type immutability on save in the store tests

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* ABAC: reuse a single unavailable-error constructor in the existence fallback

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* ABAC: re-check plugin policy ownership on the normalized get read

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* ABAC: confirm plugin policy ownership before surfacing a normalization error

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Nick Misasi
2026-08-04 09:59:48 -04:00
committed by GitHub
co-authored by Cursor Agent
parent ddffe7896e
commit c7eff70026
17 changed files with 2995 additions and 6 deletions
@@ -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 "<pluginID>:<resourceType>", 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)
}
@@ -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)
})
}
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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 {
@@ -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) {
+36
View File
@@ -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."
+139
View File
@@ -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 ("<pluginID>:<resourceType>").
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 "<pluginID>:<resourceType>"
// 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))
+247
View File
@@ -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)
}
+40 -2
View File
@@ -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"`
@@ -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())
})
}
}
+3
View File
@@ -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
+8 -3
View File
@@ -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"},
},
}
@@ -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))
})
}
+68
View File
@@ -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
// "<callingPluginID>:<type>". 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
// "<callingPluginID>:<type>". 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{
@@ -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
}
@@ -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
}
+244
View File
@@ -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)