| 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 '