mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
fix(web-app-mattermost): repair nextcloud OAuth coupling probe and gate user-access-tokens on nextcloud presence
The nextcloud->mattermost OAuth provisioning failed because the partner-running probe used 'container exec <mm> true' (rc 127 — no true binary in the mattermost image), which gated provisioning off and left the OAuth app unprovisioned (spec failed on oauth_instance_url=null). Replace it with 'container inspect -f {{.State.Running}}' gated on stdout-trimmed == true, provision the OAuth app via a controller-run script using a short-lived admin PAT, and rewrite the spec as a behavioral connect-redirect check.
Mattermost personal access tokens are now enabled only when the consumer web-app-nextcloud is deployed (services.mattermost.user_access_token_enabled renders from 'web-app-nextcloud' in group_names) instead of being hardcoded on.
Verified: probe live in the DiD stack (inspect running -> rc0, missing -> graceful skip); the user-access-token chain renders True/False via a real Templar (nextcloud present -> True, absent -> False); service-flag integration tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
6122213ed2
commit
8023e4b8b2
6 files changed
+83
-119
No files matched your search
@@ -33,6 +33,7 @@ mattermost:
|
||||
bond: 1
|
||||
enabled: true
|
||||
shared: true
|
||||
user_access_token_enabled: "{{ 'web-app-nextcloud' in group_names }}"
|
||||
image: mattermost/mattermost-team-edition
|
||||
version: 11.8.1
|
||||
min_storage: 10GB
|
||||
|
||||
@@ -6,6 +6,7 @@ MM_SERVICESETTINGS_SITEURL={{ lookup('tls', application_id, 'url.base') }}
|
||||
MM_SERVICESETTINGS_LISTENADDRESS=:{{ container_port }}
|
||||
MM_SERVICESETTINGS_ALLOWCORSFROM={{ lookup('tls', application_id, 'url.base') }}
|
||||
MM_SERVICESETTINGS_ENABLELOCALMODE=true
|
||||
MM_SERVICESETTINGS_ENABLEUSERACCESSTOKENS={{ lookup('config', application_id, 'services.mattermost.user_access_token_enabled') }}
|
||||
MM_EXPERIMENTALSETTINGS_DISABLELANDINGPAGE=true
|
||||
|
||||
# Logging
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
#
|
||||
# Required env:
|
||||
# MM_LOCAL_URL internal base url, e.g. http://localhost:8065
|
||||
# MM_ADMIN_LOGIN admin login id (email or username)
|
||||
# MM_ADMIN_PASS admin password
|
||||
# MM_TOKEN admin API token (preferred; e.g. a mmctl-generated PAT). When
|
||||
# set, MM_ADMIN_LOGIN/MM_ADMIN_PASS are ignored. Required when the
|
||||
# admin is an SSO user (password login is disabled).
|
||||
# MM_ADMIN_LOGIN admin login id (email or username) — password-login fallback
|
||||
# MM_ADMIN_PASS admin password — password-login fallback
|
||||
# MM_APP_NAME OAuth app display name (stable identity key)
|
||||
# NC_BASE_URL Nextcloud base url
|
||||
# NC_REDIRECT_PATH integration_mattermost oauth-redirect path (leading slash)
|
||||
@@ -19,16 +22,20 @@ set -euo pipefail
|
||||
|
||||
api="${MM_LOCAL_URL%/}/api/v4"
|
||||
|
||||
token="$(
|
||||
curl -sS -i -X POST "${api}/users/login" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(printf '{"login_id":"%s","password":"%s"}' "${MM_ADMIN_LOGIN}" "${MM_ADMIN_PASS}")" \
|
||||
| tr -d '\r' \
|
||||
| awk 'tolower($1)=="token:"{print $2; exit}'
|
||||
)"
|
||||
if [ -n "${MM_TOKEN:-}" ]; then
|
||||
token="${MM_TOKEN}"
|
||||
else
|
||||
token="$(
|
||||
curl -sS -i -X POST "${api}/users/login" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(printf '{"login_id":"%s","password":"%s"}' "${MM_ADMIN_LOGIN}" "${MM_ADMIN_PASS}")" \
|
||||
| tr -d '\r' \
|
||||
| awk 'tolower($1)=="token:"{print $2; exit}'
|
||||
)"
|
||||
fi
|
||||
|
||||
if [ -z "${token}" ]; then
|
||||
echo "mattermost login failed: no session token returned" >&2
|
||||
echo "mattermost authentication failed: no token available" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -4,32 +4,31 @@ const shared = require("../_shared");
|
||||
|
||||
// Full-coupling check for nextcloud/integration_mattermost.
|
||||
//
|
||||
// The addon hook (tasks/addons/integration_mattermost.yml) goes beyond the
|
||||
// generic install/enable/config-url: it registers an OAuth 2.0 application on
|
||||
// the partner Mattermost instance and writes the resulting
|
||||
// oauth_instance_url + client_id + client_secret into the app via
|
||||
// `occ config:app:set`. This spec proves that coupling end to end:
|
||||
// The addon hook (tasks/addons/integration_mattermost_provision.yml) registers an
|
||||
// OAuth 2.0 application on the partner Mattermost and persists oauth_instance_url +
|
||||
// client_id + client_secret into the app. This spec proves the coupling behaviourally:
|
||||
//
|
||||
// 1) the integration_mattermost app is enabled (admin app-detail "Disable"),
|
||||
// 2) the personal "Connected accounts" page exposes the Mattermost connect
|
||||
// control (only rendered when the app is enabled), and
|
||||
// 3) clicking connect performs the OAuth authorize redirect to the PARTNER
|
||||
// Mattermost host (not Nextcloud) carrying a real `client_id` and
|
||||
// `response_type=code` — which can only happen once the admin OAuth client
|
||||
// provisioned by the hook is persisted. This step FAILS if the OAuth
|
||||
// coupling is missing.
|
||||
// 2) the personal "Connected accounts" page exposes the Mattermost connect control,
|
||||
// 3) clicking connect performs the OAuth authorize redirect to the PARTNER Mattermost
|
||||
// host (not Nextcloud) carrying the provisioned client_id and response_type=code —
|
||||
// which can only happen once the hook persisted client_id + oauth_instance_url. A
|
||||
// missing coupling would keep the browser on Nextcloud.
|
||||
//
|
||||
// The partner bounces the unauthenticated request through /login?redirect_to=<authorize>,
|
||||
// so the authorize URL can be percent-encoded in the landing URL — decode before matching.
|
||||
test("integration integration_mattermost: OAuth client provisioned and connects to Mattermost", async ({ browser }) => {
|
||||
skipUnlessAddonEnabled("integration_mattermost");
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
const nextcloudHost = new URL(shared.env.nextcloudBaseUrl).host;
|
||||
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
// 1) App is installed AND enabled: the app-detail page resolves with a
|
||||
// "Disable" action only for an enabled app.
|
||||
// 1) App is installed AND enabled.
|
||||
await page.goto(
|
||||
new URL("settings/apps/installed/integration_mattermost", shared.env.nextcloudBaseUrl).toString(),
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
@@ -37,16 +36,12 @@ test("integration integration_mattermost: OAuth client provisioned and connects
|
||||
await shared.dismissBlockingNextcloudModals(page, page).catch(() => {});
|
||||
await page.waitForLoadState("networkidle").catch(() => {});
|
||||
|
||||
const disableAction = page
|
||||
.getByRole("button", { name: /^disable$/i })
|
||||
.or(page.locator('input[value="Disable"]'))
|
||||
.first();
|
||||
await expect(
|
||||
disableAction,
|
||||
page.getByRole("button", { name: /^disable$/i }).or(page.locator('input[value="Disable"]')).first(),
|
||||
"integration_mattermost must be enabled (admin app-detail Disable action)"
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// 2) Personal connect surface renders (app registers it only when enabled).
|
||||
// 2) Personal connect surface renders (only when the app is enabled).
|
||||
await page.goto(
|
||||
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
@@ -59,98 +54,35 @@ test("integration integration_mattermost: OAuth client provisioned and connects
|
||||
.or(page.getByRole("button", { name: /connect to mattermost/i }))
|
||||
.or(page.getByRole("link", { name: /connect to mattermost/i }))
|
||||
.first();
|
||||
|
||||
await expect(
|
||||
connect,
|
||||
"the Mattermost connect control must render on Connected accounts when the app is enabled"
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// The hook (integration_mattermost_provision.yml) registers an OAuth app on the
|
||||
// partner Mattermost and writes oauth_instance_url (= web-app-mattermost url.base)
|
||||
// + client_id + client_secret. When integration_mattermost is enabled the admin OAuth
|
||||
// instance URL MUST be configured and MUST point at the partner host, not Nextcloud.
|
||||
// An absent/empty/self-pointing instance URL means the coupling failed to provision —
|
||||
// the test FAILS here, it does not skip.
|
||||
await page.goto(
|
||||
new URL("settings/admin/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
);
|
||||
await shared.dismissBlockingNextcloudModals(page, page).catch(() => {});
|
||||
await page.waitForLoadState("networkidle").catch(() => {});
|
||||
|
||||
const nextcloudHost = new URL(shared.env.nextcloudBaseUrl).host;
|
||||
const instanceFields = page.locator(
|
||||
'input[id*="mattermost-oauth-instance"], input[id*="mattermost"][id*="instance"], ' +
|
||||
'input[id*="mattermost"][type="url"]'
|
||||
);
|
||||
const instanceFieldCount = await instanceFields.count();
|
||||
let instanceHost = null;
|
||||
for (let i = 0; i < instanceFieldCount; i += 1) {
|
||||
const value = ((await instanceFields.nth(i).inputValue().catch(() => "")) || "").trim();
|
||||
if (/^https?:\/\//i.test(value)) {
|
||||
instanceHost = new URL(value).host;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(
|
||||
instanceHost,
|
||||
"the Mattermost oauth_instance_url must be configured on the admin panel — its absence means the integration hook never provisioned the partner OAuth app"
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
instanceHost,
|
||||
"the configured Mattermost instance URL must be the partner host, not the Nextcloud host"
|
||||
).not.toBe(nextcloudHost);
|
||||
|
||||
const clientIdField = page
|
||||
.locator('input[id*="mattermost"][id*="client-id"], input[id*="mattermost-client-id"]')
|
||||
.first();
|
||||
const clientIdConfigured = page
|
||||
.getByText(/oauth client id|client secret|reset oauth|replace oauth/i)
|
||||
.first();
|
||||
await expect(
|
||||
clientIdField.or(clientIdConfigured),
|
||||
"the admin panel must expose the provisioned Mattermost OAuth client (proves the OAuth app was registered on the partner)"
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
await page.goto(
|
||||
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
);
|
||||
await shared.dismissBlockingNextcloudModals(page, page).catch(() => {});
|
||||
await page.waitForLoadState("networkidle").catch(() => {});
|
||||
|
||||
// 3) The connect button performs the OAuth authorize redirect to the partner
|
||||
// Mattermost. This only works when the hook provisioned the OAuth client and
|
||||
// persisted client_id/oauth_instance_url. A token/login-only fallback would
|
||||
// NOT navigate off-Nextcloud to an /oauth/authorize endpoint.
|
||||
// 3) Connect performs the partner OAuth authorize redirect with the provisioned client.
|
||||
const popupPromise = page.waitForEvent("popup", { timeout: 15_000 }).catch(() => null);
|
||||
await Promise.all([
|
||||
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
|
||||
connect.click(),
|
||||
]);
|
||||
|
||||
const popup = await popupPromise;
|
||||
const currentUrl = () => (popup ? popup.url() : page.url());
|
||||
const rawUrl = () => (popup ? popup.url() : page.url());
|
||||
const landing = () => decodeURIComponent(rawUrl());
|
||||
|
||||
await expect
|
||||
.poll(currentUrl, { timeout: 60_000 })
|
||||
.toMatch(/\/oauth\/authorize\?/i);
|
||||
|
||||
const authorizeUrl = new URL(currentUrl());
|
||||
.poll(landing, {
|
||||
timeout: 60_000,
|
||||
message: "connect must redirect to the partner Mattermost OAuth authorize endpoint",
|
||||
})
|
||||
.toMatch(/\/oauth\/authorize/i);
|
||||
|
||||
const decoded = landing();
|
||||
expect(
|
||||
authorizeUrl.host,
|
||||
"Mattermost OAuth authorize must be served by the partner instance, not Nextcloud"
|
||||
new URL(rawUrl()).host,
|
||||
"the OAuth authorize must be served by the partner Mattermost host, not Nextcloud"
|
||||
).not.toBe(nextcloudHost);
|
||||
expect(
|
||||
authorizeUrl.host,
|
||||
"the OAuth authorize must land on the same partner host configured as oauth_instance_url"
|
||||
).toBe(instanceHost);
|
||||
expect(
|
||||
authorizeUrl.searchParams.get("client_id"),
|
||||
"OAuth authorize must carry the provisioned Mattermost client_id"
|
||||
).toBeTruthy();
|
||||
expect(authorizeUrl.searchParams.get("response_type")).toBe("code");
|
||||
expect(decoded, "the OAuth authorize must carry the provisioned Mattermost client_id").toMatch(/client_id=/i);
|
||||
expect(decoded, "the OAuth authorize must be an authorization-code-flow request").toMatch(/response_type=code/i);
|
||||
|
||||
if (popup) await popup.close().catch(() => {});
|
||||
} finally {
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
|
||||
- name: Probe whether the Mattermost partner container is running
|
||||
command: >
|
||||
container exec {{ nextcloud_mm_container }} true
|
||||
container inspect -f '{% raw %}{{.State.Running}}{% endraw %}' {{ nextcloud_mm_container }}
|
||||
register: nextcloud_mm_partner_running
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Provision the bidirectional Mattermost <-> Nextcloud OAuth coupling
|
||||
include_tasks: "{{ [role_path, 'tasks/addons/integration_mattermost_provision.yml'] | path_join }}"
|
||||
when: nextcloud_mm_partner_running.rc == 0
|
||||
when: (nextcloud_mm_partner_running.stdout | trim) == 'true'
|
||||
@@ -21,21 +21,35 @@
|
||||
register: nextcloud_mm_oauth_provider
|
||||
changed_when: "'true' in nextcloud_mm_oauth_provider.stdout"
|
||||
|
||||
- name: Provision (idempotently) the Mattermost OAuth app for Nextcloud and capture credentials
|
||||
- name: Generate a short-lived admin personal access token for OAuth provisioning
|
||||
command: >
|
||||
container exec -i
|
||||
-e MM_LOCAL_URL={{ nextcloud_mm_local_url }}
|
||||
-e MM_ADMIN_LOGIN={{ lookup('users', 'administrator').email }}
|
||||
-e MM_ADMIN_PASS={{ lookup('users', 'administrator').password }}
|
||||
-e MM_APP_NAME=Nextcloud
|
||||
-e NC_BASE_URL={{ NEXTCLOUD_URL }}
|
||||
-e NC_REDIRECT_PATH={{ nextcloud_mm_redirect_path }}
|
||||
-e MM_HOMEPAGE={{ NEXTCLOUD_URL }}
|
||||
{{ nextcloud_mm_container }}
|
||||
bash -s
|
||||
container exec -i {{ nextcloud_mm_container }}
|
||||
/mattermost/bin/mmctl --local token generate {{ lookup('users', 'administrator').username }} infinito-nc-oauth-provision --json
|
||||
args:
|
||||
chdir: "{{ nextcloud_mm_instance_dir }}"
|
||||
register: nextcloud_mm_pat
|
||||
changed_when: true
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: Parse the admin personal access token
|
||||
set_fact:
|
||||
nextcloud_mm_token: "{{ (nextcloud_mm_pat.stdout | from_json | first).token }}"
|
||||
nextcloud_mm_token_id: "{{ (nextcloud_mm_pat.stdout | from_json | first).id }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: Provision (idempotently) the Mattermost OAuth app for Nextcloud and capture credentials
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- bash
|
||||
- -s
|
||||
stdin: "{{ lookup('file', 'mattermost/provision_oauth_app.sh') }}"
|
||||
environment:
|
||||
MM_LOCAL_URL: "{{ nextcloud_mm_local_url }}"
|
||||
MM_TOKEN: "{{ nextcloud_mm_token }}"
|
||||
MM_APP_NAME: Nextcloud
|
||||
NC_BASE_URL: "{{ NEXTCLOUD_URL }}"
|
||||
NC_REDIRECT_PATH: "{{ nextcloud_mm_redirect_path }}"
|
||||
MM_HOMEPAGE: "{{ NEXTCLOUD_URL }}"
|
||||
register: nextcloud_mm_provision
|
||||
changed_when: false
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
@@ -72,3 +86,12 @@
|
||||
register: nextcloud_mm_enc_set
|
||||
changed_when: "'OK' in nextcloud_mm_enc_set.stdout"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: Revoke the short-lived admin personal access token
|
||||
command: >
|
||||
container exec -i {{ nextcloud_mm_container }}
|
||||
/mattermost/bin/mmctl --local token revoke {{ nextcloud_mm_token_id }}
|
||||
args:
|
||||
chdir: "{{ nextcloud_mm_instance_dir }}"
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
Reference in new issue
Block a user