312 lines
13 KiB
Markdown
312 lines
13 KiB
Markdown
# Slice 1 identities: Gitea bots, Vikunja bots and their tokens
|
|
|
|
Jason runs this guide once per business, by hand (PRD REQ-CRED-1, round
|
|
3, 1A and 2A). It creates every service identity slice 1 uses and writes
|
|
each token to a 0600 file the broker reads. Nothing in v1 mints these
|
|
tokens for you. Brief: `docs/plans/2026-10-04_slice-1.md`, row SR.
|
|
|
|
Plan on about 20 minutes with an existing Vikunja, and 30 if you start
|
|
the bundled one.
|
|
|
|
## Rules for the whole guide
|
|
|
|
- A token never appears on a command line, in a URL, in shell history,
|
|
in a chat or in the repository. The commands below read secrets from
|
|
files or a password prompt, and write tokens straight to files.
|
|
- 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 broker or an agent.
|
|
|
|
## 0. Set up the shell
|
|
|
|
```sh
|
|
umask 077
|
|
BIZ=mosaic-stack
|
|
S="$HOME/.config/mosaic-dev/secrets/$BIZ" # token directory, outside the repo
|
|
mkdir -p "$S" && chmod 700 "$S"
|
|
VK=https://vikunja.example # your Vikunja base URL, no trailing slash
|
|
GITEA=https://git.example # your Gitea base URL
|
|
EXP=$(date -u -d '+90 days' +%Y-%m-%dT00:00:00Z) # Vikunja token expiry
|
|
```
|
|
|
|
Write down the date part of `EXP`. It goes in the business file as
|
|
each Vikunja token's `expires`, in `YYYY-MM-DD` form. The broker treats
|
|
00:00Z on that date as the expiry, the same instant `EXP` names.
|
|
|
|
The commands need curl 7.76 or later, for `--fail-with-body`.
|
|
|
|
## 1. Gitea: four bot users and their tokens
|
|
|
|
Gitea tokens don't expire, and the HTTP route for creating one needs a
|
|
password, so this part uses the web UI.
|
|
|
|
1. As a site admin, create the users `mosaic-stack-pm-bot`,
|
|
`mosaic-stack-cto-bot`, `mosaic-stack-coder-bot` and
|
|
`mosaic-stack-reviewer-bot` (Site Administration, User Accounts,
|
|
Create). Gitea usernames are global to the instance, so the business
|
|
id in the name keeps one business's bots, and revoking them, apart
|
|
from another's. Give
|
|
each a long random password, which you won't need again after step 3.
|
|
None of them is an admin.
|
|
2. In `mosaicstack/stack`, add each bot as a collaborator:
|
|
|
|
| Bot | Repository access |
|
|
|---|---|
|
|
| mosaic-stack-pm-bot | Write (labels, assignees and closing need it) |
|
|
| mosaic-stack-cto-bot | Write |
|
|
| mosaic-stack-coder-bot | Write |
|
|
| mosaic-stack-reviewer-bot | Read |
|
|
|
|
No bot is on a protected branch's push, merge or approvals allowlist.
|
|
Pushing to a protected branch is a gated action, and merges stay
|
|
Jason's. Reviewer-bot's approvals in Gitea are advisory and don't
|
|
count toward required approvals. The review verdict that gates a row
|
|
lives in the queue and the bus.
|
|
3. Log in as each bot, go to Settings, Applications, and generate one
|
|
token named `mosaic-stack-<role>-<date>` with these scopes:
|
|
|
|
| Bot | Scopes |
|
|
|---|---|
|
|
| mosaic-stack-pm-bot | `write:issue`, `read:repository`, `read:user` |
|
|
| mosaic-stack-cto-bot | `write:issue`, `write:repository`, `read:user` |
|
|
| mosaic-stack-coder-bot | `write:issue`, `write:repository`, `read:user` |
|
|
| mosaic-stack-reviewer-bot | `write:issue`, `write:repository`, `read:user` |
|
|
|
|
Leave every other scope unset, and set none to "public only".
|
|
Reviewer-bot's `write:repository` is there because Gitea checks pull
|
|
request reviews against the repository scope. Its Read collaborator
|
|
access stops pushes to branches and tags. It does not stop an AGit
|
|
push to `refs/for/<branch>`, which opens a pull request. That's
|
|
acceptable only because the broker holds the token and offers the
|
|
reviewer no push action.
|
|
4. Write each token to its file from a silent prompt, in the same
|
|
`umask 077` shell. Paste the token at the prompt and press Enter:
|
|
|
|
```sh
|
|
for r in pm cto coder reviewer; do
|
|
printf '%s token: ' "$r"; read -rs t; echo
|
|
printf %s "$t" > "$S/$r-gitea.token"; unset t
|
|
done
|
|
```
|
|
|
|
`read` and `printf` are shell builtins, so the value never reaches
|
|
`ps` or the history. Don't use an editor, which can leave a swap or
|
|
backup copy behind.
|
|
5. Log out of each bot account. Record today's date as each Gitea
|
|
token's `rotateBy` base. Section 5 has the rotation schedule.
|
|
|
|
## 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.
|
|
|
|
### Path A, an existing instance
|
|
|
|
Check that `curl -s "$VK/api/v1/info"` reports `v2.7.0` or later. Use your
|
|
existing account as the owner.
|
|
|
|
### Path B, the bundled instance
|
|
|
|
Row S3 ships `packages/tasks/deploy/vikunja/` for this. Until it lands,
|
|
these commands start the same pinned image the probes used, bound to
|
|
127.0.0.1:
|
|
|
|
```sh
|
|
VD="$HOME/.local/share/mosaic-dev/vikunja"; mkdir -p "$VD/db" "$VD/files"
|
|
printf 'VIKUNJA_SERVICE_SECRET=%s\n' "$(openssl rand -hex 32)" > "$VD/env"
|
|
cat >> "$VD/env" <<EOF
|
|
VIKUNJA_SERVICE_PUBLICURL=http://127.0.0.1:3456/
|
|
VIKUNJA_DATABASE_TYPE=sqlite
|
|
VIKUNJA_DATABASE_PATH=/db/vikunja.db
|
|
VIKUNJA_WEBHOOKS_ENABLED=false
|
|
VIKUNJA_SERVICE_ENABLEREGISTRATION=false
|
|
EOF
|
|
docker run -d --name mosaic-vikunja --restart unless-stopped --user "$(id -u):$(id -g)" \
|
|
-p 127.0.0.1:3456:3456 -v "$VD/db:/db" -v "$VD/files:/app/vikunja/files" \
|
|
--env-file "$VD/env" \
|
|
vikunja/vikunja@sha256:e2204a1c1c6a81e833c2b3a5442be182ca2335b54c2e7e37578cc3fe12a27cfc
|
|
docker exec -it mosaic-vikunja /app/vikunja/vikunja user create -u jason -e [email protected]
|
|
VK=http://127.0.0.1:3456
|
|
```
|
|
|
|
`user create` asks for the password when you leave out `-p`. Keep `-it`,
|
|
because without a terminal the command exits instead of prompting. Never
|
|
put the password on the command line.
|
|
|
|
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
|
|
|
|
This writes the owner's session header to a file. The password comes
|
|
from a prompt, and the token never reaches the terminal.
|
|
|
|
```sh
|
|
python3 - "$VK" jason "$S/vikunja-owner.hdr" <<'PY'
|
|
import getpass, json, sys, urllib.request
|
|
base, user, out = sys.argv[1:4]
|
|
pw = getpass.getpass("Vikunja owner password: ")
|
|
req = urllib.request.Request(base + "/api/v2/login",
|
|
data=json.dumps({"username": user, "password": pw}).encode(),
|
|
headers={"Content-Type": "application/json"})
|
|
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")
|
|
PY
|
|
```
|
|
|
|
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.
|
|
|
|
## 3. Vikunja: the project, the board and the bots
|
|
|
|
### The project and its board
|
|
|
|
In the web UI, as the owner:
|
|
1. Create the project `mosaic-stack`, and note its id (in the URL).
|
|
2. Open its Kanban view and rename the buckets: To-Do becomes `todo`,
|
|
Doing becomes `in-progress`, Done becomes `done`. Add `in-review` and
|
|
`blocked`. Each title appears once.
|
|
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.
|
|
|
|
```sh
|
|
P=<project id>
|
|
```
|
|
|
|
### The five bots
|
|
|
|
```sh
|
|
api() { curl -sf -H @"$S/vikunja-owner.hdr" -H 'Content-Type: application/json' "$@"; }
|
|
for r in pm cto coder reviewer sync; do
|
|
api -X POST "$VK/api/v2/user/bots" \
|
|
-d "{\"username\":\"bot-$BIZ-$r\",\"name\":\"$BIZ $r\"}" | jq -c '{id, username}'
|
|
done
|
|
```
|
|
|
|
Note the five ids. Bot usernames must start with `bot-`, and this guide
|
|
uses `bot-<business>-<role>` so two businesses on one instance don't
|
|
collide.
|
|
|
|
### Shares
|
|
|
|
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
|
|
api -X POST "$VK/api/v2/projects/$P/users" -d "{\"username\":\"bot-$BIZ-$r\",\"permission\":1}" | jq -c '{username, permission}'
|
|
done
|
|
api -X POST "$VK/api/v2/projects/$P/users" -d "{\"username\":\"bot-$BIZ-sync\",\"permission\":0}" | jq -c '{username, permission}'
|
|
```
|
|
|
|
### Scopes
|
|
|
|
From addendum B section 2 (lead decision 52). Every group or verb not
|
|
listed stays off.
|
|
|
|
```sh
|
|
cat > "$S/scopes-sync.json" <<'EOF'
|
|
{"projects":["read_one","views_buckets","views_buckets_tasks_get"],"projects_views":["read_all"],"tasks":["read_all","read_one"],"tasks_comments":["read_all"]}
|
|
EOF
|
|
cat > "$S/scopes-pm.json" <<'EOF'
|
|
{"tasks":["read_one","create","update"],"tasks_assignees":["create","delete"],"tasks_relations":["create","delete"],"tasks_labels":["create","delete"],"tasks_comments":["create"],"labels":["read_all"],"projects":["views_buckets_tasks"]}
|
|
EOF
|
|
cat > "$S/scopes-worker.json" <<'EOF'
|
|
{"tasks":["read_one","update"],"tasks_comments":["create"],"projects":["views_buckets_tasks"]}
|
|
EOF
|
|
```
|
|
|
|
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.
|
|
|
|
```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' \
|
|
-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" &&
|
|
jq -c '{id, owner_id, expires_at, starts_tk: (.token | startswith("tk_"))}' "$resp"
|
|
rm -f "$resp"
|
|
}
|
|
mint pm <pm id> "$S/scopes-pm.json"
|
|
mint cto <cto id> "$S/scopes-worker.json"
|
|
mint coder <coder id> "$S/scopes-worker.json"
|
|
mint reviewer <reviewer id> "$S/scopes-worker.json"
|
|
mint sync <sync id> "$S/scopes-sync.json"
|
|
```
|
|
|
|
Check that each line shows `starts_tk: true` and the `expires_at` you
|
|
set. Vikunja accepts a past expiry without complaint, so read it. On an
|
|
error, `mint` prints the response's `code` and `message`, which carry no
|
|
token. Code 14002 means a scope name is wrong. Fix the scope file and
|
|
mint again. If the response has no `token` field, `mint` writes nothing
|
|
usable and stops; delete the empty file before you retry.
|
|
|
|
## 4. Check and clean up
|
|
|
|
```sh
|
|
rm -f "$S/vikunja-owner.hdr"
|
|
stat -c '%a %s %n' "$S"/*.token
|
|
```
|
|
|
|
Each of the nine token files must show `600` and a nonzero size. Once row
|
|
S3 lands, `mosaic` runs the broker's startup probe for every identity. It
|
|
makes one control call and one call that must be refused, on a task id
|
|
that doesn't exist, and it refuses to start on any surprise. That probe,
|
|
passing for all nine, closes row SR's gate.
|
|
|
|
The business file (`~/.config/mosaic-dev/businesses/mosaic-stack.json`,
|
|
row S1) references each file by absolute path, and never holds a value.
|
|
One role's entry looks like this. Row S1's validator has the final
|
|
shape.
|
|
|
|
```json
|
|
"coder": { "definition": "coder",
|
|
"tracker": { "bot": "bot-mosaic-stack-coder", "botId": 0 },
|
|
"credentials": {
|
|
"gitea": { "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/coder-gitea.token", "rotateBy": "YYYY-MM-DD" },
|
|
"vikunja": { "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/coder-vikunja.token", "expires": "YYYY-MM-DD" } } }
|
|
```
|
|
|
|
The sync bot has its own entry under the tracker settings, not under a
|
|
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, restart the broker, and finish with section 4's
|
|
`rm -f`. Then revoke the old token:
|
|
`api -X DELETE "$VK/api/v2/tokens/<old id>"`. The broker refuses to
|
|
start with a token past its `expires`, and it treats any 401 as a
|
|
refusal, never a retry.
|
|
- **Gitea**, every 90 days or at once if a token may have leaked: log in
|
|
as the bot, generate the replacement, write it to the file with the
|
|
`read -rs` loop from section 1, restart
|
|
the broker, then delete the old token in the same settings page.
|
|
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
|
|
call gets a 401 or 403 and refuses.
|