docs: lead decision 68, a service account owns the Vikunja bots (sage)

Darkwing's row 38 labels probe on the pinned 2.7.0 image: a bot created
from the owner's account reads and attaches every label that account
created, in any project (upstream #3592). Bots owned by svc-<business>,
an account with no labels and no shares, see only labels on the shared
project's tasks. The runbook now creates the bots and mints and revokes
their tokens as svc-$BIZ; the owner keeps the project and the shares.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-08 17:13:51 -05:00
co-authored by Claude Opus 5.5
parent d228b03298
commit bc33faa38f
2 changed files with 105 additions and 29 deletions
+71 -29
View File
@@ -16,8 +16,8 @@ the bundled one.
- Run every step in one shell with `umask 077` set, so every file you
create is 0600 and every directory 0700.
- To check a token file, use `stat`, never `cat`.
- The Vikunja owner password and the Gitea admin login are the
high-value secrets. They are used only in this guide, and never reach
- The Vikunja owner and `svc-$BIZ` passwords and the Gitea admin login
are the high-value secrets. They are used only in this guide, and never reach
the broker or an agent.
## 0. Set up the shell
@@ -101,8 +101,8 @@ password, so this part uses the web UI.
## 2. Vikunja: the instance and the owner account
You need Vikunja 2.7.0 or later, with local login on and an owner account
that is yours.
You need Vikunja 2.7.0 or later, with local login on, an owner account
that is yours, and a service account `svc-$BIZ` that owns the bots.
### Path A, an existing instance
@@ -151,16 +151,47 @@ Both mounts are required. The container runs as your uid, and
`/app/vikunja` isn't writable to it, so Vikunja needs the files mount
for its startup write check and the db mount for its database.
### Owner login for this guide
### The service account
This writes the owner's session header to a file. The password comes
from a prompt, and the token never reaches the terminal.
Create the bots from `svc-$BIZ`, never from your own account. In
Vikunja 2.7.0 a bot can read and attach every label its owning account
created, in any project (upstream test `pkg/models/label_test.go`,
#3592). Row S3's probe showed it on the pinned image. A bot created by
the owner and shared only into `mosaic-stack` listed the owner's
Launchpad, personal and unattached labels, and attached two of them to
a `mosaic-stack` task. Owned by an account with no labels, the same bot
saw only the labels on `mosaic-stack` tasks and got 403 on the rest
(lead decision 68).
- `svc-$BIZ` owns this business's five bots and nothing else: no
labels, no projects, no other bots. Never use it in the web UI.
- Nobody shares a project with it. Section 3 shares the bots, not the
account.
- Its password is a high-value secret, like the owner's, and is used
only in this guide and in rotation.
On Path B, create it the same way as the owner:
```sh
python3 - "$VK" jason "$S/vikunja-owner.hdr" <<'PY'
docker exec -it mosaic-vikunja /app/vikunja/vikunja user create -u "svc-$BIZ" -e "[email protected]"
```
On Path A, the instance's operator creates it with the same command on
the instance's container, unless the instance allows registration.
### Logins for this guide
`vklogin` writes one account's session header to a file. The password
comes from a prompt, and the token never reaches the terminal. You
need both headers: the owner's for the project and the shares,
`svc-$BIZ`'s for the bots and their tokens.
```sh
vklogin() { # vklogin USER HEADER_FILE
python3 - "$VK" "$1" "$2" <<'PY'
import getpass, json, sys, urllib.request
base, user, out = sys.argv[1:4]
pw = getpass.getpass("Vikunja owner password: ")
pw = getpass.getpass("Vikunja password for %s: " % user)
req = urllib.request.Request(base + "/api/v2/login",
data=json.dumps({"username": user, "password": pw}).encode(),
headers={"Content-Type": "application/json"})
@@ -168,13 +199,16 @@ body = json.load(urllib.request.urlopen(req))
if "token" not in body:
sys.exit("login answered without a token field; stop and report the keys: %s" % sorted(body))
open(out, "w").write("Authorization: Bearer " + body["token"] + "\n")
print("owner header written")
print("header written for %s" % user)
PY
}
vklogin jason "$S/vikunja-owner.hdr"
vklogin "svc-$BIZ" "$S/vikunja-svc.hdr"
```
The `token` field is confirmed in the v2.7.0 source (`auth_login.go`).
The script still stops if it's missing. Section 4 deletes
the header file.
The script still stops if it's missing. Section 4 deletes both
header files.
## 3. Vikunja: the project, the board and the bots
@@ -188,8 +222,9 @@ In the web UI, as the owner:
3. In the view's settings, check that the done bucket is `done` and the
default bucket is `todo`. Leave bucket configuration on manual.
4. Slice 1 needs no labels, since the requirement id lives in the task,
not in a label. If you create any, note each id. The business file
names labels by id until row S3 settles which labels a bot can see.
not in a label. If you create any, note each id. A bot owned by
`svc-$BIZ` sees only the labels on `mosaic-stack` tasks, and the
broker attaches only the label ids the business file lists.
```sh
P=<project id>
@@ -197,10 +232,14 @@ P=<project id>
### The five bots
`api` calls as the owner. `svc` calls as `svc-$BIZ`, which creates the
bots.
```sh
api() { curl -sf -H @"$S/vikunja-owner.hdr" -H 'Content-Type: application/json' "$@"; }
svc() { curl -sf -H @"$S/vikunja-svc.hdr" -H 'Content-Type: application/json' "$@"; }
for r in pm cto coder reviewer sync; do
api -X POST "$VK/api/v2/user/bots" \
svc -X POST "$VK/api/v2/user/bots" \
-d "{\"username\":\"bot-$BIZ-$r\",\"name\":\"$BIZ $r\"}" | jq -c '{id, username}'
done
```
@@ -213,8 +252,8 @@ collide.
### Shares
The role bots get write (1). The sync bot gets read (0), so a bug in
the poll path can't write.
The owner shares the project with each bot. The role bots get write
(1). The sync bot gets read (0), so a bug in the poll path can't write.
```sh
for r in pm cto coder reviewer; do
@@ -244,15 +283,17 @@ cto, coder and reviewer all use `scopes-worker.json`.
### Tokens
`mint` sends the request, writes the token straight to its file, and
prints only the metadata. Vikunja shows a token once.
`mint` sends the request as `svc-$BIZ`, writes the token straight to
its file, and prints only the metadata. Vikunja shows a token once.
Only the account that owns a bot can mint its token. The owner's
account gets 404, code 1005.
```sh
mint() { # mint ROLE BOT_ID SCOPES_FILE
local r=$1 id=$2 sc=$3 resp="$S/.mint-$1.json"
jq -n --arg t "$BIZ-$r-$(date +%F)" --argjson o "$id" --arg e "$EXP" --slurpfile p "$sc" \
'{title:$t, owner_id:$o, expires_at:$e, permissions:$p[0]}' |
curl -s --fail-with-body -H @"$S/vikunja-owner.hdr" -H 'Content-Type: application/json' \
curl -s --fail-with-body -H @"$S/vikunja-svc.hdr" -H 'Content-Type: application/json' \
-X POST "$VK/api/v2/tokens" --data-binary @- -o "$resp" ||
{ jq -c '{code, message}' "$resp"; rm -f "$resp"; return 1; }
jq -je '.token | strings' "$resp" > "$S/$r-vikunja.token.new" &&
@@ -278,7 +319,7 @@ doesn't empty the live file.
## 4. Check and clean up
```sh
rm -f "$S/vikunja-owner.hdr"
rm -f "$S/vikunja-owner.hdr" "$S/vikunja-svc.hdr"
stat -c '%a %s %n' "$S"/*.token
```
@@ -307,12 +348,12 @@ role. The stack never writes this file.
## 5. Rotation
- **Vikunja**, before `expires`: in a new shell, rerun section 0, the
owner login in section 2, and the `api` and `mint` definitions in
section 3. Mint a new token for the same bot, update `expires` in the
business file, and restart the broker. Revoke the old token while the
owner header still exists:
`api -X DELETE "$VK/api/v2/tokens/<old id>"`. Then finish with section
- **Vikunja**, before `expires`: in a new shell, rerun section 0,
`vklogin` for `svc-$BIZ` only (section 2), and the `svc` and `mint`
definitions in section 3. Mint a new token for the same bot, update
`expires` in the business file, and restart the broker. Revoke the
old token while the svc header still exists:
`svc -X DELETE "$VK/api/v2/tokens/<old id>"`. Then finish with section
4's `rm -f`, which deletes the header. The broker refuses to
start with a token past its `expires`, and it treats any 401 as a
refusal, never a retry.
@@ -323,6 +364,7 @@ role. The stack never writes this file.
Update `rotateBy`. Each rotation gets one line in
`docs/SESSIONS.md`: date, who, which identities, and no values.
- **Revoking a role at once:** delete its token, the Gitea one in the
bot's settings and the Vikunja one with the `DELETE` above, or remove
the bot's collaborator access or project share. The broker's next
bot's settings and the Vikunja one with the `DELETE` above as
`svc-$BIZ`, or remove the bot's collaborator access or, as the owner,
its project share. The broker's next
call gets a 401 or 403 and refuses.