Range-diff !474
back to !474 wiki: architecture and security pages
1: c2d679e ! 1: 3b23193 docs: architecture and security package
@@ Metadata
Author: Christian Cleberg <hello@cleberg.net>
## Commit message ##
- docs: architecture and security package
+ wiki: architecture and security pages
- docs/architecture/: system context, components, deployment, trust
- boundaries and data flows, identity and access, data and cryptography,
- CI and supply chain, operations, a controls matrix and known gaps, with
- seven SVG diagrams generated by diagrams/diagrams.py. Claims cite
- path:line at 2c08460. The Threat-Model wiki page links to it.
+ .gitbay/wiki/Architecture/: overview, system context, components,
+ deployment, trust boundaries and data flows, identity and access, data
+ and cryptography, CI and supply chain, operations, a controls matrix
+ and known gaps, with seven SVG diagrams generated by
+ diagrams/diagrams.py. Claims cite the implementing file and function.
+ Home and Threat-Model link to it.
- Ref #255, #256, #257, #258, #259, #260, #261, #262
+ Ref #255, #256, #257, #258, #259, #260, #261, #262, #272
- ## .gitbay/wiki/Threat-Model.org ##
+ ## .gitbay/wiki/Architecture/00-Overview.org (new) ##
@@
-
- What the forge trusts, what it refuses to do, and where the boundaries
- are. This is the reference for security review; it complements the audit
--log and hardening notes in [[Admin]].
-+log and hardening notes in [[Admin]]. Diagrams, data flows, a controls
-+matrix and the open gaps are in the [[https://gitbay.org/krz/gitbay/blob/main/docs/architecture/README.org][architecture and security package]].
-
- * What gitbay never does
-
++#+title: Architecture and security
++
++Architecture, trust boundaries and security controls of gitbay, written
++for a security reviewer or auditor. Every statement about behaviour
++names the file, and usually the function, that implements it;
++statements that rest on documentation or deployment files say so. The
++pages track the default branch and change in the same merge request as
++the code they describe.
++
++* Scope
++
++- The =gitbayd= daemon, the =gitbay= CLI and the =gitbay-runner= CI
++ runner, all in this repository.
++- The reference deployment described by =deploy/= (a single Linux
++ host, systemd, rootless podman for CI).
++- The iOS client (krz/gitbay-ios) only where it touches the server: the
++ JSON API and push notifications.
++
++Out of scope: the host operating system beyond the unit files and
++bootstrap in =deploy/=, the object store holding offsite backups, and
++Apple's push service.
++
++* Figures as of the last review
++
++| Item | Value |
++|------------------+----------------------------------------------------|
++| Reviewed at | =2c08460= (2026-09-27) |
++| Schema version | migration 0059 |
++| Control commands | 232 registered, 79 marked =ReadOnly= |
++| Go | 1.27, =CGO_ENABLED=0= |
++| Direct Go deps | 15 (=go.mod=) |
++
++The command count comes from =gitbay help --json= on the live instance;
++the =ReadOnly= count from the =ReadOnly: true= literals in
++=internal/control/=.
++
++* Documents
++
++| # | Document | Diagram |
++|---+----------------------------------------------------+-------------------------------------------------|
++| 1 | [[file:01-System-Context.org][System context]] | [[file:diagrams/01-context.svg][01-context.svg]] |
++| 2 | [[file:02-Components.org][Components]] | [[file:diagrams/02-components.svg][02-components.svg]] |
++| 3 | [[file:03-Deployment.org][Deployment and network]] | [[file:diagrams/03-deployment.svg][03-deployment.svg]] |
++| 4 | [[file:04-Trust-Boundaries.org][Trust boundaries and data flows]] | [[file:diagrams/04-trust-boundaries.svg][04-trust-boundaries.svg]], [[file:diagrams/06-push-flow.svg][06-push-flow.svg]] |
++| 5 | [[file:05-Identity-and-Access.org][Identity and access]] | [[file:diagrams/05-authorization.svg][05-authorization.svg]] |
++| 6 | [[file:06-Data-and-Cryptography.org][Data and cryptography]] | |
++| 7 | [[file:07-CI-and-Supply-Chain.org][CI and supply chain]] | [[file:diagrams/07-ci-flow.svg][07-ci-flow.svg]] |
++| 8 | [[file:08-Operations.org][Operations]] | |
++| 9 | [[file:09-Controls.org][Controls matrix]] | |
++| 10 | [[file:10-Known-Gaps.org][Known gaps]] | |
++
++Reading order for a first pass: 1, 4, 5, 9, 10. The others are
++reference.
++
++* Conventions
++
++- Paths are relative to the repository root. For a pinned snapshot,
++ read these pages at a tag: the wiki is part of the repository.
++- "Documented" means the statement rests on the wiki
++ (=.gitbay/wiki/=) or =deploy/= rather than on code.
++- Numbers such as =#255= are issues on krz/gitbay; =krz/gitbay-ios#15=
++ names the other repository.
++- Diagrams are SVG with a light and a dark rendering chosen by the
++ viewer's colour scheme. They are generated by
++ =.gitbay/wiki/Architecture/diagrams/diagrams.py=; edit that and rerun
++ it rather than the SVGs.
++
++* Checking a claim
++
++The instance answers the same questions the documents make claims
++about:
++
++#+begin_src sh
++gitbay help --json # the command registry: paths, flags, ReadOnly
++curl -s https://gitbay.org/healthz # the deployed commit
++curl -sI https://gitbay.org/ # security headers
++ssh git@gitbay.org whoami # identity resolution over stock OpenSSH
++#+end_src
++
++* Related wiki pages
++
++- [[file:../Threat-Model.org][Threat-Model]] — the project's own threat model; this package extends it.
++- [[file:../Parity.org][Parity]] — which capability is reachable from which surface.
++- [[file:../Admin.org][Admin]] — configuration, backups, operations.
++- [[file:../API.org][API]] — the JSON API.
- ## docs/architecture/01-system-context.org (new) ##
+ ## .gitbay/wiki/Architecture/01-System-Context.org (new) ##
@@
+#+title: 1. System context
+
@@ docs/architecture/01-system-context.org (new)
+(=internal/control/control.go=). Stock OpenSSH reaches all of them; the
+CLI, the web UI and the JSON API are clients of the same registry and
+do not reimplement logic (=internal/httpd/control.go=,
-+=internal/httpd/api.go:30=).
++=internal/httpd/api.go=).
+
+* Actors
+
@@ docs/architecture/01-system-context.org (new)
+
+| System | Direction | Purpose | Code |
+|---------------------------+-----------+-------------------------------------------+------------------------------------|
-+| ACME CA (Let's Encrypt) | out | TLS certificates | =cmd/gitbayd/main.go:257-297= |
++| ACME CA (Let's Encrypt) | out | TLS certificates | =cmd/gitbayd/main.go= |
+| SMTP relay | out | verification, login links, notifications | =internal/mail/mail.go= |
+| Apple Push Notification | out | iOS notifications | =internal/push/apns.go= |
+| Webhook endpoints | out | event delivery, user-configured | =internal/webhook/webhook.go= |
+| Mirror remotes | out / in | push and pull mirrors, user-configured | =internal/mirror/mirror.go= |
-+| Package registries | out | dependency update checks (opt-in per repo)| =internal/deps/registry.go:18-22= |
++| Package registries | out | dependency update checks (opt-in per repo)| =internal/deps/registry.go= |
+| Offsite object storage | out | restic backups (host timer, not gitbayd) | documented: Admin wiki |
+
+gitbayd makes no other outbound connection: no telemetry or update
@@ docs/architecture/01-system-context.org (new)
+
+| Setting | Default | Effect |
+|----------------------------------+-----------+-------------------------------------------------------------|
-+| =web.mode= | view_only | =accounts= adds login, settings and every web write route (=routes.go:115-232=) |
++| =web.mode= | view_only | =accounts= adds login, settings and every web write route (=routes.go=) |
+| =api.enabled= | false | when false there is no credential-bearing HTTP surface |
+| =registration.mode= | closed | =open= admits unknown SSH keys to =register=; =invite= needs a code |
+| =git_daemon.enabled= | false | anonymous =git://= on 9418 |
@@ docs/architecture/01-system-context.org (new)
+=registration.mode = open=, all three observable from outside (=/login=,
+=/register=, =/api/v1/read= answering 401).
- ## docs/architecture/02-components.org (new) ##
+ ## .gitbay/wiki/Architecture/02-Components.org (new) ##
@@
+#+title: 2. Components
+
@@ docs/architecture/02-components.org (new)
+
+=gitbayd= subcommands: =serve=, =check-config=, =migrate=, =admin=,
+=authorized-keys= and =shell= (for =ssh.mode = system=), =version=, and
-+the hidden =hook= used by git (=cmd/gitbayd/main.go:76-86=,
-+=cmd/gitbayd/hook.go:133=).
++the hidden =hook= used by git (=cmd/gitbayd/main.go=,
++=cmd/gitbayd/hook.go=).
+
+* Packages
+
@@ docs/architecture/02-components.org (new)
+
+* The command registry
+
-+Every capability is a =Command= (=internal/control/control.go:79-91=):
++Every capability is a =Command= (=internal/control/control.go=):
+
+| Field | Meaning |
+|--------------+---------------------------------------------------------------------|
@@ docs/architecture/02-components.org (new)
+| =Run= | the handler |
+
+Every surface builds a =Ctx= and calls =Dispatch=
-+(=internal/control/control.go:119=):
++(=internal/control/control.go=):
+
+| Surface | =Ctx.Source= | =Ctx.Scope= | =Ctx.ReadOnly= | Code |
+|--------------+-------------------+------------------------+---------------------+-----------------------------------|
-+| SSH | key fingerprint | the key's scope | false | =internal/sshd/sshd.go:342= (=Exec=) |
++| SSH | key fingerprint | the key's scope | false | =internal/sshd/sshd.go= (=Exec=) |
+| Web | =web= | =full= | false | =internal/httpd/control.go= |
-+| JSON API | =api= | =full= | token scope = read | =internal/httpd/api.go:30=, =apiread.go:25= |
++| JSON API | =api= | =full= | token scope = read | =internal/httpd/api.go=, =apiread.go= |
+| Host (root) | =host= | =full= | false | =cmd/gitbayd= admin subcommands |
+
+=Dispatch= applies, in order: =--term= and =--json= stripping; the scope
+gate; the read-only gate; the disabled-account gate; the =admin= noun
+gate; the pending-account gate; the per-account write budget; stdin
+gating; the handler; and an audit row for every successful mutating
-+command. Details in [[file:05-identity-and-access.org][5. Identity and access]].
++command. Details in [[file:05-Identity-and-Access.org][5. Identity and access]].
+
+* Background workers
+
-+Started by =gitbayd serve= (=cmd/gitbayd/main.go:161-207=):
++Started by =gitbayd serve= (=cmd/gitbayd/main.go=):
+
+| Worker | Starts when | Trigger | Queue / table |
+|-----------------------+-----------------------------+---------------------------------+-----------------------|
@@ docs/architecture/02-components.org (new)
+* Git hooks
+
+Repositories carry generated hook scripts (mode 0755, regenerated at
-+startup, =internal/hookd/hookd.go:436-451=) that run
++startup, =internal/hookd/hookd.go=) that run
+=gitbayd hook pre-receive|post-receive=. The hook process connects to
-+the daemon's Unix socket (=<root>/hook.sock=, =hookd.go:70-79=) and asks
++the daemon's Unix socket (=<root>/hook.sock=, =hookd.go=) and asks
+for a decision; the daemon holds the policy. See
-+[[file:04-trust-boundaries.org][4. Trust boundaries]], flow B.
++[[file:04-Trust-Boundaries.org][4. Trust boundaries]], flow B.
- ## docs/architecture/03-deployment.org (new) ##
+ ## .gitbay/wiki/Architecture/03-Deployment.org (new) ##
@@
+#+title: 3. Deployment and network
+
@@ docs/architecture/03-deployment.org (new)
+
+| Port / path | Protocol | Owner | Default | Auth | Code |
+|----------------------+-------------------+------------+-------------+----------------------------------------+---------------------------------------|
-+| 22/tcp | SSH | gitbayd | on | public key; unknown keys only reach =register= when registration is open | =cmd/gitbayd/main.go:213-222=, =internal/sshd/sshd.go:121= |
-+| 443/tcp | HTTPS | gitbayd | on | none for pages; session cookie; bearer token for the API | =cmd/gitbayd/main.go:236-300= |
-+| 80/tcp | HTTP | gitbayd | on with ACME| none; ACME HTTP-01 and redirect only | =cmd/gitbayd/main.go:279-297= |
++| 22/tcp | SSH | gitbayd | on | public key; unknown keys only reach =register= when registration is open | =cmd/gitbayd/main.go=, =internal/sshd/sshd.go= |
++| 443/tcp | HTTPS | gitbayd | on | none for pages; session cookie; bearer token for the API | =cmd/gitbayd/main.go= |
++| 80/tcp | HTTP | gitbayd | on with ACME| none; ACME HTTP-01 and redirect only | =cmd/gitbayd/main.go= |
+| 9418/tcp | git:// | gitbayd | off | none; public repositories only | =internal/gitd= |
-+| 2222/tcp | SSH (operator) | host sshd | on | public key, no passwords, fail2ban | =deploy/cloud-init.yaml:29-40= |
-+| =<root>/hook.sock= | Unix socket | gitbayd | on | filesystem permissions only | =internal/hookd/hookd.go:90= |
++| 2222/tcp | SSH (operator) | host sshd | on | public key, no passwords, fail2ban | =deploy/cloud-init.yaml= |
++| =<root>/hook.sock= | Unix socket | gitbayd | on | filesystem permissions only | =internal/hookd/hookd.go= |
+
+With =ssh.mode = system= the host's sshd serves port 22 instead and
+invokes =gitbayd authorized-keys= and =gitbayd shell=
-+(=cmd/gitbayd/main.go:224-226=).
++(=cmd/gitbayd/main.go=).
+
+HTTP server limits: =ReadHeaderTimeout= 10 s, =IdleTimeout= 2 min,
+=MaxHeaderBytes= 64 KiB, no =WriteTimeout= so long git transfers and
-+live build logs can stream (=cmd/gitbayd/main.go:230-234=).
++live build logs can stream (=cmd/gitbayd/main.go=).
+
+There is no metrics endpoint. =/healthz= reports the deployed commit and
+a database check.
@@ docs/architecture/03-deployment.org (new)
+
+| Unit | User | Hardening (from the unit files) |
+|------------------------+-------------+---------------------------------------------------------------------------------------------------|
-+| =gitbayd.service= | =gitbay= | =CAP_NET_BIND_SERVICE= only; =NoNewPrivileges=; =ProtectSystem=strict= with write access to =/var/lib/gitbay= and =/var/backups/gitbay= only; =ProtectHome=; =PrivateTmp=; =PrivateDevices=; kernel, clock and cgroup protections; =RestrictNamespaces=; =MemoryDenyWriteExecute=; =RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX=; =SystemCallFilter=@system-service= (=deploy/cloud-init.yaml:205-241=) |
++| =gitbayd.service= | =gitbay= | =CAP_NET_BIND_SERVICE= only; =NoNewPrivileges=; =ProtectSystem=strict= with write access to =/var/lib/gitbay= and =/var/backups/gitbay= only; =ProtectHome=; =PrivateTmp=; =PrivateDevices=; kernel, clock and cgroup protections; =RestrictNamespaces=; =MemoryDenyWriteExecute=; =RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX=; =SystemCallFilter=@system-service= (=deploy/cloud-init.yaml=) |
+| =gitbay-runner.service=| =ci-runner= | =MemoryMax=6G=, =CPUQuota=300%=, =Delegate=yes=, =KillMode=mixed=, =RestrictSUIDSGID=yes=. =NoNewPrivileges=, =ProtectKernelTunables= and =ProtectControlGroups= are relaxed because rootless podman needs =newuidmap=, a proc mount and a writable delegated cgroup; the reasons are in =deploy/gitbay-runner.override.conf= |
-+| CI containers | subordinate uids of =ci-runner= | rootless podman, =--pull=never=, operator-provisioned image; see [[file:07-ci-and-supply-chain.org][7. CI]] |
++| CI containers | subordinate uids of =ci-runner= | rootless podman, =--pull=never=, operator-provisioned image; see [[file:07-CI-and-Supply-Chain.org][7. CI]] |
+| backup, db-backup, gc, monitor timers | =gitbay= | nightly full archive, hourly database snapshot, weekly =git gc=, hourly health heartbeat (=deploy/cloud-init.yaml=) |
+
+* Filesystem
@@ docs/architecture/03-deployment.org (new)
+| Path | Contents | Mode set by code / deploy |
+|-----------------------------------+--------------------------------------------+---------------------------|
+| =/var/lib/gitbay= (=server.root=) | everything below | 0750 (cloud-init) |
-+| =<root>/gitbay.db= | SQLite database | 0640 (=internal/store/store.go:59-64=) |
++| =<root>/gitbay.db= | SQLite database | 0640 (=internal/store/store.go=) |
+| =<root>/repos/<owner>/<name>.git= | bare repositories | process umask |
-+| =<root>/lfs= | LFS objects, content-addressed | 0755 directories (=internal/lfs/lfs.go:61=) |
-+| =<root>/ssh/host_ed25519= | SSH host key | 0600 in a 0700 directory (=internal/sshd/sshd.go:103-114=) |
++| =<root>/lfs= | LFS objects, content-addressed | 0755 directories (=internal/lfs/lfs.go=) |
++| =<root>/ssh/host_ed25519= | SSH host key | 0600 in a 0700 directory (=internal/sshd/sshd.go=) |
+| =<root>/acme= | ACME account key and certificates | autocert defaults |
+| =<root>/hooks= | generated hook scripts | 0755 |
+| =/etc/gitbay/config.toml= | configuration, including SMTP password | 0640 (cloud-init) |
@@ docs/architecture/03-deployment.org (new)
+
+| Destination | Trigger | TLS | Guard |
+|------------------------+-------------------------------+----------------------------------------------+----------------------------------------------------------------|
-+| ACME directory | certificate issue and renewal | yes | host policy limits names to the site and claimed pages domains (=main.go:257-273=) |
-+| SMTP relay | queued mail | STARTTLS when offered | Go's =PlainAuth= will not send credentials over plaintext to a non-local host (=internal/mail/mail.go:38-47=) |
++| ACME directory | certificate issue and renewal | yes | host policy limits names to the site and claimed pages domains (=main.go=) |
++| SMTP relay | queued mail | STARTTLS when offered | Go's =PlainAuth= will not send credentials over plaintext to a non-local host (=internal/mail/mail.go=) |
+| APNs | queued push | yes, HTTP/2 | provider token signed with the operator's .p8 key |
-+| Webhook URLs | recorded events | yes when https; certificate verified | private, loopback and link-local targets refused at save and again at connect time; no redirects (=internal/webhook/webhook.go:26-93=) |
-+| Mirror URLs | mirror schedule | per URL | the same address check at save time (=internal/control/mirrorcmd.go:58=); git makes the connection, so there is no connect-time re-check |
-+| Package registries | dependency checks | yes | fixed hosts; only the package name varies (=internal/deps/registry.go:15-22=) |
++| Webhook URLs | recorded events | yes when https; certificate verified | private, loopback and link-local targets refused at save and again at connect time; no redirects (=internal/webhook/webhook.go=) |
++| Mirror URLs | mirror schedule | per URL | the same address check at save time (=internal/control/mirrorcmd.go=); git makes the connection, so there is no connect-time re-check |
++| Package registries | dependency checks | yes | fixed hosts; only the package name varies (=internal/deps/registry.go=) |
+
+* Host firewall
+
+=deploy/cloud-init.yaml= opens 22, 80, 443 and 2222 inbound with ufw.
+Outbound traffic is not restricted, including from CI containers. See
-+[[file:10-known-gaps.org][10. Known gaps]].
++[[file:10-Known-Gaps.org][10. Known gaps]].
+
+* Change path
+
@@ docs/architecture/03-deployment.org (new)
+ (=deploy/install.sh=).
+3. =/healthz= reports the commit now serving.
- ## docs/architecture/04-trust-boundaries.org (new) ##
+ ## .gitbay/wiki/Architecture/04-Trust-Boundaries.org (new) ##
@@
+#+title: 4. Trust boundaries and data flows
+
@@ docs/architecture/04-trust-boundaries.org (new)
+
+| ID | Boundary | What crosses | Control at the boundary |
+|-----+------------------------------------+--------------------------------------------------+-----------------------------------------------------------------------------------------|
-+| TB1 | Z0 → Z1 SSH | key auth, exec requests, git packs | public-key auth, per-IP failure limit (=internal/sshd/sshd.go:121=, =ratelimit.go=); unknown keys reach only =register= |
-+| TB2 | Z0 → Z1 HTTPS | page requests, form posts, API calls, fetches, LFS | TLS; session cookie or bearer token; =checkOrigin= on posts; CSP and security headers (=internal/httpd/routes.go:290=); smart HTTP is fetch-only (=smart.go=) |
-+| TB3 | identity → data | every command | =Dispatch= gates, then =resolveRepo= with =policy= predicates; unreadable repositories are indistinguishable from missing ones ([[file:05-identity-and-access.org][5]]) |
++| TB1 | Z0 → Z1 SSH | key auth, exec requests, git packs | public-key auth, per-IP failure limit (=internal/sshd/sshd.go=, =ratelimit.go=); unknown keys reach only =register= |
++| TB2 | Z0 → Z1 HTTPS | page requests, form posts, API calls, fetches, LFS | TLS; session cookie or bearer token; =checkOrigin= on posts; CSP and security headers (=internal/httpd/routes.go=); smart HTTP is fetch-only (=smart.go=) |
++| TB3 | identity → data | every command | =Dispatch= gates, then =resolveRepo= with =policy= predicates; unreadable repositories are indistinguishable from missing ones ([[file:05-Identity-and-Access.org][5]]) |
+| TB4 | Z1 → Z3 git | argv, repository path, stdin packs | argv built by code, never a shell; repository path from the database, not the request (=internal/gitutil=) |
-+| TB5 | Z3 → Z1 hook socket | ref updates, repository id, user id, key scope, commit objects | the daemon decides with =policy.CheckPush= and =sig.VerifyCommit= (=internal/hookd/hookd.go:130=). The socket trusts the ids in the request, so access to the socket is equivalent to acting as any user; it is reachable only through the =gitbay= user's filesystem |
-+| TB6 | Z4 ↔ Z1 runner channel | build claims (with secrets for trusted builds), logs, results | runner-scoped SSH key; claims limited to attached repositories; secrets only when the build is trusted (=internal/control/build.go:548=) |
++| TB5 | Z3 → Z1 hook socket | ref updates, repository id, user id, key scope, commit objects | the daemon decides with =policy.CheckPush= and =sig.VerifyCommit= (=internal/hookd/hookd.go=). The socket trusts the ids in the request, so access to the socket is equivalent to acting as any user; it is reachable only through the =gitbay= user's filesystem |
++| TB6 | Z4 ↔ Z1 runner channel | build claims (with secrets for trusted builds), logs, results | runner-scoped SSH key; claims limited to attached repositories; secrets only when the build is trusted (=internal/control/build.go=) |
+| TB7 | Z5 → Z4 container | build steps, workspace, build home | rootless podman, operator-provisioned image, cgroup limits; the build home is shared per repository and the network is open (#255, #260) |
-+| TB8 | Z1 → Z0 outbound | webhooks, mirrors, mail, push | address checks on user-supplied URLs; HMAC on webhooks; no redirects ([[file:03-deployment.org][3]]) |
-+| TB9 | user content → browser | Markdown and Org bodies, READMEs, filenames | HTML sanitised (=ugcHTML=, =internal/httpd/web.go:1322=, bluemonday); CSP =script-src 'none'= |
++| TB8 | Z1 → Z0 outbound | webhooks, mirrors, mail, push | address checks on user-supplied URLs; HMAC on webhooks; no redirects ([[file:03-Deployment.org][3]]) |
++| TB9 | user content → browser | Markdown and Org bodies, READMEs, filenames | HTML sanitised (=ugcHTML=, =internal/httpd/web.go=, bluemonday); CSP =script-src 'none'= |
+| TB10| Z6 → everything | host shell | operator SSH on 2222, keys only, fail2ban; append-only offsite backup credentials |
+
+* Flows
@@ docs/architecture/04-trust-boundaries.org (new)
+
+1. Client opens SSH; =authenticate= looks up the key fingerprint and
+ records user id, key id and scope in the connection
-+ (=internal/sshd/sshd.go:121-158=). TB1.
++ (=internal/sshd/sshd.go=). TB1.
+2. Each exec request: =runExec= reloads the account, touches the key's
-+ last-used time, calls =Exec= (=sshd.go:299-312=).
++ last-used time, calls =Exec= (=sshd.go=).
+3. =Exec= tokenizes the command line (no shell) and routes git
+ transport verbs to =runGit=, everything else to =control.Dispatch=
-+ with =Source= set to the key fingerprint (=sshd.go:342-363=).
++ with =Source= set to the key fingerprint (=sshd.go=).
+4. =Dispatch= gates and runs the handler; mutating successes are
-+ audited (=internal/control/control.go:119-191=). TB3.
++ audited (=internal/control/control.go=). TB3.
+
+** B. git push over SSH
+
@@ docs/architecture/04-trust-boundaries.org (new)
+
+1. =runGit= resolves the repository, applies the deploy-key or account
+ checks, archive and pull-mirror refusals and the owner's storage
-+ quota (=sshd.go:388=). TB3.
++ quota (=sshd.go=). TB3.
+2. =git receive-pack= runs with the hook socket path, repository id,
-+ user id and key scope in its environment (=sshd.go:438-462=). TB4.
++ user id and key scope in its environment (=sshd.go=). TB4.
+3. git runs =pre-receive=, which is =gitbayd hook pre-receive=. It
+ reads the ref updates, computes ancestry in git's quarantine
+ environment and asks the daemon over the socket
-+ (=cmd/gitbayd/hook.go:133-189=). TB5.
++ (=cmd/gitbayd/hook.go=). TB5.
+4. The daemon applies =policy.CheckPush= (protected branches,
+ =require-mr=, protected tags, server-owned =refs/merge-requests/*=)
+ and, when the repository requires signed commits, asks for every
+ incoming commit object and verifies each
-+ (=internal/hookd/hookd.go:130-190=).
++ (=internal/hookd/hookd.go=).
+5. On refusal the hook exits 1 and git rejects the push atomically.
+6. On success git runs =post-receive=; the daemon records events,
+ marks mirrors dirty, queues CI, syncs merge request heads, processes
+ =Closes #N= references and audits force pushes
-+ (=hookd.go:218=).
++ (=hookd.go=).
+
+The server's own merge of a merge request does not pass through the
+hooks: =runMRMerge= updates the ref with a compare-and-swap
-+(=internal/control/mr.go:1462=) after =MergeGates=
-+(=mr.go:1568=). When signed commits are required only fast-forward
++(=internal/control/mr.go=) after =MergeGates=
++(=mr.go=). When signed commits are required only fast-forward
+merges are allowed, so the server never writes an unsigned commit
-+(=mr.go:1273=).
++(=mr.go=).
+
+** C. Fetch over smart HTTP
+
+=GET info/refs= and =POST git-upload-pack= serve public repositories
+only; a private repository answers 404. =git-receive-pack= over HTTP
+always answers a pkt-line refusal, so there is no password prompt and
-+no HTTP write path (=internal/httpd/smart.go:88-141=,
-+=routes.go:28-38=). TB2.
++no HTTP write path (=internal/httpd/smart.go=,
++=routes.go=). TB2.
+
+** D. Web read and write
+
-+1. The session cookie is hashed and looked up (=accounts.go:36-46=).
++1. The session cookie is hashed and looked up (=accounts.go=).
+2. Pages dispatch read commands into the registry with =Source=web= and
+ decode the JSON result into the template (=internal/httpd/control.go=).
-+3. Form posts pass =checkOrigin= (=accounts.go:67=), dispatch the
++3. Form posts pass =checkOrigin= (=accounts.go=), dispatch the
+ matching command, and map the exit code to a redirect or an error on
+ the page. Three toggles (pin, watch, mark read) write the store
+ directly instead (#261).
@@ docs/architecture/04-trust-boundaries.org (new)
+** E. JSON API
+
+1. =apiAuth= hashes the bearer token and looks it up; any failure is a
-+ uniform 401 (=internal/httpd/api.go:127=).
++ uniform 401 (=internal/httpd/api.go=).
+2. Per-account rate limit, with writes at a tenth of the read budget.
+3. =POST /api/v1/cmd= dispatches any command except git transport;
+ =GET /api/v1/read= refuses anything not marked =ReadOnly=, so a GET
-+ cannot write (=apiread.go:25-44=).
++ cannot write (=apiread.go=).
+4. Exit codes map to HTTP status: 0→200, 2→400, 3→404, 4→403, else 500.
+
+** F. LFS
+
+=git-lfs-authenticate= over SSH applies the same repository checks as
+git transport and returns a one-hour HMAC token scoped to repository
-+and operation (=internal/sshd/lfs.go=, =internal/lfs/lfs.go:129=). The
++and operation (=internal/sshd/lfs.go=, =internal/lfs/lfs.go=). The
+HTTP batch, upload and download endpoints verify that token; public
+repositories allow anonymous download. Objects are verified against
+their SHA-256 id on upload.
@@ docs/architecture/04-trust-boundaries.org (new)
+
+1. =/login= takes a username or email; per-IP and per-account limits
+ (5 per hour) apply, and every non-eligible case returns the same
-+ response (=internal/control/loginlink.go:38-110=).
++ response (=internal/control/loginlink.go=).
+2. A 32-byte token is mailed; only its hash is stored, valid 15 minutes.
+3. =/login?token== consumes it atomically, rechecks the account and
-+ sets the session cookie (=accounts.go:123-152=).
++ sets the session cookie (=accounts.go=).
+
+=ssh git@host web login= mints the same kind of link, valid 5 minutes,
-+to an already-authenticated key (=internal/control/web.go:80-99=).
++to an already-authenticated key (=internal/control/web.go=).
+
+** H. CI build
+
-+See [[file:07-ci-and-supply-chain.org][7. CI and supply chain]].
++See [[file:07-CI-and-Supply-Chain.org][7. CI and supply chain]].
- ## docs/architecture/05-identity-and-access.org (new) ##
+ ## .gitbay/wiki/Architecture/05-Identity-and-Access.org (new) ##
@@
+#+title: 5. Identity and access
+
@@ docs/architecture/05-identity-and-access.org (new)
+
+| Property | Values / behaviour | Code |
+|--------------+-------------------------------------------------------------------------------------+------|
-+| Registration | =closed= (host bootstrap only), =invite= (single-use code bound to an email), =open= (email required) | =internal/control/register.go:280-316= |
-+| Pending | open registrations start pending until email is verified; a pending account may run only =email verify=, =email add=, =whoami=, =help=, and has no git access | =internal/control/control.go:172-175,250-253=, =internal/sshd/sshd.go= |
-+| Disabled | refused in =Dispatch=, in SSH exec and at login-link redemption | =control.go:164-166= |
-+| Admin | =users.is_admin=; set only by =admin user create --admin= or promotion by an admin; gates the whole =admin= noun | =control.go:169-171= |
++| Registration | =closed= (host bootstrap only), =invite= (single-use code bound to an email), =open= (email required) | =internal/control/register.go= |
++| Pending | open registrations start pending until email is verified; a pending account may run only =email verify=, =email add=, =whoami=, =help=, and has no git access | =internal/control/control.go=, =internal/sshd/sshd.go= |
++| Disabled | refused in =Dispatch=, in SSH exec and at login-link redemption | =control.go= |
++| Admin | =users.is_admin=; set only by =admin user create --admin= or promotion by an admin; gates the whole =admin= noun | =control.go= |
+
+* Credentials
+
@@ docs/architecture/05-identity-and-access.org (new)
+| Invite | random code | SHA-256 hash, single use | one registration for one email | as issued | consumed on use |
+| LFS transfer token | HMAC-SHA256 over repo, operation, expiry | not stored (stateless) | one repository, upload or download | 1 h | expiry only |
+
-+Generation and hashing: =internal/store/sessions.go:14-26= (=NewToken=,
++Generation and hashing: =internal/store/sessions.go= (=NewToken=,
+=HashToken=, =crypto/rand=). Cookie attributes: =HttpOnly=,
+=SameSite=Lax=, =Secure= unless TLS is off, =MaxAge= 7 days
-+(=internal/httpd/accounts.go:159-168=). Token scope values are
++(=internal/httpd/accounts.go=). Token scope values are
+constrained by a database =CHECK= as well as the command
+(=internal/store/migrations/0004_api_tokens.up.sql=).
+
@@ docs/architecture/05-identity-and-access.org (new)
+| =full= token | all the account may run | not over the API |
+| =read= token | commands marked =ReadOnly= | not over the API |
+
-+Sources: =internal/control/control.go:156-161=,
-+=internal/policy/access.go:46-79=.
++Sources: =internal/control/control.go=,
++=internal/policy/access.go=.
+
+* Authorization decision
+
+Two layers decide every request.
+
-+1. *=Dispatch=* (=internal/control/control.go:119-191=), in order:
++1. *=Dispatch=* (=internal/control/control.go=), in order:
+ scope gate, read-only gate, disabled account, =admin= noun, pending
+ account, per-account write budget, stdin gating, handler, audit.
+2. *The handler*, which resolves the repository with =resolveRepo=
-+ (=internal/control/repo.go:228-249=) and a predicate from
++ (=internal/control/repo.go=) and a predicate from
+ =internal/policy/access.go=:
+
+| Predicate | True when |
@@ docs/architecture/05-identity-and-access.org (new)
+checks =CanRead=. If the caller cannot read the repository the answer is
+"not found", identical to a missing repository. Only a caller who can
+read it gets "permission denied" for a write. git transport follows the
-+same rule (=internal/sshd/sshd.go:388=), and so does smart HTTP, which
++same rule (=internal/sshd/sshd.go=), and so does smart HTTP, which
+serves public repositories only.
+
+Deploy keys bypass the grant model entirely: =runGit= checks only that
+the key's scope names this repository and allows the operation
-+(=policy.DeployScopeAllows=, =internal/policy/access.go:60-76=).
++(=policy.DeployScopeAllows=, =internal/policy/access.go=).
+
+* Repository write protections
+
+Enforced in the pre-receive hook, so they apply to every credential
-+that reaches git transport (=internal/policy/access.go:93-139=,
-+=internal/hookd/hookd.go:130-190=):
++that reaches git transport (=internal/policy/access.go=,
++=internal/hookd/hookd.go=):
+
+| Setting (=repo settings …=) | Effect |
+|---------------------------+-------------------------------------------------------------------------|
@@ docs/architecture/05-identity-and-access.org (new)
+| (always) | =refs/merge-requests/*= is server-owned |
+
+Merge gates, evaluated by =MergeGates= for =mr merge=, the web merge
-+button and the displayed status (=internal/control/mr.go:1568-1717=):
++button and the displayed status (=internal/control/mr.go=):
+
+| Gate | Satisfied when |
+|-----------------------+-----------------------------------------------------------------------------|
@@ docs/architecture/05-identity-and-access.org (new)
+
+- CSRF: =SameSite=Lax= withholds the cookie on cross-site posts;
+ =checkOrigin= rejects a post whose =Origin= host differs from the
-+ request host (=internal/httpd/accounts.go:67-79=).
++ request host (=internal/httpd/accounts.go=).
+- Headers on every response: CSP =default-src 'self'; script-src
+ 'none'; style-src 'self' 'unsafe-inline'; img-src * data:;
+ object-src 'none'; base-uri 'none'; form-action 'self';
+ frame-ancestors 'none'=, =X-Frame-Options: DENY=, =nosniff=,
+ =Referrer-Policy: no-referrer=, =Cross-Origin-Opener-Policy:
+ same-origin=, and HSTS for one year with subdomains when TLS is on
-+ (=internal/httpd/routes.go:290-305=).
++ (=internal/httpd/routes.go=).
+- Destructive web actions (key, email and PGP removal, release, snippet,
+ team and label deletion, user disable and demote) require the target's
+ name typed into the form (=internal/httpd/confirm.go=).
@@ docs/architecture/05-identity-and-access.org (new)
+|-------------------------+-------------------+-----------------------+---------------------------------------|
+| SSH auth failures | 10 per minute | client IP | =internal/sshd/ratelimit.go= |
+| API requests | 120 per minute | account, or IP if anonymous | =internal/httpd/apilimit.go= |
-+| API writes | a tenth of the above | same | =apilimit.go:43-45= |
-+| Command writes, all surfaces | =limits.write_rate= (60 per minute) | account | =internal/control/control.go:231-248= |
-+| Login links (web form) | 5 per hour, plus per-IP | account and IP | =internal/control/loginlink.go:19,78-84= |
-+| Email verification mails| 5 per hour | account | =internal/control/register.go:173= |
++| API writes | a tenth of the above | same | =apilimit.go= |
++| Command writes, all surfaces | =limits.write_rate= (60 per minute) | account | =internal/control/control.go= |
++| Login links (web form) | 5 per hour, plus per-IP | account and IP | =internal/control/loginlink.go= |
++| Email verification mails| 5 per hour | account | =internal/control/register.go= |
+
+=X-Forwarded-For= is honoured only from addresses listed in
-+=http.trusted_proxies= (=internal/httpd/apilimit.go:106-138=).
++=http.trusted_proxies= (=internal/httpd/apilimit.go=).
- ## docs/architecture/06-data-and-cryptography.org (new) ##
+ ## .gitbay/wiki/Architecture/06-Data-and-Cryptography.org (new) ##
@@
+#+title: 6. Data and cryptography
+
@@ docs/architecture/06-data-and-cryptography.org (new)
+
+No table stores client IP addresses as a column. The daemon writes a
+client IP into an audit row only for authentication failures and
-+throttling (=internal/sshd/sshd.go:130-151=).
++throttling (=internal/sshd/sshd.go=).
+
+* At rest
+
+| Item | Protection |
+|---------------------------------------+----------------------------------------------------------------|
-+| API tokens, sessions, login links, email codes, invites | SHA-256 of a 256-bit random value; the value is shown once and never stored (=internal/store/sessions.go:14-26=) |
++| API tokens, sessions, login links, email codes, invites | SHA-256 of a 256-bit random value; the value is shown once and never stored (=internal/store/sessions.go=) |
+| CI secrets, webhook secrets, mirror tokens, APNs device tokens | stored in clear in SQLite; protection is filesystem permissions and the rule that values are write-only through the interface |
+| SQLite file | mode 0640, directory 0750 |
+| Backups | the local archive is not encrypted; restic encrypts the offsite copy |
@@ docs/architecture/06-data-and-cryptography.org (new)
+| Runner ↔ server | SSH |
+| SMTP | STARTTLS when the relay offers it |
+| APNs | TLS, HTTP/2 |
-+| Webhooks | TLS when the URL is https; HMAC-SHA256 body signature in =X-Gitbay-Signature-256= (=internal/webhook/webhook.go:145-149=) |
-+| Mirrors | per URL; token passed through =GIT_ASKPASS=, never argv (=internal/mirror/mirror.go:84-97=) |
++| Webhooks | TLS when the URL is https; HMAC-SHA256 body signature in =X-Gitbay-Signature-256= (=internal/webhook/webhook.go=) |
++| Mirrors | per URL; token passed through =GIT_ASKPASS=, never argv (=internal/mirror/mirror.go=) |
+
+The TLS configuration uses Go's defaults; no minimum version or cipher
+list is set in code.
@@ docs/architecture/06-data-and-cryptography.org (new)
+
+| Use | Primitive | Code |
+|---------------------------------+--------------------------------------------+------------------------------------|
-+| Token generation | =crypto/rand=, 32 bytes | =internal/store/sessions.go:14= |
-+| Token storage | SHA-256 | =sessions.go:23= |
-+| LFS transfer tokens | HMAC-SHA256, secret in =settings= | =internal/lfs/lfs.go:120-175= |
-+| Webhook signatures | HMAC-SHA256 | =internal/webhook/webhook.go:145= |
++| Token generation | =crypto/rand=, 32 bytes | =internal/store/sessions.go= |
++| Token storage | SHA-256 | =sessions.go= |
++| LFS transfer tokens | HMAC-SHA256, secret in =settings= | =internal/lfs/lfs.go= |
++| Webhook signatures | HMAC-SHA256 | =internal/webhook/webhook.go= |
+| APNs provider token | ES256 JWT (ECDSA P-256) | =internal/push/token.go= |
-+| SSH host key | ed25519 | =internal/sshd/sshd.go:75-114= |
++| SSH host key | ed25519 | =internal/sshd/sshd.go= |
+| Commit and tag signatures | verify OpenPGP (ProtonMail go-crypto) and SSHSIG | =internal/sig= |
-+| LFS object ids | SHA-256 | =internal/lfs/lfs.go:71= |
++| LFS object ids | SHA-256 | =internal/lfs/lfs.go= |
+
+Signature verification results are cached in =commit_signatures= with
+the global =key_epoch= at the time of verification. Any change to a
+trust input (a key added or removed, an email verified) bumps the
+epoch, which invalidates every cached result (=internal/store/users.go=,
-+=internal/control/sig.go:124=).
++=internal/control/sig.go=).
+
+The server holds no signing key and signs nothing. A "verified" badge
+means a user's own key signed the commit.
@@ docs/architecture/06-data-and-cryptography.org (new)
+- Secrets enter only on stdin. A command must set =ReadsStdin=
+ to receive stdin at all; =TestStdinCommandsReadStdin= enforces it.
+ Examples: =repo secret set=, =repo deploy-key add=, =repo import
-+ --token-stdin= (=internal/control/build.go:65-72=, =import.go:88-90=).
++ --token-stdin= (=internal/control/build.go=, =import.go=).
+- Secrets are listed by name, never echoed back.
+- The audit log stores argv with flag values stripped
-+ (=internal/control/control.go:199-221=).
++ (=internal/control/control.go=).
+- Mail errors are logged with addresses redacted
-+ (=internal/notify/notify.go:76-80=).
++ (=internal/notify/notify.go=).
+- CI secrets travel in the runner's claim only for trusted builds and
+ reach the container as environment variables through a 0600 env file
+ or podman's =--env NAME= pass-through, never argv
-+ (=cmd/gitbay-runner/isolate.go:113-129=).
++ (=cmd/gitbay-runner/isolate.go=).
+
+* Retention
+
+Configured under =[retention]= for =audit=, =events=,
+=webhook_deliveries=, =mail= and =push=; unset means keep forever.
+Expired sessions and tokens are swept hourly regardless
-+(=internal/config/config.go:121-146=, =cmd/gitbayd/main.go:529=).
++(=internal/config/config.go=, =cmd/gitbayd/main.go=).
+Accounts that never verify are removed after
+=registration.pending_expiry=. =account export= gives a user their data.
- ## docs/architecture/07-ci-and-supply-chain.org (new) ##
+ ## .gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org (new) ##
@@
+#+title: 7. CI and supply chain
+
@@ docs/architecture/07-ci-and-supply-chain.org (new)
+| steps per job | 50, each at most 4096 bytes |
+| path filters | 50 each for =paths= and =paths-ignore= |
+| job name | =^[a-z0-9][a-z0-9_-]{0,39}$= |
-+| image | a restricted reference; it becomes a podman argument, so no whitespace or shell characters (=ci.go:33-39=) |
++| image | a restricted reference; it becomes a podman argument, so no whitespace or shell characters (=ci.go=) |
+| triggers | push, merge request, =schedule= (cron), =tags= (glob) |
+
+A file that does not parse sets a =ci/config= failure status on the
@@ docs/architecture/07-ci-and-supply-chain.org (new)
+* Build lifecycle
+
+1. *Queue.* The post-receive hook calls =queueJobs=
-+ (=internal/control/build.go:773=). Each job gets a =ci/<job>= status:
++ (=internal/control/build.go=). Each job gets a =ci/<job>= status:
+ =pending= when queued, =skipped= when path filters exclude it, or
+ =success= copied from an earlier build of the same tree (#177).
+ Merge requests from forks are queued against the target repository
+ with =trusted = false=.
+2. *Claim.* A runner calls =runner next= over SSH
-+ (=build.go:463=). Allowed for a =runner=-scoped key or an admin; a
++ (=build.go=). Allowed for a =runner=-scoped key or an admin; a
+ runner key claims only for repositories it is attached to with
+ =repo runner add=. Untrusted builds are claimable only by a runner
-+ started with =-untrusted= (=internal/store/builds.go:85-90=). The
++ started with =-untrusted= (=internal/store/builds.go=). The
+ claim returns id, repository, job, commit, ref, steps, image and —
-+ for trusted builds only — the repository's secrets (=build.go:548=).
++ for trusted builds only — the repository's secrets (=build.go=).
+3. *Run.* The runner clones over SSH into =build-<id>=, starts a
+ container and runs each step with =podman exec … sh -c <step>=
+ (=cmd/gitbay-runner/isolate.go=).
+4. *Log.* =runner log <id>= streams stdin into the build row; the server
-+ ends the stream if the build is cancelled (=build.go:570=).
++ ends the stream if the build is cancelled (=build.go=).
+5. *Result.* =runner done <id> success|failure= sets the status,
+ records an event and mails the repository's watchers a log tail on
-+ failure (=build.go:648=).
++ failure (=build.go=).
+6. *Reap.* The scheduler fails a running build whose log stream closed
+ more than 2 minutes ago, or that started more than 90 minutes ago
-+ (=internal/store/builds.go:127-197=).
++ (=internal/store/builds.go=).
+
+Who may do what:
+
@@ docs/architecture/07-ci-and-supply-chain.org (new)
+
+| Control | Implementation |
+|----------------------------+----------------------------------------------------------------------------|
-+| Isolation mode | =podman= by default; =none= must be chosen explicitly and logs a warning; an unknown value or missing prerequisites refuse start (=isolate.go:44-88=) |
++| Isolation mode | =podman= by default; =none= must be chosen explicitly and logs a warning; an unknown value or missing prerequisites refuse start (=isolate.go=) |
+| Container runtime | rootless podman under the =ci-runner= user and its subordinate uid range |
+| Image | =--pull=never=; images are built by the operator (=deploy/Containerfile.ci=) and referenced by tag |
-+| Workspace | =<workdir>/build-<id>=, removed after the build; workdir must be 0700 and owned by the runner (=main.go:549-580=) |
++| Workspace | =<workdir>/build-<id>=, removed after the build; workdir must be 0700 and owned by the runner (=main.go=) |
+| Build home | =<workdir>/home/<owner>/<name>=, one per repository, mounted read-write, shared by trusted and untrusted builds of that repository (#255) |
+| Secrets | env file 0600 outside the workspace, or =--env NAME= for multi-line values |
+| Resources | per-build cgroup with =memory.max= and =cpu.max= written by the runner; unit-level =MemoryMax=6G=, =CPUQuota=300%= |
@@ docs/architecture/07-ci-and-supply-chain.org (new)
+| Deploy | =make deploy= refuses a dirty tree, then copies, checks config and restarts over operator SSH |
+| CI image | built on the host from =deploy/Containerfile.ci= (=golang:1.27-trixie= plus git-lfs, gnupg, openssh, python3, sqlite3); tagged, never pulled at build time |
- ## docs/architecture/08-operations.org (new) ##
+ ## .gitbay/wiki/Architecture/08-Operations.org (new) ##
@@
+#+title: 8. Operations
+
@@ docs/architecture/08-operations.org (new)
+
+| Recorded | How |
+|----------------------------------------------+------------------------------------------------------|
-+| Every successful mutating command, every surface | =Dispatch= writes =cmd <path>= with pruned argv and the source: key fingerprint, =web=, =api= or =host= (=internal/control/control.go:183-189=) |
++| Every successful mutating command, every surface | =Dispatch= writes =cmd <path>= with pruned argv and the source: key fingerprint, =web=, =api= or =host= (=internal/control/control.go=) |
+| SSH authentication failures and throttling | =auth.failed= (IP, fingerprint), =auth.throttled= (IP) |
+| Registration | =auth.registered=, =pending.expired= |
+| Administration | =admin user.*=, =admin email.*=, =admin invite.issued=, =admin repo.*=, =admin mr.prune=, =admin runners.forget= |
@@ docs/architecture/08-operations.org (new)
+
+- The database snapshot is taken before repositories are read, so a
+ push during the backup leaves only unreferenced objects
-+ (=cmd/gitbayd/backup.go:21-26=).
++ (=cmd/gitbayd/backup.go=).
+- Excluded: WAL files, the hook socket, askpass scripts, generated
+ hooks.
+- =gitbayd admin backup --verify= checks SQLite integrity and that every
-+ repository the database names is present (=backup.go:187=). It does
++ repository the database names is present (=backup.go=). It does
+ not check git object connectivity.
+- The host's restic credentials are append-only; the key that can
+ delete or prune snapshots is held off the host, so a compromised host
@@ docs/architecture/08-operations.org (new)
+Open connections of a removed key keep working until they close; see
+#256.
- ## docs/architecture/09-controls.org (new) ##
+ ## .gitbay/wiki/Architecture/09-Controls.org (new) ##
@@
+#+title: 9. Controls matrix
+
+One row per control an auditor typically asks about. *Status*: =in
+place= (implemented and cited), =partial= (implemented with a stated
-+limit), =gap= (not implemented; see [[file:10-known-gaps.org][10]]). Categories follow the
++limit), =gap= (not implemented; see [[file:10-Known-Gaps.org][10]]). Categories follow the
+chapter names of OWASP ASVS 4.0 where one fits.
+
+** Architecture (V1)
+
+| Control | Status | Evidence |
+|---------------------------------------------+----------+------------------------------------------------------------------|
-+| One authorization path for every surface | partial | all surfaces call =control.Dispatch= (=internal/control/control.go:119=); three web toggles write the store directly (#261) |
++| One authorization path for every surface | partial | all surfaces call =control.Dispatch= (=internal/control/control.go=); three web toggles write the store directly (#261) |
+| No server-side signing key | in place | =internal/sig= verifies only |
-+| Least functionality by default | in place | API, web accounts, git://, push and registration default off (=internal/config/config.go:270-290=) |
++| Least functionality by default | in place | API, web accounts, git://, push and registration default off (=internal/config/config.go=) |
+| No git library; git runs as a subprocess with built argv | in place | =internal/gitutil= |
+
+** Authentication (V2) and session management (V3)
@@ docs/architecture/09-controls.org (new)
+| No passwords anywhere | in place | SSH keys, emailed single-use links, bearer tokens |
+| Credentials stored as hashes | in place | SHA-256 of 256-bit random values (=internal/store/sessions.go=) |
+| Brute-force limit on SSH auth | in place | 10 failures a minute per IP (=internal/sshd/ratelimit.go=) |
-+| Account enumeration resistance at login | in place | uniform response (=internal/control/loginlink.go:33-76=) |
-+| Session cookie flags | in place | HttpOnly, SameSite=Lax, Secure with TLS (=internal/httpd/accounts.go:161=) |
++| Account enumeration resistance at login | in place | uniform response (=internal/control/loginlink.go=) |
++| Session cookie flags | in place | HttpOnly, SameSite=Lax, Secure with TLS (=internal/httpd/accounts.go=) |
+| Session lifetime | partial | 7 days absolute, no idle timeout |
+| Credential expiry | partial | API tokens optional; SSH and deploy keys none |
+| Revocation takes effect immediately | gap | removed SSH key keeps open connections (#256) |
@@ docs/architecture/09-controls.org (new)
+
+| Control | Status | Evidence |
+|---------------------------------------------+----------+------------------------------------------------------------------|
-+| Deny by default on private data | in place | =CanRead= requires owner, public or grant (=internal/policy/access.go:14=) |
-+| Private resources indistinguishable from missing | in place | =resolveRepo= (=internal/control/repo.go:228-249=), =runGit=, smart HTTP |
-+| Credential scopes narrow account rights | in place | key and token scopes (=control.go:156-161=, =policy/access.go:46-79=) |
-+| Server-side write protections | in place | pre-receive =CheckPush=, signed commits (=internal/hookd/hookd.go:130=) |
++| Deny by default on private data | in place | =CanRead= requires owner, public or grant (=internal/policy/access.go=) |
++| Private resources indistinguishable from missing | in place | =resolveRepo= (=internal/control/repo.go=), =runGit=, smart HTTP |
++| Credential scopes narrow account rights | in place | key and token scopes (=control.go=, =policy/access.go=) |
++| Server-side write protections | in place | pre-receive =CheckPush=, signed commits (=internal/hookd/hookd.go=) |
+| Merge gates | partial | =MergeGates=; any writer can post a =ci/*= status (#258) |
+| Admin functions isolated | in place | =admin= noun gated in =Dispatch=; =audit= admin-only |
-+| CSRF protection | in place | SameSite=Lax plus =checkOrigin= (=accounts.go:67=) |
++| CSRF protection | in place | SameSite=Lax plus =checkOrigin= (=accounts.go=) |
+| Typed confirmation for destructive web actions | in place | =internal/httpd/confirm.go= |
+
+** Input handling and output encoding (V5)
+
+| Control | Status | Evidence |
+|---------------------------------------------+----------+------------------------------------------------------------------|
-+| User markup sanitised | in place | =ugcHTML= with bluemonday (=internal/httpd/web.go:1322=) |
-+| No script execution in pages | in place | CSP =script-src 'none'= (=internal/httpd/routes.go:290=) |
-+| Control characters stripped at the terminal | in place | =termSafe= (=internal/control/term.go:54=) |
++| User markup sanitised | in place | =ugcHTML= with bluemonday (=internal/httpd/web.go=) |
++| No script execution in pages | in place | CSP =script-src 'none'= (=internal/httpd/routes.go=) |
++| Control characters stripped at the terminal | in place | =termSafe= (=internal/control/term.go=) |
+| No shell in command execution | in place | =protocol.Tokenize= for SSH argv; git and podman with argv slices |
+| Parsers fuzzed | partial | five fuzz targets run briefly by =deploy/audit.sh= |
+
@@ docs/architecture/09-controls.org (new)
+| Control | Status | Evidence |
+|---------------------------------------------+----------+------------------------------------------------------------------|
+| TLS for all authenticated HTTP | in place | ACME or certificate files; HSTS |
-+| Secrets encrypted at rest | gap | CI secrets, webhook secrets, mirror tokens stored in clear ([[file:06-data-and-cryptography.org][6]]) |
++| Secrets encrypted at rest | gap | CI secrets, webhook secrets, mirror tokens stored in clear ([[file:06-Data-and-Cryptography.org][6]]) |
+| Secrets kept out of argv, logs and output | in place | =ReadsStdin=, pruned audit argv, write-only secret commands |
+| Local backups encrypted | gap | tar.gz in clear; offsite copy encrypted by restic |
-+| Data retention configurable | in place | =[retention]= (=internal/config/config.go:121-146=) |
++| Data retention configurable | in place | =[retention]= (=internal/config/config.go=) |
+| User data export | in place | =account export= |
+
+** Logging (V7)
+
+| Control | Status | Evidence |
+|---------------------------------------------+----------+------------------------------------------------------------------|
-+| Security-relevant writes audited | in place | every successful mutating command (=control.go:183-189=) |
++| Security-relevant writes audited | in place | every successful mutating command (=control.go=) |
+| Authentication failures audited | in place | =auth.failed=, =auth.throttled= |
+| Denied attempts audited | gap | refused commands are not recorded |
+| Audit log tamper resistance | gap | same database, writable by the daemon user |
@@ docs/architecture/09-controls.org (new)
+| Control | Status | Evidence |
+|---------------------------------------------+----------+------------------------------------------------------------------|
+| Untrusted code runs isolated | partial | rootless podman, cgroup limits; shared build home per repository (#255) |
-+| No secrets for untrusted builds | in place | =internal/control/build.go:548= |
-+| Runner limited to attached repositories | in place | =runnerMayBuild= (=build.go:425-462=) |
++| No secrets for untrusted builds | in place | =internal/control/build.go= |
++| Runner limited to attached repositories | in place | =runnerMayBuild= (=build.go=) |
+| Build images fixed by the operator | in place | =--pull=never= |
+| Build network egress restricted | gap | #260 |
+| Build results reused only across equal trust | gap | tree reuse ignores trust and image (#258) |
@@ docs/architecture/09-controls.org (new)
+
+| Control | Status | Evidence |
+|---------------------------------------------+----------+------------------------------------------------------------------|
-+| Rate limits on API and writes | in place | [[file:05-identity-and-access.org][5. Rate limits]] |
++| Rate limits on API and writes | in place | [[file:05-Identity-and-Access.org][5. Rate limits]] |
+| Concurrency limit on git pack generation | gap | #262 |
-+| Service hardening | in place | systemd sandboxing ([[file:03-deployment.org][3]]) |
++| Service hardening | in place | systemd sandboxing ([[file:03-Deployment.org][3]]) |
+| Backups offsite and append-only | in place | restic with append-only credentials (documented) |
+| Restore tested | gap | #259 |
+| Migrations validated before commit | gap | foreign-key check runs after commit (#261) |
+| Signed, reviewed changes to production | in place | signed commits, =require-mr=, ff-only merges, clean-tree deploys |
- ## docs/architecture/10-known-gaps.org (new) ##
+ ## .gitbay/wiki/Architecture/10-Known-Gaps.org (new) ##
@@
+#+title: 10. Known gaps
+
-+Open weaknesses as of the baseline commit. Issues on krz/gitbay are
-+public; this list gives the title and the consequence, not a
-+reproduction.
++Open weaknesses. Issues on krz/gitbay are public; this page gives the
++title and the consequence, not a reproduction. The current list is the
++open issues labelled =security=:
++https://gitbay.org/krz/gitbay/issues?label=security. The table below is
++what the 2026-09-27 review found; remove a row when its issue closes.
+
+* Filed
+
@@ docs/architecture/10-known-gaps.org (new)
+
+* Not yet filed
+
-+Found while writing this package.
++Found during the 2026-09-27 review.
+
+| Area | Gap | Where |
+|------------------+---------------------------------------------------------------------------------------+---------------------------------------------|
+| Data at rest | CI secrets, webhook secrets and mirror tokens are stored in clear in SQLite | =build_secrets=, =webhooks=, =mirrors= |
-+| Backups | The local backup archive is not encrypted | =cmd/gitbayd/backup.go:83-84= |
-+| Audit | Refused commands are not audited; the audit table is writable by the daemon user | =internal/control/control.go:183-189= |
-+| Sessions | Web sessions have a 7-day absolute lifetime and no idle timeout | =internal/httpd/accounts.go:147= |
++| Backups | The local backup archive is not encrypted | =cmd/gitbayd/backup.go= |
++| Audit | Refused commands are not audited; the audit table is writable by the daemon user | =internal/control/control.go= |
++| Sessions | Web sessions have a 7-day absolute lifetime and no idle timeout | =internal/httpd/accounts.go= |
+| Credentials | SSH and deploy keys never expire | =ssh_keys= |
-+| Login links | =web login= over SSH is not counted against the 5-per-hour login-link limit | =internal/control/web.go:80-99= |
-+| SSRF | Mirror URLs are checked when saved but not when git connects, so a DNS change can redirect a mirror to a private address | =internal/control/mirrorcmd.go:58= |
-+| Mail | STARTTLS is used only when the relay offers it | =internal/mail/mail.go:38-42= |
++| Login links | =web login= over SSH is not counted against the 5-per-hour login-link limit | =internal/control/web.go= |
++| SSRF | Mirror URLs are checked when saved but not when git connects, so a DNS change can redirect a mirror to a private address | =internal/control/mirrorcmd.go= |
++| Mail | STARTTLS is used only when the relay offers it | =internal/mail/mail.go= |
+| TLS | Go defaults; no explicit minimum version | =cmd/gitbayd/main.go= |
-+| Hook socket | Any process that can open =hook.sock= can claim any user id; it relies on the data directory's permissions | =internal/hookd/hookd.go:90-102= |
++| Hook socket | Any process that can open =hook.sock= can claim any user id; it relies on the data directory's permissions | =internal/hookd/hookd.go= |
+
+* Questions an auditor will ask that have no answer yet
+
@@ docs/architecture/10-known-gaps.org (new)
+| What can a build reach on the host's network? | configuration inspected, reachability untested (#260) |
+| Have the collaboration features been used by independent users? | no; one human user, tests only |
- ## docs/architecture/README.org (new) ##
-@@
-+#+title: gitbay architecture and security package
-+
-+Architecture, trust boundaries and security controls of gitbay, written
-+for a security reviewer or auditor. Every statement about behaviour
-+cites the code that implements it as =path:line= against the commit
-+named below; statements that rest on documentation or deployment files
-+say so.
-+
-+* Scope
-+
-+- The =gitbayd= daemon, the =gitbay= CLI and the =gitbay-runner= CI
-+ runner, all in this repository.
-+- The reference deployment described by =deploy/= (a single Linux
-+ host, systemd, rootless podman for CI).
-+- The iOS client (krz/gitbay-ios) only where it touches the server: the
-+ JSON API and push notifications.
-+
-+Out of scope: the host operating system beyond the unit files and
-+bootstrap in =deploy/=, the object store holding offsite backups, and
-+Apple's push service.
-+
-+* Baseline
-+
-+| Item | Value |
-+|------------------+----------------------------------------------------|
-+| Commit | =2c08460= plus this package |
-+| Schema version | migration 0059 |
-+| Control commands | 232 registered, 79 marked =ReadOnly= |
-+| Go | 1.27, =CGO_ENABLED=0= |
-+| Direct Go deps | 15 (=go.mod=) |
-+
-+The command count comes from =gitbay help --json= on the live instance;
-+the =ReadOnly= count from the =ReadOnly: true= literals in
-+=internal/control/=.
-+
-+* Documents
-+
-+| # | Document | Diagram |
-+|---+----------------------------------------------------+-------------------------------------------------|
-+| 1 | [[file:01-system-context.org][System context]] | [[file:diagrams/01-context.svg][01-context.svg]] |
-+| 2 | [[file:02-components.org][Components]] | [[file:diagrams/02-components.svg][02-components.svg]] |
-+| 3 | [[file:03-deployment.org][Deployment and network]] | [[file:diagrams/03-deployment.svg][03-deployment.svg]] |
-+| 4 | [[file:04-trust-boundaries.org][Trust boundaries and data flows]] | [[file:diagrams/04-trust-boundaries.svg][04-trust-boundaries.svg]], [[file:diagrams/06-push-flow.svg][06-push-flow.svg]] |
-+| 5 | [[file:05-identity-and-access.org][Identity and access]] | [[file:diagrams/05-authorization.svg][05-authorization.svg]] |
-+| 6 | [[file:06-data-and-cryptography.org][Data and cryptography]] | |
-+| 7 | [[file:07-ci-and-supply-chain.org][CI and supply chain]] | [[file:diagrams/07-ci-flow.svg][07-ci-flow.svg]] |
-+| 8 | [[file:08-operations.org][Operations]] | |
-+| 9 | [[file:09-controls.org][Controls matrix]] | |
-+| 10 | [[file:10-known-gaps.org][Known gaps]] | |
-+
-+Reading order for a first pass: 1, 4, 5, 9, 10. The others are
-+reference.
-+
-+* Conventions
-+
-+- =path:line= is relative to the repository root at the baseline commit.
-+ Line numbers drift; the function names given alongside do not.
-+- "Documented" means the statement rests on the wiki
-+ (=.gitbay/wiki/=) or =deploy/= rather than on code.
-+- Numbers such as =#255= are issues on krz/gitbay; =krz/gitbay-ios#15=
-+ names the other repository.
-+- Diagrams are SVG with a light and a dark rendering chosen by the
-+ viewer's colour scheme. They are generated by
-+ =diagrams/diagrams.py=; edit that and rerun it rather than the SVGs.
-+
-+* Checking a claim
-+
-+The instance answers the same questions the documents make claims
-+about:
-+
-+#+begin_src sh
-+gitbay help --json # the command registry: paths, flags, ReadOnly
-+curl -s https://gitbay.org/healthz # the deployed commit
-+curl -sI https://gitbay.org/ # security headers
-+ssh git@gitbay.org whoami # identity resolution over stock OpenSSH
-+#+end_src
-+
-+* Related wiki pages
-+
-+- [[file:../../.gitbay/wiki/Threat-Model.org][Threat-Model]] — the project's own threat model; this package extends it.
-+- [[file:../../.gitbay/wiki/Parity.org][Parity]] — which capability is reachable from which surface.
-+- [[file:../../.gitbay/wiki/Admin.org][Admin]] — configuration, backups, operations.
-+- [[file:../../.gitbay/wiki/API.org][API]] — the JSON API.
-
- ## docs/architecture/diagrams/01-context.svg (new) ##
+ ## .gitbay/wiki/Architecture/diagrams/01-context.svg (new) ##
@@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 970 590" width="970" height="590" role="img" aria-labelledby="t d">
+<title id="t">1. System context</title><desc id="d">Actors and external systems around a gitbay instance.</desc>
@@ docs/architecture/diagrams/01-context.svg (new)
+<text class="ts" x="423.59999999999997" y="570" text-anchor="start">untrusted or refused</text>
+</svg>
- ## docs/architecture/diagrams/02-components.svg (new) ##
+ ## .gitbay/wiki/Architecture/diagrams/02-components.svg (new) ##
@@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 970 680" width="970" height="680" role="img" aria-labelledby="t d">
+<title id="t">2. Components inside gitbayd</title><desc id="d">Packages of the gitbayd daemon and how requests move between them.</desc>
@@ docs/architecture/diagrams/02-components.svg (new)
+<text class="ts" x="179.0" y="660" text-anchor="start">on-disk state</text>
+</svg>
- ## docs/architecture/diagrams/03-deployment.svg (new) ##
+ ## .gitbay/wiki/Architecture/diagrams/03-deployment.svg (new) ##
@@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 970 640" width="970" height="640" role="img" aria-labelledby="t d">
+<title id="t">3. Deployment (reference host)</title><desc id="d">Processes, users, ports and files on the single gitbay host.</desc>
@@ docs/architecture/diagrams/03-deployment.svg (new)
+<text class="ts" x="327.6" y="620" text-anchor="start">untrusted code</text>
+</svg>
- ## docs/architecture/diagrams/04-trust-boundaries.svg (new) ##
+ ## .gitbay/wiki/Architecture/diagrams/04-trust-boundaries.svg (new) ##
@@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 970 650" width="970" height="650" role="img" aria-labelledby="t d">
+<title id="t">4. Trust boundaries</title><desc id="d">Zones Z0 to Z6 and the boundaries TB1 to TB10 that data crosses between them.</desc>
@@ docs/architecture/diagrams/04-trust-boundaries.svg (new)
+<text class="ts" x="395.8" y="640" text-anchor="start">untrusted code</text>
+</svg>
- ## docs/architecture/diagrams/05-authorization.svg (new) ##
+ ## .gitbay/wiki/Architecture/diagrams/05-authorization.svg (new) ##
@@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 970 860" width="970" height="860" role="img" aria-labelledby="t d">
+<title id="t">5. Authorization decision</title><desc id="d">How a request is authorised: Dispatch gates, then repository resolution with a policy predicate, and the git transport path.</desc>
@@ docs/architecture/diagrams/05-authorization.svg (new)
+<text class="ts" x="253.20000000000002" y="835" text-anchor="start">allowed</text>
+</svg>
- ## docs/architecture/diagrams/06-push-flow.svg (new) ##
+ ## .gitbay/wiki/Architecture/diagrams/06-push-flow.svg (new) ##
@@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 970 780" width="970" height="780" role="img" aria-labelledby="t d">
+<title id="t">6. git push over SSH</title><desc id="d">Sequence of a push: SSH checks, pre-receive decision by the daemon, optional signature verification, post-receive side effects.</desc>
@@ docs/architecture/diagrams/06-push-flow.svg (new)
+<text class="ts" x="250.0" y="693" text-anchor="middle">result</text>
+</svg>
- ## docs/architecture/diagrams/07-ci-flow.svg (new) ##
+ ## .gitbay/wiki/Architecture/diagrams/07-ci-flow.svg (new) ##
@@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 970 660" width="970" height="660" role="img" aria-labelledby="t d">
+<title id="t">7. CI build</title><desc id="d">Sequence of a CI build from push to result, including the claim and where secrets travel.</desc>
@@ docs/architecture/diagrams/07-ci-flow.svg (new)
+<text class="ts" x="506" y="588" text-anchor="start">scheduler reaps: log closed > 2 min, or started > 90 min</text>
+</svg>
- ## docs/architecture/diagrams/diagrams.py (new) ##
+ ## .gitbay/wiki/Architecture/diagrams/diagrams.py (new) ##
@@
+#!/usr/bin/env python3
+"""Emit the architecture package's SVG diagrams.
+
-+Usage: python3 docs/architecture/diagrams/diagrams.py docs/architecture/diagrams
++Usage: python3 .gitbay/wiki/Architecture/diagrams/diagrams.py .gitbay/wiki/Architecture/diagrams
+"""
+import sys
+from xml.sax.saxutils import escape as esc
@@ docs/architecture/diagrams/diagrams.py (new)
+authz()
+push()
+ci()
+
+ ## .gitbay/wiki/Home.org ##
+@@ .gitbay/wiki/Home.org: CLI-first git forge: SSH is the API, the web is a rendering.
+ - [[Admin][Admin guide]] — install, configuration reference, backup, security
+ - [[API][API and webhooks]] — the JSON API contract, tokens, payloads
+ - [[Threat-Model][Threat model]] — what the forge trusts and never does
++- [[file:Architecture/00-Overview.org][Architecture and security]] — diagrams, trust boundaries, controls, known gaps
+ - [[Parity][Parity]] — what each surface can do, and what has no page yet
+ - [[Performance][Performance]] — stress-test numbers from importing git.git
+
+
+ ## .gitbay/wiki/Threat-Model.org ##
+@@
+
+ What the forge trusts, what it refuses to do, and where the boundaries
+ are. This is the reference for security review; it complements the audit
+-log and hardening notes in [[Admin]].
++log and hardening notes in [[Admin]]. Diagrams, data flows, a controls
++matrix and the open gaps are in the [[file:Architecture/00-Overview.org][Architecture]] pages.
+
+ * What gitbay never does
+