diff --git a/.github/workflows/docs-cd.yml b/.github/workflows/docs-cd.yml
index a697e8437191..7495585ed104 100644
--- a/.github/workflows/docs-cd.yml
+++ b/.github/workflows/docs-cd.yml
@@ -27,6 +27,7 @@ jobs:
- name: cd/checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+ submodules: true
persist-credentials: false
- name: cd/setup-node
diff --git a/.github/workflows/docs-ci.yaml b/.github/workflows/docs-ci.yaml
index 90f15bde4287..4de003a7684d 100644
--- a/.github/workflows/docs-ci.yaml
+++ b/.github/workflows/docs-ci.yaml
@@ -25,6 +25,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
+ submodules: true
persist-credentials: false
- name: Set up Node
diff --git a/.gitignore b/.gitignore
index 85f72775953c..21fb55c03b51 100644
--- a/.gitignore
+++ b/.gitignore
@@ -193,6 +193,12 @@ docs/pdf/build/
docs/pdf/node_modules/
docs/site/openapi/
+# Agents docs, staged from the mattermost-plugin-agents submodule at build
+# time by docs/site/scripts/stage-agents-docs.mjs (see docs/vendor/) — never
+# tracked as source, regenerated on every prestart/prebuild.
+docs/main/agents/docs/
+docs/site/static/images/agents/
+
# OpenAPI make build artifacts
api/v4/html/static/mattermost-openapi-v4.yaml
api/v4/html/index.html
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 000000000000..38fe69994ee3
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,4 @@
+[submodule "docs/vendor/mattermost-plugin-agents"]
+ path = docs/vendor/mattermost-plugin-agents
+ url = https://github.com/mattermost/mattermost-plugin-agents.git
+ branch = master
diff --git a/docs/main/administration-guide/configure/agents-admin-guide.mdx b/docs/main/administration-guide/configure/agents-admin-guide.mdx
index e20cbfa6d483..18df4212a3f4 100644
--- a/docs/main/administration-guide/configure/agents-admin-guide.mdx
+++ b/docs/main/administration-guide/configure/agents-admin-guide.mdx
@@ -1,9 +1,11 @@
---
title: "Mattermost Agents Admin Guide"
---
+import AgentsAdminGuideBody from '../../agents/docs/_admin_guide_partial.mdx';
+
role_updated WebSocket events are scoped to the affected team/channel instead of broadcasting globally, improving performance and reducing unnecessary network traffic. No manual action is required from administrators as the migration runs automatically during the upgrade. No downtime is expected, and no special planning is needed for the upgrade as the migration can run during normal operations. All installations are affected by this change, but the impact is minimal since the roles table is small with well under 10k rows even on large installations. The upgrade includes automatic database migrations to add a schemeid column to the roles table for efficient role-scheme lookups. The migrations use PostgreSQL's non-blocking techniques: first adding the column with a brief ACCESS EXCLUSIVE lock (catalog-only operation), then backfilling existing data with a ROW EXCLUSIVE lock, and finally creating an index concurrently with a SHARE UPDATE EXCLUSIVE lock that doesn't block other operations. The roles table size is bounded and unaffected by posts, reactions, or other high-volume data. The migrations are fully backwards-compatible.
v11.7.6 migration removes orphaned rows from the threadmemberships table — specifically, rows where the associated user is no longer a member of the channel that the thread belongs to. The cleanup is performed via a filtered DELETE using a three-way join across threadmemberships, threads, and channelmembers. No schema objects (tables, columns, or indexes) are added or removed; only data rows are affected. Note for large instances: this migration issues a single unbatched delete that scans the entirety of threadmemberships with a join against threads and channelmembers. On databases with tens of millions of rows or significant historical channel-membership churn, this query may run for an extended period, block autovacuum on threadmemberships, increase WAL pressure, and contribute to replication lag. Administrators of large deployments should test execution time on a representative dataset before upgrading and consider scheduling the upgrade during a low-traffic window. The down migration contains no rollback SQL because the deleted rows cannot be recovered; this migration is irreversible. The migrations are fully backwards-compatible and no database downtime is expected for this upgrade. The SQL queries included are:
-- Drop ThreadMembership rows whose user is no longer a member of the thread's channel.
diff --git a/docs/main/end-user-guide/agents.mdx b/docs/main/end-user-guide/agents.mdx
index f856fcb46434..d09722fe317f 100644
--- a/docs/main/end-user-guide/agents.mdx
+++ b/docs/main/end-user-guide/agents.mdx
@@ -1,9 +1,11 @@
---
title: "AI Agents"
---
+import AgentsUserGuideBody from '../agents/docs/_user_guide_partial.mdx';
+
-{/* TODO: include /agents/docs/user_guide.md could not be resolved */}
+
diff --git a/docs/main/product-overview/mattermost-v11-changelog.mdx b/docs/main/product-overview/mattermost-v11-changelog.mdx
index 184395c2b960..06ffb83f5024 100644
--- a/docs/main/product-overview/mattermost-v11-changelog.mdx
+++ b/docs/main/product-overview/mattermost-v11-changelog.mdx
@@ -437,7 +437,7 @@ See [this blog post](https://mattermost.com/blog/mattermost-v11-8-0-is-now-avail
**Breaking Changes**
- FIPS builds require a minimum of 14 characters for passwords, atmos/camo proxy configuration, and shared channel secrets. Shorter passwords for existing users will no longer be valid and require a password reset. Non-FIPS builds are unaffected.
- - v11.7 includes Agents plugin v2. Please see [this guide](https://github.com/mattermost/mattermost-plugin-agents/blob/master/docs/upgrading_to_2.0.md) on how to upgrade the Mattermost Agents plugin from a v1.x release to v2.0.0. It covers the supported version path, the migrations that run automatically on first start of v2.0.0, the breaking changes and default-behavior flips that admins should know about before the upgrade window, and the verification steps to confirm the upgrade succeeded.
+ - v11.7 includes Agents plugin v2. Please see [this guide](/agents/docs/upgrading_to_2.0) on how to upgrade the Mattermost Agents plugin from a v1.x release to v2.0.0. It covers the supported version path, the migrations that run automatically on first start of v2.0.0, the breaking changes and default-behavior flips that admins should know about before the upgrade window, and the verification steps to confirm the upgrade succeeded.
diff --git a/docs/site/README.md b/docs/site/README.md
index 3fac6da33129..700bf05594bf 100644
--- a/docs/site/README.md
+++ b/docs/site/README.md
@@ -83,6 +83,38 @@ leaving it at the root.
existing one (Integrations Guide is the simplest example), then wire it
into `main()` alongside the existing `dir === '...'` checks.
+**Nesting a doc from one section under a page in another section:** most
+group `items` are plain basenames relative to that section's own
+directory, but `buildAdminConfigureItem` also accepts `{doc: ''}`
+for cross-directory references (their label is read directly from the
+target file's frontmatter via `docLabelById`, since it won't be in that
+section's `leafLabels` map). This is how `ADMIN_CONFIGURE_GROUPS.agents`
+nests the vendored Agents plugin pages (`main/agents/docs/`, staged by
+`stage-agents-docs.mjs` — not one of the `TOP_LEVEL` sections, so it has
+no top-level nav entry of its own) under
+`administration-guide/configure/agents-admin-guide`. The same page is
+also nested for End User Guide's `end-user-guide/agents` doc, but since
+that section has no manual grouping override at all, it uses the smaller
+standalone `promoteDocToCategory` helper instead of a full `*_GROUPS`
+override — copy that pattern for other one-off single-doc nestings rather
+than building a whole grouping override for a section that's otherwise
+fine auto-generated.
+
+**Inlining another doc's content onto a page** (rather than just linking
+or nesting it): Docusaurus's built-in [Markdown
+partials](https://docusaurus.io/docs/next/create-doc#markdown-partials)
+feature — any `.md`/`.mdx` file with a leading underscore in its name is
+excluded from the docs plugin's routing/sidebars and can be `import`ed
+into another MDX file and rendered as ` `. `stage-agents-docs.mjs`
+uses this to reproduce Sphinx's `.. include:: /agents/docs/admin_guide.md`
+behavior: it stages `admin_guide.md`/`user_guide.md` as normal (but
+`unlisted: true`) docs for direct-link parity, *and* as
+`_admin_guide_partial.mdx`/`_user_guide_partial.mdx` partials that
+`administration-guide/configure/agents-admin-guide.mdx` and
+`end-user-guide/agents.mdx` import and render inline — so those two pages
+show the full vendored guide content directly, with zero extra clicks,
+instead of just linking out to a separate page.
+
The API reference section (`docs/api/reference/`, also gitignored) has the
same requirement: `docusaurus-plugin-openapi-docs` needs `docusaurus
gen-api-docs mattermost` run before it has any pages to render. `prestart`
diff --git a/docs/site/package.json b/docs/site/package.json
index ea02f58ffff3..3e9f47f19bb8 100644
--- a/docs/site/package.json
+++ b/docs/site/package.json
@@ -6,12 +6,13 @@
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
+ "stage:agents-docs": "node scripts/stage-agents-docs.mjs",
"build:sidebars": "node scripts/gen-documentation-sidebar.mjs && node scripts/gen-developer-sidebar.mjs",
"build:openapi:spec": "node scripts/build-openapi.mjs",
"build:openapi:docs": "docusaurus gen-api-docs mattermost",
"build:openapi": "npm run build:openapi:spec && npm run build:openapi:docs",
- "prestart": "npm run build:sidebars && ( [ -f openapi/mattermost-openapi-v4.yaml ] || npm run build:openapi:spec ) && npm run build:openapi:docs",
- "prebuild": "npm run build:sidebars && npm run build:openapi",
+ "prestart": "npm run stage:agents-docs && npm run build:sidebars && ( [ -f openapi/mattermost-openapi-v4.yaml ] || npm run build:openapi:spec ) && npm run build:openapi:docs",
+ "prebuild": "npm run stage:agents-docs && npm run build:sidebars && npm run build:openapi",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
diff --git a/docs/site/scripts/gen-documentation-sidebar.mjs b/docs/site/scripts/gen-documentation-sidebar.mjs
index b64313932586..424a33547bb5 100644
--- a/docs/site/scripts/gen-documentation-sidebar.mjs
+++ b/docs/site/scripts/gen-documentation-sidebar.mjs
@@ -56,7 +56,6 @@ const TOP_LEVEL = [
{dir: 'end-user-guide', label: 'End User Guide'},
{dir: 'integrations-guide', label: 'Integrations Guide'},
{dir: 'get-help', label: 'Get Help'},
- {dir: 'agents', label: 'Agents'},
];
// ---------------------------------------------------------------------------
@@ -380,6 +379,23 @@ const ADMIN_CONFIGURE_GROUPS = {
'optimize-your-workspace',
],
},
+ // Nests the Agents plugin's own provider/setup pages (vendored from the
+ // mattermost-plugin-agents submodule, staged by stage-agents-docs.mjs
+ // into main/agents/docs/) under the admin guide landing page, instead of
+ // a standalone top-level "Agents" section — mirrors Sphinx, which hides
+ // these behind a small toctree on administration-guide/configure/
+ // agents-admin-guide.rst rather than giving Agents its own nav entry.
+ // Items use the {doc: ''} form since they live outside
+ // administration-guide/configure/.
+ agents: {
+ label: 'AI Agents Configuration',
+ landing: 'agents-admin-guide',
+ items: [
+ {doc: 'agents/docs/providers'},
+ {doc: 'agents/docs/aws_bedrock_setup'},
+ {doc: 'agents/docs/sovereign_ai'},
+ ],
+ },
};
// Top-level Configure order. Strings are doc basenames relative to
@@ -389,7 +405,7 @@ const ADMIN_CONFIGURE_GROUPS = {
const ADMIN_CONFIGURE_ORDER = [
{group: 'settingsReference'},
{group: 'search'},
- 'agents-admin-guide',
+ {group: 'agents'},
{group: 'calls'},
{group: 'storage'},
{group: 'email'},
@@ -903,11 +919,28 @@ function buildDeploymentSidebar(autoCat) {
// Administration Guide — builder (regroups the "Configure" sub-category).
// ---------------------------------------------------------------------------
+// Resolves the label for a fully-qualified doc id (one that lives outside
+// the section currently being built, e.g. an Agents doc nested under
+// Administration Guide → Configure) by reading its own frontmatter
+// directly, since it won't be present in that section's `leafLabels` map.
+function docLabelById(id) {
+ for (const ext of ['.mdx', '.md']) {
+ const abs = join(SRC, `${id}${ext}`);
+ if (existsSync(abs)) {
+ return readFm(abs, 'sidebar_label') || readFm(abs, 'title') || humanize(id.split('/').pop());
+ }
+ }
+ throw new Error(`doc id not found on disk: ${id}`);
+}
+
function buildAdminConfigureItem(spec, leafLabels) {
if (typeof spec === 'string') {
const id = `administration-guide/configure/${spec}`;
return {type: 'doc', id, label: leafLabels[id] || humanize(spec)};
}
+ if (spec.doc) {
+ return {type: 'doc', id: spec.doc, label: docLabelById(spec.doc)};
+ }
const g = ADMIN_CONFIGURE_GROUPS[spec.group];
if (!g) throw new Error(`unknown admin configure group: ${spec.group}`);
const items = g.items.map((it) => buildAdminConfigureItem(it, leafLabels));
@@ -1092,6 +1125,46 @@ function buildIntegrationsSidebar(autoCat) {
};
}
+// ---------------------------------------------------------------------------
+// End User Guide — nests the Agents plugin's usage-tips page under the
+// existing "AI Agents" doc, the same way Configure nests Agents' admin-side
+// pages (see ADMIN_CONFIGURE_GROUPS.agents above). End User Guide is
+// otherwise fully filesystem-driven, so this is a narrow, targeted
+// promotion rather than a full manual-grouping override.
+// ---------------------------------------------------------------------------
+
+// Finds the {type: 'doc', id: docId} leaf anywhere in `items` and replaces
+// it in place with a category that links to that same doc and nests
+// `children` (each a fully-qualified doc id) underneath it. Returns true if
+// the promotion was applied, so callers can warn when it wasn't.
+function promoteDocToCategory(items, docId, children) {
+ for (let i = 0; i < items.length; i++) {
+ const it = items[i];
+ if (it.type === 'doc' && it.id === docId) {
+ items[i] = {
+ type: 'category',
+ label: it.label,
+ collapsed: true,
+ link: {type: 'doc', id: docId},
+ items: children.map((childId) => ({type: 'doc', id: childId, label: docLabelById(childId)})),
+ };
+ return true;
+ }
+ if (it.type === 'category' && it.items && promoteDocToCategory(it.items, docId, children)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function buildEndUserGuideSidebar(autoCat) {
+ const promoted = promoteDocToCategory(autoCat.items, 'end-user-guide/agents', ['agents/docs/usage_tips']);
+ if (!promoted) {
+ console.warn('[sidebar] WARN: End User Guide "agents" doc not found — Agents usage-tips nesting was not applied.');
+ }
+ return autoCat;
+}
+
// ---------------------------------------------------------------------------
// Entry point.
// ---------------------------------------------------------------------------
@@ -1115,6 +1188,8 @@ function main() {
cat = buildAdminGuideSidebar(cat);
} else if (dir === 'integrations-guide') {
cat = buildIntegrationsSidebar(cat);
+ } else if (dir === 'end-user-guide') {
+ cat = buildEndUserGuideSidebar(cat);
}
sidebar.push(cat);
}
diff --git a/docs/site/scripts/stage-agents-docs.mjs b/docs/site/scripts/stage-agents-docs.mjs
new file mode 100644
index 000000000000..351782b4a9d0
--- /dev/null
+++ b/docs/site/scripts/stage-agents-docs.mjs
@@ -0,0 +1,180 @@
+#!/usr/bin/env node
+// Stage the mattermost-plugin-agents submodule's docs/ subfolder into
+// main/agents/docs/ so the two curated Agents pages (administration-guide/
+// configure/agents-admin-guide.mdx and end-user-guide/agents.mdx) and a
+// handful of nested reference pages have real content to link to.
+//
+// Why staging instead of checking out the submodule directly under docs/:
+// submodules can't do a sparse "just this subfolder" checkout, and the repo
+// also has README/LICENSE/Go source/etc. we don't want as pages. So it's
+// vendored out-of-tree at vendor/mattermost-plugin-agents, and this script
+// copies+transforms only its docs/** into main/agents/docs/ before the
+// sidebar is generated — same effect as Sphinx's conf.py exclude/redirect
+// rules on its own full-repo submodule checkout.
+//
+// Navigation mirrors Sphinx exactly (source/end-user-guide/agents.rst,
+// source/administration-guide/configure/agents-admin-guide.rst): no
+// top-level Agents nav section. admin_guide.md/user_guide.md are `..
+// include::`d into the two curated pages; providers/aws_bedrock_setup/
+// sovereign_ai/usage_tips get their own nested pages via a small hidden
+// toctree; everything else (load-testing, upgrading_to_2.0, features/*) is
+// direct-URL-only, never in any toctree. Reproduced as a three-way split:
+// - INLINE_PARTIALS: staged as an unlisted doc (direct-link parity) AND
+// as a Markdown partial (leading underscore) the curated pages
+// `import` and render inline, reproducing `.. include::`.
+// - NAV_CHILDREN: normal listed docs, nested under the curated pages by
+// gen-documentation-sidebar.mjs via cross-directory {doc: ...} items.
+// - Everything else: unlisted docs — built and linkable, absent from
+// the sidebar.
+//
+// Re-run safely at any time (e.g. after `git submodule update --remote`) —
+// it fully regenerates its output directories.
+//
+// Usage: node docs/site/scripts/stage-agents-docs.mjs
+
+import {
+ readFileSync, writeFileSync, mkdirSync, readdirSync, statSync,
+ existsSync, rmSync, copyFileSync,
+} from 'node:fs';
+import {join, resolve, relative, dirname, extname, posix} from 'node:path';
+import {fileURLToPath} from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const SITE_ROOT = resolve(HERE, '..');
+const REPO_ROOT = resolve(SITE_ROOT, '..');
+
+const VENDOR_DOCS = join(REPO_ROOT, 'vendor', 'mattermost-plugin-agents', 'docs');
+const DEST_DOCS = join(REPO_ROOT, 'main', 'agents', 'docs');
+const DEST_IMAGES = join(SITE_ROOT, 'static', 'images', 'agents');
+
+// Files whose body is inlined into a curated page via a Markdown partial
+// import, in addition to being staged as their own unlisted page.
+const INLINE_PARTIALS = new Set(['admin_guide', 'user_guide']);
+
+// Files that get their own listed page, nested under a curated page by
+// gen-documentation-sidebar.mjs. Everything else staged ends up unlisted.
+const NAV_CHILDREN = new Set(['providers', 'aws_bedrock_setup', 'sovereign_ai', 'usage_tips']);
+
+function rmrf(p) {
+ if (existsSync(p)) rmSync(p, {recursive: true, force: true});
+}
+
+function walk(dir, exclude = []) {
+ const out = [];
+ for (const name of readdirSync(dir)) {
+ if (exclude.includes(name)) continue;
+ const abs = join(dir, name);
+ if (statSync(abs).isDirectory()) out.push(...walk(abs, exclude));
+ else out.push(abs);
+ }
+ return out;
+}
+
+// Several vendored files lead with a raw `` HTML
+// comment. Plain Markdown tolerates that, but MDX doesn't parse bare HTML
+// comments the same way (it wants `{/* ... */}`), so strip it before any
+// further processing rather than trying to convert it — license headers
+// aren't meaningful on rendered docs pages anyway.
+function stripLeadingLicenseComment(body) {
+ return body.replace(/^\r?\n\r?\n?/, '');
+}
+
+// Extract a leading `# Title` line as Docusaurus frontmatter `title`, since
+// migrated pages elsewhere in main/ carry the title in frontmatter rather
+// than as an in-body H1 (Docusaurus renders the frontmatter title as the
+// page's H1 automatically).
+function extractTitle(body) {
+ const m = body.match(/^# (.+)\r?\n\r?\n?/);
+ if (!m) return {title: null, body};
+ return {title: m[1].trim(), body: body.slice(m[0].length)};
+}
+
+// Vendored markdown references sibling images as `img/foo.png` or
+// `../img/foo.png` (relative to its own location under docs/). Those PNGs
+// are copied to static/images/agents/, so rewrite refs to the site's
+// standard absolute `/images//` convention.
+function rewriteImagePaths(body) {
+ return body.replace(/(!\[[^\]]*]\()(?:\.\.\/)?img\/([^)\s]+)(\))/g, '$1/images/agents/$2$3');
+}
+
+// Vendored markdown cross-links sibling docs with relative paths, e.g.
+// `[...](../admin_guide.md#license-requirements)` or
+// `[...](features/channel_summaries.md)`. Resolve those relative to the
+// linking file's own directory under docs/ and rewrite to the site's
+// absolute `/agents/docs/` doc convention, so links keep working
+// regardless of where the linking content ends up rendered (including
+// when inlined into a curated page in a completely different directory).
+function rewriteRelativeMdLinks(body, fileRelDir) {
+ return body.replace(/(\[[^\]]*]\()(?!https?:\/\/|\/|#)([^)\s#]+)\.md(#[^)\s]+)?(\))/g, (_m, pre, target, anchor, post) => {
+ const resolved = posix.normalize(posix.join(fileRelDir, target));
+ return `${pre}/agents/docs/${resolved}${anchor || ''}${post}`;
+ });
+}
+
+function stageDocs() {
+ // Clear previous output unconditionally, even on failure below, so a
+ // reused workspace (stale checkout, missing submodule init) never ships
+ // docs left over from a prior run instead of failing loudly.
+ rmrf(DEST_DOCS);
+ rmrf(DEST_IMAGES);
+
+ if (!existsSync(VENDOR_DOCS)) {
+ throw new Error(
+ `[stage-agents-docs] submodule content not found at ${VENDOR_DOCS}. ` +
+ 'Run `git submodule update --init --remote docs/vendor/mattermost-plugin-agents` first.',
+ );
+ }
+
+ const mdFiles = walk(VENDOR_DOCS, ['img']).filter((f) => extname(f) === '.md');
+ let partialCount = 0;
+ for (const src of mdFiles) {
+ const rel = relative(VENDOR_DOCS, src); // e.g. "admin_guide.md" or "features/channel_summaries.md"
+ const relDir = posix.dirname(rel.replace(/\\/g, '/'));
+ const baseName = basenameNoExt(rel);
+
+ const raw = stripLeadingLicenseComment(readFileSync(src, 'utf8'));
+ const {title, body} = extractTitle(raw);
+ const transformed = rewriteRelativeMdLinks(rewriteImagePaths(body), relDir === '.' ? '' : relDir);
+
+ const dest = join(DEST_DOCS, rel);
+ mkdirSync(dirname(dest), {recursive: true});
+
+ const listed = NAV_CHILDREN.has(baseName);
+ const inlined = INLINE_PARTIALS.has(baseName);
+ const frontmatterLines = [];
+ if (title) frontmatterLines.push(`title: "${title.replace(/"/g, '\\"')}"`);
+ if (!listed) frontmatterLines.push('unlisted: true');
+ const frontmatter = frontmatterLines.length ? `---\n${frontmatterLines.join('\n')}\n---\n\n` : '';
+ writeFileSync(dest, frontmatter + transformed);
+
+ if (inlined) {
+ // Markdown partials (leading underscore) are auto-excluded from
+ // Docusaurus routing/sidebars and can be `import`ed + rendered
+ // inline elsewhere — this is what reproduces Sphinx's
+ // `.. include:: /agents/docs/*.md` behavior.
+ const partialDest = join(dirname(dest), `_${baseName}_partial.mdx`);
+ writeFileSync(partialDest, transformed);
+ partialCount++;
+ }
+ }
+
+ const imgDir = join(VENDOR_DOCS, 'img');
+ let imageCount = 0;
+ if (existsSync(imgDir)) {
+ mkdirSync(DEST_IMAGES, {recursive: true});
+ for (const name of readdirSync(imgDir)) {
+ copyFileSync(join(imgDir, name), join(DEST_IMAGES, name));
+ imageCount++;
+ }
+ }
+
+ return {files: mdFiles.length, partials: partialCount, images: imageCount};
+}
+
+function basenameNoExt(relPath) {
+ const base = posix.basename(relPath.replace(/\\/g, '/'));
+ return base.replace(/\.md$/, '');
+}
+
+const {files, partials, images} = stageDocs();
+console.log(`[stage-agents-docs] staged ${files} doc(s) (${partials} with inline partials), ${images} image(s) into ${relative(REPO_ROOT, DEST_DOCS)}`);
diff --git a/docs/vendor/mattermost-plugin-agents b/docs/vendor/mattermost-plugin-agents
new file mode 160000
index 000000000000..666ae7a55da8
--- /dev/null
+++ b/docs/vendor/mattermost-plugin-agents
@@ -0,0 +1 @@
+Subproject commit 666ae7a55da8af2a1ec092909efa7ac1d7daad56
diff --git a/server/channels/api4/access_control.go b/server/channels/api4/access_control.go
index ff84e336295c..c330f36ad7f9 100644
--- a/server/channels/api4/access_control.go
+++ b/server/channels/api4/access_control.go
@@ -6,6 +6,7 @@ package api4
import (
"encoding/json"
"net/http"
+ "slices"
"strconv"
"strings"
@@ -21,6 +22,28 @@ 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)
@@ -135,6 +158,11 @@ func createAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Reques
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.
@@ -155,6 +183,11 @@ func createAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Reques
return
}
}
+
+ if appErr := preserveSystemManagedFields(c, &policy); appErr != nil {
+ c.Err = appErr
+ return
+ }
}
default:
c.SetInvalidParam("type")
diff --git a/server/channels/api4/access_control_test.go b/server/channels/api4/access_control_test.go
index c4f1ebca5b13..b2bff591d033 100644
--- a/server/channels/api4/access_control_test.go
+++ b/server/channels/api4/access_control_test.go
@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"net/http"
+ "slices"
"testing"
"github.com/mattermost/mattermost/server/public/model"
@@ -143,6 +144,8 @@ func TestCreateAccessControlPolicy(t *testing.T) {
// Create and set up the mock
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
+ notFound := model.NewAppError("GetPolicy", "app.access_control.not_found.app_error", nil, "", http.StatusNotFound)
+ mockAccessControlService.On("GetPolicy", mock.AnythingOfType("*request.Context"), privateChannel.Id).Return(nil, notFound)
mockAccessControlService.On("SavePolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("*model.AccessControlPolicy")).Return(channelPolicy, nil).Times(1)
th.App.UpdateConfig(func(cfg *model.Config) {
@@ -265,6 +268,8 @@ func TestCreateAccessControlPolicy(t *testing.T) {
ch := th.CreatePrivateChannel(t)
// Set up mock expectations
+ notFound := model.NewAppError("GetPolicy", "app.access_control.not_found.app_error", nil, "", http.StatusNotFound)
+ mockAccessControlService.On("GetPolicy", mock.AnythingOfType("*request.Context"), ch.Id).Return(nil, notFound)
mockAccessControlService.On("SavePolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("*model.AccessControlPolicy")).Return(samplePolicy, nil).Times(1)
// Set the mock on the app
@@ -513,6 +518,271 @@ func TestCreateAccessControlPolicy(t *testing.T) {
})
}
+// A channel/team admin can't change a policy's parent imports or team-scope through the general
+// update endpoint — those belong to the assign/unassign paths. System admins still can.
+func TestCreateAccessControlPolicyPreservesSystemManagedFields(t *testing.T) {
+ th := SetupConfig(t, maskingOffTestConfig).InitBasic(t)
+
+ parentID := model.NewId()
+
+ membershipRule := func(expression string) []model.AccessControlPolicyRule {
+ return []model.AccessControlPolicyRule{{Expression: expression, Actions: []string{"membership"}}}
+ }
+
+ // Saved policy must have exactly these imports and no scope: channel/team policies never carry
+ // scope, so a caller-supplied value must be dropped.
+ savedWith := func(imports ...string) any {
+ return mock.MatchedBy(func(p *model.AccessControlPolicy) bool {
+ return slices.Equal(p.Imports, imports) && p.Scope == "" && p.ScopeID == ""
+ })
+ }
+
+ enableABAC := func() *mocks.AccessControlServiceInterface {
+ mockACS := &mocks.AccessControlServiceInterface{}
+ th.App.Srv().Channels().AccessControl = mockACS
+ th.App.UpdateConfig(func(cfg *model.Config) {
+ cfg.AccessControlSettings.EnableAttributeBasedAccessControl = new(true)
+ })
+ return mockACS
+ }
+
+ setupChannelAdmin := func(t *testing.T) (*model.Channel, *model.Client4, *mocks.AccessControlServiceInterface) {
+ t.Helper()
+ ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
+ require.True(t, ok, "SetLicense should return true")
+ th.AddPermissionToRole(t, model.PermissionManageChannelAccessRules.Id, model.ChannelAdminRoleId)
+
+ privateChannel := th.CreatePrivateChannel(t)
+ channelAdmin := th.CreateUser(t)
+ th.LinkUserToTeam(t, channelAdmin, th.BasicTeam)
+ th.AddUserToChannel(t, channelAdmin, privateChannel)
+ th.MakeUserChannelAdmin(t, channelAdmin, privateChannel)
+ client := th.CreateClient()
+ _, _, err := client.Login(context.Background(), channelAdmin.Email, channelAdmin.Password)
+ require.NoError(t, err)
+
+ return privateChannel, client, enableABAC()
+ }
+
+ // Logs th.Client in as a team admin and stubs the per-rule self-inclusion check so the request
+ // reaches the save. Caller must defer th.LoginBasic(t).
+ setupTeamAdmin := func(t *testing.T) *mocks.AccessControlServiceInterface {
+ t.Helper()
+ ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
+ require.True(t, ok, "SetLicense should return true")
+ th.AddPermissionToRole(t, model.PermissionManageTeamAccessRules.Id, model.TeamAdminRoleId)
+
+ teamAdmin := th.CreateUser(t)
+ makeTeamAdminAndLogin(t, th, teamAdmin, th.BasicTeam)
+
+ mockACS := enableABAC()
+ mockACS.On("QueryUsersForExpression", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("string"), mock.AnythingOfType("model.SubjectSearchOptions")).
+ Return([]*model.User{{Id: teamAdmin.Id}}, int64(1), nil)
+ return mockACS
+ }
+
+ // withCallerScope sets scope fields on the request body; the handler owns scope for
+ // channel/team policies and must ignore what the caller sends.
+ withCallerScope := func(p *model.AccessControlPolicy) *model.AccessControlPolicy {
+ p.Scope = model.AccessControlPolicyScopeTeam
+ p.ScopeID = model.NewId()
+ return p
+ }
+
+ notFound := model.NewAppError("GetPolicy", "app.access_control.not_found.app_error", nil, "", http.StatusNotFound)
+
+ t.Run("channel admin cannot detach a stored parent import", func(t *testing.T) {
+ ch, client, mockACS := setupChannelAdmin(t)
+
+ stored := &model.AccessControlPolicy{
+ ID: ch.Id,
+ Type: model.AccessControlPolicyTypeChannel,
+ Version: model.AccessControlPolicyVersionV0_3,
+ Imports: []string{parentID},
+ Rules: membershipRule("user.attributes.department == 'security'"),
+ }
+ mockACS.On("GetPolicy", mock.AnythingOfType("*request.Context"), ch.Id).Return(stored, nil)
+ mockACS.On("SavePolicy", mock.AnythingOfType("*request.Context"), savedWith(parentID)).Return(stored, nil).Once()
+
+ req := withCallerScope(&model.AccessControlPolicy{
+ ID: ch.Id,
+ Type: model.AccessControlPolicyTypeChannel,
+ Version: model.AccessControlPolicyVersionV0_3,
+ Imports: []string{},
+ Rules: membershipRule("user.attributes.department == 'finance'"),
+ })
+ _, resp, err := client.CreateAccessControlPolicy(context.Background(), req)
+ require.NoError(t, err)
+ CheckOKStatus(t, resp)
+ mockACS.AssertExpectations(t)
+ })
+
+ t.Run("channel admin cannot swap a stored parent import", func(t *testing.T) {
+ ch, client, mockACS := setupChannelAdmin(t)
+
+ stored := &model.AccessControlPolicy{
+ ID: ch.Id,
+ Type: model.AccessControlPolicyTypeChannel,
+ Version: model.AccessControlPolicyVersionV0_3,
+ Imports: []string{parentID},
+ Rules: membershipRule("true"),
+ }
+ mockACS.On("GetPolicy", mock.AnythingOfType("*request.Context"), ch.Id).Return(stored, nil)
+ mockACS.On("SavePolicy", mock.AnythingOfType("*request.Context"), savedWith(parentID)).Return(stored, nil).Once()
+
+ req := &model.AccessControlPolicy{
+ ID: ch.Id,
+ Type: model.AccessControlPolicyTypeChannel,
+ Version: model.AccessControlPolicyVersionV0_3,
+ Imports: []string{model.NewId()},
+ Rules: membershipRule("true"),
+ }
+ _, resp, err := client.CreateAccessControlPolicy(context.Background(), req)
+ require.NoError(t, err)
+ CheckOKStatus(t, resp)
+ mockACS.AssertExpectations(t)
+ })
+
+ t.Run("channel admin cannot seed imports on create", func(t *testing.T) {
+ ch, client, mockACS := setupChannelAdmin(t)
+
+ mockACS.On("GetPolicy", mock.AnythingOfType("*request.Context"), ch.Id).Return(nil, notFound)
+ mockACS.On("SavePolicy", mock.AnythingOfType("*request.Context"), savedWith()).Return(&model.AccessControlPolicy{ID: ch.Id, Type: model.AccessControlPolicyTypeChannel}, nil).Once()
+
+ req := withCallerScope(&model.AccessControlPolicy{
+ ID: ch.Id,
+ Type: model.AccessControlPolicyTypeChannel,
+ Version: model.AccessControlPolicyVersionV0_3,
+ Imports: []string{parentID},
+ Rules: membershipRule("true"),
+ })
+ _, resp, err := client.CreateAccessControlPolicy(context.Background(), req)
+ require.NoError(t, err)
+ CheckOKStatus(t, resp)
+ mockACS.AssertExpectations(t)
+ })
+
+ t.Run("channel admin gets an error when the stored policy lookup fails", func(t *testing.T) {
+ ch, client, mockACS := setupChannelAdmin(t)
+
+ serverErr := model.NewAppError("GetPolicy", "app.pap.get_policy.app_error", nil, "", http.StatusInternalServerError)
+ mockACS.On("GetPolicy", mock.AnythingOfType("*request.Context"), ch.Id).Return(nil, serverErr)
+
+ req := &model.AccessControlPolicy{
+ ID: ch.Id,
+ Type: model.AccessControlPolicyTypeChannel,
+ Version: model.AccessControlPolicyVersionV0_3,
+ Imports: []string{},
+ Rules: membershipRule("true"),
+ }
+ _, resp, err := client.CreateAccessControlPolicy(context.Background(), req)
+ require.Error(t, err)
+ CheckInternalErrorStatus(t, resp)
+ mockACS.AssertNotCalled(t, "SavePolicy", mock.Anything, mock.Anything)
+ })
+
+ t.Run("system admin retains full control over imports", func(t *testing.T) {
+ ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
+ require.True(t, ok, "SetLicense should return true")
+
+ privateChannel := th.CreatePrivateChannel(t)
+ mockACS := enableABAC()
+
+ newParent := model.NewId()
+ mockACS.On("SavePolicy", mock.AnythingOfType("*request.Context"), mock.MatchedBy(func(p *model.AccessControlPolicy) bool {
+ return len(p.Imports) == 1 && p.Imports[0] == newParent
+ })).Return(&model.AccessControlPolicy{ID: privateChannel.Id, Type: model.AccessControlPolicyTypeChannel}, nil).Once()
+
+ policy := &model.AccessControlPolicy{
+ ID: privateChannel.Id,
+ Type: model.AccessControlPolicyTypeChannel,
+ Version: model.AccessControlPolicyVersionV0_3,
+ Imports: []string{newParent},
+ Rules: membershipRule("true"),
+ }
+ _, resp, err := th.SystemAdminClient.CreateAccessControlPolicy(context.Background(), policy)
+ require.NoError(t, err)
+ CheckOKStatus(t, resp)
+ // The system-admin path must not consult the stored policy at all.
+ mockACS.AssertNotCalled(t, "GetPolicy", mock.Anything, mock.Anything)
+ mockACS.AssertExpectations(t)
+ })
+
+ t.Run("team admin cannot detach a stored parent import", func(t *testing.T) {
+ mockACS := setupTeamAdmin(t)
+ defer th.LoginBasic(t)
+
+ stored := &model.AccessControlPolicy{
+ ID: th.BasicTeam.Id,
+ Type: model.AccessControlPolicyTypeTeam,
+ Version: model.AccessControlPolicyVersionV0_3,
+ Imports: []string{parentID},
+ Rules: membershipRule("true"),
+ }
+ mockACS.On("GetPolicy", mock.AnythingOfType("*request.Context"), th.BasicTeam.Id).Return(stored, nil)
+ mockACS.On("SavePolicy", mock.AnythingOfType("*request.Context"), savedWith(parentID)).Return(stored, nil).Once()
+
+ req := &model.AccessControlPolicy{
+ ID: th.BasicTeam.Id,
+ Type: model.AccessControlPolicyTypeTeam,
+ Version: model.AccessControlPolicyVersionV0_3,
+ Imports: []string{},
+ Rules: membershipRule("user.attributes.department == 'finance'"),
+ }
+ _, resp, err := th.Client.CreateAccessControlPolicy(context.Background(), req)
+ require.NoError(t, err)
+ CheckOKStatus(t, resp)
+ mockACS.AssertExpectations(t)
+ })
+
+ t.Run("team admin cannot swap a stored parent import", func(t *testing.T) {
+ mockACS := setupTeamAdmin(t)
+ defer th.LoginBasic(t)
+
+ stored := &model.AccessControlPolicy{
+ ID: th.BasicTeam.Id,
+ Type: model.AccessControlPolicyTypeTeam,
+ Version: model.AccessControlPolicyVersionV0_3,
+ Imports: []string{parentID},
+ Rules: membershipRule("true"),
+ }
+ mockACS.On("GetPolicy", mock.AnythingOfType("*request.Context"), th.BasicTeam.Id).Return(stored, nil)
+ mockACS.On("SavePolicy", mock.AnythingOfType("*request.Context"), savedWith(parentID)).Return(stored, nil).Once()
+
+ req := &model.AccessControlPolicy{
+ ID: th.BasicTeam.Id,
+ Type: model.AccessControlPolicyTypeTeam,
+ Version: model.AccessControlPolicyVersionV0_3,
+ Imports: []string{model.NewId()},
+ Rules: membershipRule("true"),
+ }
+ _, resp, err := th.Client.CreateAccessControlPolicy(context.Background(), req)
+ require.NoError(t, err)
+ CheckOKStatus(t, resp)
+ mockACS.AssertExpectations(t)
+ })
+
+ t.Run("team admin cannot seed imports on create", func(t *testing.T) {
+ mockACS := setupTeamAdmin(t)
+ defer th.LoginBasic(t)
+
+ mockACS.On("GetPolicy", mock.AnythingOfType("*request.Context"), th.BasicTeam.Id).Return(nil, notFound)
+ mockACS.On("SavePolicy", mock.AnythingOfType("*request.Context"), savedWith()).Return(&model.AccessControlPolicy{ID: th.BasicTeam.Id, Type: model.AccessControlPolicyTypeTeam}, nil).Once()
+
+ req := withCallerScope(&model.AccessControlPolicy{
+ ID: th.BasicTeam.Id,
+ Type: model.AccessControlPolicyTypeTeam,
+ Version: model.AccessControlPolicyVersionV0_3,
+ Imports: []string{parentID},
+ Rules: membershipRule("true"),
+ })
+ _, resp, err := th.Client.CreateAccessControlPolicy(context.Background(), req)
+ require.NoError(t, err)
+ CheckOKStatus(t, resp)
+ mockACS.AssertExpectations(t)
+ })
+}
+
func TestGetAccessControlPolicy(t *testing.T) {
th := SetupConfig(t, maskingOffTestConfig).InitBasic(t)