Files
team/server/channels/app/plugin_access_control.go
zeekay 8feb24ea1f One name for this fork, and it is ours
The server module named github.com/hanzoai/team/server/v8 — a path that
resolves to the retired workspace's repository, not this one — while
server/public still published github.com/mattermost/mattermost/server/public.
Both are github.com/hanzoteam/server/... now, which is where this code
actually lives.

That second half is what made the binary unbootable. Upstream's public path
stayed claimed by upstream, mattermost-plugin-ai imported it, and the build
linked public/model twice; public/plugin's gob.Register then ran twice under
one name and panicked at init, before main. Reverting the rename traded the
brand for a working binary. Rebranding the plugin alongside it needs no such
trade: the agents fork is github.com/hanzoteam/agents/v2 against our public
module, so no module named mattermost/mattermost is in the graph at all.

Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and
it is ours.
2026-08-28 23:35:36 -07:00

445 lines
20 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"errors"
"net/http"
"strings"
"github.com/hanzoteam/server/server/public/model"
"github.com/hanzoteam/server/server/public/shared/mlog"
"github.com/hanzoteam/server/server/public/shared/request"
"github.com/hanzoteam/server/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)
}