multica_agent_host: op shim reads the service account token (WDWL-37)
Deliver vault_op_service_account_claude to the agent hosts and install a role-owned ~multica/.local/bin/op shim that exports OP_SERVICE_ACCOUNT_TOKEN to /usr/bin/op only. op run / op plugin run get env -u spliced after "--" so the child never sees the token. The daemon unit is untouched (WDWL-18). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0d674e0c4b
commit
78ac06378e
5 files changed
+145
-20
No files matched your search
@@ -56,11 +56,12 @@
|
|||||||
# Environment variable on the control node:
|
# Environment variable on the control node:
|
||||||
# multica_pat: "{{ lookup('env', 'MULTICA_PAT') }}"
|
# multica_pat: "{{ lookup('env', 'MULTICA_PAT') }}"
|
||||||
|
|
||||||
# 1Password service account token for `op`. Off until the service
|
# 1Password service account token for `op` (WDWL-37). One shared token,
|
||||||
# account exists and the delivery design is agreed (WDWL-37). Same shape as
|
# vault_op_service_account_claude in group_vars/all/vault, scoped by
|
||||||
# the PAT: a per-host dictionary in group_vars/all/vault.
|
# 1Password to the agents' vault. The role writes it to a 0600 file and
|
||||||
# multica_op_deliver_token: true
|
# installs an `op` shim that reads it; it never reaches the daemon unit.
|
||||||
# multica_op_service_account_token: "{{ vault_op_service_account_tokens[inventory_hostname_short] }}"
|
multica_op_deliver_token: true
|
||||||
|
multica_op_service_account_token: "{{ vault_op_service_account_claude }}"
|
||||||
|
|
||||||
# The role's own PAT assert is no_log, so a missing dictionary entry would
|
# The role's own PAT assert is no_log, so a missing dictionary entry would
|
||||||
# surface there only as "censored". This checks key presence, never the
|
# surface there only as "censored". This checks key presence, never the
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ otherwise clean at the `production` profile.
|
|||||||
| `multica` CLI, pinned, from the GitHub release with `checksums.txt` verification | yes |
|
| `multica` CLI, pinned, from the GitHub release with `checksums.txt` verification | yes |
|
||||||
| Agent CLIs (`claude`, `hermes`, …) | yes |
|
| Agent CLIs (`claude`, `hermes`, …) | yes |
|
||||||
| 1Password CLI (`op`), from 1Password's signed apt/yum repository | yes (`multica_op_install`) |
|
| 1Password CLI (`op`), from 1Password's signed apt/yum repository | yes (`multica_op_install`) |
|
||||||
| 1Password service account token, as a 0600 file for the daemon user | opt-in (`multica_op_deliver_token`); not exported to the daemon |
|
| 1Password service account token, as a 0600 file for the daemon user, plus an `op` shim that reads it | opt-in (`multica_op_deliver_token`); not exported to the daemon |
|
||||||
| `workspaces_root`, on the intended mount, writable by the daemon user | yes |
|
| `workspaces_root`, on the intended mount, writable by the daemon user | yes |
|
||||||
| Multica authentication (`multica login --token`) | yes |
|
| Multica authentication (`multica login --token`) | yes |
|
||||||
| CLI config (`server_url`, `app_url`, `workspaces_root`, `device_name`, …) | yes |
|
| CLI config (`server_url`, `app_url`, `workspaces_root`, `device_name`, …) | yes |
|
||||||
@@ -241,6 +241,7 @@ See `defaults/main.yml` for the annotated list. The ones you will actually set:
|
|||||||
| `multica_op_install` | `true` | 1Password CLI from 1Password's apt/yum repo. |
|
| `multica_op_install` | `true` | 1Password CLI from 1Password's apt/yum repo. |
|
||||||
| `multica_op_version` | `""` | `""` tracks the repo (upgrades on every run); set e.g. `2.41.1` to pin. |
|
| `multica_op_version` | `""` | `""` tracks the repo (upgrades on every run); set e.g. `2.41.1` to pin. |
|
||||||
| `multica_op_deliver_token` | `false` | Write `multica_op_service_account_token` to `multica_op_token_file`. |
|
| `multica_op_deliver_token` | `false` | Write `multica_op_service_account_token` to `multica_op_token_file`. |
|
||||||
|
| `multica_op_shim` | `true` | With the token delivered, install the `op` shim at `multica_op_shim_path`. |
|
||||||
|
|
||||||
### Adding an agent CLI
|
### Adding an agent CLI
|
||||||
|
|
||||||
@@ -399,13 +400,13 @@ The role can deliver the token, but by default does not:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
multica_op_deliver_token: true
|
multica_op_deliver_token: true
|
||||||
multica_op_service_account_token: "{{ vault_op_service_account_tokens[inventory_hostname_short] }}"
|
multica_op_service_account_token: "{{ vault_op_service_account_claude }}"
|
||||||
```
|
```
|
||||||
|
|
||||||
writes it to `multica_op_token_file`
|
writes it to `multica_op_token_file`
|
||||||
(`~multica/.config/op-service-account/token`, directory 0700, file 0600,
|
(`~multica/.config/op-service-account/token`, directory 0700, file 0600,
|
||||||
owned by the daemon user). That is all it does. **The role does not put the
|
owned by the daemon user) and installs an `op` shim. **The role does not put
|
||||||
token into the daemon's environment**, for two reasons:
|
the token into the daemon's environment**, for two reasons:
|
||||||
|
|
||||||
1. The daemon's systemd user unit is written and owned by the multica CLI
|
1. The daemon's systemd user unit is written and owned by the multica CLI
|
||||||
(`multica daemon autostart`). The role must not template or edit it, or it
|
(`multica daemon autostart`). The role must not template or edit it, or it
|
||||||
@@ -413,16 +414,35 @@ token into the daemon's environment**, for two reasons:
|
|||||||
2. Anything in the daemon's environment is inherited by every agent process
|
2. Anything in the daemon's environment is inherited by every agent process
|
||||||
it spawns, where a casual `env` or `printenv` puts it in a transcript.
|
it spawns, where a casual `env` or `printenv` puts it in a transcript.
|
||||||
|
|
||||||
How the token reaches `op` is a separate decision, not yet made. Until then
|
`op` reads the token only from `OP_SERVICE_ACCOUNT_TOKEN`; it has no
|
||||||
an agent can use it explicitly:
|
token-file option. So the role installs a shim at `multica_op_shim_path`
|
||||||
|
(`~multica/.local/bin/op`, root-owned 0755), which the CLI-written unit
|
||||||
|
already puts first on `PATH`. The shim:
|
||||||
|
|
||||||
|
- reads the token file and exports `OP_SERVICE_ACCOUNT_TOKEN` to
|
||||||
|
`multica_op_binary` (`/usr/bin/op`) only, then `exec`s it. Agents call plain
|
||||||
|
`op run` / `op inject` / `op read` and the token never enters their shell;
|
||||||
|
- fails closed (exit 78, no token in the message) when the file is missing
|
||||||
|
or empty;
|
||||||
|
- for `op run` and `op plugin run`, splices `/usr/bin/env -u
|
||||||
|
OP_SERVICE_ACCOUNT_TOKEN` in after the first `--`. `op run` otherwise
|
||||||
|
hands its whole environment, token included, to the command it starts.
|
||||||
|
Because it needs that `--` to find the command, the shim refuses
|
||||||
|
`op run <command>` without one (exit 64).
|
||||||
|
|
||||||
|
The play checks, as the daemon user, that `op` resolves to the shim and that
|
||||||
|
the token is not in that environment.
|
||||||
|
|
||||||
|
The shim keeps the token out of transcripts by accident; it is not a
|
||||||
|
boundary. Any process running as the daemon user can read the token file on
|
||||||
|
purpose. What actually limits exposure is the service account's vault access
|
||||||
|
in 1Password: keep it read-only. Check it as the daemon user:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
OP_SERVICE_ACCOUNT_TOKEN="$(cat ~/.config/op-service-account/token)" op run -- <cmd>
|
op vault list --permission read_items # vaults it can read
|
||||||
|
op vault list --permission edit_items # should be empty
|
||||||
```
|
```
|
||||||
|
|
||||||
`op` reads the token only from `OP_SERVICE_ACCOUNT_TOKEN`; it has no
|
|
||||||
token-file option.
|
|
||||||
|
|
||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
- **Provider CLI login.** Unavoidable for subscription auth.
|
- **Provider CLI login.** Unavoidable for subscription auth.
|
||||||
@@ -444,7 +464,7 @@ token-file option.
|
|||||||
| `multica_auth` | `multica login` |
|
| `multica_auth` | `multica login` |
|
||||||
| `multica_daemon` | autostart + start |
|
| `multica_daemon` | autostart + start |
|
||||||
| `multica_verify` | the assertions |
|
| `multica_verify` | the assertions |
|
||||||
| `multica_op` | 1Password CLI, and the token file when enabled |
|
| `multica_op` | 1Password CLI, and the token file and shim when enabled |
|
||||||
| `multica` | all of the above |
|
| `multica` | all of the above |
|
||||||
|
|
||||||
Preflight and the service-account step are tagged `always`: they build
|
Preflight and the service-account step are tagged `always`: they build
|
||||||
|
|||||||
@@ -158,13 +158,18 @@ multica_op_apt_base_url: "https://downloads.1password.com/linux/debian"
|
|||||||
multica_op_rpm_base_url: "https://downloads.1password.com/linux/rpm/stable"
|
multica_op_rpm_base_url: "https://downloads.1password.com/linux/rpm/stable"
|
||||||
|
|
||||||
# Service account token delivery. Off by default. When on, the token is written
|
# Service account token delivery. Off by default. When on, the token is written
|
||||||
# to multica_op_token_file (0600, owned by the daemon user) and nowhere else.
|
# to multica_op_token_file (0600, owned by the daemon user) and read from there
|
||||||
# The role does NOT export it into the daemon environment; see the README.
|
# by the op shim below. The role does NOT export it into the daemon
|
||||||
# Supply the VALUE from the playbook, like multica_pat, e.g.
|
# environment; see the README. Supply the VALUE from the playbook, e.g.
|
||||||
# multica_op_service_account_token: "{{ vault_op_service_account_tokens[inventory_hostname_short] }}"
|
# multica_op_service_account_token: "{{ vault_op_service_account_claude }}"
|
||||||
multica_op_deliver_token: false
|
multica_op_deliver_token: false
|
||||||
multica_op_service_account_token: ""
|
multica_op_service_account_token: ""
|
||||||
multica_op_token_file: "{{ multica_user_home }}/.config/op-service-account/token"
|
multica_op_token_file: "{{ multica_user_home }}/.config/op-service-account/token"
|
||||||
|
# With the token delivered, install a shim at multica_op_shim_path (first on
|
||||||
|
# the daemon unit's PATH) that exports the token to multica_op_binary only.
|
||||||
|
multica_op_shim: true
|
||||||
|
multica_op_shim_path: "{{ multica_user_home }}/.local/bin/op"
|
||||||
|
multica_op_binary: /usr/bin/op
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Node.js (installed only when a selected agent CLI needs npm)
|
# Node.js (installed only when a selected agent CLI needs npm)
|
||||||
|
|||||||
@@ -35,3 +35,47 @@
|
|||||||
mode: "0600"
|
mode: "0600"
|
||||||
no_log: true
|
no_log: true
|
||||||
become: true
|
become: true
|
||||||
|
|
||||||
|
# --- op shim -----------------------------------------------------------------
|
||||||
|
# `op` reads the token only from OP_SERVICE_ACCOUNT_TOKEN. The shim, first on
|
||||||
|
# the daemon unit's PATH, exports it for the real binary alone, so agents call
|
||||||
|
# plain `op` and the token never enters their shell environment. The daemon
|
||||||
|
# unit itself stays the CLI's (WDWL-18).
|
||||||
|
|
||||||
|
- name: 1Password token | Create the daemon user's bin directory
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ multica_op_shim_path | dirname }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ multica_user }}"
|
||||||
|
group: "{{ multica_group }}"
|
||||||
|
mode: "0755"
|
||||||
|
become: true
|
||||||
|
when: multica_op_shim | bool
|
||||||
|
|
||||||
|
- name: 1Password token | Install the op shim
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: op-shim.sh.j2
|
||||||
|
dest: "{{ multica_op_shim_path }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
become: true
|
||||||
|
when: multica_op_shim | bool
|
||||||
|
|
||||||
|
# As the daemon user, through its own environment: plain `op` must resolve to
|
||||||
|
# the shim, and the token must not be in that environment.
|
||||||
|
- name: 1Password token | Check op resolves to the shim as the daemon user
|
||||||
|
ansible.builtin.shell:
|
||||||
|
cmd: 'command -v op; env | grep -c "^OP_SERVICE_ACCOUNT_TOKEN=" || true'
|
||||||
|
become: true
|
||||||
|
become_user: "{{ multica_user }}"
|
||||||
|
environment: "{{ _multica_user_env }}"
|
||||||
|
register: _multica_op_shim_check
|
||||||
|
changed_when: false
|
||||||
|
check_mode: false
|
||||||
|
failed_when: >-
|
||||||
|
_multica_op_shim_check.rc != 0
|
||||||
|
or _multica_op_shim_check.stdout_lines != [multica_op_shim_path, '0']
|
||||||
|
when:
|
||||||
|
- multica_op_shim | bool
|
||||||
|
- not ansible_check_mode
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# {{ ansible_managed }}
|
||||||
|
#
|
||||||
|
# 1Password CLI shim for the Multica daemon user (multica_agent_host role,
|
||||||
|
# WDWL-37). Reads the service account token from a 0600 file and exports it
|
||||||
|
# to {{ multica_op_binary }} only, so it never sits in an agent's shell
|
||||||
|
# environment.
|
||||||
|
token_file="{{ multica_op_token_file }}"
|
||||||
|
|
||||||
|
if [ ! -r "$token_file" ]; then
|
||||||
|
echo "op: no 1Password service account token at $token_file" >&2
|
||||||
|
exit 78
|
||||||
|
fi
|
||||||
|
IFS= read -r OP_SERVICE_ACCOUNT_TOKEN < "$token_file"
|
||||||
|
if [ -z "$OP_SERVICE_ACCOUNT_TOKEN" ]; then
|
||||||
|
echo "op: 1Password service account token file $token_file is empty" >&2
|
||||||
|
exit 78
|
||||||
|
fi
|
||||||
|
export OP_SERVICE_ACCOUNT_TOKEN
|
||||||
|
|
||||||
|
# `op run` (and `op plugin run`) hand their own environment, token included,
|
||||||
|
# to the command they start. Find the subcommand, skipping global flags that
|
||||||
|
# take a value, and stop at the first `--`.
|
||||||
|
sub1=""; sub2=""; skip=0; help=0
|
||||||
|
for a in "$@"; do
|
||||||
|
if [ "$skip" = 1 ]; then skip=0; continue; fi
|
||||||
|
case "$a" in
|
||||||
|
--) break ;;
|
||||||
|
-h|--help) help=1 ;;
|
||||||
|
--account|--config|--encoding|--format|--session) skip=1 ;;
|
||||||
|
-*) ;;
|
||||||
|
*) if [ -z "$sub1" ]; then sub1=$a; elif [ -z "$sub2" ]; then sub2=$a; fi ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$sub1" = run ] || { [ "$sub1" = plugin ] && [ "$sub2" = run ]; }; then
|
||||||
|
# Rebuild the arguments with `env -u` spliced in after the first `--`, so
|
||||||
|
# the child starts without the token.
|
||||||
|
n=$#; i=0; spliced=0
|
||||||
|
while [ "$i" -lt "$n" ]; do
|
||||||
|
a=$1; shift
|
||||||
|
set -- "$@" "$a"
|
||||||
|
if [ "$spliced" = 0 ] && [ "$a" = "--" ]; then
|
||||||
|
set -- "$@" /usr/bin/env -u OP_SERVICE_ACCOUNT_TOKEN
|
||||||
|
spliced=1
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
if [ "$spliced" = 0 ] && [ "$help" = 0 ]; then
|
||||||
|
echo "op: use 'op run [flags] -- <command>'; the shim needs the '--' to keep the service account token out of <command>" >&2
|
||||||
|
exit 64
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "{{ multica_op_binary }}" "$@"
|
||||||
Reference in new issue
Block a user