forge.admins in the server's git config ($FORGE_ROOT/.gitconfig) and is_admin ; admins get ssh invite, invites and users from their own git-shell-commands; to everyone else they are git-shell's unknown command, byte for byte.
| 1/1 | Admins, and the people commands over ssh for them | +163 | -12 | ✓ |
No comments yet: forge comment 20 adds one.
1/1 Admins, and the people commands over ssh for them
Inviting someone or listing users meant forge-admin inside the container.
Now the forge has admins: the users named in forge.admins in the server's
git config, $FORGE_ROOT/.gitconfig (several values, or names split by
spaces or commas), read by one helper, is_admin <user>. Owning repos
doesn't make anyone one.
An admin runs the operator's people commands over ssh:
ssh git@src.rest invite fox [--ttl 7d] [--repo cat/012 --as collaborator]
ssh git@src.rest invites [rm fox]
ssh git@src.rest users
They are the same code as forge user invite, user invites and users,
which stay for the server, and their hints name the form you used. They
live in a git-shell-commands of their own, .forge/admin (server init
writes it), and forge shell points git-shell there only for an admin's
admin command. Anyone else's goes to git-shell as any unknown command
does, so the answer is git-shell's own, byte for byte and with its exit
status, and help lists them only for admins.| M | README.md | 29 | +++++++++++++++++ |
| M | bin/ | 52 | ++++++++++++++++++++++++++++-- |
| M | lib/ | 19 | ++++++----- |
| A | test/ | 37 | +++++++++++++++++++++ |
| A | test/ | 38 | ++++++++++++++++++++++ |
@@ -175,6 +175,35 @@ posts come only from the forge's own page (Origin), at most 30 a minute
175175 across the forge, one taking a key at a time. `user add` checks a key the176176 same way, so no key adds a second line to keys.177177 178+#### Admins179+180+The forge's admins are the users named in `forge.admins` in the server's181+git config, `/srv/git/.gitconfig` (the git user's own): one value each, or182+several names split by spaces or commas. Owning repos doesn't make anyone183+an admin. Set it on the server, as git:184+185+```sh186+git config --file /srv/git/.gitconfig --add forge.admins dami # in the container:187+ssh slate docker exec -u git forge git config --file /srv/git/.gitconfig --add forge.admins cat188+git config --file /srv/git/.gitconfig --get-all forge.admins # the list; --unset-all forge.admins empties it189+```190+191+An admin runs the people commands above over ssh, from their own machine,192+the same as on the server:193+194+```sh195+ssh git@git.example.com invite fox [--ttl 7d] [--repo dami/notes --as reader|collaborator]196+ssh git@git.example.com invites [rm fox]197+ssh git@git.example.com users198+```199+200+An admin's ssh session finds them in a `git-shell-commands` of their own,201+`.forge/admin/git-shell-commands` (`forge server init` writes it), so to202+anyone else they don't exist: git-shell answers `fatal: unrecognized203+command`, with the exit status of any command that isn't there, and204+`help` doesn't list them. A change to `forge.admins` counts from the next205+ssh command on.206+178207 `ssh git@git.example.com new <name>` creates a repo under your name, and so179208 does pushing to one that doesn't exist yet: `forge shell` sees the push before180209 git does, creates the repo the same way (same name rules, hooks installed),@@ -56,6 +56,9 @@ NS=forge-ci
5656 # What SHA256SUMS.sig of a release is signed for (forge release publish).5757 RNS=forge-release5858 ZERO=000000000000000000000000000000000000000059+# ssh commands for the forge's admins only (is_admin): the operator's60+# forge user invite, user invites and users.61+ADMIN_CMDS='invite invites users'5962 CI_SCRIPT=.forge/ci6063 6164 die() { printf 'forge: %s\n' "$*" >&2; exit 1; }@@ -1038,6 +1041,14 @@ cmd_server_init() {
10381041 printf '#!/bin/sh\nexec "%s" shell-cmd %s "$@"\n' "$FORGE_BIN" "$c" >"$FORGE_ROOT/git-shell-commands/$c"10391042 chmod +x "$FORGE_ROOT/git-shell-commands/$c"10401043 done1044+ # The admins' commands, in a git-shell-commands of their own that only1045+ # an admin's ssh session gets (cmd_shell): to anyone else they aren't1046+ # there, and git-shell says so itself.1047+ mkdir -p "$FORGE_STATE/admin/git-shell-commands"1048+ for c in $ADMIN_CMDS; do1049+ printf '#!/bin/sh\nexec "%s" shell-cmd %s "$@"\n' "$FORGE_BIN" "$c" >"$FORGE_STATE/admin/git-shell-commands/$c"1050+ chmod +x "$FORGE_STATE/admin/git-shell-commands/$c"1051+ done10411052 # Existing repos get this forge's hooks and current settings (the10421053 # container runs this on every boot).10431054 for d in "$FORGE_ROOT"/*/*.git; do@@ -1302,13 +1313,16 @@ cmd_user() {
13021313 esac13031314 }13041315 1305-# user invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator]:1316+# user invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator]1317+# (and ssh invite, for admins):13061318 # a one-time link on the forge's /login/ page where whoever has it pastes13071319 # an SSH public key and becomes <name>, which is held for them until then;13081320 # with --repo, they are that repo's reader or collaborator too.13091321 user_invite() {13101322 load_config1311- iu="usage: forge user invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator]"1323+ # Over ssh (an admin, FORGE_USER) it is ssh <host> invite.1324+ if [ -n "${FORGE_USER:-}" ]; then iu="usage: invite"; else iu="usage: forge user invite"; fi1325+ iu="$iu <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator]"13121326 [ $# -ge 1 ] || die "$iu"13131327 name=$1 life=7d repo= role=13141328 shift@@ -1494,6 +1508,14 @@ can_read() {
14941508 return 114951509 }14961510 1511+# is_admin <user>: is user one of the forge's admins? They are the values1512+# of forge.admins in the server's git config, $FORGE_ROOT/.gitconfig (the1513+# git user's own; several values, or names split by spaces or commas).1514+is_admin() {1515+ [ -n "$1" ] || return 11516+ git config --file "$FORGE_ROOT/.gitconfig" --get-all forge.admins 2>/dev/null | tr ' ,' '\n\n' | grep -qxF -- "$1"1517+}1518+14971519 cmd_shell() {14981520 user=${1:?usage: forge shell <user>}14991521 load_config@@ -1510,6 +1532,15 @@ cmd_shell() {
15101532 no_such_repo15111533 fi15121534 [ "$GSERVICE" != receive-pack ] || push_create1535+ # An admin's admin command runs from the admins' git-shell-commands1536+ # (git-shell looks for them in HOME); anyone else's goes to git-shell1537+ # as any unknown command does.1538+ if [ -z "$GSERVICE" ] && is_admin "$user"; then1539+ set -f1540+ set -- $SSH_ORIGINAL_COMMAND1541+ set +f1542+ case " $ADMIN_CMDS " in *" ${1:-} "*) HOME=$FORGE_STATE/admin exec git-shell -c "$SSH_ORIGINAL_COMMAND" ;; esac1543+ fi15131544 exec git-shell -c "$SSH_ORIGINAL_COMMAND"15141545 }15151546 @@ -2153,6 +2184,15 @@ cmd_shell_cmd() {
21532184 release) sc_release "$@" ;;21542185 rm) sc_rm "$@" ;;21552186 passkey | passkeys | sessions | tokens) credentials "$c" "$@" ;;2187+ invite | invites | users)2188+ # Reached only through the admins' git-shell-commands (cmd_shell).2189+ is_admin "$user" || die "unknown command $c"2190+ case $c in2191+ invite) user_invite "$@" ;;2192+ invites) node "$FORGE_HOME/lib/identity.ts" invites "$@" ;;2193+ users) [ $# -eq 0 ] || die "usage: users"; node "$FORGE_HOME/lib/identity.ts" users ;;2194+ esac2195+ ;;21562196 token)21572197 # A token for one repo needs a repo you can read, or it isn't there.21582198 r= next=0@@ -2164,7 +2204,11 @@ cmd_shell_cmd() {
21642204 [ -z "$r" ] || open_repo "$r"21652205 credentials token "$@"21662206 ;;2167- *) printf 'commands:\n new [--private] <name> [description] create %s/<name>\n ls list your repos, and private ones you may read\n signers keys that may sign CI results (for forge verify)\n site-url <owner>/<repo> <site> [branch] where a site deploy is served\n reviews <owner>/<repo> open and draft reviews (forge reviews)\n land <owner>/<repo> <id|branch> land a review here, with the same gates as a push\n comment, approve, approvals used by forge comment, approve and land\n private <owner>/<repo> only its owner, collaborators and readers can see it\n public <owner>/<repo> [--force] anyone can, once its whole history passes the secret check\n readers <owner>/<repo> [add|rm <user>] who may read it while it is private\n collaborators <owner>/<repo> [add|rm <user>] who may push anything to it; only its owner changes the list\n who <owner>/<repo> its owner, collaborators and readers\n release ls <owner>/<repo> its releases: tag, file, size, sha256\n release get <owner>/<repo> <tag> <file> a release file, on stdout\n rm <owner>/<repo> --yes remove a repo of yours (the admin can restore it for 14 days)\n passkey [name] a one-time link that adds a passkey, to sign in on the web\n passkeys [rm <id>] your passkeys\n sessions [rm <id>|--all] where you are signed in on the web; rm signs one out, --all all\n token [--repo <owner>/<repo>] [--ttl 30d] [name] a token for curl and HTTPS clones, shown once\n tokens [rm <id>] your tokens\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user" ;;2207+ *) printf 'commands:\n new [--private] <name> [description] create %s/<name>\n ls list your repos, and private ones you may read\n signers keys that may sign CI results (for forge verify)\n site-url <owner>/<repo> <site> [branch] where a site deploy is served\n reviews <owner>/<repo> open and draft reviews (forge reviews)\n land <owner>/<repo> <id|branch> land a review here, with the same gates as a push\n comment, approve, approvals used by forge comment, approve and land\n private <owner>/<repo> only its owner, collaborators and readers can see it\n public <owner>/<repo> [--force] anyone can, once its whole history passes the secret check\n readers <owner>/<repo> [add|rm <user>] who may read it while it is private\n collaborators <owner>/<repo> [add|rm <user>] who may push anything to it; only its owner changes the list\n who <owner>/<repo> its owner, collaborators and readers\n release ls <owner>/<repo> its releases: tag, file, size, sha256\n release get <owner>/<repo> <tag> <file> a release file, on stdout\n rm <owner>/<repo> --yes remove a repo of yours (the admin can restore it for 14 days)\n passkey [name] a one-time link that adds a passkey, to sign in on the web\n passkeys [rm <id>] your passkeys\n sessions [rm <id>|--all] where you are signed in on the web; rm signs one out, --all all\n token [--repo <owner>/<repo>] [--ttl 30d] [name] a token for curl and HTTPS clones, shown once\n tokens [rm <id>] your tokens\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user"2208+ if is_admin "$user"; then2209+ printf 'for admins of the forge:\n invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator] a one-time link that makes someone a user\n invites [rm <name>] invites not taken up yet; rm makes a link dead\n users everyone here: keys, passkeys, tokens; then the invites\n'2210+ fi2211+ ;;21682212 esac21692213 }21702214 @@ -3128,7 +3172,7 @@ stage_repo() {
31283172 # copied.31293173 derived_state() {31303174 case $1 in3131- www | store | cache | backup | secrets | allowed_signers | config.restored | restore.tmp | lake | private | \3175+ www | store | cache | backup | secrets | allowed_signers | config.restored | restore.tmp | lake | private | admin | \31323176 identity.db | identity.db-* | identity.db.* | .identity.db-litestream | litestream.yml | \31333177 *.log | *.lock | *.tmp | backup.* | build.* | events.* | metrics.* | mirror.* | lake.*) return 0 ;;31343178 esac@@ -617,35 +617,38 @@ function sshCommand(state: string, user: string, cmd: string, args: string[], ou
617617 618618 const grants = (i: Invite): string => (i.repo ? `${i.role} of ${i.repo}` : '-');619619 620-// forge user invite, invites, users; and user accept's part, and user add's check.620+// forge user invite, invites, users (and their ssh forms, for admins, run621+// with FORGE_USER); and user accept's part, and user add's check.621622 function operatorCommand(state: string, cmd: string, args: string[], out: (s: string) => void): number {622623 const base = (process.env.FORGE_BASE_URL || '').replace(/\/$/, '');623624 const ssh = process.env.FORGE_SSH || 'git@host';624625 const id = Identity.open(state)!;626+ // How the user who ran this runs the others: over ssh, or on the server.627+ const again = process.env.FORGE_USER ? `ssh ${ssh} ` : 'forge user ';625628 switch (cmd) {626629 case 'invite': {627630 const [user, life, repo, role] = args;628631 if (args.length !== 4 || !['-', 'reader', 'collaborator'].includes(role)) fail('usage: invite <name> <ttl> <repo|-> <reader|collaborator|->');629632 const r = id.invite(user, ttl(life, 'invite', INVITE_MAX), repo === '-' ? null : repo, role === '-' ? null : (role as Invite['role']));630- if (!r) fail(`${user} is invited already (forge user invites rm ${user} takes it back, for a new link)`);633+ if (!r) fail(`${user} is invited already (${again}invites rm ${user} takes it back, for a new link)`);631634 out(`Send this to whoever should be ${user} here. It works once, until ${when(r!.row.expires)} UTC:\n\n ${base}/login/#invite=${r!.code}\n\n`);632635 if (!base) out('(FORGE_BASE_URL is not set, so that is only the path.)\n');633636 out(`On that page they paste an SSH public key, which makes them ${user} here (ssh ${ssh}), and may then add a passkey for the web.`);634637 out(r!.row.repo ? ` They are a ${r!.row.role} of ${r!.row.repo} too.\n` : '\n');635- out(`Until then nobody else can have the name. forge user invites rm ${user} takes the link back.\n`);638+ out(`Until then nobody else can have the name. ${again}invites rm ${user} takes the link back.\n`);636639 return 0;637640 }638641 case 'invites': {639642 if (args[0] === 'rm') {640- if (args.length !== 2) fail('usage: forge user invites [rm <name>]');643+ if (args.length !== 2) fail(`usage: ${process.env.FORGE_USER ? '' : 'forge user '}invites [rm <name>]`);641644 const r = id.removeInvite(args[1]);642- if (!r) fail(`no invite for ${args[1]} (forge user invites lists them)`);645+ if (!r) fail(`no invite for ${args[1]} (${again}invites lists them)`);643646 out(`took back the invite for ${r!.user}; its link is dead\n`);644647 return 0;645648 }646- if (args.length) fail('usage: forge user invites [rm <name>]');649+ if (args.length) fail(`usage: ${process.env.FORGE_USER ? '' : 'forge user '}invites [rm <name>]`);647650 const list = id.invites();648- if (!list.length) return out('no invites; forge user invite <name> makes one\n'), 0;651+ if (!list.length) return out(`no invites; ${again}invite <name> makes one\n`), 0;649652 out(columns(['invited', 'made', 'until', 'grants'], list.map((i) => [i.user, when(i.created), when(i.expires), grants(i)])));650653 return 0;651654 }@@ -660,7 +663,7 @@ function operatorCommand(state: string, cmd: string, args: string[], out: (s: st
660663 return 0;661664 }662665 case 'users': {663- if (args.length) fail('usage: forge users');666+ if (args.length) fail(`usage: ${process.env.FORGE_USER ? '' : 'forge '}users`);664667 const keys = new Map<string, number>();665668 let text = '';666669 try {@@ -0,0 +1,37 @@
1+# To anyone who isn't an admin (owning repos doesn't make one), ssh2+# invite, invites and users are unknown commands: the same output and3+# exit status as a command that doesn't exist, nothing done, and not in4+# their help.5+. "$(dirname "$0")/../lib.sh"6+user cat7+user bob8+repo bob/stuff9+export FORGE_BASE_URL=https://forge.test10+git config --file "$FORGE_ROOT/.gitconfig" forge.admins cat11+12+# as user word args...: its output and exit status, with the command's13+# first word written as "WORD", to compare with an unknown one's.14+as() {15+ u=$1 w=$216+ shift 217+ rc=018+ ssh_as "$u" "$w" "$@" >"$T/out.$w" 2>&1 || rc=$?19+ printf 'exit %s\n' "$rc" >>"$T/out.$w"20+ sed "s/$w/WORD/g" "$T/out.$w"21+}22+for args in "invite fox --ttl 7d" "invite fox" "invites" "invites rm fox" "users"; do23+ set -- $args24+ w=$125+ shift26+ got=$(as bob "$w" "$@")27+ want=$(as bob nosuch "$@")28+ eq "$got" "$want"29+done30+eq "$(as bob users)" "fatal: unrecognized command 'WORD'31+exit 128"32+ok forge user invites33+eq "$(output)" "no invites; forge user invite <name> makes one"34+ok ssh_as bob help35+lacks "$T_LOG" 'admins|invite|users'36+# An admin's own unknown command is still unknown.37+fails_with "^fatal: unrecognized command 'nosuch'$" ssh_as cat nosuch@@ -0,0 +1,38 @@
1+# An admin (forge.admins in the server's git config) runs the operator's2+# people commands over ssh: invite prints the one-time link, invites lists3+# and takes back, users lists everyone, and help lists them. The server4+# forms (forge user invite, ...) still work, for anyone on the server.5+. "$(dirname "$0")/../lib.sh"6+user cat7+user ana8+repo cat/0129+export FORGE_BASE_URL=https://forge.test10+git config --file "$FORGE_ROOT/.gitconfig" --add forge.admins "dami, cat"11+12+ok ssh_as cat invite fox --ttl 2d --repo cat/012 --as collaborator13+has "$T_LOG" '^ https://forge\.test/login/#invite=[A-Za-z0-9_-]{24}$'14+has "$T_LOG" 'until 2026-09-03 00:00 UTC'15+has "$T_LOG" 'ssh git@forge\.test invites rm fox takes the link back'16+ok ssh_as cat invites17+has "$T_LOG" '^fox +2026-09-01 00:00 +2026-09-03 00:00 +collaborator of cat/012$'18+ok ssh_as cat users19+eq "$(head -n 3 "$T_LOG")" "user keys passkeys tokens20+ana 1 0 021+cat 1 0 0"22+fails_with "fox is invited already \(ssh git@forge\.test invites rm fox" ssh_as cat invite fox23+fails_with "^forge: usage: invite <name>" ssh_as cat invite24+fails_with "^forge: usage: users$" ssh_as cat users now25+ok ssh_as cat invites rm fox26+eq "$(output)" "took back the invite for fox; its link is dead"27+ok ssh_as cat invites28+eq "$(output)" "no invites; ssh git@forge.test invite <name> makes one"29+30+ok ssh_as cat help31+has "$T_LOG" '^for admins of the forge:$'32+for c in 'invite <name> \[--ttl 7d\]' 'invites \[rm <name>\]' 'users '; do has "$T_LOG" "^ $c"; done33+34+# The server's forms, as before.35+ok forge user invite owl36+has "$T_LOG" 'forge user invites rm owl takes the link back'37+ok forge users38+has "$T_LOG" '^owl '