Commit Graph
9 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 5 1e64d3da9c feat(web-app-gitlab): make the platform administrator a real GitLab admin
The role declared `platform_administrator` as its administrator and GitLab
disagreed. Community Edition maps no OIDC group onto admin, templates/config/
gitlab.yml.j2 sets no `admin_groups`, and nothing promoted the account after
its first sign-in, so `/admin` answered 404 to the one identity the
deployment calls the administrator. The rights lived on `root` instead,
which carries that same person's password, so one secret opened two doors
with different privileges and an audit of "who administers this" named an
account that belongs to nobody.

tasks/utils/platform_admin.yml provisions the account instead of deriving
it: files/ruby/ensure_platform_admin.rb creates it when absent, sets the
admin flag when missing, and realigns the password, reporting [CHANGED],
[UNCHANGED] or [FAIL] the way the root realign beside it already does.

Three details decide whether this works, and each is read off the role
rather than assumed. `skip_confirmation: true` is what lets
`auto_link_user: ["openid_connect"]`, already set in gitlab.yml.j2, bind the
first OIDC sign-in to this account by e-mail instead of creating a second
one; the role's own MCP provisioning uses that flag for the same reason.
Clearing `password_automatically_set` is what keeps an account OmniAuth
created earlier from rejecting every password, a trap the root script next
door documents. And the account is looked up by username or by any e-mail,
so a pre-existing OIDC identity is promoted rather than duplicated.

`root` stays the break-glass account and keeps its realign. Giving the
declared administrator its own rights is what lets root stop being the
everyday admin; blocking it, as the designer branch proposes, would lock out
the recovery path this branch relies on.

The sharpened persona check from 989bf86374 should go green with this, which
is the point: it was red because the deployment was wrong, not the test.

mobilizon and port-ui share the shape and are handled separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-13 15:01:08 +02:00
kevinveenbirkenbachandClaude Opus 5 463053e061 fix(roles): keep credentials out of the argv list form too
`container exec` was handed 25 live secrets across 10 roles through an
`argv:` list, where `-e` sits on one entry and `NAME={{ secret }}` on the
next. Each value is an argv element of the host-side process, so `ps` shows
it to every local account while the call runs, and `no_log` masks Ansible's
output rather than the process table. All 25 now travel as `-e NAME` with
the value in the task's `environment:`.

The three container lints added earlier this week all missed this, because
they scan line by line and the two halves of the flag never share a line.
`container-exec-env-value` therefore reads `argv:` lists out of the parsed
YAML as well.

Widening it exposed the name heuristic as too blunt: `_TOKEN_NAME`,
`_API_KEY_NAME` and `_LIFETIME_DAYS` match `TOKEN` and `KEY` while carrying
nothing secret. Nine `nocheck` markers would have papered over that, so the
rule now disqualifies a name whose last segment describes a credential
instead of holding one. A secret reached through such a name is still caught
by its source expression.

Counting by variable name hid one site: discourse carries the OIDC client
secret in three tasks, and deduplicating by name collapsed them to one. The
sweep is verified by the raw occurrence count instead, which is why this is
25 rather than the 24 first reported.

Roles touched: bigbluebutton, checkmk, discourse, gitlab, jellyfin, n8n,
nextcloud, openproject, openwebui, snipe-it.

With `command` and `shell` the value still appears once on the module
wrapper's line, which `Shell.env_prefix` builds, so the exposure shrinks to
a single process rather than vanishing.

feature/designer fixes the same bigbluebutton defect in 90d491b5a7 and goes
further: it drops the base64 encoding, which only ever existed to survive
argv quoting and is pointless once the value skips the shell, and it repairs
an administrator created unverified. That side wins at the merge, except for
its `lookup('users', 'administrator')`, which this branch forbids outside
user-administrator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-13 01:47:23 +02:00
kevinveenbirkenbachandClaude Opus 5 23e642a3f3 fix(roles): realign the admin password on every deploy, not only at install
3c72c2002a and 5366788ab1 moved application roles from the host account
administrator onto platform_administrator. administrator never rotates: it
declares ansible_become_password in roles/user-administrator/meta/users.yml, so
the generator pins nothing into host_vars, and rotate.py:12 exempts it anyway.
platform_administrator rotates with every --users pass, between PASS 1 and
PASS 2 of a matrix-deploy round. The roles below wrote the password only when
they created the account, so from the second pass on the instance kept the
previous value while the deploy rendered the new one, and the sign-in failed.

Each one now re-applies the declared password through its own handle. baserow
drops the 'if created' guard and compares with check_password. gitea and
mattermost gain a change-password call beside the create they already had.
gitlab realigns its native root break-glass through the Rails runner and clears
password_automatically_set, which would otherwise make valid_password? return
false whatever the password is. magento and snipe-it get a script because
neither ships a CLI for an existing account; magento's runs under AREA_GLOBAL so
the adminhtml-only admin_user_save_after observer never loads, at the price of
the admin_passwords history row. matomo moves its realign out from behind the
token gate a rotation never opens. The matrix ansible flavor gets the realign
its compose flavor already had. bluesky resets the native PDS account through
the admin API when the create reports the handle taken.

Every new path follows the contract web-app-nextcloud and sys-svc-rdbms already
use: [CHANGED] / [UNCHANGED] / [FAIL] on the wire, failed_when on rc or [FAIL],
and an absent account is a failure rather than a silent zero-row success. The
password reaches the container through container exec -e NAME plus a task
environment, never the host-side argv.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-12 21:21:57 +02:00
kevinveenbirkenbachandClaude Opus 5 791eee7c9f refactor(mcp)!: prove the endpoint contract with a CLI test, not a deploy probe
The contract check ran as an Ansible task in the middle of every provider deploy, and the two client checks ran the same way. A test that is part of the deploy cannot be run without deploying, cannot be re-run on its own, and reports its verdict as a deploy failure. Twenty-seven task files lose 491 lines to this.

It is now what it always was. Each provider ships files/test/test.sh, templates/test.env.j2 and meta/tests.yml, and test-e2e-cli stages one shared harness beside it: run.sh drives contract.sh in a throwaway container on the provider's own network, then isolation.sh for adapter-fronted providers. The read probe each provider offers moved from the calling task into meta/mcp.yml next to the allowlist it must satisfy, and the schema now rejects a read probe naming a tool outside that allowlist.

The bearer the test presents is the one a client resolves through mcp.credential, via the new mcp_client_bearer lookup over resolve_credential, which moved to utils.roles.applications.mcp so it is the same function mcp_servers renders every client connection with. The probe presented whatever the calling role passed, which for the fifteen adapter-fronted providers is a different secret than any client holds.

Sidecar isolation is read from the engine rather than measured inside the container, so it no longer needs a delegation to the node holding the task: attachment is compared by network name, which drops the gwbridge special case, and a swarm worker says it cannot measure instead of skipping silently.

Retrying is bounded by wall clock against MCP_DEADLINE_SECONDS, which scales through the same lookup('timeout') as the harness budget it has to stay under. A flag that is neither true nor false aborts rather than reading as off.

mcp-provider-probe becomes mcp-provider-cli-test: an enabled provider must ship the test rather than include the task. mcp-probe-bearer becomes mcp-adapter-bearer, tying ADAPTER_BEARER to the declared credential key now that no probe include carries it. A new integration test renders the shared env template for every provider through Ansible's own templar, so a later change to it breaks visibly instead of poisoning twenty deploys quietly.

Hermes and OpenClaw keep their client-side proof as CLI tests of their own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 20:46:26 +02:00
kevinveenbirkenbach 85af82b956 fix(gitlab): drop the licence tier MCP never required
The upstream survey recorded GitLab MCP as Premium and Ultimate only,
and c76de1a2d4 carried that into the probe's failure message, which sent
the operator to 'blocker: licence'. This role deploys the -ce images, so
under that reading it could never have served MCP at all.

The source says otherwise. lib/api/mcp/base.rb sits in the CE tree, not
under ee/, and at the pinned v19.3.1 its mcp_denial_reason returns
:instance_setting_disabled unless CurrentSettings.mcp_server_enabled? -
no licence check on that path - while application_setting.rb:836
declares that setting default: true. A stock CE instance serves it.

minimum_version moves to 19.0 rather than the 18.3 that first shipped the
route: at 18.3 the endpoint sits behind the per-user mcp_server feature
flag, and 19.0.0 is where CE defines feature_available? as an
unconditional true.

The assertion that commit added stays, because it is the right one - it
measures the tool inventory instead of trusting a declaration. Only its
explanation changes, to the two causes that actually produce an empty
list.
2026-08-29 11:01:48 +02:00
kevinveenbirkenbachandClaude Opus 5 9967c806bd fix(roles): follow the python scripts to their new folder
Twelve callers load their scripts with the relative form
lookup('file', 'name.py'), which resolves against the role's files/
directory. dfe7a7fdd moved those scripts into files/python/ without
updating them, so the MCP provisioning and probes of baserow, checkmk,
flowise, gitlab, homeassistant, jellyfin, n8n, openwebui and pretix, plus
n8n's AI gateway task, all resolved a path that no longer exists.

This is the second time the same relocation left callers behind. After
2aec4326a fixed the php paths, the claim that no stale references remained
came from grepping the worktree, which already carried the corrected paths;
HEAD still held the old ones. The check has to run against the commit.

Verified against HEAD this time: the eleven remaining stale relative lookups
are exactly the files in this bundle, and no other loading form (copy src,
script) carries a stale path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 12:51:10 +02:00
kevinveenbirkenbachandClaude Opus 5 28475455db feat(mcp): give every provider its own service account from the registry
Nine roles reached their MCP surface as the deployment administrator or with
an account password kept beside the account in credentials. A Moodle site
admin bypasses every capability check, a Nextcloud app password is not
scopable, a Gitea token reads every private repository, and a GitLab token on
root reads every private project. Each role now declares its own principal in
meta/users.yml and provisions it, so the exposed surface is what the service
role permits. jellyfin, zammad and snipe-it kept their account but took its
password from a credential named token; they read the registry too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 15:44:12 +02:00
kevinveenbirkenbach 838b64a6bf feat(gitlab): proxy MCP through adapter 2026-08-12 02:02:40 +02:00
kevinveenbirkenbachandClaude Opus 5 a423bde6cc refactor(mcp): unify client entrypoints and read provider tokens through one lookup
Every MCP role keeps its task tree under tasks/utils/ without numeric
prefixes, so a caller can reach any of them through include_role
tasks_from. utils/roles/mapping.py holds that path as the single source.

Ansible resolves a relative include against the including file's own
directory when that file was reached by path, but against tasks/ when it
was reached through tasks_from. The same file therefore loaded from its
own role and failed from a caller using tasks_from. Every nested include
now names {{ role_path }}/tasks/utils/..., which holds under both bases,
and a lint walks each tasks_from entrypoint transitively to keep it so.
The exec-gating lint learns the same form, otherwise it loses the include
chain and reports inherited IS_STACK_HOST gates as missing.

The expression resolving a provider's stored token appeared eighteen
times. plugins/lookup/mcp_credential.py replaces it, and mcp_probe_owner
disappears from eight roles: it was only ever a step towards the bearer
and no probe read it.

The Hermes and OpenClaw probes stop aborting the play while it is still
running. Roles deploy in dependency order, so a client probing during its
own role can run before a provider exists at all, and failing there
prevents that provider from ever deploying. The verdict moves to the
reconciliation pass, gated on MCP_RECONCILE_STRICT, which the reconciler
sets and group_vars declares so the readers need no default.

The sys-svc-mcp-reconcile role is added here; wiring it into a stage is a
separate change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:06:31 +02:00