mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
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.
1424 lines
50 KiB
Go
1424 lines
50 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package api4
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/hanzoteam/server/server/public/model"
|
|
"github.com/hanzoteam/server/server/public/shared/mlog"
|
|
"github.com/hanzoteam/server/server/v8/channels/app"
|
|
)
|
|
|
|
// shouldRedactExpressions reports whether raw CEL expressions should be masked for this caller.
|
|
// Masking is attribute-based, not permission-based: system admins who do not hold all values
|
|
// in a policy must also receive redacted raw expressions.
|
|
func shouldRedactExpressions(c *Context) bool {
|
|
return c.App.Config().FeatureFlags.AttributeValueMasking
|
|
}
|
|
|
|
// preserveSystemManagedFields pins parent imports and team-scope metadata to the stored values:
|
|
// attaching/detaching parents belongs to the assign/unassign endpoints, so a channel/team admin
|
|
// editing rules here can't change them. No stored policy (first-time create) means they start empty.
|
|
func preserveSystemManagedFields(c *Context, policy *model.AccessControlPolicy) *model.AppError {
|
|
stored, appErr := c.App.GetAccessControlPolicy(c.AppContext, policy.ID)
|
|
if appErr != nil {
|
|
if appErr.StatusCode == http.StatusNotFound {
|
|
policy.Imports = nil
|
|
policy.Scope = ""
|
|
policy.ScopeID = ""
|
|
return nil
|
|
}
|
|
return appErr
|
|
}
|
|
|
|
// Clone so a later mutation of policy.Imports can't reach back into the stored object.
|
|
policy.Imports = slices.Clone(stored.Imports)
|
|
policy.Scope = stored.Scope
|
|
policy.ScopeID = stored.ScopeID
|
|
return nil
|
|
}
|
|
|
|
func (api *API) InitAccessControlPolicy() {
|
|
api.BaseRoutes.AccessControlPolicies.Handle("", api.APISessionRequired(createAccessControlPolicy)).Methods(http.MethodPut)
|
|
api.BaseRoutes.AccessControlPolicies.Handle("/search", api.APISessionRequired(searchAccessControlPolicies)).Methods(http.MethodPost)
|
|
api.BaseRoutes.AccessControlPolicies.Handle("/activate", api.APISessionRequired(setActiveStatus)).Methods(http.MethodPut)
|
|
|
|
api.BaseRoutes.AccessControlPolicies.Handle("/cel/check", api.APISessionRequired(checkExpression)).Methods(http.MethodPost)
|
|
api.BaseRoutes.AccessControlPolicies.Handle("/cel/test", api.APISessionRequired(testExpression)).Methods(http.MethodPost)
|
|
api.BaseRoutes.AccessControlPolicies.Handle("/cel/simulate_users", api.APISessionRequired(simulatePolicyForUsers)).Methods(http.MethodPost)
|
|
api.BaseRoutes.AccessControlPolicies.Handle("/cel/validate_requester", api.APISessionRequired(validateExpressionAgainstRequester)).Methods(http.MethodPost)
|
|
api.BaseRoutes.AccessControlPolicies.Handle("/cel/autocomplete/fields", api.APISessionRequired(getFieldsAutocomplete)).Methods(http.MethodGet)
|
|
api.BaseRoutes.AccessControlPolicies.Handle("/cel/visual_ast", api.APISessionRequired(convertToVisualAST)).Methods(http.MethodPost)
|
|
|
|
api.BaseRoutes.AccessControlPolicy.Handle("", api.APISessionRequired(getAccessControlPolicy)).Methods(http.MethodGet)
|
|
api.BaseRoutes.AccessControlPolicy.Handle("", api.APISessionRequired(deleteAccessControlPolicy)).Methods(http.MethodDelete)
|
|
api.BaseRoutes.AccessControlPolicy.Handle("/activate", api.APISessionRequired(updateActiveStatus)).Methods(http.MethodGet)
|
|
api.BaseRoutes.AccessControlPolicy.Handle("/assign", api.APISessionRequired(assignAccessPolicy)).Methods(http.MethodPost)
|
|
api.BaseRoutes.AccessControlPolicy.Handle("/unassign", api.APISessionRequired(unassignAccessPolicy)).Methods(http.MethodDelete)
|
|
api.BaseRoutes.AccessControlPolicy.Handle("/resources/channels", api.APISessionRequired(getChannelsForAccessControlPolicy)).Methods(http.MethodGet)
|
|
api.BaseRoutes.AccessControlPolicy.Handle("/resources/channels/search", api.APISessionRequired(searchChannelsForAccessControlPolicy)).Methods(http.MethodPost)
|
|
}
|
|
|
|
func createAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
var policy model.AccessControlPolicy
|
|
if jsonErr := json.NewDecoder(r.Body).Decode(&policy); jsonErr != nil {
|
|
c.SetInvalidParamWithErr("policy", jsonErr)
|
|
return
|
|
}
|
|
|
|
if policy.Type == model.AccessControlPolicyTypePermission && !c.App.Config().FeatureFlags.PermissionPolicies {
|
|
c.Err = model.NewAppError("createAccessControlPolicy", "api.access_control_policy.permission_policies.feature_disabled", nil, "", http.StatusNotImplemented)
|
|
return
|
|
}
|
|
|
|
// Channel-scope policies are always available, but a channel policy
|
|
// that carries a permission-rule action (upload_file_attachment,
|
|
// download_file_attachment) is gated behind the channel-level
|
|
// sub-flag — that's the toggle that exposes the Channel Settings →
|
|
// Permissions Policy tab on the frontend. Membership-only channel
|
|
// policies stay unaffected. Helper enforces the PermissionPolicies
|
|
// umbrella too, so a request slipping in with the sub-flag on but
|
|
// the umbrella off is also rejected here.
|
|
if policy.Type == model.AccessControlPolicyTypeChannel && policy.HasPermissionRuleAction() && !c.App.Config().FeatureFlags.IsChannelPermissionPoliciesEnabled() {
|
|
c.Err = model.NewAppError("createAccessControlPolicy", "api.access_control_policy.channel_permission_policies.feature_disabled", nil, "", http.StatusNotImplemented)
|
|
return
|
|
}
|
|
|
|
auditRec := c.MakeAuditRecord(model.AuditEventCreateAccessControlPolicy, model.AuditStatusFail)
|
|
defer c.LogAuditRec(auditRec)
|
|
model.AddEventParameterAuditableToAuditRec(auditRec, "requested", &policy)
|
|
|
|
// Sysadmin is allowed in every case; each case layers its own non-sysadmin fallback.
|
|
hasManageSystem := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
|
|
|
switch policy.Type {
|
|
case model.AccessControlPolicyTypeParent:
|
|
teamID := r.URL.Query().Get("team_id")
|
|
if !hasManageSystem {
|
|
if teamID == "" || !model.IsValidId(teamID) {
|
|
c.SetPermissionError(model.PermissionManageSystem)
|
|
return
|
|
}
|
|
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules) {
|
|
c.SetPermissionError(model.PermissionManageTeamAccessRules)
|
|
return
|
|
}
|
|
// For updates, verify the team admin owns the policy
|
|
if policy.ID != "" {
|
|
owned, appErr := c.App.ValidateTeamAdminPolicyOwnership(c.AppContext, teamID, policy.ID)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
if !owned {
|
|
c.SetPermissionError(model.PermissionManageTeamAccessRules)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
// Scope stamping: for team admins never trust the body's scope fields — always
|
|
// override from the authenticated query param so a crafted request cannot assign
|
|
// the policy to a different team. For system admins, inject only when the body
|
|
// did not supply scope (preserves the existing sysadmin-sets-scope-explicitly path).
|
|
if !hasManageSystem {
|
|
policy.Scope = model.AccessControlPolicyScopeTeam
|
|
policy.ScopeID = teamID
|
|
} else if teamID != "" && model.IsValidId(teamID) && policy.Scope == "" {
|
|
policy.Scope = model.AccessControlPolicyScopeTeam
|
|
policy.ScopeID = teamID
|
|
}
|
|
case model.AccessControlPolicyTypePermission:
|
|
if !hasManageSystem {
|
|
c.SetPermissionError(model.PermissionManageSystem)
|
|
return
|
|
}
|
|
case model.AccessControlPolicyTypeChannel:
|
|
if !hasManageSystem {
|
|
// For non-system admins, check channel-specific permission
|
|
if !model.IsValidId(policy.ID) {
|
|
c.SetInvalidParam("policy.id")
|
|
return
|
|
}
|
|
|
|
hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, policy.ID, model.PermissionManageChannelAccessRules)
|
|
if !hasChannelPermission {
|
|
c.SetPermissionError(model.PermissionManageChannelAccessRules)
|
|
return
|
|
}
|
|
|
|
// Now do the full validation (channel exists, is private, etc.)
|
|
if appErr := c.App.ValidateChannelAccessControlPolicyCreation(c.AppContext, c.AppContext.Session().UserId, &policy); appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
if appErr := preserveSystemManagedFields(c, &policy); appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
}
|
|
case model.AccessControlPolicyTypeTeam:
|
|
// Team-type policies are keyed by the team ID, so policy.ID is the team.
|
|
if !hasManageSystem {
|
|
if !model.IsValidId(policy.ID) {
|
|
c.SetInvalidParam("policy.id")
|
|
return
|
|
}
|
|
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), policy.ID, model.PermissionManageTeamAccessRules) {
|
|
c.SetPermissionError(model.PermissionManageTeamAccessRules)
|
|
return
|
|
}
|
|
// Hard-block a team admin from saving rules that would lock themselves
|
|
// out of their own team.
|
|
for _, rule := range policy.Rules {
|
|
if appErr := c.App.ValidateTeamAdminSelfInclusion(c.AppContext, c.AppContext.Session().UserId, rule.Expression); appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
}
|
|
|
|
if appErr := preserveSystemManagedFields(c, &policy); appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
}
|
|
default:
|
|
c.SetInvalidParam("type")
|
|
return
|
|
}
|
|
|
|
np, appErr := c.App.CreateOrUpdateAccessControlPolicy(c.AppContext, &policy)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
auditRec.Success()
|
|
auditRec.AddEventObjectType("access_control_policy")
|
|
auditRec.AddEventResultState(np)
|
|
|
|
if shouldRedactExpressions(c) {
|
|
c.App.MaskPolicyExpressions(c.AppContext, np, c.AppContext.Session().UserId)
|
|
}
|
|
|
|
js, err := json.Marshal(np)
|
|
if err != nil {
|
|
c.Err = model.NewAppError("createAccessControlPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
return
|
|
}
|
|
|
|
if _, err := w.Write(js); err != nil {
|
|
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
func getAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
c.RequirePolicyId()
|
|
if c.Err != nil {
|
|
return
|
|
}
|
|
policyID := c.Params.PolicyId
|
|
|
|
// Extract optional channelId from query parameters for context
|
|
channelID := r.URL.Query().Get("channelId")
|
|
|
|
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
|
if !hasSystemPermission {
|
|
// Try delegated admin permissions: channel admin first, then team admin
|
|
channelPermErr := c.App.ValidateAccessControlPolicyPermissionWithChannelContext(c.AppContext, c.AppContext.Session().UserId, policyID, true, channelID)
|
|
if channelPermErr != nil {
|
|
// Try team-admin permission
|
|
teamID := r.URL.Query().Get("team_id")
|
|
if teamID != "" && model.IsValidId(teamID) &&
|
|
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules) {
|
|
owned, appErr := c.App.ValidateTeamAdminPolicyOwnership(c.AppContext, teamID, policyID)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
if !owned {
|
|
c.SetPermissionError(model.PermissionManageTeamAccessRules)
|
|
return
|
|
}
|
|
} else {
|
|
c.SetPermissionError(model.PermissionManageSystem)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
policy, appErr := c.App.GetAccessControlPolicy(c.AppContext, policyID)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
// Stamp assigned-resource counts (channel_count, team_count, child_ids) so the
|
|
// policy editor can disable deletion while any channel or team is still linked,
|
|
// mirroring the policy list. GetAccessControlPolicy does not compute these.
|
|
c.App.PopulateAccessControlPolicyChildCounts(c.AppContext, policy)
|
|
|
|
if shouldRedactExpressions(c) {
|
|
c.App.MaskPolicyExpressions(c.AppContext, policy, c.AppContext.Session().UserId)
|
|
}
|
|
|
|
js, err := json.Marshal(policy)
|
|
if err != nil {
|
|
c.Err = model.NewAppError("getAccessControlPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
return
|
|
}
|
|
|
|
if _, err := w.Write(js); err != nil {
|
|
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
func deleteAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
c.RequirePolicyId()
|
|
if c.Err != nil {
|
|
return
|
|
}
|
|
policyID := c.Params.PolicyId
|
|
|
|
auditRec := c.MakeAuditRecord(model.AuditEventDeleteAccessControlPolicy, model.AuditStatusFail)
|
|
defer c.LogAuditRec(auditRec)
|
|
model.AddEventParameterToAuditRec(auditRec, "id", policyID)
|
|
|
|
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
|
if !hasSystemPermission {
|
|
// Try delegated admin permissions: channel admin first, then team admin
|
|
channelPermErr := c.App.ValidateAccessControlPolicyPermission(c.AppContext, c.AppContext.Session().UserId, policyID)
|
|
if channelPermErr != nil {
|
|
teamID := r.URL.Query().Get("team_id")
|
|
if teamID == "" || !model.IsValidId(teamID) {
|
|
c.SetPermissionError(model.PermissionManageSystem)
|
|
return
|
|
}
|
|
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules) {
|
|
c.SetPermissionError(model.PermissionManageTeamAccessRules)
|
|
return
|
|
}
|
|
// Team-type policies use the team's own ID as the policy ID.
|
|
// A team admin can delete their team's direct policy without the
|
|
// parent-ownership check (which only covers parent-type policies).
|
|
if policyID != teamID {
|
|
owned, appErr := c.App.ValidateTeamAdminPolicyOwnership(c.AppContext, teamID, policyID)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
if !owned {
|
|
c.SetPermissionError(model.PermissionManageTeamAccessRules)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
appErr := c.App.DeleteAccessControlPolicy(c.AppContext, policyID)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
auditRec.Success()
|
|
|
|
// Middleware forces a JSON content-type, so an empty 200 body fails the
|
|
// client's parse and surfaces a spurious error on a successful delete.
|
|
ReturnStatusOK(w)
|
|
}
|
|
|
|
func checkExpression(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
checkExpressionRequest := struct {
|
|
Expression string `json:"expression"`
|
|
ChannelId string `json:"channelId,omitempty"`
|
|
TeamId string `json:"teamId,omitempty"`
|
|
}{}
|
|
if jsonErr := json.NewDecoder(r.Body).Decode(&checkExpressionRequest); jsonErr != nil {
|
|
c.SetInvalidParamWithErr("user", jsonErr)
|
|
return
|
|
}
|
|
|
|
channelId := checkExpressionRequest.ChannelId
|
|
if channelId != "" && !model.IsValidId(channelId) {
|
|
c.SetInvalidParam("channelId")
|
|
return
|
|
}
|
|
|
|
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
|
if !hasSystemPermission {
|
|
if !teamAdminCELContextOK(c, channelId, checkExpressionRequest.TeamId) {
|
|
if channelId == "" {
|
|
c.SetPermissionError(model.PermissionManageSystem)
|
|
return
|
|
}
|
|
hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
|
|
if !hasChannelPermission {
|
|
c.SetPermissionError(model.PermissionManageChannelAccessRules)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
errs, appErr := c.App.CheckExpression(c.AppContext, checkExpressionRequest.Expression)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
js, err := json.Marshal(errs)
|
|
if err != nil {
|
|
c.Err = model.NewAppError("checkExpression", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
return
|
|
}
|
|
|
|
if _, err := w.Write(js); err != nil {
|
|
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
func testExpression(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
var checkExpressionRequest model.QueryExpressionParams
|
|
if jsonErr := json.NewDecoder(r.Body).Decode(&checkExpressionRequest); jsonErr != nil {
|
|
c.SetInvalidParamWithErr("user", jsonErr)
|
|
return
|
|
}
|
|
|
|
channelId := checkExpressionRequest.ChannelId
|
|
if channelId != "" && !model.IsValidId(channelId) {
|
|
c.SetInvalidParam("channelId")
|
|
return
|
|
}
|
|
|
|
teamID := checkExpressionRequest.TeamId
|
|
if teamID != "" && !model.IsValidId(teamID) {
|
|
c.SetInvalidParam("teamId")
|
|
return
|
|
}
|
|
|
|
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
|
hasTeamPermission := !hasSystemPermission && teamAdminCELContextOK(c, channelId, teamID)
|
|
|
|
if !hasSystemPermission && !hasTeamPermission {
|
|
if channelId == "" {
|
|
c.SetPermissionError(model.PermissionManageSystem)
|
|
return
|
|
}
|
|
hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
|
|
if !hasChannelPermission {
|
|
c.SetPermissionError(model.PermissionManageChannelAccessRules)
|
|
return
|
|
}
|
|
}
|
|
|
|
var users []*model.User
|
|
var count int64
|
|
var appErr *model.AppError
|
|
|
|
searchOpts := model.SubjectSearchOptions{
|
|
Term: checkExpressionRequest.Term,
|
|
Limit: checkExpressionRequest.Limit,
|
|
Cursor: model.SubjectCursor{
|
|
TargetID: checkExpressionRequest.After,
|
|
},
|
|
}
|
|
|
|
// Scope results to a team's current members only for callers authorized on
|
|
// that team. A channel admin clears the permission gate via the channel
|
|
// branch, so binding the team scope to teamID alone would let them point the
|
|
// search at any team — gate it on the verified team permission instead.
|
|
if hasSystemPermission || hasTeamPermission {
|
|
searchOpts.TeamID = teamID
|
|
}
|
|
|
|
// Only system admins see ALL matching users (unrestricted).
|
|
// Delegated admins (team and channel) see only users matching expressions with attributes they possess.
|
|
if hasSystemPermission {
|
|
users, count, appErr = c.App.TestExpression(c.AppContext, checkExpressionRequest.Expression, searchOpts)
|
|
} else if hasTeamPermission {
|
|
users, count, appErr = c.App.TestExpressionWithTeamContext(c.AppContext, checkExpressionRequest.Expression, searchOpts)
|
|
} else {
|
|
users, count, appErr = c.App.TestExpressionWithChannelContext(c.AppContext, checkExpressionRequest.Expression, searchOpts)
|
|
}
|
|
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
for _, user := range users {
|
|
c.App.SanitizeProfile(user, hasSystemPermission)
|
|
}
|
|
|
|
resp := model.AccessControlPolicyTestResponse{
|
|
Users: users,
|
|
Total: count,
|
|
}
|
|
|
|
js, err := json.Marshal(resp)
|
|
if err != nil {
|
|
c.Err = model.NewAppError("checkExpression", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
return
|
|
}
|
|
|
|
if _, err := w.Write(js); err != nil {
|
|
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
// validateTeamIdsExist returns a 400 if any entry is not a well-formed, existing
|
|
// team id. GetTeams silently drops unknown ids (partial list) or returns 404 (all
|
|
// invalid), so the handler must confirm existence itself to reject bad input here.
|
|
func validateTeamIdsExist(c *Context, teamIDs []string) *model.AppError {
|
|
if len(teamIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
for _, teamID := range teamIDs {
|
|
if !model.IsValidId(teamID) {
|
|
return model.NewAppError("validateTeamIdsExist", "api.context.invalid_body_param.app_error", map[string]any{"Name": "team_ids"}, "", http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
// One batch lookup instead of a GetTeam per id. GetTeams returns 404 only when
|
|
// every id is unknown (treated as a bad request here) and otherwise drops
|
|
// unknown ids from a partial list, so confirm each requested id is present.
|
|
teams, appErr := c.App.GetTeams(teamIDs)
|
|
if appErr != nil {
|
|
if appErr.StatusCode == http.StatusNotFound {
|
|
return model.NewAppError("validateTeamIdsExist", "api.context.invalid_body_param.app_error", map[string]any{"Name": "team_ids"}, "", http.StatusBadRequest)
|
|
}
|
|
return appErr
|
|
}
|
|
|
|
found := make(map[string]bool, len(teams))
|
|
for _, team := range teams {
|
|
found[team.Id] = true
|
|
}
|
|
for _, teamID := range teamIDs {
|
|
if !found[teamID] {
|
|
return model.NewAppError("validateTeamIdsExist", "api.context.invalid_body_param.app_error", map[string]any{"Name": "team_ids"}, "", http.StatusBadRequest)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// teamAdminCELContextOK reports whether the session may use the delegated
|
|
// team-admin shortcut for CEL tooling: valid team_id, ManageTeamAccessRules on
|
|
// that team, and when a channel_id is supplied it must resolve to a channel in
|
|
// that same team. Prevents pairing a team the admin manages with an unrelated
|
|
// channel solely to satisfy the channel branch of auth.
|
|
func teamAdminCELContextOK(c *Context, channelID, teamID string) bool {
|
|
if teamID == "" || !model.IsValidId(teamID) {
|
|
return false
|
|
}
|
|
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules) {
|
|
return false
|
|
}
|
|
if channelID == "" {
|
|
return true
|
|
}
|
|
if !model.IsValidId(channelID) {
|
|
return false
|
|
}
|
|
channel, appErr := c.App.GetChannel(c.AppContext, channelID)
|
|
if appErr != nil {
|
|
return false
|
|
}
|
|
return channel.TeamId == teamID
|
|
}
|
|
|
|
// authorizeSimulatePolicy checks the caller's permission to simulate a
|
|
// policy and returns whether they have system-level access — used by
|
|
// the caller to scope SanitizeProfile.
|
|
//
|
|
// Authorization order:
|
|
// - system admin: always.
|
|
// - team admin: only when teamID is set AND any provided channelID
|
|
// resolves to a channel in that team. Without this guard a team
|
|
// admin could simulate a policy for any channel by pairing their
|
|
// team_id with a foreign channel_id; the cross-team check forces
|
|
// the auth to fall through to HasPermissionToChannel for any
|
|
// channel outside the admin's team.
|
|
// - channel admin: when channelID is set, via HasPermissionToChannel
|
|
// (which already covers the channel's actual team admins).
|
|
//
|
|
// On failure the function sets the appropriate permission error on `c`
|
|
// and returns ok=false. Callers MUST early-return when ok=false.
|
|
func authorizeSimulatePolicy(c *Context, channelID, teamID string) (hasSystemPermission bool, ok bool) {
|
|
hasSystemPermission = c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
|
if hasSystemPermission {
|
|
return true, true
|
|
}
|
|
|
|
if teamAdminCELContextOK(c, channelID, teamID) {
|
|
return false, true
|
|
}
|
|
|
|
if channelID == "" {
|
|
c.SetPermissionError(model.PermissionManageSystem)
|
|
return false, false
|
|
}
|
|
hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelID, model.PermissionManageChannelAccessRules)
|
|
if !hasChannelPermission {
|
|
c.SetPermissionError(model.PermissionManageChannelAccessRules)
|
|
return false, false
|
|
}
|
|
return false, true
|
|
}
|
|
|
|
// simulatePolicyForUsers runs the dual-lane PDP simulation against a draft
|
|
// policy (not persisted) plus any higher-scoped persisted permission
|
|
// policies, for an explicit set of user IDs (with optional per-user session
|
|
// attribute overrides). The response carries per-user, per-action
|
|
// ALLOW/DENY decisions plus blame attribution for any deny — used by the
|
|
// "Simulate access" picker UX in the System Console and Channel Settings.
|
|
//
|
|
// Permission gates:
|
|
// - System admins: full access.
|
|
// - Team admins (with PermissionManageTeamAccessRules on the team): when a
|
|
// team_id is present in the body and any provided channel_id resolves
|
|
// to a channel in that team.
|
|
// - Channel admins (with PermissionManageChannelAccessRules on the
|
|
// channel): when a channel_id is present in the body.
|
|
//
|
|
// Non-system admins may only simulate users who belong to the request's
|
|
// channel (when channel_id is set) or team (team-scoped simulation).
|
|
// The endpoint requires the PolicySimulation feature flag (which
|
|
// itself depends on the PermissionPolicies umbrella) and an
|
|
// Enterprise Advanced license. Returns 501 when ABAC is unavailable.
|
|
func simulatePolicyForUsers(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
if !c.App.Config().FeatureFlags.IsPolicySimulationEnabled() {
|
|
c.Err = model.NewAppError("simulatePolicyForUsers", "api.access_control_policy.policy_simulation.feature_disabled", nil, "", http.StatusNotImplemented)
|
|
return
|
|
}
|
|
|
|
var params model.PolicySimulationByUsersParams
|
|
if jsonErr := json.NewDecoder(r.Body).Decode(¶ms); jsonErr != nil {
|
|
c.SetInvalidParamWithErr("simulation", jsonErr)
|
|
return
|
|
}
|
|
|
|
if params.Policy == nil {
|
|
c.SetInvalidParam("policy")
|
|
return
|
|
}
|
|
if len(params.Users) == 0 {
|
|
c.SetInvalidParam("users")
|
|
return
|
|
}
|
|
if params.ChannelID != "" && !model.IsValidId(params.ChannelID) {
|
|
c.SetInvalidParam("channel_id")
|
|
return
|
|
}
|
|
if params.TeamID != "" && !model.IsValidId(params.TeamID) {
|
|
c.SetInvalidParam("team_id")
|
|
return
|
|
}
|
|
switch params.EvaluationScope {
|
|
case "", model.PolicyEvaluationScopeThisRule, model.PolicyEvaluationScopeAll:
|
|
default:
|
|
c.SetInvalidParam("evaluation_scope")
|
|
return
|
|
}
|
|
|
|
// Normalize the empty string up front to the default
|
|
if params.EvaluationScope == "" {
|
|
params.EvaluationScope = model.PolicyEvaluationScopeThisRule
|
|
}
|
|
|
|
hasSystemPermission, ok := authorizeSimulatePolicy(c, params.ChannelID, params.TeamID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Cross-team consistency check: when both IDs are provided, the
|
|
// channel must actually belong to the named team. authorizeSimulatePolicy
|
|
// covers this for the team-admin shortcut, but a system admin's auth
|
|
// short-circuit happens earlier so we re-check here for everyone.
|
|
// Mismatched IDs would otherwise let downstream user-scope validation
|
|
// run against the wrong team. We canonicalise params.TeamID from the
|
|
// channel rather than rejecting outright — the channel ID is the
|
|
// authoritative scope for a channel-policy simulation.
|
|
if params.ChannelID != "" && params.TeamID != "" {
|
|
channel, appErr := c.App.GetChannel(c.AppContext, params.ChannelID)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
if channel.TeamId != params.TeamID {
|
|
c.SetInvalidParam("team_id")
|
|
return
|
|
}
|
|
params.TeamID = channel.TeamId
|
|
}
|
|
|
|
if !hasSystemPermission {
|
|
if appErr := c.App.ValidatePolicySimulationUsersInScope(c.AppContext, params.TeamID, params.ChannelID, params.Users); appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
}
|
|
|
|
resp, appErr := c.App.SimulateAccessControlPolicyForUsers(c.AppContext, params)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
for i := range resp.Results {
|
|
c.App.SanitizeProfile(resp.Results[i].User, hasSystemPermission)
|
|
}
|
|
|
|
// Redact protected CPA attribute values for non-system-admin
|
|
// callers. Targets the user's actual attribute values shown in
|
|
// the Decision Details panel and per-leaf ActualValue strings.
|
|
c.App.RedactSimulationAttributesForCaller(c.AppContext, resp, hasSystemPermission)
|
|
|
|
// Sanitize evaluation traces for non-system-admin callers.
|
|
// Traces are sysadmin-only; channel/team admins get flat
|
|
// expressions and the frontend falls back when trees are absent.
|
|
c.App.SanitizeSimulationEvaluationTracesForCaller(resp, hasSystemPermission)
|
|
|
|
js, err := json.Marshal(resp)
|
|
if err != nil {
|
|
c.Err = model.NewAppError("simulatePolicyForUsers", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
return
|
|
}
|
|
|
|
if _, err := w.Write(js); err != nil {
|
|
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
func validateExpressionAgainstRequester(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
var request struct {
|
|
Expression string `json:"expression"`
|
|
ChannelId string `json:"channelId,omitempty"`
|
|
TeamId string `json:"teamId,omitempty"`
|
|
}
|
|
|
|
if jsonErr := json.NewDecoder(r.Body).Decode(&request); jsonErr != nil {
|
|
c.SetInvalidParamWithErr("request", jsonErr)
|
|
return
|
|
}
|
|
|
|
channelId := request.ChannelId
|
|
if channelId != "" && !model.IsValidId(channelId) {
|
|
c.SetInvalidParam("channelId")
|
|
return
|
|
}
|
|
|
|
teamID := request.TeamId
|
|
if teamID != "" && !model.IsValidId(teamID) {
|
|
c.SetInvalidParam("teamId")
|
|
return
|
|
}
|
|
|
|
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
|
if !hasSystemPermission {
|
|
if !teamAdminCELContextOK(c, channelId, teamID) {
|
|
if channelId == "" {
|
|
c.SetPermissionError(model.PermissionManageSystem)
|
|
return
|
|
}
|
|
hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
|
|
if !hasChannelPermission {
|
|
c.SetPermissionError(model.PermissionManageChannelAccessRules)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Direct validation against requester
|
|
matches, appErr := c.App.ValidateExpressionAgainstRequester(c.AppContext, request.Expression, c.AppContext.Session().UserId)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
response := struct {
|
|
RequesterMatches bool `json:"requester_matches"`
|
|
}{
|
|
RequesterMatches: matches,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
func searchAccessControlPolicies(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
var props *model.AccessControlPolicySearch
|
|
err := json.NewDecoder(r.Body).Decode(&props)
|
|
if err != nil || props == nil {
|
|
c.SetInvalidParamWithErr("access_control_policy_search", err)
|
|
return
|
|
}
|
|
|
|
permissionPoliciesEnabled := c.App.Config().FeatureFlags.PermissionPolicies
|
|
var policies []*model.AccessControlPolicy
|
|
var total int64
|
|
var appErr *model.AppError
|
|
|
|
if props.TeamID != "" {
|
|
// Team-scoped search: requires manage_team_access_rules (system admins have this implicitly)
|
|
if !model.IsValidId(props.TeamID) {
|
|
c.SetInvalidParam("team_id")
|
|
return
|
|
}
|
|
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), props.TeamID, model.PermissionManageTeamAccessRules) {
|
|
c.SetPermissionError(model.PermissionManageTeamAccessRules)
|
|
return
|
|
}
|
|
requesterID := c.AppContext.Session().UserId
|
|
policies, total, appErr = c.App.SearchTeamAccessPolicies(c.AppContext, props.TeamID, requesterID, *props)
|
|
} else {
|
|
if props.Type == model.AccessControlPolicyTypePermission && !permissionPoliciesEnabled {
|
|
c.Err = model.NewAppError("searchAccessControlPolicies", "api.access_control_policy.permission_policies.feature_disabled", nil, "", http.StatusNotImplemented)
|
|
return
|
|
}
|
|
|
|
// System-wide search: requires system permission
|
|
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
|
|
c.SetPermissionError(model.PermissionManageSystem)
|
|
return
|
|
}
|
|
policies, total, appErr = c.App.SearchAccessControlPolicies(c.AppContext, *props)
|
|
}
|
|
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
if !permissionPoliciesEnabled && props.Type == "" {
|
|
filtered := make([]*model.AccessControlPolicy, 0, len(policies))
|
|
for _, p := range policies {
|
|
if p.Type != model.AccessControlPolicyTypePermission {
|
|
filtered = append(filtered, p)
|
|
}
|
|
}
|
|
total -= int64(len(policies) - len(filtered))
|
|
policies = filtered
|
|
}
|
|
|
|
if shouldRedactExpressions(c) {
|
|
for _, p := range policies {
|
|
c.App.MaskPolicyExpressions(c.AppContext, p, c.AppContext.Session().UserId)
|
|
}
|
|
}
|
|
|
|
result := model.AccessControlPoliciesWithCount{
|
|
Policies: policies,
|
|
Total: total,
|
|
}
|
|
|
|
js, err := json.Marshal(result)
|
|
if err != nil {
|
|
c.Err = model.NewAppError("searchAccessControlPolicies", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
return
|
|
}
|
|
|
|
if _, err := w.Write(js); err != nil {
|
|
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
// updateActiveStatus updates the active status of a single access control policy.
|
|
//
|
|
// Deprecated: This endpoint is deprecated and will be removed in a future release.
|
|
// Use PUT /v1/workspace/access_control/policies/activate instead, which supports batch updates.
|
|
func updateActiveStatus(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
c.RequirePolicyId()
|
|
if c.Err != nil {
|
|
return
|
|
}
|
|
|
|
// CSRF barrier: only allow header-based auth (reject cookie sessions)
|
|
token, tokenLocation := app.ParseAuthTokenFromRequest(r)
|
|
if token == "" || tokenLocation == app.TokenLocationCookie {
|
|
c.Err = model.NewAppError("updateActiveStatus", "api.context.session_cookie_not_allowed.app_error", nil,
|
|
"This endpoint requires header-based authentication", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
policyID := c.Params.PolicyId
|
|
|
|
// Check if user has system admin permission OR channel-specific permission for this policy
|
|
hasManageSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
|
if !hasManageSystemPermission {
|
|
// For non-system admins, validate policy access permission
|
|
if appErr := c.App.ValidateAccessControlPolicyPermission(c.AppContext, c.AppContext.Session().UserId, policyID); appErr != nil {
|
|
c.SetPermissionError(model.PermissionManageSystem)
|
|
return
|
|
}
|
|
}
|
|
|
|
auditRec := c.MakeAuditRecord(model.AuditEventUpdateActiveStatus, model.AuditStatusFail)
|
|
defer c.LogAuditRec(auditRec)
|
|
model.AddEventParameterToAuditRec(auditRec, "id", policyID)
|
|
|
|
active := r.URL.Query().Get("active")
|
|
if active != "true" && active != "false" {
|
|
c.SetInvalidParam("active")
|
|
return
|
|
}
|
|
activeBool, err := strconv.ParseBool(active)
|
|
if err != nil {
|
|
c.SetInvalidParamWithErr("active", err)
|
|
return
|
|
}
|
|
model.AddEventParameterToAuditRec(auditRec, "active", activeBool)
|
|
|
|
// Wrap single update in slice to use the batch update method
|
|
updates := []model.AccessControlPolicyActiveUpdate{
|
|
{ID: policyID, Active: activeBool},
|
|
}
|
|
_, appErr := c.App.UpdateAccessControlPoliciesActive(c.AppContext, updates)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
auditRec.Success()
|
|
|
|
// Return success response
|
|
response := map[string]any{
|
|
"status": "OK",
|
|
}
|
|
|
|
// Set deprecation header to inform clients
|
|
w.Header().Set("Deprecation", "true")
|
|
w.Header().Set("Link", "<"+model.APIURLSuffix+"/access_control/policies/activate>; rel=\"successor-version\"")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
func setActiveStatus(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
var list model.AccessControlPolicyActiveUpdateRequest
|
|
if jsonErr := json.NewDecoder(r.Body).Decode(&list); jsonErr != nil {
|
|
c.SetInvalidParamWithErr("request", jsonErr)
|
|
return
|
|
}
|
|
|
|
auditRec := c.MakeAuditRecord(model.AuditEventSetActiveStatus, model.AuditStatusFail)
|
|
defer c.LogAuditRec(auditRec)
|
|
model.AddEventParameterAuditableToAuditRec(auditRec, "requested", &list)
|
|
|
|
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
|
if !hasSystemPermission {
|
|
for _, entry := range list.Entries {
|
|
// Try delegated admin permissions: channel admin first, then team admin
|
|
channelPermErr := c.App.ValidateAccessControlPolicyPermission(c.AppContext, c.AppContext.Session().UserId, entry.ID)
|
|
if channelPermErr != nil {
|
|
// Try team-admin permission via team_id in request
|
|
if list.TeamID != "" && model.IsValidId(list.TeamID) &&
|
|
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), list.TeamID, model.PermissionManageTeamAccessRules) {
|
|
// Team-type policies use the team's own ID as the policy ID.
|
|
// Skip the parent-ownership check for a team admin toggling their
|
|
// own team policy's active flag.
|
|
if entry.ID != list.TeamID {
|
|
owned, appErr := c.App.ValidateTeamAdminPolicyOwnership(c.AppContext, list.TeamID, entry.ID)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
if !owned {
|
|
c.SetPermissionError(model.PermissionManageTeamAccessRules)
|
|
return
|
|
}
|
|
}
|
|
} else {
|
|
c.SetPermissionError(model.PermissionManageChannelAccessRules)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
policies, appErr := c.App.UpdateAccessControlPoliciesActive(c.AppContext, list.Entries)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
auditRec.Success()
|
|
|
|
if shouldRedactExpressions(c) {
|
|
for _, p := range policies {
|
|
c.App.MaskPolicyExpressions(c.AppContext, p, c.AppContext.Session().UserId)
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(policies); err != nil {
|
|
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
func assignAccessPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
c.RequirePolicyId()
|
|
if c.Err != nil {
|
|
return
|
|
}
|
|
policyID := c.Params.PolicyId
|
|
|
|
var assignments struct {
|
|
ChannelIds []string `json:"channel_ids"`
|
|
TeamID string `json:"team_id"`
|
|
TeamIds []string `json:"team_ids"`
|
|
}
|
|
|
|
err := json.NewDecoder(r.Body).Decode(&assignments)
|
|
if err != nil {
|
|
c.SetInvalidParamWithErr("assignments", err)
|
|
return
|
|
}
|
|
|
|
// Team-resource assignment ships behind its own dark-launch flag. Reject it
|
|
// outright when team membership ABAC is not fully enabled so inert team child
|
|
// policy rows can't be created and later go live when the flag is flipped on.
|
|
if len(assignments.TeamIds) != 0 && !c.App.TeamMembershipAccessControlEnabled() {
|
|
c.Err = model.NewAppError("assignAccessPolicy", "api.access_control_policy.team_membership.feature_disabled", nil, "", http.StatusNotImplemented)
|
|
return
|
|
}
|
|
|
|
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
|
|
|
// Assigning a parent policy to a team resource is a system-level operation —
|
|
// distinct from the team-admin channel_ids+team_id path below.
|
|
if len(assignments.TeamIds) != 0 && !hasSystemPermission {
|
|
c.SetPermissionError(model.PermissionManageSystem)
|
|
return
|
|
}
|
|
|
|
if !hasSystemPermission {
|
|
if assignments.TeamID == "" || !model.IsValidId(assignments.TeamID) {
|
|
c.SetPermissionError(model.PermissionManageSystem)
|
|
return
|
|
}
|
|
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), assignments.TeamID, model.PermissionManageTeamAccessRules) {
|
|
c.SetPermissionError(model.PermissionManageTeamAccessRules)
|
|
return
|
|
}
|
|
// Policy must be team-scoped to this team. Channel validation below ensures all
|
|
// channels belong to the requesting team regardless.
|
|
owned, ownershipErr := c.App.ValidateTeamAdminPolicyOwnership(c.AppContext, assignments.TeamID, policyID)
|
|
if ownershipErr != nil {
|
|
c.Err = ownershipErr
|
|
return
|
|
}
|
|
if !owned {
|
|
c.SetPermissionError(model.PermissionManageTeamAccessRules)
|
|
return
|
|
}
|
|
if appErr := c.App.ValidateTeamScopePolicyChannelAssignment(c.AppContext, assignments.TeamID, assignments.ChannelIds); appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
}
|
|
|
|
auditRec := c.MakeAuditRecord(model.AuditEventAssignAccessPolicy, model.AuditStatusFail)
|
|
defer c.LogAuditRec(auditRec)
|
|
model.AddEventParameterToAuditRec(auditRec, "id", policyID)
|
|
model.AddEventParameterToAuditRec(auditRec, "team_id", assignments.TeamID)
|
|
model.AddEventParameterToAuditRec(auditRec, "channel_ids", assignments.ChannelIds)
|
|
model.AddEventParameterToAuditRec(auditRec, "team_ids", assignments.TeamIds)
|
|
|
|
// The app layer's GetTeams silently drops unknown IDs from a partial list and
|
|
// returns 404 for an all-invalid one, so neither surfaces a usable 400. Validate
|
|
// existence here so any unknown team_ids entry is rejected as a bad request.
|
|
if appErr := validateTeamIdsExist(c, assignments.TeamIds); appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
if len(assignments.ChannelIds) != 0 {
|
|
_, appErr := c.App.AssignAccessControlPolicyToChannels(c.AppContext, policyID, assignments.ChannelIds)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
}
|
|
|
|
if len(assignments.TeamIds) != 0 {
|
|
_, appErr := c.App.AssignAccessControlPolicyToTeams(c.AppContext, policyID, assignments.TeamIds)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
}
|
|
|
|
// Reconcile team scope: if all channels now belong to one team, set scope;
|
|
// if channels span multiple teams (e.g. system admin added cross-team), clear scope.
|
|
if appErr := c.App.ReconcilePolicyTeamScope(c.AppContext, policyID); appErr != nil {
|
|
c.Logger.Warn("Failed to reconcile policy team scope after assign", mlog.String("policy_id", policyID), mlog.Err(appErr))
|
|
}
|
|
|
|
auditRec.Success()
|
|
ReturnStatusOK(w)
|
|
}
|
|
|
|
func unassignAccessPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
c.RequirePolicyId()
|
|
if c.Err != nil {
|
|
return
|
|
}
|
|
policyID := c.Params.PolicyId
|
|
|
|
var assignments struct {
|
|
ChannelIds []string `json:"channel_ids"`
|
|
TeamID string `json:"team_id"`
|
|
TeamIds []string `json:"team_ids"`
|
|
}
|
|
|
|
err := json.NewDecoder(r.Body).Decode(&assignments)
|
|
if err != nil {
|
|
c.SetInvalidParamWithErr("assignments", err)
|
|
return
|
|
}
|
|
|
|
// Mirror the assign path: team-resource unassignment is gated by the same
|
|
// dark-launch flag so the feature can be rolled back cleanly.
|
|
if len(assignments.TeamIds) != 0 && !c.App.TeamMembershipAccessControlEnabled() {
|
|
c.Err = model.NewAppError("unassignAccessPolicy", "api.access_control_policy.team_membership.feature_disabled", nil, "", http.StatusNotImplemented)
|
|
return
|
|
}
|
|
|
|
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
|
|
|
if len(assignments.TeamIds) != 0 && !hasSystemPermission {
|
|
c.SetPermissionError(model.PermissionManageSystem)
|
|
return
|
|
}
|
|
|
|
if !hasSystemPermission {
|
|
if assignments.TeamID == "" || !model.IsValidId(assignments.TeamID) {
|
|
c.SetPermissionError(model.PermissionManageSystem)
|
|
return
|
|
}
|
|
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), assignments.TeamID, model.PermissionManageTeamAccessRules) {
|
|
c.SetPermissionError(model.PermissionManageTeamAccessRules)
|
|
return
|
|
}
|
|
owned, appErr := c.App.ValidateTeamAdminPolicyOwnership(c.AppContext, assignments.TeamID, policyID)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
if !owned {
|
|
c.SetPermissionError(model.PermissionManageTeamAccessRules)
|
|
return
|
|
}
|
|
if appErr := c.App.ValidateTeamScopePolicyChannelAssignment(c.AppContext, assignments.TeamID, assignments.ChannelIds); appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
}
|
|
|
|
auditRec := c.MakeAuditRecord(model.AuditEventUnassignAccessPolicy, model.AuditStatusFail)
|
|
defer c.LogAuditRec(auditRec)
|
|
model.AddEventParameterToAuditRec(auditRec, "id", policyID)
|
|
model.AddEventParameterToAuditRec(auditRec, "team_id", assignments.TeamID)
|
|
model.AddEventParameterToAuditRec(auditRec, "channel_ids", assignments.ChannelIds)
|
|
model.AddEventParameterToAuditRec(auditRec, "team_ids", assignments.TeamIds)
|
|
|
|
if appErr := validateTeamIdsExist(c, assignments.TeamIds); appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
// Pre-flight: ensure scope is set before removing channels. This handles
|
|
// pre-scope policies (created before the scope field existed) by capturing
|
|
// the team from current channels before they are removed. Without this,
|
|
// unassigning all channels would leave the policy with no scope and no
|
|
// channels, making it unmanageable by team admins.
|
|
if appErr := c.App.ReconcilePolicyTeamScope(c.AppContext, policyID); appErr != nil {
|
|
c.Logger.Warn("Failed to reconcile policy team scope before unassign", mlog.String("policy_id", policyID), mlog.Err(appErr))
|
|
}
|
|
|
|
if len(assignments.ChannelIds) != 0 {
|
|
appErr := c.App.UnassignPoliciesFromChannels(c.AppContext, policyID, assignments.ChannelIds)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
}
|
|
|
|
if len(assignments.TeamIds) != 0 {
|
|
if appErr := c.App.UnassignPoliciesFromTeams(c.AppContext, policyID, assignments.TeamIds); appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
}
|
|
|
|
// Post-unassign reconcile: update scope based on remaining channels.
|
|
// If channels remain in one team → scope set. Multiple teams → scope cleared.
|
|
// No channels remain → no-op (scope preserved from pre-flight or creation).
|
|
if appErr := c.App.ReconcilePolicyTeamScope(c.AppContext, policyID); appErr != nil {
|
|
c.Logger.Warn("Failed to reconcile policy team scope after unassign", mlog.String("policy_id", policyID), mlog.Err(appErr))
|
|
}
|
|
|
|
auditRec.Success()
|
|
ReturnStatusOK(w)
|
|
}
|
|
|
|
func getChannelsForAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
c.RequirePolicyId()
|
|
if c.Err != nil {
|
|
return
|
|
}
|
|
policyID := c.Params.PolicyId
|
|
|
|
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
|
if !hasSystemPermission {
|
|
teamID := r.URL.Query().Get("team_id")
|
|
if teamID != "" && model.IsValidId(teamID) &&
|
|
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionManageTeamAccessRules) {
|
|
owned, appErr := c.App.ValidateTeamAdminPolicyOwnership(c.AppContext, teamID, policyID)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
if !owned {
|
|
c.SetPermissionError(model.PermissionManageTeamAccessRules)
|
|
return
|
|
}
|
|
} else {
|
|
c.SetPermissionError(model.PermissionManageSystem)
|
|
return
|
|
}
|
|
}
|
|
|
|
afterID := r.URL.Query().Get("after")
|
|
if afterID != "" && !model.IsValidId(afterID) {
|
|
c.SetInvalidParam("after")
|
|
return
|
|
}
|
|
|
|
limitStr := r.URL.Query().Get("limit")
|
|
limit, err := strconv.Atoi(limitStr)
|
|
if err != nil {
|
|
c.Err = model.NewAppError("getChannelsForAccessControlPolicy", "api.access_control_policy.get_channels.limit.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
|
return
|
|
}
|
|
|
|
channels, total, appErr := c.App.GetChannelsForPolicy(c.AppContext, policyID, model.AccessControlPolicyCursor{
|
|
ID: afterID,
|
|
}, limit)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
data := model.ChannelsWithCount{Channels: channels, TotalCount: total}
|
|
|
|
js, err := json.Marshal(data)
|
|
if err != nil {
|
|
c.Err = model.NewAppError("getChannelsForAccessControlPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
return
|
|
}
|
|
|
|
if _, err := w.Write(js); err != nil {
|
|
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
func searchChannelsForAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
c.RequirePolicyId()
|
|
if c.Err != nil {
|
|
return
|
|
}
|
|
|
|
var authorizedTeamID string
|
|
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
|
if !hasSystemPermission {
|
|
authorizedTeamID = r.URL.Query().Get("team_id")
|
|
if authorizedTeamID != "" && model.IsValidId(authorizedTeamID) &&
|
|
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), authorizedTeamID, model.PermissionManageTeamAccessRules) {
|
|
owned, appErr := c.App.ValidateTeamAdminPolicyOwnership(c.AppContext, authorizedTeamID, c.Params.PolicyId)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
if !owned {
|
|
c.SetPermissionError(model.PermissionManageTeamAccessRules)
|
|
return
|
|
}
|
|
} else {
|
|
c.SetPermissionError(model.PermissionManageSystem)
|
|
return
|
|
}
|
|
}
|
|
|
|
var props *model.ChannelSearch
|
|
err := json.NewDecoder(r.Body).Decode(&props)
|
|
if err != nil || props == nil {
|
|
c.SetInvalidParamWithErr("channel_search", err)
|
|
return
|
|
}
|
|
|
|
policyID := c.Params.PolicyId
|
|
|
|
// For non-system-admins, force the search to only the authorized team
|
|
teamIds := props.TeamIds
|
|
if !hasSystemPermission && authorizedTeamID != "" {
|
|
teamIds = []string{authorizedTeamID}
|
|
}
|
|
|
|
opts := model.ChannelSearchOpts{
|
|
Deleted: props.Deleted,
|
|
IncludeDeleted: props.IncludeDeleted,
|
|
ExcludeGroupConstrained: true,
|
|
TeamIds: teamIds,
|
|
ParentAccessControlPolicyId: policyID,
|
|
}
|
|
|
|
channels, total, appErr := c.App.SearchAllChannels(c.AppContext, props.Term, opts)
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
data := model.ChannelsWithCount{Channels: channels, TotalCount: total}
|
|
|
|
channelsJSON, jsonErr := json.Marshal(data)
|
|
if jsonErr != nil {
|
|
c.Err = model.NewAppError("searchChannelsInPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
|
|
return
|
|
}
|
|
|
|
if _, err := w.Write(channelsJSON); err != nil {
|
|
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
func getFieldsAutocomplete(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
channelId := r.URL.Query().Get("channelId")
|
|
if channelId != "" && !model.IsValidId(channelId) {
|
|
c.SetInvalidParam("channelId")
|
|
return
|
|
}
|
|
|
|
teamID := r.URL.Query().Get("team_id")
|
|
|
|
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
|
if !hasSystemPermission {
|
|
if !teamAdminCELContextOK(c, channelId, teamID) {
|
|
if channelId == "" {
|
|
c.SetPermissionError(model.PermissionManageSystem)
|
|
return
|
|
}
|
|
|
|
hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
|
|
if !hasChannelPermission {
|
|
c.SetPermissionError(model.PermissionManageChannelAccessRules)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
after := r.URL.Query().Get("after")
|
|
if after != "" && !model.IsValidId(after) {
|
|
c.SetInvalidParam("after")
|
|
return
|
|
} else if after == "" {
|
|
after = strings.Repeat("0", 26)
|
|
}
|
|
|
|
limitStr := r.URL.Query().Get("limit")
|
|
limit, err := strconv.Atoi(limitStr)
|
|
if err != nil {
|
|
c.Err = model.NewAppError("getFieldsAutocomplete", "api.access_control_policy.get_fields.limit.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
|
return
|
|
}
|
|
if limit <= 0 || limit > 100 {
|
|
c.Err = model.NewAppError("getFieldsAutocomplete", "api.access_control_policy.get_fields.limit.app_error", nil, "", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var ac []*model.PropertyField
|
|
var appErr *model.AppError
|
|
|
|
ac, appErr = c.App.GetAccessControlFieldsAutocomplete(c.AppContext, after, limit, c.AppContext.Session().UserId)
|
|
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
js, err := json.Marshal(ac)
|
|
if err != nil {
|
|
c.Err = model.NewAppError("getExpressionAutocomplete", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
return
|
|
}
|
|
|
|
if _, err := w.Write(js); err != nil {
|
|
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
|
}
|
|
}
|
|
|
|
func convertToVisualAST(c *Context, w http.ResponseWriter, r *http.Request) {
|
|
var cel struct {
|
|
Expression string `json:"expression"`
|
|
ChannelId string `json:"channelId,omitempty"`
|
|
TeamId string `json:"teamId,omitempty"`
|
|
}
|
|
if jsonErr := json.NewDecoder(r.Body).Decode(&cel); jsonErr != nil {
|
|
c.SetInvalidParamWithErr("user", jsonErr)
|
|
return
|
|
}
|
|
|
|
channelId := cel.ChannelId
|
|
if channelId != "" && !model.IsValidId(channelId) {
|
|
c.SetInvalidParam("channelId")
|
|
return
|
|
}
|
|
|
|
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
|
|
if !hasSystemPermission {
|
|
if !teamAdminCELContextOK(c, channelId, cel.TeamId) {
|
|
if channelId == "" {
|
|
c.SetPermissionError(model.PermissionManageSystem)
|
|
return
|
|
}
|
|
hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
|
|
if !hasChannelPermission {
|
|
c.SetPermissionError(model.PermissionManageChannelAccessRules)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
var visualAST *model.VisualExpression
|
|
var appErr *model.AppError
|
|
|
|
// Masking is attribute-based, not permission-based: all admins receive a
|
|
// filtered AST based on what they themselves hold, regardless of role.
|
|
if shouldRedactExpressions(c) {
|
|
visualAST, appErr = c.App.GetMaskedVisualAST(c.AppContext, cel.Expression, c.AppContext.Session().UserId)
|
|
} else {
|
|
visualAST, appErr = c.App.ExpressionToVisualAST(c.AppContext, cel.Expression)
|
|
}
|
|
if appErr != nil {
|
|
c.Err = appErr
|
|
return
|
|
}
|
|
|
|
b, err := json.Marshal(visualAST)
|
|
if err != nil {
|
|
c.Err = model.NewAppError("convertToVisualAST", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
return
|
|
}
|
|
if _, err := w.Write(b); err != nil {
|
|
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
|
}
|
|
}
|