mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-26 21:27:40 -05:00
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:
co-authored by
Cursor Agent
parent
ddffe7896e
commit
c7eff70026
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user