fogwall documentation
fogwall is a Git push proxy: it sits between developers and upstream Git hosting providers, and runs every push through a validation and approval pipeline before it reaches the upstream remote.
Start here
- User Guide — you push code through fogwall. Setting up a remote, reading push output, and what to do when a push is blocked or waiting for approval.
- Administrator and Operator Guide — you run fogwall. Accounts, permissions, deployment, networking, and diagnosing problems.
- Configuration Reference — every YAML key, what it does, and what it defaults to.
- Architecture — you are changing fogwall. Modules, proxy modes, request flow, and the core abstractions.
- Internals — contributor working notes on git, JGit, and SCM API behaviour.
Build and test instructions live in CONTRIBUTING.md.
User Guide — Pushing Through fogwall
This guide is for developers who push code through fogwall. It covers setting up your git remote, understanding proxy output, and what to do when a push is blocked or waiting for approval.
If you want to operate or configure fogwall, see the Configuration Reference. If you want to build on or contribute to the codebase, see CONTRIBUTING.md.
What fogwall does
fogwall sits between your git push and the upstream host (GitHub, GitLab, Bitbucket, etc.). Every push is inspected
before it reaches the upstream:
- Commit author emails are checked against allowed domains
- Commit messages are scanned for blocked patterns
- Diff content is scanned for sensitive data and secrets
- Commit trailers may be required or restricted (DCO
Signed-off-by,Co-authored-by) - Your git identity is verified against your proxy account
- You may need approval from a reviewer before the push is forwarded
If everything passes, your push lands on the upstream as normal. If something fails, the push is rejected and you get a message explaining what to fix.
Before you start
You need the following from your administrator before you can push through the proxy:
- The proxy URL — something like
https://fogwall.corp.example.comorhttp://localhost:8080for local development. - A proxy user account — username and password for the fogwall dashboard. This is separate from your upstream SCM credentials.
- A personal access token (PAT) for the upstream SCM — the proxy forwards your token to authenticate with GitHub/GitLab/etc. on your behalf.
- Push permission on the target repo — the administrator must grant you
PUSHpermission for the specific repository you want to push to. - Your SCM identity registered — the proxy verifies that your token resolves to the same person as your proxy account. Your administrator needs to add your upstream username (e.g. your GitHub login) to your proxy user profile.
If the admin has configured attribution-policy in warn mode, pushes will go through even without a registered SCM
identity, but you will see a warning in the push output. If it is set to strict, pushes will be blocked until your
identity is registered.
Contents
- Setting up your remote — the proxy URL, credentials, and the token scopes you need
- SSH remotes — pushing over SSH and how your key identifies you
- Choosing a proxy mode:
/server/vs/proxy/—/server/versus/proxy/, and what changes - Commit trailer requirements (DCO / co-authors) — DCO sign-off and co-author trailers
- What a successful push looks like — reading the
remote:output of a push that went through - Understanding the approval workflow — auto-approve, review-required, and attestation questions
- Reviewing a push — push record states, approving, rejecting, self-certifying
- When a push is blocked — what each block message means and what to do about it
- Identity verification — how fogwall works out who you are, over HTTP and SSH
- Contributions (PRs, MRs & issues through fogwall) — opening PRs, MRs and issues with
gh,glab,teaandfj - User permissions vs access rules — why a repo can be blocked even when you have push rights
- Common problems — credential prompts, TLS errors, rejected pushes
- Tips — cloning through the proxy, multiple remotes, scrubbing history
Setting up your remote
Fastest path — the in-app setup page. Your fogwall deployment serves a Setup page in the dashboard (the help / quick-start icon in the top bar, reachable without logging in) that generates copy-pasteable git config for this deployment, with the real hostnames already filled in. By default it reroutes only your pushes to fogwall (via git’s
pushInsteadOf) and leaves your clones and fetches going straight to the upstream — so read-only access is unaffected and you don’t need it at all if you only clone or fetch. It offers both a one-paste global form and an explicit per-repository form. The manual per-remote steps below are the same thing done by hand.
The proxy URL is structured as:
http[s]://<proxy-host>/<mode>/<provider-host>/<owner>/<repo>.git
For example, if you normally push to https://github.com/myorg/myrepo, the proxy remote is:
https://fogwall.corp.example.com/server/github.com/myorg/myrepo
The
/server/prefix was previously/push/(when this mode was called store-and-forward). Remotes using/push/still work — it is a deprecated alias — but new remotes should use/server/.
Add it as a new remote (recommended — keeps your direct-to-GitHub remote as a fallback):
git remote add proxy https://fogwall.corp.example.com/server/github.com/myorg/myrepo
Then push via the proxy:
git push proxy main
Credentials in the remote URL
The git push path (/server/ and /proxy/) uses HTTP Basic authentication — this is what the git protocol requires,
and it matches what the upstream SCM expects. Your upstream PAT is the password; the username can be any non-empty
string — me, git, your name — it is not used for identity resolution (see
Identity verification below). It must not be empty or the upstream SCM will reject the
request. The exception is Bitbucket — see below.
This is separate from the dashboard: the dashboard login uses your proxy user account (via your org’s IdP or local
credentials), not your SCM token. The two credential sets are independent — one is for git push, the other is for the
web UI.
Embed credentials directly in the URL if your git credential helper does not pick them up automatically:
git remote add proxy https://me:ghp_yourtoken@fogwall.corp.example.com/server/github.com/myorg/myrepo
Or use git credential store / your OS keychain as you normally would.
Fetching from a public repository needs no credentials. When you clone or pull through the proxy, fogwall asks the upstream SCM whether that repository serves anonymous reads. If it does, your request goes through without a credential prompt. If it doesn’t — a private repository — you get the usual 401 challenge and your git client supplies the token, which fogwall forwards upstream.
Pushing always requires credentials, whatever the repository’s visibility, because fogwall forwards the push upstream using your own token.
Tip
Most credential helpers (macOS Keychain, Windows Credential Manager,
git-credential-store) pin credentials to a hostname. git authenticates to the proxy host, not the upstream — so if you have previously authenticated directly to the upstream (e.g.github.com), that credential won’t be reused for the proxy; git looks for one stored underfogwall.corp.example.cominstead. Either let git prompt on the first push and your helper store it under the proxy host, store a separate entry for the proxy host yourself, or embed the token in the remote URL as shown above. For local development environments that are frequently recreated, embedding the token in the URL is simpler than managing keychain entries.
Tip
Pushing to more than one provider through the same proxy? The proxy serves every provider under one hostname, differing only by URL path (
/server/github.com/…vs/server/codeberg.org/…), but credential helpers key on hostname alone — so one stored credential would be reused for all of them. Rungit config --global credential.https://fogwall.corp.example.com.useHttpPath trueto key credentials on the full URL (host + path) instead, so each provider gets its own entry.
Warning
Bitbucket only: the username in the remote URL must be your Bitbucket account email address (e.g.
you@company.com). This is required for identity resolution — see the Configuration Reference for details.
Required token scopes
The proxy calls the SCM API to resolve your identity. Your PAT needs at least:
| Provider | Minimum scope |
|---|---|
| GitHub | No additional scopes required (classic or fine-grained PATs both work) |
| GitLab | read_user |
| Bitbucket | read:user:bitbucket and write:repository:bitbucket |
| Codeberg / Gitea | read:user |
SSH remotes
If your administrator has configured the proxy with an SSH provider, you can push over SSH instead of HTTPS. SSH pushes do not use a PAT — your identity is tied to your SSH key instead.
Setting up SSH
-
Register your SSH public key in the proxy dashboard (profile → SSH keys → add key). This is the key you use to connect to the proxy, not directly to the SCM. If you already have a key at
~/.ssh/id_ed25519.pub, paste its contents. -
Register the same key on the upstream SCM (e.g. GitHub → Settings → SSH keys; Gitea/Codeberg → Settings → SSH keys). The proxy verifies that the key you connected with is also registered on your SCM account. If it is not, the push is blocked.
-
Enable agent forwarding. The proxy needs your SSH agent to authenticate outbound connections to the upstream SCM. Add a
ForwardAgent yesentry in your~/.ssh/config:Host <proxy-host> ForwardAgent yesOr pass
-Aon the command line:GIT_SSH_COMMAND="ssh -A" git push. -
Add an SSH remote. Your administrator will give you the proxy SSH hostname and port. SSH push URLs look like:
ssh://proxy-host:2222/<scm-host>:<scm-ssh-port>/<owner>/<repo>.gitFor example, pushing to a Gitea instance at
git@gitea.corp.example.com:git remote add proxy ssh://fogwall.corp.example.com:2222/gitea.corp.example.com:22/myorg/myrepo.git git push proxy mainCheck the Providers page in the dashboard to see the exact SCM host and port for each configured SSH provider — it shows the upstream URI verbatim, exactly as your administrator configured it. This matters because whether the
<scm-ssh-port>segment is needed is an exact match against that URI string, not a “is this the default port” check: if the port was written explicitly there, include it; if it wasn’t, omit it entirely (including it when it’s not expected is itself a mismatch).Why not the
git@host:owner/repo.gitshorthand you’re used to from GitHub? That shorthand syntax has no way to specify a non-default SSH port, and fogwall’s SSH listener normally runs on a non-standard port (2222by default) rather than22. It’s the same underlying SSH protocol either way — just ask your administrator whether they’ve exposed the proxy’s SSH port behind a standard:22mapping. If so, the shorthand form works too (same caveat about the<scm-ssh-port>segment applies):git remote add proxy git@fogwall.corp.example.com:gitea.corp.example.com:22/myorg/myrepo.git
SSH identity verification
SSH pushes are subject to the same compliance guarantee as HTTP pushes. The proxy:
- Verifies your SSH key against the fogwall user database (MINA public-key auth).
- Calls the upstream SCM API to fetch the SSH public keys registered on your linked SCM identity.
- Checks that the connecting key’s SHA-256 fingerprint appears in that list.
If step 3 fails — for example because you have a key registered in fogwall but not on your SCM account — the push is blocked. Add the key to your SCM account and retry. If the provider or SCM identity is misconfigured, contact your administrator.
There is no token to supply for SSH pushes — no Authorization header, no credential in the URL. The agent-forwarded
key is the only credential.
Choosing a proxy mode: /server/ vs /proxy/
There are two URL prefixes, each with different behaviour:
/server/ (server mode) | /proxy/ (transparent proxy) | |
|---|---|---|
| How it works | The proxy receives your push locally, validates it, then forwards to upstream | The proxy forwards HTTP requests directly to upstream while inspecting them inline |
| Terminal feedback | Live streaming — each validation step prints as it runs | Silent until the end — one response after all checks complete |
| Approval workflow | Push stays open waiting for approval; same git push command completes once approved | Push is blocked and you must run git push again after a reviewer approves — the second push is matched to the existing push record |
| Push record | Every push is persisted with a full event history | Every push is persisted; the re-push after approval references the same record |
| Local disk usage | Clones each repo to ephemeral pod storage for diff inspection — proportional to repo history size | None — git bytes stream directly through the proxy with no local storage |
| Recommendation | Use this for most workflows | Use when network reliability or disk constraints are a concern |
For day-to-day use, /server/ gives a better experience: you see each validation step in real time and the same
git push command completes once approved.
Prefer /proxy/ if your network infrastructure is flaky or connections between client → proxy → upstream are
unreliable. Server mode keeps the client connection open for the full validation and approval cycle — a dropped
connection means starting over. Transparent proxy completes each HTTP request atomically, so a network hiccup during
approval does not lose the push record.
If you don’t re-push after a /proxy/ push is queued for review
A queued (PENDING) transparent-proxy push has no held connection to time out on its own, so two things can cancel it
automatically instead:
- If you push a different commit to the same branch, the earlier queued push is canceled — you’ve moved on, so it’s no longer waiting to be reviewed.
- If nothing happens to it at all, it’s canceled after an administrator-configured age (30 days by default) as timed out.
Either way this is a state change, not a deletion — the original record and its history remain visible in the dashboard.
Disk usage in server mode
In server mode, the proxy maintains local mirrors of each upstream repository on ephemeral pod storage (emptyDir in
Kubernetes/OpenShift). A full clone is kept for the serve path (so clients can fetch through the proxy) and a shallow
clone (depth 100) for diff inspection. These are rebuilt automatically on pod restart — there is no durable state in the
cache.
Large repositories (deep history, large binaries, monorepos) can consume significant disk on the proxy pod.
Operators should set an emptyDir.sizeLimit in the pod spec to prevent runaway clones from exhausting node disk:
volumes:
- name: tmp
emptyDir:
sizeLimit: 5Gi
If disk pressure becomes an issue for a specific large repo, route it through /proxy/ instead — transparent proxy mode
uses zero local disk and shifts the concern purely to network reliability between the proxy and upstream. A transient
network failure during a push just means the developer retries; the push record is preserved.
Commit trailer requirements (DCO / co-authors)
Your administrator may require or restrict commit-message trailers. These are enforced per-commit, so a single offending commit anywhere in the pushed range blocks the push — the rejection names the specific SHAs.
Signed-off-by (Developer Certificate of Origin). If sign-off is required, every commit must carry a
Signed-off-by: Your Name <you@corp.com> line. Add it as you commit with -s:
git commit -s -m "Fix the thing" # new commit
git commit --amend --signoff # add sign-off to the latest commit
git rebase --signoff <base> # add sign-off across a range
If the policy also requires the sign-off to match the author, the Signed-off-by email must equal your commit
author email — set git config user.email to your work address before signing off.
Co-authored-by. Depending on policy, co-author trailers may be banned (remove any Co-authored-by: lines with
git commit --amend), required (add a Co-authored-by: Name <email> line), or allowlisted (only approved
co-author addresses are permitted — an unapproved one is rejected). The rejection message tells you which case applies
and how to fix it.
Both trailers are also recorded on the push record and shown per-commit in the dashboard, so they double as an attribution audit trail even when no policy is configured.
What a successful push looks like
$ git push proxy my-feature
Enumerating objects: 4, done.
Counting objects: 100% (4/4), done.
Delta compression using up to 20 threads
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 523 bytes | 523.00 KiB/s, done.
Total 3 (delta 1), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (1/1)
remote: 🔑 Checking URL allow rules...
remote: ✅ repository allowed
remote: 🔑 Checking user permission...
remote: ✅ user authorized
remote: 🔑 Verifying commit identity...
remote: ✅ identity verified
remote: 🔑 Checking branch...
remote: ✅ branch OK
remote: 🔑 Checking for hidden commits...
remote: ✅ no hidden commits
remote: 🔑 Checking author emails...
remote: ✅ emails OK
remote: 🔑 Checking commit messages...
remote: ✅ messages OK
remote: 🔑 Scanning diff content...
remote: ✅ clean
remote: 🔑 Checking GPG signatures...
remote: ✅ signatures OK
remote: 🔑 Scanning for secrets...
remote: ✅ no secrets detected
remote:
remote: ────────────────────────────────────────
remote: 🔗 View push record: http://fogwall.corp.example.com/dashboard/push/4d6196fb-...
remote: ✅ Push approved by reviewer
remote: 🔗 Forwarding to https://github.com/myorg/myrepo.git...
remote: ✅ refs/heads/my-feature -> OK
remote: ✅ Forwarding complete
To http://fogwall.corp.example.com/server/github.com/myorg/myrepo.git
* [new branch] my-feature -> my-feature
Each remote: line is a validation step streaming in real time. The example above shows ui approval mode — a reviewer
approved in the dashboard before the push was forwarded. In auto mode the ✅ Push approved by reviewer line is
replaced by immediate forwarding with no wait.
Understanding the approval workflow
What happens after validation depends on how the administrator has configured the approval mode:
Auto-approve (approval-mode: auto)
Clean pushes (no validation failures) are immediately approved and forwarded. You see output like the example above — no human reviewer is needed. This is the typical setting for solo developers or teams that use validation as a guardrail without a manual review step.
Review required (approval-mode: ui)
After validation passes, the push enters a PENDING state and waits for a reviewer to approve it in the dashboard. You will see:
remote: 🔗 View push record: http://fogwall.corp.example.com/dashboard/push/4d6196fb-...
remote: ⚠ Push requires review. Waiting for approval...
remote: 🔑 Push ID: 4d6196fb-4cc3-47d1-ac6d-17fbcc5f71d3
remote: Review at: http://fogwall.corp.example.com/dashboard/push/4d6196fb-...
remote: Awaiting review... (5s elapsed, ~1794s remaining)
remote: .
remote: Awaiting review... (10s elapsed, ~1789s remaining)
The push command stays open, printing keepalive dots while it waits. Once a reviewer approves in the dashboard, the proxy forwards the push and the command completes:
remote: ✅ Push approved by reviewer
remote: Updating references: 100% (1/1)
remote: 🔗 Forwarding to https://github.com/myorg/myrepo.git...
remote: Pushing 1 ref(s) to upstream...
remote: ✅ refs/heads/my-feature -> OK
remote: ✅ Forwarding complete
To http://fogwall.corp.example.com/server/github.com/myorg/myrepo.git
* [new branch] my-feature -> my-feature
If no approval comes, your git client will eventually time out. You can re-run the push — it will resume waiting for approval on the existing push record rather than creating a new one.
Attestation questions
The administrator may configure attestation questions that you must answer before a push is approved. These appear in
the dashboard push record view, not in the terminal. A reviewer (or yourself, if you have SELF_CERTIFY permission for
the repo) answers them as part of the approval step. A question may carry one or more linked references (e.g. a link to
the internal policy the attestation is checking against) — these render as clickable links alongside the question so
reviewers can check the source policy before attesting.
Reviewing a push
If you have been asked to review a push, or you are an administrator, log in to the dashboard and open the Pushes page. Pushes awaiting review have status PENDING.
Push record states
| State | Meaning |
|---|---|
RECEIVED | Push has arrived and is being processed |
PENDING | Validation passed; awaiting a reviewer’s decision |
APPROVED | Approved by a reviewer (or self-certified) — will be forwarded |
FORWARDED | Successfully sent to the upstream SCM |
REJECTED | Reviewer declined the push |
BLOCKED | Validation failed — push will not be forwarded |
CANCELED | Canceled by the pusher or an administrator |
Approving or rejecting
Open the push record to see the full diff, commit list, and validation results. You can:
- Approve — forwards the push to the upstream. If attestation questions are configured, you must answer them before approving.
- Reject — blocks the push. The reason field is optional but recommended — it is shown to the pusher in the dashboard and helps them understand what to fix.
The reason field is recorded in the audit log regardless of whether it is shown to the pusher.
Self-certification
If you have SELF_CERTIFY permission for the repository, you can approve your own pushes from the push record view. The
approval is recorded in the audit log with a self-certification flag, distinguishing it from peer review. Attestation
questions still apply.
Who can review
By default any authenticated user can review any push they did not push themselves. If your administrator has set
server.require-review-permission: true, you need an explicit REVIEW permission entry for the repository to approve
or reject. Contact your administrator if you receive a 403 trying to approve a push.
An admin reviewing another user’s push may tick admin override to approve on admin authority when the assigned reviewer is unavailable, bypassing the review-permission check. This is a break-glass action, recorded in the audit log. It never applies to an admin’s own push — approving your own push always requires self-certification, whether or not you are an admin.
When a push is blocked
In server mode (/server/), each validation step streams live and all failures are summarised at the end. A push with
multiple issues across several commits looks like this:
remote: 🔑 Checking URL allow rules...
remote: ✅ repository allowed
remote: 🔑 Checking user permission...
remote: ✅ user authorized
remote: 🔑 Verifying commit identity...
remote: ⚠ 2 commit email(s) not registered to thomas-cooper
remote: 🔑 Checking branch...
remote: ✅ branch OK
remote: 🔑 Checking for hidden commits...
remote: ✅ no hidden commits
remote: 🔑 Checking author emails...
remote: ❌ blocked local part (noreply)
remote: 🔑 Checking commit messages...
remote: ❌ contains blocked term: "WIP"
remote: 🔑 Scanning diff content...
remote: ❌ Diff contains blocked content
remote: 🔑 Checking GPG signatures...
remote: ✅ signatures OK
remote: 🔑 Scanning for secrets...
remote: ❌ [github-pat] ci-config.env:1
remote: commit: e9085c9
remote: match: REDACTED
remote: ────────────────────────────────────────
remote: ⛔ Push Blocked - 5 validation issue(s)
remote: ❌ noreply@example.com: blocked local part (noreply)
remote: → git config user.email "you@example.com"
remote: ❌ WIP: commit 2 — bad commit message: contains blocked term: "WIP"
remote: → Messages must not contain: WIP, fixup!, squash!, DO NOT MERGE
remote:
remote: ⛔ Push Blocked - Diff Contains Blocked Content
remote: ❌ blocked term: "internal.corp.example.com" in config.yml
remote: ❌ blocked pattern: (?i)https?://[a-z0-9.-]*\.corp\.example\.com\b in config.yml
remote:
remote: ❌ [github-pat] ci-config.env:1
remote: commit: e9085c9
remote: match: REDACTED
remote: ────────────────────────────────────────
remote: 🔗 View push record: http://fogwall.corp.example.com/dashboard/push/b65bee10-...
To http://fogwall.corp.example.com/server/github.com/myorg/myrepo.git
! [remote rejected] my-feature -> my-feature (5 validation issue(s) - see above)
error: failed to push some refs to 'http://fogwall.corp.example.com/server/github.com/myorg/myrepo.git'
In transparent proxy mode (/proxy/), all validation runs first and the summary is returned in one response at the end.
The terminal output is otherwise identical to the above, but ends with:
remote: push rejected by fogwall
fatal: the remote end hung up unexpectedly
error: failed to push some refs to 'http://fogwall.corp.example.com/proxy/github.com/myorg/myrepo.git'
Common block reasons and what to do:
| Message | Fix |
|---|---|
author email '...' is not allowed | Your git config user.email does not match an allowed domain. Set it to your corporate email: git config user.email you@corp.example.com then amend or rebase to update the commits. |
commit message contains blocked pattern | Reword the commit message (git commit --amend or git rebase -i) to remove the blocked string. |
diff contains blocked content | The push contains content matching a deny rule (e.g. an internal hostname, a secret pattern). Remove it from the commit and amend/rebase. |
secret detected by gitleaks | A secret was found in the diff. Remove it from the commit history — a simple amend is not enough if the secret was ever committed; rewrite the history with git filter-repo or similar. |
Repository Not Allowed | The repository is not in the proxy’s allow list — it hasn’t been enabled for use through the proxy at all. Contact your administrator to add it to the access rules. |
Repository Denied | The repository is explicitly blocked by a deny rule. Contact your administrator. |
Push Blocked - Unauthorized | The repository is allowed through the proxy but you do not have a PUSH permission entry for it. Contact your administrator to grant you access. |
identity not resolved | Your PAT did not resolve to a known SCM identity. Check your token scopes and ask your administrator to register your upstream username. |
After fixing the issue, push again normally — the proxy will re-validate from scratch.
Annotated tags: the message you pass to
git tag -a -m "…"is validated the same way a commit message is — the same blocked terms, patterns, and content-pattern (PII) checks apply. The tag’s tagger email (git fills it from the sameuser.emailas a commit’s committer line) is likewise held to the committer email policy. If a tag push is blocked for its message or tagger, fix the cause (git config user.emailfor a tagger block), then re-create the tag (git tag -d <tag>thengit tag -a <tag> -m "…") and push again.
Identity verification
The proxy confirms that the person pushing is who they say they are. The mechanism differs by transport.
HTTP pushes
- Token → SCM username: your PAT is used to call the SCM API (
GET /user). The returned username must match the SCM identity registered in your proxy user profile. This check is always enforced — a push is blocked immediately if your token cannot be matched to a registered proxy user, regardless of any other settings. - Commit emails → proxy user: every author and committer email in the pushed commits must match an email address
registered on your proxy account. This check is controlled by
attribution-policy— inwarnmode mismatches are logged but the push proceeds; instrictmode the push is blocked.
The HTTP Basic-auth username in your remote URL is not used for identity. Use any value — me, git, your name —
it makes no difference. Only the password (your PAT) matters.
You can add and remove your own SCM identities and email addresses from your profile page in the dashboard. If your push is blocked with “Identity Not Linked” or a commit email mismatch, log in to the dashboard and add the missing identity or email under your profile before pushing again.
If you cannot resolve it yourself — for example, because the email address or SCM username is already registered to another user — contact an administrator. Duplicate identity conflicts (two users claiming the same email or SCM handle) require admin intervention to resolve.
SSH pushes
SSH identity verification enforces the same compliance guarantee, but via SSH key fingerprint rather than a PAT:
- Public-key auth (connection gate): your SSH key must be registered in your proxy profile.
- SCM fingerprint check (compliance gate): the proxy calls the upstream SCM API and fetches the SSH public keys registered on your linked SCM identity. Your connecting key’s fingerprint must appear in that list.
Both steps are required and both are always enforced — there is no warn-only mode for SSH identity. If either step fails the push is blocked.
Your SSH key must be registered in two places: your fogwall profile, and your upstream SCM account. Registering it in fogwall alone is not enough — the proxy cross-checks against the SCM to confirm the key actually belongs to you.
If you are blocked with “SSH key not linked to any SCM identity”, add the key to your SCM account settings and retry.
Linking your account via OAuth
If your administrator has configured it, your profile page’s SCM Identities tab has a “Link with <hostname>”
button for each supported provider instance (GitHub, GitLab, or a Forgejo/Gitea/Codeberg instance) — the hostname
identifies which specific account you’re about to link, since a GitHub- or GitLab-type provider isn’t always
github.com/gitlab.com (it may be a GitHub Enterprise tenant or a self-hosted GitLab/Forgejo/Gitea instance). This is the
preferred way to register an SCM identity — instead of typing your SCM username into a free-text field, you authorize
fogwall through the provider’s real OAuth login, and fogwall sets a verified badge on the resulting identity once
it’s confirmed you actually control that account.
Some deployments require a verified identity for push authorization — if your push is blocked with “SCM Identity Not Verified,” link your account this way rather than adding a free-text identity.
Linking also saves you some manual setup:
- Emails your provider has verified are imported automatically and locked onto your account (shown as
locked (github)orlocked (gitlab)on the Emails tab) — including a GitHub noreply address, if you use one. You no longer need to add these yourself. - Your registered SSH public keys are imported automatically too, shown with the same locked badge on the SSH Keys tab.
A verified identity (and any keys/emails it locked in) can’t be removed the normal way — click “Unlink” instead, which removes the identity, the stored OAuth token, and any SSH keys/emails that came from that provider in one step. If the same key or email is also verified by another linked provider, it stays registered under that provider instead of being deleted outright. You can re-link at any time.
If an SSH push is refused
Some deployments identify an SSH push only by keys that came in when you linked your account, so a key you pasted in yourself is not enough:
⛔️ Push Blocked - SSH Key Not Linked
❌️ This SSH key is not linked to a verified account.
Link the account for that provider and push again. If it is already linked and you have registered a new key with your SCM since, unlink and re-link to import your current keys. Removing a key from your SCM account stops it working here too. If it still fails, ask your administrator.
Contributions (PRs, MRs & issues through fogwall)
Available since v1.4.0, if your administrator has enabled it per-provider.
Overview
Point your SCM CLI at fogwall and open and iterate on a pull or merge request as you normally would — fogwall inspects and forwards the traffic instead of you talking to the SCM’s API directly. Issue commands work the same way.
If all you need is to file or comment on an issue, you don’t need a CLI or a token at all: the dashboard’s
Issues page does it for you, using the account you linked under Profile. That path needs only the narrow ISSUE
grant (or PROPOSE), and your administrator must have enabled it for the provider. The rest of this guide is the CLI
path.
Two permissions gate what you can do, and they’re separate: PROPOSE lets you open and iterate on issues and pull/merge
requests; MERGE lets you merge one. Ask your administrator which grants you hold on a given repository.
Three things apply to every provider below:
-
Each provider has its own endpoint, and it is never a URL path. Which form it takes depends on how your administrator deployed fogwall:
deployment what you configure a port per provider fogwall.corp.example.com:9443a hostname per provider fogwall-github-api.corp.example.comEither way it is separate from the address you push git through, and the CLIs accept only a host — optionally with a port — so there is nowhere to put a path even if you wanted to. It is always reached over HTTPS; the CLIs offer no way to ask for plain HTTP. Get the actual value from your administrator or your internal documentation; the examples below use a placeholder.
-
Reviewing isn’t proxied. Approving and requesting changes happen in the SCM’s own web UI.
-
What you write is scanned before it is sent. Titles, descriptions and comment bodies go through the same content checks as a push — blocked terms and patterns, secret scanning. A match refuses the request and tells you which rule matched; nothing is sent upstream. Unlike a push, there is no reviewer to override it, because there is nothing held waiting for one — edit the text and run the command again.
GitHub — gh
Usage
Point gh at fogwall and run issue/PR commands as usual:
export GH_HOST="<fogwall-github-endpoint>" # host:port or a dedicated hostname — ask your administrator
export GH_ENTERPRISE_TOKEN="<your PAT>" # NOT GH_TOKEN — see below
gh issue create -R <fogwall-github-endpoint>/<owner>/<repo> --title "..." --body "..."
gh pr create -R <fogwall-github-endpoint>/<owner>/<repo> --base main --head <your-branch> ...
Authentication
Bring your own personal access token. Use a classic PAT with the repo scope — GitHub has no narrower classic scope
covering issues or pull requests alone (public_repo only reaches public repositories). A fine-grained PAT needs its
repository’s “Issues” and “Pull requests” permissions set to read/write.
Two quirks worth knowing, because neither fails in an obvious way:
- Use
GH_ENTERPRISE_TOKEN, notGH_TOKEN.ghreservesGH_TOKENfor github.com itself and ignores it for any other host, so setting it gets you a 401 that looks like a rejected token.GITHUB_ENTERPRISE_TOKENworks too. gh auth logindoes not work against fogwall. Set the environment variables above instead.
Supported commands
| command | permission needed |
|---|---|
gh issue create/edit/close/comment | PROPOSE |
gh pr create/edit/close/comment | PROPOSE |
gh pr merge | MERGE |
gh issue list/view, gh pr list/view, etc. (reads) | none — if enabled by your administrator |
Limitations
gh pr close --delete-branch. The close succeeds; deleting the branch does not. gh deletes a branch over GitHub’s
REST API, and this surface carries GraphQL only. Delete the branch with git push --delete, which goes through
fogwall’s git path as usual, or in the web UI.
GitLab — glab
Usage
Point glab at fogwall and run issue/MR commands as usual:
export GITLAB_HOST="<fogwall-gitlab-endpoint>" # host:port or a dedicated hostname — ask your administrator
export GITLAB_TOKEN="<your PAT>" # your own token, `api` scope — see below
glab issue create -R <owner>/<repo> --title "..." --description "..."
glab mr create -R <owner>/<repo> --source-branch <your-branch> --target-branch main --title "..." ...
Authentication
Bring your own GitLab personal access token with the api scope — GitLab has no narrower scope covering issues or
merge requests alone.
glab mr create needs a matching git remote. It refuses to run unless one of the repository’s remotes points at
whatever GITLAB_HOST is set to, so add one alongside your normal origin:
git remote add glab-proxy-do-not-use https://<fogwall-gitlab-endpoint>/<owner>/<repo>.git
Warning
This remote is not a working git remote. It exists only to satisfy
glab’s check — the endpoint serves the GitLab API, not git, sogit pushorgit fetchthrough it will fail. Keep using your normal remote for all git operations.
Supported commands
| command | permission needed |
|---|---|
glab issue create/update/note/close | PROPOSE |
glab mr create/update/note/close | PROPOSE |
glab mr merge | MERGE |
glab issue list/view, glab mr list/view, etc. (reads) | none — if enabled by your administrator |
Gitea / Forgejo — tea and fj
Both CLIs share one endpoint, because they talk to the same API.
Usage
# tea (Gitea)
tea login add --name fogwall --url https://<fogwall-gitea-endpoint> --token "<your token>"
tea issue create --login fogwall --repo <owner>/<repo> --title "..." --description "..."
tea pr create --login fogwall --repo <owner>/<repo> --head <your-branch> --base main --title "..."
# fj (Forgejo)
fj -H https://<fogwall-gitea-endpoint> issue create "..." --body "..."
fj -H https://<fogwall-gitea-endpoint> pr create "..." --body "..."
Authentication
Bring your own Gitea/Forgejo access token, scoped to at least write:issue and write:repository — pull requests
are created under the repository scope, and issue and PR comments/edits under the issue scope (Forgejo models a pull
request as an issue for several operations, so both scopes are needed even if you only intend to open PRs).
tea pr close and tea pr edit are the same request on the wire. tea sends a full object with "state":"closed"
alongside every other field, so fogwall permits or denies them together. It cannot tell them apart, and doesn’t pretend
to.
Supported commands
| command | permission needed |
|---|---|
issue create/edit/close/comment (tea/fj) | PROPOSE |
PR create/edit/close/comment (tea/fj) | PROPOSE |
tea pr merge/fj pr merge | MERGE |
reads (tea issue list, fj pr list, etc.) | none — if enabled by your administrator |
Limitations
tea issue edit --remove-labels. Silently does nothing. tea resolves the labels and then sends no request at all,
so there is nothing for fogwall to forward or refuse — it fails the same way without a proxy in the path.
User permissions vs access rules
Every request passes two independent layers: a site-wide gate your administrator configures, and a permission granted to you. Both must say yes, and both deny by default — nothing is open because it was never mentioned.
The two surfaces have their own pair:
| git push and fetch | contributions (PR/MR and issue operations) | |
|---|---|---|
| site-wide gate | rules.allow / rules.deny, per repository | none — the surface is on or off |
| your permission | PUSH grant on that repository | PROPOSE grant on that repository |
Access rules decide which repositories the proxy will handle at all. A repository that is not allowed is rejected immediately, before any user-level check runs. They exist because a fetch of a public repository sends no credential — there is no user to check, so the URL is the only thing to gate on. Every contribution request is authenticated, so it is checked against your permissions directly.
Your permissions decide what you personally may do. PUSH and PROPOSE are separate grants — pushing code to a
fork and opening a pull request against the upstream are different operations on different repositories, so holding one
does not imply the other. A contribution is always authorized against the repository it is opened on, never the fork the
branch came from.
Read commands (gh issue list, glab mr view) are not checked against your PROPOSE grants at all; only the
provider-level rule applies to them.
The error message tells you which layer rejected the request — see When a push is blocked for the push-path messages and what to do for each.
Common problems
Push hangs on credential prompt
Your git credential helper is prompting for the proxy URL but nothing appears. Embed credentials directly in the remote URL or configure your credential helper to recognise the proxy host.
Cloning a public repository asks for credentials, or fails in CI
fogwall determines whether to ask for credentials by checking whether the upstream repository serves anonymous reads. If
it cannot reach the upstream to check — a network timeout, an outbound proxy misconfiguration — it asks for credentials
rather than assuming the repository is public. In a non-interactive environment (GIT_TERMINAL_PROMPT=0, most CI
runners) that surfaces as an outright failure rather than a prompt.
Check the fogwall server log for a line about probing the upstream. If the upstream genuinely is private, supply a token as normal.
SSL certificate problem
Your corporate PKI certificate is not trusted by your git client. Ask your administrator for the CA bundle and install it:
git config http.sslCAInfo /path/to/corporate-ca.pem
Or for a specific remote only:
git config --local http.https://fogwall.corp.example.com.sslCAInfo /path/to/corporate-ca.pem
Push succeeds but commits appear with wrong author
The push was forwarded using your PAT, but your git config user.name / user.email were not set correctly when you
committed. The upstream shows the author from the commit object — fix your git config and amend before pushing next
time.
error: src refspec main does not match any
Standard git error — the branch name in your push command does not match a local branch. Not a proxy issue.
Push blocked as too large
fogwall accepts pushes up to a configured size — 64 MiB by default. Over that, the push is refused before any data is read:
remote: ⛔ Push Blocked - Too Large
remote: ❌ This push is 512 MiB; the limit is 64 MiB.
A push this large is usually one of three things: a binary or archive committed by mistake, generated build output that
should be in .gitignore, or a large file’s entire history still present after it was deleted in a later commit (git
keeps every version). Check what is actually big:
git count-objects -vH
git rev-list --objects --all | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
| awk '$1=="blob" {print $3, $4}' | sort -rn | head
If the content genuinely belongs in the repository — a first push of a long-lived history, for example — talk to your administrator rather than trying to split it. A one-time import is normally seeded directly upstream instead of pushed through the proxy.
Git LFS pushes are rejected
Git LFS is not supported through fogwall at this time.
Git LFS moves file content outside the git protocol, so fogwall never sees the bytes and cannot make any statement about them — secret scanning, content checks, and diff review would all inspect the small pointer file instead of the real content. Rather than pass content it cannot inspect, fogwall refuses the upload.
Cloning and fetching repositories that already contain LFS objects is unaffected; only uploads are refused. If you need LFS for a repository, raise it with your administrator.
Push options (git push -o) are rejected
Push options (git push -o ...) are not supported. Please push again without -o.
Push options are instructions to the hosting platform, not part of the commits: on GitLab they can open a merge request
or skip CI, on Gitea and Forgejo they can change a repository’s visibility. fogwall’s validation and approval steps
never see them, so rather than relay an instruction it cannot review, fogwall refuses the push. In server mode the
capability is not offered at all and git itself reports the receiving end does not support push options.
Push without -o and perform the platform-side action (opening a merge request, changing a setting) through the
platform’s UI or CLI, where the usual permissions apply.
Tips
Clone through the proxy from the start
The recommended approach is to clone via the proxy rather than cloning directly from the upstream and adding a proxy
remote later. Most repos are permitted for both fetch and push — push-only access rules are the exception rather than
the norm. Cloning through the proxy means all activity is audited from the first checkout, and your origin remote is
already pointed at the proxy with no extra setup needed.
Use the Clone via proxy button on the Repositories page in the dashboard, or construct the URL manually:
# Clone directly through the proxy — origin is set to the proxy URL automatically
git clone https://me:ghp_yourtoken@fogwall.corp.example.com/proxy/github.com/myorg/myrepo
cd myrepo
# Confirm origin points at the proxy
git remote -v
If you need a reference to the upstream directly (e.g. to pull in upstream changes that are not yet in your fork), add it as a second remote after cloning:
git remote add upstream https://github.com/myorg/myrepo
Managing multiple remotes
If you already have a local clone pointed directly at the upstream, add the proxy as a named remote or redirect pushes through it while keeping direct fetch:
git remote set-url --push origin https://fogwall.corp.example.com/server/github.com/myorg/myrepo
Private forks and internal mirrors
If your org maintains a private internal fork of a public repo (e.g. a patched version of an upstream library), both can be proxied independently. A common three-remote setup:
# upstream — the public project (fetch only, direct)
git remote add upstream https://github.com/someproject/somerepo
# origin — your org's internal fork (all traffic through proxy)
git remote add origin https://fogwall.corp.example.com/server/github.corp.example.com/myorg/somerepo
# The proxy URL reflects whichever provider hosts the fork —
# it does not have to be the same provider as upstream.
Each remote is a separate entry in the proxy’s access rules and permission grants. Coordinate with your administrator to ensure both the public upstream and internal fork URLs are configured.
Finding proxy URLs from the dashboard
The Repositories page in the dashboard lists every repo that has seen activity through the proxy. Each entry has a
Clone via proxy button that copies the ready-to-use git clone command to your clipboard — useful when setting up a
new local clone or adding a proxy remote to an existing one.
The Clone button uses the /proxy/ mode URL. Swap /proxy/ for /server/ if you want the server mode path instead.
When the SSH listener is enabled and the provider serves SSH (see
Configuration Reference), the Clone button also offers
an HTTPS / SSH toggle — pick SSH to copy the ssh://… form instead.
The repository only appears in the list after it has been pushed to or fetched through the proxy at least once. If you do not see it yet, push or fetch first.
Scrubbing a commit history before pushing
If the proxy blocks your push due to secrets, blocked URLs, or disallowed commit authors in older commits, a simple
git commit --amend only fixes the tip. You need to rewrite history. The recommended tool is
git filter-repo:
# Remove a file that contained a secret from all history
git filter-repo --path path/to/secret-file --invert-paths
# Replace a hardcoded internal URL across all commits
git filter-repo --replace-text <(echo 'internal.corp.example.com==>REDACTED')
# Rewrite all commits by a specific author email to a new address
git filter-repo --email-callback 'return email.replace(b"old@corp.com", b"new@corp.com")'
After rewriting, force-push to a new branch and open a pull request rather than force-pushing to a protected branch. If you’re pushing through the proxy, the rewritten history will be re-validated from scratch — confirm the issues are gone with a dry-run push to a test branch first.
Administrator and Operator Guide
This guide covers deploying, configuring, and operating fogwall. It is written for the person responsible for running the proxy — setting up user accounts, configuring providers and rules, diagnosing problems, and keeping the service healthy.
For the YAML configuration reference, see Configuration Reference. For developers pushing through the proxy, see User Guide.
Contents
- Conceptual model: three independent layers — how access rules, permissions and approval fit together
- Developer onboarding — the Setup page — what a new developer sees on their first visit
- User accounts — provisioning, roles, emails and SCM identities
- Repo permissions — who may push, approve, self-certify or propose where
- Access rules — which repositories fogwall will proxy at all
- Approval mode — auto-approve versus human review
- Logging — log locations, debug profiles, and reading a failed push
- JGit filesystem requirements — home directory,
/tmp, and gitleaks permissions - Externalized configuration — mounting config into a container and overriding it
- Network requirements — outbound connections, corporate proxies, reverse proxies, sizing
- Production checklist — what to settle before the first real push
- SSH transport — exposing SSH, host keys, identity verification and agent forwarding
- SCM OAuth account linking — registering OAuth apps and operating strict identity mode
- SCM API — enabling the PR/MR path, its token model and authorization
- Common operational problems — symptoms an operator sees, and what causes them
Conceptual model: three independent layers
Before diving into configuration details, it helps to understand that access control in fogwall is three orthogonal layers that all must pass before a push is forwarded:
1. Access rules rules.allow / rules.deny
"Is this repo even on the proxy's allowed list?"
↓
2. User permissions permissions:
"Is this user allowed to push to this specific repo?"
↓
3. Commit validation commit:
"Does the content of this push comply with policy?"
A push fails at the first layer that rejects it. A common misconfiguration is to add a repo to rules.allow but forget
to add a permissions entry for the user — or vice versa. Both are required.
Access rules are site-wide policy: they determine what the proxy will route at all, independently of who is pushing. Think of them as a firewall rule list.
User permissions are per-user grants scoped to a provider and path. They determine whether a particular authenticated user is permitted to push to (or review) a particular repository.
Commit validation runs against the push content: author/committer email policy, commit messages, commit-trailer
policy (DCO Signed-off-by, Co-authored-by), diff scanning, secret scanning. These apply to everyone regardless of
permissions.
Developer onboarding — the Setup page
The dashboard serves a Setup page (reached from the help / quick-start icon in the top bar) that generates deployment-specific git config for developers, with this deployment’s real hostnames filled in. It is generated from the running configuration (providers, service URL, SSH listener), so it cannot drift from what fogwall actually serves.
- Push-only by default. The generated config reroutes only developers’ pushes to fogwall (git
pushInsteadOf); clones and fetches keep going straight to the upstream. This keeps read-only access unaffected and avoids a read-time dependency on fogwall — reads are typically already inspected elsewhere. Routing fetches through fogwall is offered as an explicit opt-in, and the page tells developers who only clone/fetch that they need nothing at all. - Global vs per-repo. The page offers both a one-paste global
~/.gitconfigform (applies to every repo under the upstream host, gated by your URL rules) and an explicit per-repository form (git remote set-url --push, visible ingit remote -v), noting the global form’s blast radius. - It is public (served at
/api/setup, no login) — a developer who cannot yet log in is exactly who needs setup instructions, and fogwall is often deployed where the GitHub-hosted docs are blocked. It exposes only routing information already implied by the provider list; no secrets. - Set
server.service-urlso the generated URLs are correct. When it is unset, the page derives the base URL from the developer’s own browser address, which is wrong behind a reverse proxy — the page shows a warning in that case. Settingservice-urlis the fix.
User accounts
fogwall supports four authentication backends. LDAP, AD, and OIDC are the expected production choices. Local auth manages users in the database (add/remove users, reset passwords via the dashboard) with passwords defined in YAML config. It is self-contained and requires no external directory, but every user must be provisioned manually. It is suitable for small teams or single-operator deployments; LDAP, AD, or OIDC are preferable when the org already has a directory.
Authentication backends
| Backend | auth.provider | When to use |
|---|---|---|
| Local (static) | local | Dev / demo only. Passwords in YAML config. |
| LDAP | ldap | Generic LDAP directory (OpenLDAP, 389 DS, etc.) |
| Active Directory | ad | On-premises AD domain. UPN bind, no user-dn-patterns needed. |
| OIDC | oidc | Keycloak, Okta, Entra ID, Dex, etc. |
See Authentication for the full config reference and worked examples.
How users are provisioned per backend
Local: users are defined entirely in the users: YAML block. Each entry needs a username, BCrypt password hash, and
at least one email. Roles and SCM identities are set here too. Changes require a config reload.
users:
- username: alice
password-hash: "{bcrypt}$2a$12$..."
roles: [ADMIN]
emails:
- alice@corp.example.com
scm-identities:
- provider: github/github.com
username: alice-github
LDAP / AD: users are provisioned automatically on first login. The proxy creates a user record from the directory
attributes returned at bind time. The mail attribute (if present) is stored as a locked email — locked means it cannot
be edited from the profile UI, since the directory is the source of truth. Roles are assigned via auth.role-mappings
(LDAP group CNs → role names). When role-mappings is configured, a user who does not match any mapped group is
denied access entirely — they authenticate successfully against the directory but are refused by the proxy. This is
intentional: the proxy is not open to all directory users by default. To grant baseline access, map a broad group (e.g.
all-staff) to USER, or set auth.require-role-mapping: false to treat the directory purely as an authentication
mechanism and grant ROLE_USER to anyone who authenticates. See
Role mappings.
SCM identities and permissions still need to be set up after first login — either by the user themselves from their
profile page, by an admin via the dashboard, or via a supplemental users: YAML entry (which can carry scm-identities
without a password-hash for IdP-authed users).
OIDC: same auto-provisioning and deny-by-default behaviour as LDAP. Groups from the configured groups-claim
(default: groups) are mapped to roles via auth.role-mappings. Email comes from the email claim in the ID token.
Users whose token carries no matching group claim are denied access.
Dashboard roles
Roles control what a user can do in the dashboard and REST API:
| Role | What it grants |
|---|---|
USER (default) | View push records; approve or reject pushes they have REVIEW permission on; manage their own profile (emails, SCM identities) |
ADMIN | Everything USER can do, plus: create/delete users, reset passwords, manage any user’s profile, view all push records |
SELF_CERTIFY | Grants the capability to self-approve pushes. This is the prerequisite gate — it must be present before any per-repo SELF_CERTIFY permission takes effect. |
ROLE_USER is granted to every authenticated user automatically when no role-mappings are configured (open mode).
When role-mappings are configured, access is deny-by-default — a user must belong to at least one mapped group or they
are refused login entirely. Map a broad group to USER to grant baseline access to all directory members.
ROLE_SELF_CERTIFY is the prerequisite gate for self-approval. It represents the capability, attested by your org’s IdP
or IAM process. Self-approval requires both this role and a per-repo SELF_CERTIFY permission entry — neither alone
is sufficient. This separation lets organisations externalise the capability grant (who is trusted to self-certify at
all) to their existing directory/IAM procedures, while the per-repo entitlement remains managed inside fogwall.
How to grant ROLE_SELF_CERTIFY:
- LDAP / AD / OIDC: add
SELF_CERTIFYtoauth.role-mappingsand map it to the appropriate IdP group. - Local auth (config user): add
SELF_CERTIFYtoroles:in the user’susers:YAML entry. - Local auth (dashboard-created user): tick the “Grant self-certify role” box in the Add User form. Local auth is intended for demonstration and proof-of-concept; a production deployment grants the capability through its IdP instead.
Note
Organisations that require mandatory peer review (four-eyes) for all activity should simply not grant
SELF_CERTIFYrole or permissions. If no user holdsSELF_CERTIFY, all pushes require a separate reviewer.
Roles are dashboard-level access only. They do not control which repos a user can push to — that is what permissions (below) are for.
Emails and SCM identities
Every user record carries two independent data sets that the proxy uses to verify identity on each push:
Emails — the set of email addresses the user commits with (i.e. the value in git config user.email). On every
push, every author and committer email in the incoming commits is checked against this list. If an email is not
registered to the authenticated user, the push fails in strict mode or warns in warn mode. This is what ties commit
attribution to a verified real person.
SCM identities — the upstream provider username(s) for this user (e.g. their GitHub login). On every push, the proxy calls the upstream API using the PAT supplied in the git credentials and checks the returned username against this list. This confirms that the token being used actually belongs to the person who authenticated with the proxy, not a shared or borrowed token.
These are two independent checks and both must pass in strict mode. They catch different things: a commit email
mismatch means the developer’s git client is misconfigured or the commit is attributed to someone else; an SCM identity
mismatch means the token does not belong to the authenticated user.
How emails are populated:
- Local auth: set in the
users:YAML block; editable from the profile UI. - LDAP/AD: the directory
mailattribute is imported on first login as a locked email (not editable from the UI — the directory is the source of truth). Additional emails can be added via the admin dashboard. - OIDC: the
emailclaim from the ID token is imported on first login as a locked email.
How SCM identities are populated:
There is no automatic source for SCM identities — they must be added manually regardless of auth backend. After first
login, either the user themselves or an admin can add SCM identities from the profile page in the dashboard. For
example: provider github/github.com, username alice-gh. Users manage their own profile; admins can manage any user’s
profile.
For local auth, SCM identities can also be set in the users: YAML block:
users:
- username: alice
# ...
scm-identities:
- provider: github/github.com
username: alice-gh
- provider: gitlab/gitlab.com
username: alice
Until SCM identities are populated, pushes from that user will fail identity verification in strict mode. Use
attribution-policy with committer: warn during rollout to let pushes through while identities are being registered.
See Identity verification for the developer-facing view of what these checks look
like at the terminal.
Disabling local admin when using an IdP
When LDAP, AD, or OIDC is configured, the static users: block still works and is evaluated alongside the IdP. In most
production setups you want to remove static local accounts (or at minimum remove any with roles: [ADMIN]) once
IdP-based login is confirmed working. See #103 for planned enforcement of this.
Repo permissions
Permissions control which users can push to which repos, and who can review pushes.
permissions:
- username: alice
provider: github/github.com
path: /myorg/myrepo
grant: PUSH
Operations
| Value | What it grants |
|---|---|
PUSH | User can submit pushes to this repo for validation and review |
REVIEW | User can approve or reject pushes to this repo submitted by others |
PUSH_AND_REVIEW | Shorthand for both PUSH and REVIEW |
SELF_CERTIFY | Per-repo entitlement: this user may self-approve pushes to this repo. Requires ROLE_SELF_CERTIFY (the capability role) to also be present — see Dashboard roles. Does not imply PUSH or REVIEW; grant those separately if needed. |
ISSUE | User can file, edit, comment on and close/reopen issues on this repo through the dashboard’s issue form — and nothing more (no pull/merge-request or push ability). The narrow floor under PROPOSE, which is a superset; needs the provider issues-enabled and the user’s account linked for it via OAuth. Independent of PUSH and REVIEW. |
PROPOSE | User can open and edit pull/merge requests and issues on this repo through the SCM API proxy. A superset of ISSUE. Independent of PUSH and REVIEW; implies neither. |
MERGE | User can merge a pull/merge request on this repo through the SCM API proxy’s maintainer path. Independent of PUSH, REVIEW and PROPOSE; implies none of them. |
MAINTAIN | Sole-maintainer bundle: PUSH + PROPOSE + MERGE in one entry, so a trusted maintainer is one permission instead of three. Deliberately excludes SELF_CERTIFY — bypassing peer review stays a separate, explicit grant. |
SELF_CERTIFY — for solo contributors
SELF_CERTIFY is the right choice for a developer who works independently and does not have a team reviewer. Without
it, pushes in ui approval mode wait indefinitely for someone else to approve them.
Self-approval requires two things — both must be in place:
- The
SELF_CERTIFYrole (capability gate) — granted viaauth.role-mappingsorroles: [SELF_CERTIFY]in local config. This is the org-level attestation that the user is trusted to self-certify at all. - A
SELF_CERTIFYpermission entry for the specific repo — the per-repo entitlement.
To set up a trusted solo contributor who approves their own work:
# Step 1: grant the SELF_CERTIFY capability role (local auth example)
users:
- username: bob
password-hash: "{bcrypt}$2a$12$..."
roles: [SELF_CERTIFY] # or via auth.role-mappings for LDAP/AD/OIDC
# Step 2: grant the per-repo entitlement
permissions:
- username: bob
provider: github/github.com
path: /myorg/myrepo
grant: PUSH
- username: bob
provider: github/github.com
path: /myorg/myrepo
grant: SELF_CERTIFY
Bob’s pushes are validated as normal (commit rules, secret scanning, identity checks). Once validation passes, the proxy records a self-certification in the audit log and forwards without waiting for a reviewer.
If Bob also needs to review others’ pushes to that repo, add a third entry with grant: REVIEW.
Permission groups
Available since v1.3.0.
For teams larger than a handful of users, granting permissions one entry per user gets unwieldy. A groups: block
grants the same target/match model to every member at once:
groups:
- name: platform-team
description: Platform engineering
members: [alice, bob, carol]
grants:
- provider: github/github.com
path: /myorg/*
path-type: GLOB
grant: PUSH
A member’s effective access is the union of their direct permissions: entries and every group they belong to — groups
are additive, not a replacement for per-user grants. Groups defined in YAML are read-only in the dashboard (config is
the source of truth); groups created via the dashboard UI are DB-backed and fully editable there. Both kinds show up
together in the Groups admin page.
Path matching
Paths default to exact (LITERAL) matching, ignoring case — /myorg/repo covers a push to /MyOrg/Repo, since both
name the same repository upstream. Use path-type for wildcards:
# GLOB — all repos under an owner
- username: alice
provider: gitlab/gitlab.com
path: /myorg/*
path-type: GLOB
grant: PUSH
# REGEX — Java regex matched against the full repository path
- username: alice
provider: github/github.com
path: \/myorg\/service\-.*
path-type: REGEX
grant: PUSH
Nested namespaces (GitLab subgroups)
A repository path is not limited to two segments: the repository name is the last segment and the owner is everything
before it. A GitLab subgroup project /group/subgroup/project has owner group/subgroup and name project, and its
slug keeps every segment. This applies to permissions, group grants and access rules alike, and to every path fogwall
serves — both proxy modes and both of server mode’s transports. See
Nested namespaces for the per-target breakdown.
If you run GitLab with subgroups, check any rule or grant that names a subgroup: a value of /group/subgroup matches
that path only, not the projects inside it. Write /group/subgroup/* as a GLOB to cover them. An entry that stops
matching blocks the push rather than admitting it, so the failure is visible — the affected users are told the
repository is not permitted.
Permissions vs access rules
A user with PUSH permission on /myorg/myrepo can still be blocked if /myorg/myrepo is not in rules.allow. Both
must be satisfied. The distinction:
- Access rules → “does the proxy route this repo at all?” — operator policy
- Permissions → “can this user push to it?” — per-user grant
A wildcard allow rule (slugs: ["*/*"]) effectively means “route everything” and shifts all control to the permissions
layer. A tightly scoped allow rule means you do not need to worry about accidentally granting a user permission to a
repo the proxy does not handle.
Access rules
rules:
allow:
- enabled: true
order: 110
operation: [FETCH, PUSH]
providers: [github/github.com]
slugs:
- /myorg/repo-one
- /myorg/repo-two
deny:
- enabled: true
order: 100 # deny rules with lower order numbers take precedence
operation: [PUSH]
slugs:
- /myorg/archived-repo
Rules are evaluated in order number order (lower = earlier). Deny rules override allow rules at the same order number.
The proxy is default-deny: if no allow rule matches, the request is rejected.
operation scopes a rule to PUSH, FETCH, or both. A repo can be open for fetch but restricted for push.
Disabling fetch serving entirely
Access rules gate which upstreams are reachable for FETCH. A separate, coarser switch controls whether server mode
serves clone/fetch from its local mirror at all:
server:
serve-fetch: false # global default; push-only gateway, no local mirror served
providers:
github:
serve-fetch: true # optional per-provider override of the global default
Serving fetches is the default and the right one for most deployments — a developer whose remote is the fogwall URL
expects git pull to work against it, and taking that away breaks the single-remote workflow. Turn it off when:
- fogwall is a push-validation gateway that is not meant to be a read path for anything;
- the mirror holds repositories you would rather not serve from fogwall’s disk at all, regardless of who asks;
- you want the reachable surface as small as the use case requires.
When disabled, the git-upload-pack capability is simply not mounted (HTTP) and is refused on the SSH transport; a
fetch is rejected with a clear git-side message — fatal: remote error: fetches are not served through this gateway —
rather than a 404 that reads as a missing repository. Push (receive-pack) is unaffected, and the switch applies to
both server mode transports so neither can serve a fetch the other refuses.
This is deliberately not a per-user read-permission model: for a public upstream there is no credential to authorize,
and for a private one the fetch already carries the caller’s own upstream credentials, which answers the question
authoritatively. Use access rules to gate which repos are reachable, and serve-fetch to decide whether fogwall
serves fetches at all. Transparent proxy mode forwards to upstream rather than serving a local mirror, so it is
unaffected by this setting.
Dry-run testing rules and permissions
Available since v1.3.0.
Before rolling out a new access rule or permission grant, verify the outcome against the live configuration without waiting for a real push:
POST /api/repos/rules/test
{ "provider": "github/github.com", "owner": "myorg", "name": "myrepo", "operation": "PUSH" }
→ { "decision": "ALLOW", "matchedRuleId": 110, "steps": [...] }
POST /api/users/{username}/permissions/test
{ "provider": "github/github.com", "path": "/myorg/myrepo", "grant": "PUSH" }
→ { "allowed": true, "source": "GROUP", "groupName": "platform-team" }
Both endpoints are read-only evaluations against whatever rules, permissions, and groups are currently loaded — no push
is created. source on the permission check distinguishes a direct per-user grant (DIRECT) from one inherited via a
permission group (GROUP). These endpoints are dashboard-only
(fogwall-dashboard); the standalone server has no REST API.
Approval mode
server:
approval-mode: auto # auto | ui
| Mode | Behaviour |
|---|---|
auto | Clean pushes are immediately approved and forwarded after validation. No reviewer needed. Good for teams that use validation as a guardrail without a manual review step, and for solo contributors. |
ui | Every push enters PENDING state and waits for a reviewer to approve or reject in the dashboard. The git push command stays open until a decision is made. |
SELF_CERTIFY permission interacts with ui mode: users with the capability and per-repo entitlement can self-review
their own push in the dashboard. The review step still happens — they attest to and record their own approval. This
signals to operators and the audit log that the pusher has reviewed and accepted responsibility for the changes. Other
users’ pushes still require a peer reviewer.
The dashboard module (fogwall-dashboard) always uses ui mode. The standalone server module defaults to auto.
Logging
Default log locations
| Environment | Log output |
|---|---|
./gradlew run | fogwall-server/logs/application.log + console |
| Docker / production | console only (stdout); redirect or use a log driver |
The default Log4j2 config logs com.rbc.fogwall at DEBUG and everything else at INFO.
Enabling debug logging for specific subsystems
Override the bundled log4j2.xml at runtime — no rebuild required:
# Local run
JAVA_TOOL_OPTIONS=-Dlog4j2.configurationFile=/path/to/my-log4j2.xml \
./gradlew :fogwall-dashboard:run
# Docker
volumes:
- ./my-log4j2.xml:/app/conf/log4j2.xml:ro
environment:
JAVA_TOOL_OPTIONS: -Dlog4j2.configurationFile=/app/conf/log4j2.xml
Debug profiles by problem area
OIDC / Spring Security authentication failures
docker/log4j2-debug.xml is included for this. Activate it in Docker Compose:
volumes:
- ./docker/log4j2-debug.xml:/app/conf/log4j2-debug.xml:ro
environment:
JAVA_TOOL_OPTIONS: -Dlog4j2.configurationFile=/app/conf/log4j2-debug.xml
This enables DEBUG on org.springframework.security and org.springframework.web.client. Remove it when done — it is
very chatty.
JGit HTTP transport (upstream push/fetch failures)
Add to your log4j2.xml:
<Logger name="org.eclipse.jgit" level="DEBUG"/>
<Logger name="org.eclipse.jgit.http.server" level="DEBUG"/>
<Logger name="org.eclipse.jgit.transport" level="DEBUG"/>
Produces detailed output for each step of the JGit credential negotiation and pack transfer. Useful when a push reaches the proxy but fails forwarding to upstream.
Jetty request handling (incoming connections, servlet dispatch)
<Logger name="org.eclipse.jetty" level="DEBUG"/>
<Logger name="org.eclipse.jetty.server" level="DEBUG"/>
<Logger name="org.eclipse.jetty.http" level="DEBUG"/>
Upstream HTTP client (transparent proxy mode)
<Logger name="org.eclipse.jetty.client" level="DEBUG"/>
Logs each HTTP request and response made by Jetty’s ProxyServlet to the upstream. Useful when the transparent proxy
path (/proxy/) fails to reach the upstream.
Administrative action logging
Every mutating dashboard REST endpoint — user create/delete/password reset/email and SCM identity changes, group
create/delete/membership/rule changes, permission grants and revocations, access rule changes, cache invalidation —
emits one INFO-level line to the application log:
admin_action actor=<login> action=<user.create|group.delete|permission.grant|...> target=<resource> outcome=<SUCCESS|DENIED> [detail=<...>]
outcome=DENIED covers refusals such as an admin trying to delete the last remaining admin account or modify a
config-defined group. Password resets log that a reset happened, never the new value. Read endpoints are not logged.
This is the operational history of who changed what through the dashboard; push and SCM API records remain separately in
the database as evidence about proxy traffic. Grep the application log for admin_action to filter this stream from
everything else.
Reading logs for a failed push
Each push gets a requestId in the MDC (visible in the [%X{requestId}] field in the log pattern). To follow a single
push through the log:
grep "your-request-id" logs/application.log
The requestId is also printed in the sideband output to the git client, so you can match terminal output to log lines.
When OpenTelemetry is enabled, each log line emitted inside a request span also carries trace_id and span_id, so a
log entry can be pivoted straight to its trace in the collector. Those fields are omitted (and the log format is
unchanged) when observability is off. See Observability for enabling it and the
full list of exported traces and metrics.
Git client output formatting
Two environment variables control the remote: sideband messages sent to git clients during a push:
| Variable | Effect |
|---|---|
NO_COLOR | Disables ANSI colour. Follows the no-color.org convention — set to any non-empty value. |
FOGWALL_NO_EMOJI | Replaces emoji (✅ ❌ ⛔ 🔑) with plain ASCII. Useful for CI systems or terminals that do not render Unicode. |
Set on the server process, not on the client. See Git client output for Docker Compose examples.
JGit filesystem requirements
JGit requires write access to two locations at runtime. Failures here produce cryptic errors that look like git transport problems but are actually filesystem permission issues.
Home directory
JGit reads ~/.gitconfig and writes lock files in $HOME. In a container, HOME must point to a writable directory.
The Docker image sets ENV HOME=/app/home and creates /app/home with correct permissions. If you override the image’s
entrypoint or run under a different UID, verify that $HOME is writable:
# Inside the container:
ls -la $HOME
touch $HOME/.test && rm $HOME/.test # must succeed
OpenShift / arbitrary UID: OpenShift runs containers as a random UID by default. The image is built with GID 0
group-write on /app/home, /app/.data, and /app/logs (chmod g+rwX) so that any UID in group 0 can write to them.
If you see Permission denied errors on startup, check whether your security context is overriding the GID.
/tmp for scratch repos and gitleaks
JGit creates temporary bare repositories in java.io.tmpdir (defaults to /tmp) for server mode pushes and for
transparent proxy diff inspection. Gitleaks also writes temporary files there.
If /tmp is not writable (e.g. noexec mount, read-only root filesystem), override the JVM temp dir:
environment:
JAVA_TOOL_OPTIONS: -Djava.io.tmpdir=/app/.data/tmp
And create the directory in your deployment:
mkdir -p /app/.data/tmp
chmod 700 /app/.data/tmp
For Kubernetes with a readOnlyRootFilesystem: true security context, mount an emptyDir at /tmp:
volumes:
- name: tmp
emptyDir: {}
volumeMounts:
- name: tmp
mountPath: /tmp
Gitleaks binary permissions
When secret-scan.enabled: true, the proxy needs to execute the gitleaks binary. The bundled binary (inside the JAR) is
extracted to java.io.tmpdir at startup — that directory must allow executable files (noexec prevents this).
If the temp dir is noexec, point gitleaks at a writable, exec-allowed path:
commit:
secret-scan:
enabled: true
scanner-path: /app/.data/gitleaks # explicit path bypasses auto-extraction
Or pre-install gitleaks and put it on PATH — the proxy will find it via system path lookup before falling back to the
bundled binary.
Externalized configuration
fogwall follows 12-factor config principles: the application ships with safe defaults baked into the JAR, and operators layer environment-specific values on top without modifying the image.
Both fogwall-server and fogwall-dashboard load config through Gestalt,
a lightweight Java config library rather than Spring’s @ConfigurationProperties/Environment stack — same reasoning
as not using Spring Boot — fogwall needs
config loading to work identically in fogwall-server, which has no Spring on its classpath at all. Gestalt is a much
smaller dependency that covers the same core need (typed config binding, layered sources, environment variable
overrides) without pulling in a DI container. The profile mechanism below is directly modeled on Spring profiles — the
concept of named, composable config overlays activated by name is worth keeping even without the rest of Spring’s config
machinery.
How config is loaded
Sources are merged in priority order (lowest → highest):
| Priority | Source | Mechanism |
|---|---|---|
| 1 (lowest) | fogwall.yml | Bundled in the JAR — base defaults |
| 2 | Profile YAMLs named in FOGWALL_CONFIG_PROFILES | Classpath lookup (see below) |
| 3 | FOGWALL_* environment variables | Strip prefix, lowercase, _ → . |
| 4 (highest) | Hot-reload overlay (reload.file.path or reload.git) | Filesystem path; applied on every reload |
A higher-priority source only overrides the specific keys it defines — other base values are preserved.
Profile-based config files — the /app/conf/ pattern
The Docker image prepends /app/conf/ to the JVM classpath. Any YAML file mounted there is treated as a classpath
resource and loaded automatically when its profile is activated.
Step 1 — Mount the file:
# docker-compose.yml or Kubernetes pod spec
volumes:
- ./my-config.yml:/app/conf/fogwall-my-config.yml:ro
# Or in Kubernetes, mount a ConfigMap:
# - name: fogwall-config
# mountPath: /app/conf
Step 2 — Activate the profile:
environment:
FOGWALL_CONFIG_PROFILES: my-config
The loader looks for fogwall-{profile}.yml on the classpath. With /app/conf/ prepended, your mounted file is found
first.
Important
A file mounted at
/app/conf/is silently ignored unless the matching profile name is set inFOGWALL_CONFIG_PROFILES. There is no auto-discovery — the profile name is the activation key.
Multiple profiles are comma-separated; later profiles take priority over earlier ones:
FOGWALL_CONFIG_PROFILES=docker-default,ldap
This loads fogwall-docker-default.yml then fogwall-ldap.yml; ldap wins on any key both files define.
Warning
List merge caveat: Gestalt replaces lists at the key level — it does not append. If two profile files both define
permissions:, the later file’s list replaces the earlier one entirely. Keep all entries for a given list key in a single profile file. A common split that avoids this: one profile for organizational config (users, permissions, rules) and a second for environment-specific connectivity (auth provider URL, database, TLS) which never defines list keys.
Environment variable overrides
Any FOGWALL_ prefixed env var overrides the equivalent config key at the highest priority (above profiles, below
hot-reload overlays). The mapping is: strip FOGWALL_, lowercase, replace _ with .:
FOGWALL_SERVER_PORT=9090 → server.port
FOGWALL_DATABASE_TYPE=postgres → database.type
FOGWALL_SECRET__SCAN_ENABLED=false → secret-scan.enabled
Use env vars for values that differ per-environment (secrets, hostnames, ports) and profile YAML files for structural config (users, permissions, rules) that is too complex to express as a flat key-value pair.
Hot-reload overlay
The reload: block configures a separate high-priority overlay that is re-read at runtime without restarting the
server. See Hot reload for the full reference.
The overlay file path can be a ConfigMap mount too:
reload:
file:
enabled: true
path: /app/conf/fogwall-runtime.yml
This lets operations teams push rule or permission changes by updating a ConfigMap and triggering
POST /api/config/reload — no pod restart needed.
Network requirements
fogwall opens outbound connections to upstream SCM providers (GitHub, GitLab, Bitbucket, Gitea) from the server, not from the developer’s workstation. Your network team needs to allow egress from the proxy host, not from individual developer machines.
Outbound connections the proxy makes
| Path | Library | Destination |
|---|---|---|
| Server mode upstream push (HTTPS) | JGit Transport (HTTPS) | SCM provider git endpoint |
| Server mode upstream push (SSH) | JGit Transport (SSH) | SCM provider SSH endpoint |
| Transparent proxy forwarding | Jetty HttpClient (HTTPS) | SCM provider git endpoint |
| SCM identity resolution (PAT verification) | Apache HttpClient 5 | SCM provider REST API |
| SSH fingerprint lookup | Apache HttpClient 5 | SCM provider REST API |
All paths must be able to reach the upstream SCM provider. A common operational mistake is opening the firewall for one path but not the others — pushes appear to succeed locally but fail when the proxy tries to verify the committer’s identity via the API.
Corporate HTTP proxy
If outbound internet access requires routing through a corporate HTTP proxy, set the standard environment variables before starting fogwall:
export HTTPS_PROXY=http://proxy.corp.example.com:8080
export HTTP_PROXY=http://proxy.corp.example.com:8080
export NO_PROXY=localhost,127.0.0.1,*.internal.example.com
fogwall reads these at startup and configures all three outbound paths accordingly. No YAML config is needed.
When the configured proxy requires authentication, set server.outbound-proxy.auth in YAML — see
Outbound proxy in the configuration reference for Basic and Kerberos options. NTLM
is not supported as a scheme fogwall speaks directly: it’s a deprecated protocol, and Jetty’s HTTP client (used for
transparent-proxy forwarding) has no NTLM support at all. Kerberos/Negotiate is the modern successor in Active-Directory
environments and is supported natively across all three outbound paths.
Connectivity diagnostics (dashboard)
The dashboard admin panel includes a Provider Connectivity section (Admin → Provider Connectivity) that runs
layered outbound checks against each configured provider. Use this to generate a sharable diagnostic report for your
network team without requiring them to access server logs.
Baseline check (all providers): for each provider runs in sequence and stops at the first failure:
- TCP — opens a socket to
host:port(5 s timeout). Classifies the outcome as REFUSED, TIMEOUT, or RESET so a firewall DROP vs REJECT is immediately distinguishable. - TLS — completes the TLS handshake and reports the negotiated protocol, cipher suite, and peer certificate CN. Detects MITM/SSL-inspection appliances that swap the upstream certificate.
- HTTP — sends
GET /and records the HTTP status code and response time.
Targeted check (single provider + optional repo path): runs the same three steps, then adds:
- Git probe — sends
GET /info/refs?service=git-upload-packandGET /info/refs?service=git-receive-packwith aUser-Agent: git/2.x.xheader. Any HTTP response (200, 401, 403 …) means the request reached the upstream — git URL patterns and the git user-agent are not being filtered. A TIMEOUT or RESET after TCP/TLS passed indicates a DLP appliance blocking git-specific traffic specifically.
The targeted check returns a structured steps log in the API response (GET /api/admin/connectivity?provider=<name>)
that can be copied directly into a ticket for the network team.
DLP appliances and non-GET blocking
Some enterprises deploy DLP (Data Loss Prevention) appliances that inspect or selectively block outbound HTTPS traffic.
A common policy blocks anything other than GET requests to github.com or similar SCM hosts — this will prevent fogwall
from forwarding pushes upstream even if the proxy can reach the host.
Symptoms: clones through the proxy succeed, but pushes fail at the upstream forwarding step with a 403 or a TCP reset.
The git probe in the targeted connectivity check will show this as a TIMEOUT or RESET on the git-receive-pack step
after TCP and TLS both pass.
Resolution: work with your network team to allowlist the proxy server’s egress IP for POST/PUT traffic to the SCM provider’s git endpoint. A transparent HTTPS inspection proxy (MITM) will also break JGit’s certificate pinning — the proxy host’s egress IP should bypass SSL inspection, not just be allowlisted at the IP layer.
TLS termination and forwarded headers
The git and SCM API listeners need nothing special behind a TLS-terminating proxy: the git protocol does not consult
forwarded headers, and fogwall reads none and emits none on those paths. The dashboard is the exception — it
resolves the external scheme, host and port so that OIDC login redirects, other absolute URLs, and the session cookie’s
Secure flag reflect the address the browser used. server.trust-forwarded-headers (default true) controls where it
reads that address from. There are two supported shapes.
TLS terminated at fogwall. The browser reaches fogwall’s own HTTPS connector directly on the external hostname (see
TLS configuration). fogwall’s own request is already https on the right host, so it can
resolve the external address from the connection itself. Set server.trust-forwarded-headers: false — there is no proxy
in front setting the headers, and leaving them trusted would let any client that reaches the port spoof them.
TLS terminated at an ingress, plaintext inside the cluster. The browser reaches an ingress or load balancer over
HTTPS, which forwards plaintext HTTP to fogwall. fogwall’s own request is http on an internal host, so it cannot
derive the external address from the connection — it must read the Forwarded / X-Forwarded-* headers the ingress
sets. Keep server.trust-forwarded-headers: true (the default), and also set server.service-url to the external
base URL: it is separate from forwarded-header handling and needed by the paths that run without a browser request in
scope — SCM OAuth account-linking (which refuses to build a redirect_uri without it) and the links embedded in
sideband messages to git clients (which are omitted without it).
The precondition for trust-forwarded-headers: true is that the dashboard listener is reachable only through the
ingress that sets the headers; a client able to reach the dashboard port directly could otherwise spoof scheme and host.
A startup log line names the active setting and this precondition. Turning the setting off on a deployment behind a TLS
ingress breaks login redirects and drops the session cookie’s Secure flag, which is why the default is true.
Large pushes failing behind a reverse proxy (chunked transfer-encoding)
When fogwall is deployed behind a reverse proxy (HAProxy, nginx, a cloud load balancer), pushes with large packs (> 1 MiB) can fail with:
send-pack: unexpected disconnect while reading sideband packet
fatal: the remote end hung up unexpectedly
Server-side logs show ParseGitRequestFilter errors such as EOFException: Short read of block or
Invalid packet line header.
Root cause: git uses Transfer-Encoding: chunked for pushes exceeding http.postBuffer (default 1 MiB). Many
reverse proxies don’t fully support chunked request forwarding — they may terminate the chunked stream early, dechunk
and rebuffer it, or split the body across multiple backend requests, so fogwall receives a truncated or malformed
request. Small pushes (< 1 MiB) use Content-Length instead and are unaffected, which is why this often shows up only
once a repo or commit grows past that size.
Client-side workaround — force git to send the pack as a single Content-Length request instead of chunked:
git config --global http.postBuffer 524288000
Server-side workaround (nginx) — ensure the proxy buffers the full request body before forwarding and allows a large enough body size:
proxy_request_buffering on;
client_max_body_size 500m;
Sizing memory for pushes
fogwall buffers each request body in memory for the life of the request, in both proxy modes — validation needs the whole pack before it can decide anything. Two settings bound that, and they multiply:
| Setting | Default | Bounds |
|---|---|---|
server.max-push-bytes | 64 MiB | how large one push may be |
server.max-concurrent-requests | 512 | how many run at the same time |
The worst case is max-push-bytes × concurrent large pushes, so raising max-push-bytes means raising the
container’s memory limit to match. Do not set JVM heap flags to compensate: fogwall’s image deliberately ships without
-Xmx so the JVM sizes its heap from the container’s cgroup limit (about 25% of it by default). Setting -Xmx yourself
overrides that and pins the heap regardless of how the container is sized. Give the container more memory instead.
A push over the limit is rejected before the body is read, so it costs no memory and the developer gets a clear message naming the limit rather than a timeout or a connection reset.
Interaction with http.postBuffer. The client workaround above raises the threshold at which git switches to
chunked encoding; it does not change how large a push may be. A push under http.postBuffer declares a
Content-Length, which lets fogwall reject an over-size push without reading anything. Above it, the push is chunked
and fogwall counts bytes as they arrive instead. Both paths enforce the same limit.
If 64 MiB is too small for your estate, prefer these over raising the limit:
- Seed one-off imports and repository migrations directly upstream, then let the proxy handle incremental pushes. A migration is a coordinated, one-time event and does not need to be self-service.
- Push large histories in stages — older commits first, then newer.
- Keep large binaries out of git history in the first place. Note that Git LFS is not currently supported through fogwall (see the User Guide); LFS uploads are refused because fogwall cannot inspect content that travels outside the git protocol.
Sizing disk for pushes
Received pack data is inflated into a per-push quarantine directory on disk before validation runs, and max-push-bytes
caps only the compressed wire size. server.max-object-size-bytes (default 128 MiB) caps what any single object may
inflate to, which stops the cheap decompression-bomb case, but there is no total-decompressed limit: a pack split across
many highly-compressible objects can still inflate to roughly max-push-bytes × 1000 on disk in the worst case before
it is rejected. Quarantine directories are deleted when the request ends, so this is transient pressure, not growth —
but the volume holding the quarantine (the working directory by default) should be sized, or quota’d, with that worst
case and max-concurrent-requests in mind rather than assuming pushes stay near their wire size.
Local mirror clone depth
fogwall keeps a local bare mirror of each upstream repo to inspect push content. Its clone depth is configurable per
proxy mode under cache: (see Configuration Reference). Server mode defaults to
full history; the transparent proxy defaults to a shallow clone, because a first full clone of a very large repository
through the proxy can exceed HTTP connection timeouts. If proxy-mode first-clones are timing out for a large repo, keep
it shallow (the default) or tune cache.proxy.shallow-since; if you want the proxy to mirror full history and can
absorb the first-clone cost, set cache.proxy.clone-depth: 0. A shallow default is safe: reachability and hidden-commit
checks deepen the mirror to full history on demand before deciding.
Inspecting and invalidating the local mirror cache
The Admin → Local mirror cache page (requires ROLE_ADMIN) shows the mirrors each mode currently holds — server
mode and transparent proxy are listed separately — with each mirror’s upstream URL, on-disk size, ref count (expandable
to the branches and tags present), when it was first cloned, and when it last fetched upstream. Two actions are
available: Invalidate removes one mirror, and Invalidate all clears a mode’s cache. Either way the local clone
is deleted and re-created from upstream on the repo’s next push/fetch, so this is the fix for a mirror that has gone
stale or been poisoned (e.g. a failed upstream forward left objects upstream never received) — recovery that previously
required a pod restart. Invalidation is safe on a running server: it deletes the per-repo clone but keeps the cache
directory, and every invalidation is logged with the acting admin’s login.
This state is per-pod — each pod serves its own in-memory cache, so the page reflects the cache of whichever pod
handled the request. To inspect or invalidate a specific pod’s cache in a multi-pod deployment, reach that pod directly
(e.g. via kubectl port-forward to the pod). The same operations are exposed over REST under /api/admin/cache for
scripting.
Production checklist
Database
Default h2-mem loses all push records on restart. For production:
# PostgreSQL — recommended
database:
type: postgres
url: jdbc:postgresql://db.internal:5432/fogwall?sslmode=verify-full&sslrootcert=/certs/ca.crt
username: fogwall
password: secret
# H2 file — zero external dependencies, persistent
database:
type: h2-file
path: /app/.data/fogwall
# MySQL / MariaDB — same config shape, different type
database:
type: mysql # or mariadb
url: jdbc:mysql://db.internal:3306/fogwall
username: fogwall
password: secret
database.type accepts h2-mem (default), h2-file, postgres, mysql, mariadb, or mongo. Schema is applied
automatically via Flyway on startup for the JDBC backends.
TLS
Put fogwall behind a reverse proxy (nginx, Caddy, Envoy) for TLS termination in production. The application can also terminate TLS directly if preferred — see TLS.
For upstream connections to internal GitLab/Bitbucket/Forgejo instances with a corporate CA:
server:
tls:
trust-ca-bundle: /etc/fogwall/tls/internal-ca.pem
This merges the corporate CA with the JVM’s built-in trust anchors so public providers (GitHub, GitLab SaaS) continue to work without changes.
Standalone server image (no dashboard)
The default docker build . produces the dashboard image (FogwallDashboardApplication) — proxy, REST API, approval
UI. For enforcement-only deployments that don’t need the dashboard or approval UI (CI pipelines, automated
environments), build the lighter standalone server target instead:
docker build --target server -t fogwall-server .
This runs FogwallJettyApplication — the git validation and forwarding pipeline with YAML-driven configuration, no
Spring, no React/Node build step, no REST API. It uses the same config override mechanism as the dashboard image (mount
a fogwall-{profile}.yml at /app/conf/, set FOGWALL_CONFIG_PROFILES) and exposes the same port 8080.
A push’s lifecycle here is automated checks and nothing else: decisions are recorded to the database, but there is no review step, because there is nothing to review with. Two settings therefore fail startup on this image rather than being accepted and then never satisfied:
| Setting | Why it cannot work here |
|---|---|
server.approval-mode: ui | A held push waits for a decision over the REST API, which this image does not serve. |
scm-oauth.identity-mode: strict | Only OAuth-verified identities count, and account linking is a dashboard flow. |
Pick the dashboard image if you want either. The two are alternatives, not halves of one deployment.
docker run -e FOGWALL_CONFIG_PROFILES=docker-default \
-v ./docker/fogwall-docker-default.yml:/app/conf/fogwall-docker-default.yml:ro \
-p 8080:8080 fogwall-server
Health check
The dashboard module exposes an unauthenticated health endpoint:
GET /api/health → 200 OK with status payload when the server is up
The standalone server module (fogwall-server) does not expose a health endpoint — use a TCP check against the proxy
port instead.
Imported SSH keys and revocation
Linking an account imports the SSH keys registered on it, marked as coming from that provider. In
scm-oauth.identity-mode: strict those imported keys are the only ones that resolve an SCM identity for an SSH push.
fogwall does not poll providers to notice a key removed upstream, and does not need to: a push is forwarded with the client’s own SSH agent, so a key revoked on the SCM fails there whatever fogwall still holds. Fetches re-sync from upstream with that same agent, and fogwall grants no fetch permission of its own. A user who wants their imported keys re-read unlinks and re-links the account, which imports them again.
Identifying which build is running
The edge image is rebuilt from main on every commit, so a version alone does not identify a deployment. Both modules
log their version and short commit on the first line at startup:
Starting fogwall with dashboard 1.3.2 (f04b5ff)...
The dashboard also reports both over the API, which is the easier one to check against a running deployment:
GET /api → {"version":"1.3.2","commit":"f04b5ffc…","apiDocs":"/api/openapi.json"}
commit reads unknown when the build could not establish one — a docker build run without the BUILD_COMMIT build
argument, for instance. The published images always carry it. fogwall also names its version in the User-Agent it
sends on requests it originates against a provider’s API (fogwall/1.3.2), so a provider-side rate-limit or deprecation
notice can be traced back to a build; requests it merely brokers for a CLI keep that CLI’s own User-Agent untouched.
For Kubernetes (dashboard module):
livenessProbe:
httpGet:
path: /api/health
port: 8080
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
httpGet:
path: /api/health
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
Session timeout
Default session lifetime is 24 hours. Tighten for compliance environments:
auth:
session-timeout-seconds: 28800 # 8 hours
API key
The REST API accepts a single shared API key for machine-to-machine calls (e.g. approval scripts). Change the default before going to production:
# In config or via env var:
FOGWALL_API_KEY: "your-secret-key"
The shared key is a stopgap for automation until proper machine auth is available. It carries no user identity — all calls made with it are unattributed. Prefer session-based access (log in as a named service account) for any automation that needs an audit trail.
Note
Roadmap: Per-user and per-service API keys, and an OAuth2 resource server mode for machine-to-machine auth, are tracked in #57. Until then, treat the shared key as a temporary measure and rotate it regularly.
SSH transport
Available since v1.3.0.
fogwall can accept pushes over SSH on port 2222 (default). This is an alternative to the HTTP push path — not a replacement. SSH transport and HTTP transport run side-by-side; a provider can be reached via either or both.
Exposing SSH on the standard port
The container never binds port 22 directly — that would need root or CAP_NET_BIND_SERVICE, the same constraint that
already keeps the HTTP listener on plaintext 8080 behind your load balancer’s TLS termination for 443. Apply the same
pattern for SSH: a plain TCP/L4 passthrough rule (external :22 → the pod’s :2222) needs no app or container change,
since SSH is a single TCP stream with no Host-header-style routing for an L7 proxy to key off. The Helm chart’s
sshService.* values do this out of the box.
This matters for clients: Git’s SCP-like shorthand (git@host:owner/repo.git, what GitHub’s own
git@github.com:owner/repo.git uses) has no field for a non-default port — only the explicit ssh://host:port/path
form does. Without the port-22 passthrough above, your users are stuck with the explicit form (see
Adding an SSH remote in the user guide). With it, the shorthand form works
unchanged, since fogwall’s own command parsing doesn’t care which URL syntax the client’s git produced it from.
How SSH identity verification works
The SSH push path enforces the same compliance guarantee as the HTTP path — every push is tied to a verified SCM user — but the mechanism is different because there is no token available:
- Inbound MINA auth (connection gate): the client’s public key must be registered in the pusher’s fogwall profile
(
ssh-keys). This is equivalent to HTTP Basic auth — it authenticates the proxy user. - SCM identity verification (compliance gate): fogwall calls the provider REST API to fetch the SSH public keys
registered by each SCM identity linked to the proxy user, then checks whether the connecting key’s SHA-256
fingerprint is among them. If it is, the push record’s
scmUsernameis set to the matching SCM login. If it is not, the push is blocked — the same outcome as a failed token verification on the HTTP path.
Both steps are required. Step 1 alone is not sufficient — a key registered only in fogwall (but not on the SCM) will clear MINA auth but fail step 2.
Provider support: fingerprint lookup is implemented for GitHub, GitLab, Forgejo, and Gitea. Providers that do not implement this lookup (Bitbucket, generic proxy) will block all SSH pushes fail-closed. SSH is intentionally not supported for those providers until a compliant identity verification path exists.
Configuring a provider for SSH
The single-entry model below is available since v1.4.0 (earlier releases required a separate ssh:// provider entry).
SSH transport is a property of a provider entry — the same entry serves both HTTP and SSH. Turn it on with an ssh:
sub-block. For a self-hosted Gitea instance:
providers:
gitea:
type: gitea
uri: https://gitea.corp.example.com # HTTP/API endpoint (also used for SSH-key identity lookup)
api-token: <service-account-PAT> # see below
ssh:
enabled: true # also serve SSH; endpoint derived as ssh://git@gitea.corp.example.com
# uri: ssh://git@gitea.corp.example.com:3022 # set explicitly for a non-standard SSH port or username
With ssh.enabled: true and no ssh.uri, the SSH endpoint is derived as ssh://git@<host> from the provider’s HTTP
uri. Set ssh.uri explicitly when the upstream uses a non-git SSH username (GitHub Enterprise Cloud with data
residency uses the enterprise slug: ssh://{slug}@{tenant}.ghe.com) or a non-standard SSH port. The path clients use is
ssh://fogwall-host:2222/<provider-host>/<org>/<repo>.git, keyed on the provider’s HTTP host.
Permissions, access rules, and SCM identities are all keyed by the single provider name and apply to both transports:
permissions:
- username: alice
provider: gitea # one entry covers HTTP and SSH pushes
match:
target: SLUG
value: /myorg/.*
type: REGEX
grant: PUSH
SCM identity link for SSH
Because HTTP and SSH share one provider entry, a user needs only one scm-identities entry — it applies to both
transports. The provider ID is the provider’s name in the providers: block:
users:
- username: alice
scm-identities:
- provider: gitea # applies to HTTP and SSH pushes alike
username: alice-gitea
An identity linked via OAuth (see SCM OAuth) likewise applies to both transports — a user who links their account over HTTP can then push over SSH with no extra configuration.
Upstream host key verification
When fogwall forwards an SSH push it authenticates to the upstream SCM using the developer’s forwarded SSH agent. The upstream host key is what binds that agent to the genuine provider, so fogwall verifies it and fails closed by default: an unknown or changed upstream host key aborts the forward. (Without this, an attacker able to redirect the upstream connection would receive the developer’s forwarded agent — an account-takeover primitive.)
Trust is resolved in this order:
-
Bundled defaults. fogwall ships pinned host keys for its built-in hosts — github.com, gitlab.com, codeberg.org, bitbucket.org, gitea.com — so they work out of the box. Regenerate with
scripts/pin-ssh-host-keys.shwhen a provider rotates its key. -
Pinned in config (recommended for custom providers). Pin a private/internal SCM’s host key with a standard
known_hostsline — globally underserver.ssh.extra-known-hosts, or per-provider under that provider’sssh.known-hosts(scoped to its upstream, since known_hosts lines are host-keyed):server: ssh: extra-known-hosts: - "git.internal.example.com ssh-ed25519 AAAA..." providers: gitea: uri: https://gitea.corp.example.com ssh: enabled: true known-hosts: - "gitea.corp.example.com ssh-ed25519 AAAA..." # known-hosts-path: /etc/fogwall/gitea_known_hosts # or point at a file -
Operator-supplied file. Point
server.ssh.known-hosts-pathat aknown_hostsfile. The container image bakes the bundled keys at/etc/fogwall/known_hosts; mount your own file there (or anywhere, and set the path) to add or rotate host keys without upgrading fogwall. -
Trust on first use (opt-in).
server.ssh.trust-on-first-use: truepins an otherwise-unknown host’s key on the first connection — logged loudly with its fingerprint — and rejects a later change. Convenient for internal providers on a trusted network whose key can’t be pinned ahead of time; it is not a substitute for pinning across an untrusted network. Default isfalse(unknown key rejected).
Effective trust is the union of the bundled/configured file, the inline extra-known-hosts, and any TOFU-pinned keys.
The api-token requirement
The provider REST API is called to fetch SSH public keys for registered SCM identities. GitHub’s endpoint
(GET /users/{login}/keys) is public — no token is needed. Forgejo and GitLab require authentication when the instance
is configured with REQUIRE_SIGNIN_VIEW=true (common in corporate deployments where the git server is not publicly
accessible).
Create a service account on the upstream SCM and generate a PAT with read:user scope (Forgejo) or read_user scope
(GitLab). This account does not need repository access — it only needs to list user SSH public keys. Set the token in
the provider config:
providers:
gitea:
type: gitea
uri: https://gitea.corp.example.com
api-token: <service-account-PAT>
ssh:
enabled: true
There is no environment variable override for api-token (the env var mechanism does not support hyphenated config
keys). Use a profile config file to supply the token outside of the checked-in base config:
# /app/conf/fogwall-local.yml (mounted into the container, not committed)
providers:
gitea:
api-token: gta_xxxxx
api-uri — when it is needed
The provider’s uri is the HTTP/HTTPS endpoint, so the REST API base is derived from it directly and no api-uri is
needed in the normal case.
api-uri is only required when the HTTP API runs on a non-standard port on the same host — for example a local
development Gitea where HTTP is on 3000 and SSH on 3022:
gitea:
type: gitea
uri: http://localhost:3000
api-uri: http://localhost:3000
ssh:
enabled: true
uri: ssh://git@localhost:3022
Requiring agent forwarding
Fogwall uses the client’s forwarded SSH agent to authenticate outbound SSH connections to the upstream SCM. The client
must connect with ssh -A (or ForwardAgent yes in ~/.ssh/config). If agent forwarding is absent, the push is
blocked with a clear error:
error: SSH agent forwarding required — connect with 'ssh -A' or set 'ForwardAgent yes' in ~/.ssh/config
There is no configuration to disable this requirement — fogwall never reads local identity files for upstream auth.
SCM OAuth account linking
Available since v1.4.0.
Lets developers link their proxy account to an upstream SCM identity via OAuth from their profile page, instead of typing a free-text SCM username. See SCM OAuth for the full config reference; this section covers operator setup steps and operational behaviour.
Registering a GitHub App
For identity linking only — verifying a user’s GitHub identity and importing their emails and SSH keys — fogwall’s
linking flow works with a GitHub App (GitHub’s currently recommended integration type), and this is the least-scope
option. If you also enable the dashboard issue feature (issues-enabled) for a GitHub provider, use a classic
OAuth App instead — see the note below, because a GitHub App cannot do it.
- Create the app under your org’s GitHub settings (or a personal account, for testing).
- Account permissions — grant exactly:
- Email addresses: Read-only
- Git SSH keys: Read-only
- Callback URL:
https://<your-fogwall-host>/api/scm-oauth/<provider-name>/callback, where<provider-name>is the top-levelproviders:key this app’soauth:block is nested under — e.g.github, not the literal string “github.com”. This is always your fogwall host, regardless of whether the provider instance points at github.com, a GHEC-with-data-residency*.ghe.comtenant, or a self-managed GHES host — only the outbound authorize/token/user-API calls fogwall makes differ by host, not where GitHub calls back to.<your-fogwall-host>is exactlyserver.service-url(the bare origin, no path suffix — see the breaking-change note below if you’re upgrading from a pre-1.4.0 release). - Generate a client secret and note the client ID. No private key is needed — a GitHub App’s private key authenticates the app/installation itself (server-to-server), which this user-to-server linking flow never uses; only the client-id/client-secret pair is used, for the token exchange.
- Install the app on your GitHub org (or your personal account, for testing) so it can be authorized by member accounts.
Repeat with a second, separately registered app for each additional GitHub-type provider instance you run (e.g. one app
for github.com/GHEC, a second for a *.ghe.com tenant) — each needs its own client-id/secret, set under that distinct
providers: entry’s own nested oauth: block.
GitHub issue filing needs a classic OAuth App, not a GitHub App
The dashboard issue feature has fogwall act as the user to write issues on whichever repository they hold the grant for. A GitHub App cannot do this: it is installation-gated — even its user-to-server tokens only reach repositories the app is installed on, and installation requires repo/org admin, so it can never act on a public or third-party repo the user does not administer. A classic OAuth App is the only GitHub credential whose user token acts on any repository the user can access, no installation.
So for a GitHub provider with issues-enabled, register a classic OAuth App (GitHub → Settings → Developer settings
→ OAuth Apps, a different registration from GitHub Apps) with the same callback URL shape as above. fogwall requests
the scopes it needs at link time — read:user, user:email, read:public_key (identity, emails, SSH keys) plus
repo (the issue write; GitHub has no “issues only” OAuth scope, so repo is the narrowest that permits it, or
public_repo if only public repositories are in scope). The repo scope is only added when issues-enabled is set, so
an identity-only deployment still requests read-only scopes. Keep the GitHub App for fogwall’s own installation-token
operations (service-account features); use the OAuth App for user-on-behalf issue filing.
Registering a GitLab OAuth application
Under User Settings → Applications (or your GitLab instance’s admin area for an instance-wide app): set the same
callback URL shape as above, and check the read_user scope. fogwall’s authorize request asks for exactly this
scope for a GitLab-type provider — unless the provider has issues-enabled, in which case it asks for the broader
api scope (there is no narrower GitLab scope that permits an issue write), so grant that scope on the application
instead. It isn’t otherwise configurable.
Registering a Forgejo/Gitea OAuth application
Under the instance’s own Settings → Applications page: register an OAuth2 application with the same callback URL
shape as above, and note the generated client ID/secret. Request the read:user scope — fogwall’s authorize request
asks for exactly this for a forgejo-type provider, unless the provider has issues-enabled, in which case it also
asks for write:issue so it can file issues on the user’s behalf.
This works against a self-hosted Forgejo/Gitea instance you administer, and also against Codeberg — its OAuth2 applications live under Settings → Applications → Manage OAuth2 Applications (or under an organization’s own settings, for an org-owned app), same flow as any other Forgejo instance.
What strict identity mode changes operationally
With scm-oauth.identity-mode: strict, CheckUserPushPermissionHook only honors OAuth-verified SCM identities for push
authorization — on both HTTP and SSH transports. A user whose only SCM identity is manually/free-text entered (or who
hasn’t linked one at all) gets a clear push-time rejection pointing them at the profile page to link via OAuth. There is
no fallback to permissive behavior if OAuth linking becomes unavailable (see token encryption key handling below) — the
two are deliberately decoupled: a token-encryption problem disables the link/callback endpoints, never push
authorization, so strict mode’s guarantee can’t be silently weakened by an infrastructure fault.
POST /api/me/identities (manually adding an SCM identity) is also disabled in strict mode, both in the dashboard UI
and server-side on the endpoint itself — a manually-entered identity would never actually be usable for push
authorization in this mode, so allowing it to be added would only create a confusing dead state. This is narrower than
it might sound: it does not affect POST /api/me/emails — commit-author-email verification is governed by the
independent commit.attribution-policy setting, not scm-oauth.identity-mode.
What happens on unlink
DELETE /api/scm-oauth/<provider>/unlink (the “Unlink” button in the profile page’s SCM Identities tab) removes:
- the verified SCM identity itself
- the stored OAuth token (with a best-effort revocation call to the provider)
- any SSH keys that were imported from that provider
- any emails that were imported from that provider’s verified-emails list
If the same SSH key or email was also verified by a second linked provider (e.g. the same key registered on both GitHub and GitLab), unlinking one only removes that provider’s claim on it — the key/email stays registered, now attributed solely to the remaining provider(s), and is only fully removed once no linked provider claims it anymore. Re-linking is always available to restore the identity and re-import SSH keys/emails if needed.
Production checklist addition: token encryption key
See Production checklist below for database/TLS. For SCM OAuth specifically: generate a 32-byte key and mount it as a secret rather than relying on the local-devex auto-generated fallback:
openssl rand -base64 32 > fogwall-scm-oauth-key
scm-oauth:
token-encryption-key-path: /run/secrets/fogwall-scm-oauth-key
If this is left unset, fogwall auto-generates and persists a key under ./.data/ and logs a loud WARN on every
startup — fine for local development, but that file may not survive a container restart/redeploy in production. If lost,
every linked user simply needs to re-link (push authorization is never affected).
SCM API
Available since v1.4.0, opt-in per provider.
Extends fogwall past git push into the rest of the contribution lifecycle — proxying the gh CLI’s issue/PR
create-edit-comment-review traffic through the same identity resolution, permission engine, and audit trail as the
git-push path. See SCM API proxy for the full config reference and
docs/internals/scm-api-proxy.md for the design rationale; this section covers what an
operator needs to understand before turning it on.
Token model and the egress assumption
Developers bring their own personal access token. fogwall forwards it upstream unchanged after inspecting the request, and never mints or supplies a credential for this path.
SCM OAuth is a separate mechanism, for fogwall-managed operations — today the account-linking UI, later potentially fogwall acting on a user’s behalf. It does not provide a token for a CLI.
Enforcement is content interception, not the credential. So what you get here depends on something fogwall does not provide: direct access to the SCM API has to be blocked elsewhere, or a developer can bypass the proxy with the same token. fogwall governs the sanctioned API host and inspects what goes through it; organization-wide egress control is better served by traditional web proxies and network security appliances.
The git-push path already works this way — a developer with a valid PAT can git push straight to github.com if nothing
stops them.
Enabling it
One switch, off by default, per provider — plus the port that listener will bind:
providers:
github:
scm-api:
enabled: true
port: 9443 # required — see "Each provider needs its own port" below
Each enabled provider needs its own port. The SCM API proxy does not share the main fogwall server port that serves
git traffic, and does not sit under a URL path: the dialect is mounted at the root of a dedicated listener
(/api/graphql for GitHub, /api/v4/* for GitLab, /api/v1/* for Gitea/Forgejo). That is forced by the clients — gh
and fj address the API from the host root and silently discard any path prefix — and a single shared listener would
collide between two instances of the same platform, since every GitLab claims /api/v4. fogwall refuses to start if a
provider has scm-api.enabled: true with no port, rather than opening a listener no CLI could reach. Developers are
then given a host and port; see the user guide.
The dashboard’s Providers page shows an SCM API badge on each provider that has the proxy enabled, alongside the HTTP/SSH transport badges. It is presence-only — it confirms the capability is on, not how to connect, since the connect address is deployment-determined (dedicated port, its own TLS termination).
The caller’s User-Agent and the CLI version it advertises are recorded on every audit record — how you spot a CLI
upgrade changing its wire format. fogwall does not gate on it: User-Agent is client-set and forgeable, so nothing
branches on it.
TLS on the SCM API listeners
Every one of these ports has to be reachable over HTTPS. gh, glab, tea and fj all address a custom host over
HTTPS and give you no way to ask for plain HTTP, so a plaintext listener is unreachable by the tools it exists to serve.
TLS must terminate somewhere in front of it — you have two shapes:
- Terminate at the edge. An ingress, route, or load balancer per provider port, with fogwall’s listeners left on plain HTTP behind it. This is the usual Kubernetes/OpenShift shape: one Service exposing the SCM API ports, one Ingress per provider, each with its own hostname and certificate.
- Terminate at fogwall. Configure
server.tlsand every SCM API listener inherits it automatically — same certificate, its own port. There is no per-provider TLS block to configure: the certificate is issued per hostname and these listeners differ only by port. If you give each provider its own hostname and terminate at fogwall, the certificate’s SANs must cover all of them.
fogwall can’t tell whether something upstream is terminating TLS for it, so it doesn’t guess: with server.tls unset it
logs a warning at startup naming each plaintext listener. That warning is expected and harmless in the edge-termination
shape. Treat a CLI reporting a connection or handshake error against an SCM API port as this, until ruled out.
If you terminate at fogwall with a certificate from an internal CA, the CLIs are Go binaries and will need that CA in
their trust store (or SSL_CERT_FILE pointing at it) — worth saying in whatever you hand developers.
If you terminate at the edge, check that your ingress does not decode or normalise the request path. GitLab
addresses a project as a single owner%2Frepo segment, and Gitea encodes a repository-relative file path into one
segment of its blob endpoints. Both encoded slashes have to reach fogwall intact: decoded, the segment splits and the
request names a different repository, which fogwall refuses. nginx-ingress changes path handling once a rewrite-target
with a capture group is involved; HAProxy-backed OpenShift Routes are generally pass-through.
The failure mode is narrow enough to be confusing — GitLab denied or 404ing while GitHub works fine — so confirm it
rather than assume it. curl a project path with an encoded slash through the ingress and check what fogwall logs as
the request URI.
What the allowlist permits
Enabling a provider does not expose its API. fogwall forwards a fixed set of operations, held in code rather than configuration, and denies everything else:
| permitted | denied |
|---|---|
| issue create, edit, close, comment | submitting a review, approving |
| PR/MR create, edit, close, comment, merge | branch deletion, auto-merge, merge queues |
| label, assignee and reviewer-request changes | release, tracked-time, dependency and project-board endpoints |
Labels, assignees and reviewers are permitted whichever way the CLI sends them — as fields on the create, or as the separate follow-up call each CLI makes when the same attribute is changed by an edit. Requesting a review is permitted; submitting one is not, and they are different endpoints. Merge is a separate grant from the rest of this table — see Merging.
Anything the allowlist does not recognise is denied, so a CLI reaching a new endpoint after an upgrade is refused rather than forwarded. Per-CLI command names are in User Guide; the endpoint and mutation tables behind them are in docs/internals/scm-api-proxy.md.
Authorization
Per-repo authorization for mutations goes through the ordinary permissions: mechanism — grant a user PROPOSE on the
repos they should be able to file issues/PRs against (see Permissions). PROPOSE is
its own grant: a user can hold it without push access, or hold PUSH without it.
It covers the full request surface of the allowlisted endpoints on a matching repo, not only title, body and comment —
and not only fields. Where a CLI changes an attribute through its own call rather than a field, that call is
allowlisted too and authorized against the same repo: GitHub sends replaceActorsForAssignable for any --assignee and
requestReviewsByLogin for any --reviewer, and Gitea reaches POST /issues/{n}/labels for --add-labels. Both forms
are behind one of the CLIs’ own flags (glab mr update --target-branch, gh pr edit --base, --add-assignee,
--add-label, --milestone, --lock-discussion), and tea PATCHes the whole object on every edit. Three
consequences:
- A pull/merge request’s base branch can be retargeted, always within the same repository — no allowlisted edit endpoint takes a repository-valued field, so a pull/merge request cannot be moved elsewhere.
- A few associations reach beyond the repo: GitHub projects are org-level, and on GitLab a milestone or epic can be group-level. (GitHub and Gitea milestones are repo-scoped.) This is the only effect not confined to the matched repo.
- One command is often several audited operations.
gh pr create --label --assignee --revieweris a create plus three follow-up calls, each authorized against the same repo and recorded separately, so the audit trail holds more rows than the developer ran commands.
Submitting or approving a review stays out of reach: no allowlisted endpoint reaches it. Requesting a reviewer is permitted — a different operation from giving the verdict.
Merging
Merging a pull/merge request through fogwall is off by default and gated twice — both are required:
merge-enabled, per provider (underscm-api, default off) turns the capability on. Merge is the highest-consequence operation on this path, so exposing it is a deliberate choice rather than a side effect of enabling the SCM API proxy.- The
MERGEgrant, standalone fromPROPOSE: a contributor who can open a pull/merge request cannot merge one with the same grant, and a maintainer given onlyMERGEcannot open one. Grant it the same way asPROPOSE(see Permissions).
With the capability off, a merge is refused even for a caller who holds MERGE. Merging is reached through the CLI
merge commands (gh pr merge, glab mr merge, tea/fj pr merge); the MERGE grant is assignable in the dashboard,
but a dashboard-driven merge action is not built yet.
When require-validated-head (see SCM API proxy) is on, it is enforced at merge as well,
and conclusively so — a create that passed can be undone by a later push, but the branch about to merge is final.
Enforcement stays opt-in; the check is the same one the SCM API config docs describe.
Two per-dialect limits come from CLI behaviour rather than fogwall (the SCM API proxy notes have the detail):
fj pr mergeis refused wheneverrequire-validated-headis on —fjsends no head commit for fogwall to validate against.- The audit record’s merge commit SHA is populated only for GitLab merges — GitHub’s and Gitea/Forgejo’s merge responses do not carry one.
Out of scope: branch deletion after merge, auto-merge, merge queues, and required-status evaluation. fogwall refuses nothing upstream would otherwise allow, except on provenance.
Content inspection
The prose an SCM API entity carries — a pull/merge request title and description, a comment body — is inspected before
it is forwarded, against three sets of rules: the blocked literals and patterns in scm-api.block; gitleaks, when
secret-scan.enabled is on; and the built-in PII/identifier bundles, when content-patterns.enabled is on with at
least one bundle selected. scm-api.block is separate from diff-scan.block: one governs pushed diffs, the other SCM
API content. Secret scanning and the pattern bundles are shared with the push path — neither is diff-specific.
This is not optional hardening. Without it, a contributor blocked from pushing a secret can paste the same secret into a pull request description and fogwall relays it verbatim.
Inspection reads the whole request body, not a list of known fields: every key and scalar in the JSON, at any depth, plus the raw bytes. The raw reading covers anything an extractor does not name — a dialect gaining a new prose field stays covered — while the decoded reading defeats escaping, since a token written as an escape sequence matches nothing as raw text but is plain once decoded. GitHub adds a third reading, the GraphQL query’s own literals: a GraphQL request wraps its query in JSON, so decoding the transport leaves GraphQL’s own string escaping intact, and arguments inlined in the query text never appear as JSON values at all.
A content violation is recorded as REJECTED. DENIED is for operations that are not allowlisted, or that the caller
holds no PROPOSE grant for.
scm-api:
block:
literals:
- "internal.corp.example.com"
patterns:
- '(?i)https?://[a-z0-9.-]*\.corp\.example\.com\b'
With no scm-api.block entries configured, only secret scanning applies.
Secret scanning fails closed here, unlike the push path: if scanning is enabled but the scanner cannot run, the request is refused. A push that slips through is still recorded and reviewable afterwards, whereas a forwarded request has already published its text upstream where fogwall cannot reach it.
PII bundles block here, rather than warning
Content-pattern bundles (content-patterns.bundles — SIN, SSN, NINO and the rest) are
WARN-only on the push path: a match is surfaced to the human reviewer
every push already requires, and never blocks. An SCM API entity has no such reviewer — it is forwarded or refused — so
a warning recorded against a description that is already upstream is not a control. A match therefore refuses the
request, recorded as REJECTED alongside the data type and jurisdiction. The matched value itself is never written to
the audit record; it is the thing the rule exists to withhold.
Set content-patterns.scan-scm-api: false to keep bundle scanning on pushes while leaving SCM API content to
scm-api.block and secret scanning alone.
When content inspection is what refused a request, the request variables are deliberately not stored on the audit record. The offending text is the payload, so keeping it would put the secret fogwall just blocked into fogwall’s own database; the recorded reason still names the rule that matched, with the matched value redacted by the scanner.
Why reads and mutations are gated differently
Mutations get real per-repo enforcement, checked against that user’s PROPOSE grants — how the target repo is
determined differs by dialect: GitHub’s GraphQL mutation carries only an opaque node ID, resolved to owner/repo via a
cache (TTL is itself a security parameter — see the configuration reference); GitLab’s REST calls carry owner/repo
directly in the URL, so no resolution step is needed. Reads (gh issue list, glab mr list, etc.) are not
individually resolved or permission-checked in any dialect — they are forwarded for any authenticated caller, which
keeps read traffic cheap. Per-repo read gating is not currently implemented.
Audit trail
Every proxied mutation produces one audit record — who, the resolved repo, the operation performed, the request
payload (GitHub’s GraphQL variables, or the REST body on the other dialects), and the allow/deny outcome — following the
same auditability bar as the push path. The payload is dropped from a record whose content inspection refused the
request, so a secret fogwall blocked is not kept by fogwall; the reason names the rule and field instead. These are
viewable in the dashboard under Contributions (a plain list, no approval workflow — these are already-decided audit
records), or queryable directly from the scm_api_action_records table/collection.
A forwarded mutation’s record also carries what the upstream answered: its HTTP status, and — read from the
upstream’s own response — the pull/merge request or issue the mutation created or touched. Those live in a second
table/collection, scm_api_entities, keyed on what the upstream calls the thing (provider, repository, kind, number),
holding its URL, title and current state as last reported through fogwall, and pointing back at the action records that
created and last touched it. The audit log stays append-only; the registry is what changes when a pull/merge request or
issue opened through fogwall is later closed through it. One opened elsewhere and then edited or closed through fogwall
is registered from that response too. The one gap is GitHub: gh’s edit, close and merge mutations return nothing but
an acknowledgement, so a GitHub pull/merge request fogwall never saw created stays unregistered until a response names
it.
A refused request is recorded too, once the caller has been authenticated — including one fogwall turned away
because the endpoint matched no allowlist rule, where there is no operation to name and mutation_field is null (the
reason carries the method and path instead). That case is how you notice a CLI upgrade has started calling an endpoint
the allowlist doesn’t know: the failures show up here, attributed to a user and a client version, rather than reaching
you as a bug report.
Two things are deliberately not recorded. Successful reads produce no record, which is what keeps the read path close to pass-through. Neither does anything refused before authentication — an unrecognised client, or a token that resolves to no user — matching the push path, where a request rejected before it parses as a push writes no push record. It also means writing to the audit trail costs valid credentials, so it can’t be filled by an anonymous caller.
Reads are also not restricted by path: a GET against the provider’s API, or a GraphQL query, is forwarded for any
authenticated caller. A read changes nothing upstream, and it returns only what the caller’s own token would return
asking the provider directly — fogwall relays that token and grants nothing on top of it. What it does mean is that if
fogwall is the sanctioned route to a self-hosted provider inside your perimeter, read traffic through it is not in
fogwall’s audit trail. The provider’s own access logs are the record of what was read. Worth knowing you are relying
on them, rather than discovering it during an investigation.
Filing issues from the dashboard (no CLI)
Reporting a bug or joining an issue discussion needs no code access, so fogwall also lets a developer create, edit, comment on and close/reopen issues through the dashboard’s Issues page — no CLI to install and no personal token to manage. It is enabled per provider, separately from the CLI proxy above and off by default:
providers:
github:
issues-enabled: true
issues-enabled sits directly on the provider, not under scm-api: unlike the proxy, this feature makes the calls
itself on the user’s behalf, so it needs no dedicated listener or port. It does need the user to have linked their
account for that provider via OAuth (see SCM OAuth account linking) — fogwall acts as them — and it uses
each provider’s plain issue REST API, covering the same providers as the proxy (GitHub, GitLab, Forgejo/Gitea). A
provider is offered in the form only when it is both issues-enabled and linked by that user.
Two grants permit it: the narrow ISSUE grant (issues only) or PROPOSE, which is a superset — so someone can be
permitted to file bugs while holding no ability to push code or open a pull request. The issue title and body pass
through the same content inspection as any SCM API entity (secret scanning, blocked literals and patterns,
content-pattern bundles) before they leave, and every attempt writes one record to the audit trail above, marked with
the dashboard client type. Fail-closed throughout: no grant, no linked account, or a content match, and the operation
is refused.
Common operational problems
Push is rejected with “repository not permitted”
Check both layers:
- Is the repo in
rules.allow? Verify the slug matches exactly (including leading/). - Does the user have a
permissionsentry for this provider + path withgrant: PUSH?
Push hangs waiting for approval indefinitely
The server is in ui mode and no reviewer has approved the push. Either:
- Any authenticated user (other than the pusher) can open the push record and approve it in the dashboard.
- Or grant the pusher
SELF_CERTIFYpermission so they can approve their own clean pushes.
If require-review-permission: true is set, only users with an explicit REVIEW permission entry for the repository
can approve.
Push blocked: identity not linked
The proxy cannot match the token to a registered proxy user. This check is always enforced — attribution-policy mode
does not affect it. Check:
- Does the user’s profile have an
scm-identitiesentry for the correct provider? - Does the token have the required API scope to call
GET /user?
SSH push rejected: SSH key not linked to any SCM identity
The fingerprint of the connecting SSH key was not found among the keys registered on the upstream SCM for the linked SCM identity. Possible causes:
- The key is in fogwall (
ssh-keys) but not on the upstream SCM account. Have the user add it in their SCM account settings. - The linked
scm-identitiesentry refers to the wrong provider or username. The provider must be the SSH provider name (e.g.gitea-ssh), not the HTTP provider name (gitea). - The
api-tokenis missing or has expired, causing the key lookup to return an empty list. Check the server log forFailed to fetch SSH keys for ... user '...'and renew the token.
SSH push rejected: SSH identity verification not supported by provider
The provider used for this push does not implement SSH fingerprint lookup (e.g. type: generic), and the connecting
key is not one that OAuth linking imported. The SSH path is fail-closed — pushes are blocked unless the connecting key
can be tied to an SCM account one way or the other. Either switch to a supported provider type (forgejo, gitlab, or
github), or have the user link their account so their keys are imported with it. Opt-in fail-open behaviour for
unsupported providers is planned as a follow-up feature.
SSH push rejected in strict mode for a key the user has linked
A public SSH key can only be registered to one fogwall user. If someone else’s account already holds the fingerprint — anyone can paste any public key into their own profile — the import at link time skips that key and logs:
Skipping SSH key from '<provider>' for user '<user>': fingerprint already registered to a different proxy user ('<owner>')
The link itself succeeds, so nothing tells the user their key was left out. In scm-oauth.identity-mode: strict the
result is a refused SSH push for the key’s actual owner, since only imported keys resolve an identity there. Remove the
key from the account holding it — Users → the other account → SSH Keys — and have the owner unlink and link again to
re-import.
Push blocked or warned: commit email mismatch
One or more commit author/committer emails are not registered to the authenticated user. This is controlled by
attribution-policy:
- In
strictmode the push is blocked. Check that the user’s email list includes the address they commit with (git config user.email), and that the commits were not authored by someone else. - In
warnmode the push goes through but the mismatch is logged and visible in the push record. Switch tostrictonce you are confident emails are populated for all users.
SSH push still blocked after adding a key to the SCM account
The SSH fingerprint enricher caches results per (provider, scm-login) with a 7-day TTL. If a user registers a new SSH
key on their SCM account after the cache was last populated, the new fingerprint will not be visible until the entry
expires or the server restarts. To force an immediate re-fetch without a restart, the operator can reload config (if
live reload is configured) or restart the server. Future pushes from that user will populate a fresh cache entry.
The same applies when a key is removed from the SCM account — the old fingerprint remains cached until TTL expiry.
OIDC login fails / redirect loop
- Enable the Spring Security debug profile (
docker/log4j2-debug.xml) — see Debug profiles. - Check the redirect URI registered in the IdP matches
https://<your-host>/login/oauth2/code/fogwallexactly. - For Entra ID: make sure
issuer-uriends in/v2.0andskip-user-info: trueis set — see the Entra ID section of the configuration reference.jwk-set-uriis not needed for Entra: it is a plain endpoint override and no longer changes how tokens are validated.
OIDC login fails with “Claim ‘…’ not present in the ID token”
The configured auth.oidc.user-name-attribute names a claim your IdP did not include. The OIDC spec guarantees only
sub in an ID token — email and the rest are voluntary, and IdPs differ in what they send (Entra ID, for example,
omits email unless the optional claim is added to the app registration). Either configure the IdP to include the
claim, or point user-name-attribute at one it actually sends. The warning in the application log lists the claims that
were present.
Upgrading from a pre-1.2.0 deployment: OIDC redirect URI mismatch (AADSTS50011)
The project was renamed in 1.2.0, which changed the Spring Security OAuth2 registration ID from gitproxy to fogwall.
This shifts the callback URL that fogwall sends to the IdP in the authorization request:
| Version | Redirect URI sent to IdP |
|---|---|
| < 1.2.0 | https://<host>/login/oauth2/code/gitproxy |
| ≥ 1.2.0 | https://<host>/login/oauth2/code/fogwall |
Fix: add the new URI to your IdP app registration alongside the existing one. In Entra ID: App registrations → your
app → Authentication → add https://<host>/login/oauth2/code/fogwall as a redirect URI. Both URIs can coexist — remove
the old one once all deployments are on 1.2.0+.
Upgrading from a pre-1.4.0 deployment: server.service-url no longer includes /dashboard
Before 1.4.0, server.service-url was expected to already carry whatever path prefix your reverse proxy or load
balancer put the dashboard behind (typically https://<host>/dashboard), and fogwall concatenated routes directly onto
it. As of 1.4.0 (introduced alongside SCM OAuth account linking, #40, which needs to build a callback URL for a REST
endpoint that isn’t under the dashboard’s own path) service-url must be the bare origin instead — fogwall appends
/dashboard, /api, etc. itself.
Fix: if your existing service-url ends in /dashboard (or any other path), drop that suffix. This is not optional
to skip — leaving the old value in place means push-record links and the “identity not linked” hint in sideband messages
point at the wrong path (.../dashboard/dashboard/push/<id>, a 404), and OAuth linking’s callback URL registered with
your GitHub App/GitLab OAuth app won’t match what fogwall actually sends.
Gitleaks produces no output / scan appears to be skipped
Check logs/application.log for lines containing gitleaks. The log will show which binary path was resolved and
whether the scan ran. If the binary cannot be executed (permission denied, noexec mount), the proxy falls back to
skipping the scan rather than failing the push — add gitleaks to PATH or set scanner-path explicitly.
Push fails after approval with an upstream error (404, 403, etc.)
Once a push passes validation and is approved, the proxy forwards it to the upstream SCM transparently — no further processing occurs. Any error from the upstream is passed straight back to the git client exactly as if the developer were pushing directly.
Common upstream errors and their causes:
| Error | Likely cause |
|---|---|
Repository not found / 404 | The token does not have access to the repository. GitHub returns 404 (not 403) for both missing repos and insufficient permissions on private repos — this is intentional on GitHub’s part to avoid leaking repo existence. |
403 Forbidden | The token has repo access but lacks the required write scope (e.g. a fine-grained PAT missing Contents: write). |
pre-receive hook declined | The upstream has its own server-side hooks that rejected the push. Nothing the proxy can do — the developer needs to resolve it upstream. |
remote: error: GH006: Protected branch | The target branch has branch protection rules on the upstream. Again, upstream-side — not a proxy issue. |
These errors appear in the developer’s terminal and in the push record in the dashboard. They are not logged as proxy errors — from the proxy’s perspective the forwarding succeeded.
Diagnosing token scope issues: if a push consistently fails with 404 or 403 immediately after approval, ask the developer to test the same push directly (bypassing the proxy) with the same token. If it also fails direct, the problem is the token — not the proxy.
Permission denied on startup in a container
JGit failed to write to $HOME or /tmp. Verify:
docker exec <container> sh -c 'ls -la $HOME && touch $HOME/.probe && rm $HOME/.probe'
docker exec <container> sh -c 'touch /tmp/.probe && rm /tmp/.probe'
If either fails, see JGit filesystem requirements above.
Configuration Reference
fogwall uses layered YAML configuration merged at startup. A base file ships with the jar; additional profile files and environment variable overrides are applied on top in a defined order.
A section introducing a new config surface is tagged with the release it first shipped in, e.g.
_Available since v1.3.0._, right under the heading. Untagged sections predate this convention — it isn’t backfilled retroactively, only applied going forward from the section’s introduction.
Contents
- Configuration files and profiles — load order, profiles, and the bundled profile files
- Environment variable overrides — mapping a YAML key to a
FOGWALL_*variable - Server settings — ports, service URL, timeouts, and session persistence
- Local mirror cache — the local clone fogwall keeps of each upstream repo
- TLS — the HTTPS listener and trusting a custom upstream CA
- Outbound proxy — reaching upstreams through a corporate HTTP proxy
- Database — JDBC and MongoDB backends, pool tuning, connection strings
- Authentication — local, LDAP, Active Directory and OIDC sign-in, plus role mappings
- Providers — declaring the upstream SCM hosts fogwall proxies for
- SCM OAuth — linking a fogwall account to an SCM identity
- SCM API — proxying PR/MR traffic from
gh,glab,teaandfj - SSH transport — serving
git-receive-pack/git-upload-packover SSH - Commit validation — author email policy and required commit trailers
- Diff scan — blocking literals and patterns in added lines
- Secret scanning — gitleaks configuration and binary resolution
- Binary blob detection — size and MIME-type limits on added blobs
- Content-pattern scanning — bundled recognizers for structured sensitive data
- Hot reload — which sections reload without a restart, and how to trigger one
- Commit attribution policy — requiring commits to be attributed to the pushing user
- URL rules — matching pushes by repo URL to decide what applies
- Permissions — granting users PUSH, APPROVE, PROPOSE and friends
- Groups — naming a set of users once and reusing it
- Attestations — the questions a reviewer answers before approving
- Running and logging — starting the process, log levels, and git client output
- Observability — OpenTelemetry traces and metrics over OTLP, and trace/span log correlation
Configuration files and profiles
Load order (lowest → highest priority)
| Layer | Source | When loaded |
|---|---|---|
| 1 | fogwall.yml | Always — base defaults bundled in the jar |
| 2 | fogwall-{profile}.yml | For each profile listed in FOGWALL_CONFIG_PROFILES |
| 3 | Environment variables (FOGWALL_*) | Always — highest priority |
FOGWALL_CONFIG_PROFILES
Set this environment variable to a comma-separated list of profile names. For each name, fogwall looks for
fogwall-{name}.yml on the classpath (including any files mounted into /app/conf/ in Docker). Unknown or missing
profile files are silently skipped.
# Local development — loads fogwall-local.yml
FOGWALL_CONFIG_PROFILES=local
# Docker with LDAP auth — loads fogwall-docker-default.yml then fogwall-ldap.yml
FOGWALL_CONFIG_PROFILES=docker-default,ldap
# Docker with OIDC auth and PostgreSQL
FOGWALL_CONFIG_PROFILES=docker-default,oidc
# (postgres settings come from FOGWALL_DATABASE_* env vars, no profile file needed)
Later profiles take priority over earlier ones. All profiles take priority over fogwall.yml. Environment variables
override everything.
Bundled profiles
| Profile name | File | Purpose |
|---|---|---|
local | fogwall-local.yml | Local development: dev users, Vite CORS, test allow rules |
docker-default | fogwall-docker-default.yml | Docker base: admin user, Gitea provider, validation rules |
ldap | fogwall-ldap.yml | LDAP authentication config (used with docker-default) |
oidc | fogwall-oidc.yml | OIDC authentication config (used with docker-default) |
When running via
./gradlew run,FOGWALL_CONFIG_PROFILES=localis set automatically. In Docker, set it explicitly via the Compose file or your deployment config.
Docker Compose
The Docker Compose setup uses overlay files to compose the stack. See docker/docker-compose.ldap.yml and docker/docker-compose.oidc.yml for examples of how profiles are combined.
# Default (local auth, h2 database)
docker compose up -d
# LDAP auth
docker compose -f docker/docker-compose.yml -f docker/docker-compose.ldap.yml up -d
# OIDC auth + PostgreSQL
docker compose --profile postgres \
-f docker/docker-compose.yml -f docker/docker-compose.oidc.yml -f docker/docker-compose.postgres.yml up -d
Environment variable overrides
Strip the FOGWALL_ prefix, lowercase, and replace _ with . to get the config path.
| Environment Variable | Config path | Example |
|---|---|---|
FOGWALL_CONFIG_PROFILES | (meta — not a config key) | docker-default,ldap |
FOGWALL_SERVER_PORT | server.port | 9090 |
FOGWALL_SERVER_APPROVAL_MODE | server.approvalMode | ui |
FOGWALL_SERVER_SERVICE_URL | server.serviceUrl | https://fogwall.example.com/dashboard |
FOGWALL_DATABASE_TYPE | database.type | postgres |
FOGWALL_DATABASE_URL | database.url | jdbc:postgresql://... |
FOGWALL_DATABASE_HOST | database.host | db.internal |
FOGWALL_DATABASE_POOL_MAXIMUMPOOLSIZE | database.pool.maximum-pool-size | 3 |
FOGWALL_DATABASE_POOL_MINIMUMIDLE | database.pool.minimum-idle | 1 |
FOGWALL_DATABASE_POOL_CONNECTIONTIMEOUT | database.pool.connection-timeout | 30000 |
FOGWALL_SERVER_SESSIONSTORE | server.session-store | jdbc |
FOGWALL_SERVER_REDIS_HOST | server.redis.host | redis.cluster.local |
FOGWALL_SERVER_REDIS_PORT | server.redis.port | 6379 |
FOGWALL_SERVER_ALLOWEDORIGINS | server.allowed-origins | https://dashboard.example.com |
FOGWALL_PROVIDERS_GITHUB_ENABLED | providers.github.enabled | false |
FOGWALL_PROVIDERS_<NAME>_URI | providers.<name>.uri | https://gitlab.corp.com |
Complex nested structures (URL rules, full commit validation blocks) are not overridable via env vars. Use YAML profile files instead.
Hyphenated keys and provider names
Available since v1.3.0.
The single-underscore rule above can’t produce a hyphen — there’s no way to write providers.gitea-ssh.api-token as an
env var name if every _ is a path separator. For a config key or a provider name that itself contains a hyphen, use
the double-underscore convention instead (same idea as systemd, Kubernetes, and Docker Compose): __ is the path
separator, and a lone _ becomes a hyphen.
| Environment Variable | Config path |
|---|---|
FOGWALL_PROVIDERS__GITEA_SSH__API_TOKEN | providers.gitea-ssh.api-token |
FOGWALL_SERVER__SESSION_STORE | server.session-store |
This only activates when the env var name contains __ — every existing single-underscore example above still works
unchanged, since none of them contain __.
Server settings
server:
port: 8080
# Approval mode for server mode pushes:
# auto — approves every clean push immediately (default; no dashboard required)
# ui — waits for a human reviewer via the REST API
# servicenow — delegates to a ServiceNow approval workflow
# Note: FogwallDashboardApplication always uses 'ui' regardless of this setting.
approval-mode: auto
# How long a server mode push waits for a review decision while the client
# connection is held open (approval-mode: ui or servicenow). On expiry the push is
# marked CANCELED and rejected — the developer must re-push and re-review. This is a
# live-session bound, not a durable queue; hold it short. Default 1800 (30 minutes).
approval-timeout-seconds: 1800
# How long, in days, an unreviewed PENDING push in transparent proxy mode may sit
# before it's automatically canceled as timed out. Unrelated to approval-timeout-seconds
# above: that bounds a server mode connection held open synchronously during the wait,
# while a proxy mode PENDING record has no held connection and would otherwise sit
# forever until a human acts on it or a new push to the same branch supersedes it.
# Canceling is a state change, not a deletion — the record and its history stay visible
# to reviewers. Default 30 days, long enough that a review still genuinely in progress
# is never swept.
pending-push-expiry-days: 30
# Sideband keepalive interval in seconds for server mode operations.
# Sends periodic progress packets to prevent idle-timeout disconnects during
# long steps (secret scanning, approval polling). Set to 0 to disable.
heartbeat-interval-seconds: 10
# Whether server mode serves clone/fetch from its local mirror. Default true —
# a developer whose remote is the fogwall URL expects `git pull` to work against it.
# Applies to both server mode transports (HTTP and SSH). Set to false to make fogwall
# a push-only gateway that never serves a local mirror; fetches are then refused with
# a clear git-side error ("fetches are not served through this gateway"), not a 404
# that reads as a missing repository. Push (receive-pack) is unaffected either way.
# Transparent proxy mode is unaffected — it forwards to upstream, serving no local
# mirror. Override per provider with providers.<name>.serve-fetch.
serve-fetch: true
# Maximum number of requests handled concurrently on virtual threads. Requests over
# the limit wait for a slot. Each in-flight push holds its buffered pack data in
# memory until the request completes, so size this to heap capacity and typical pack
# size — prefer adding instances over raising this limit when scaling out.
# Set to 0 to disable virtual-thread dispatch (requests then run directly on the
# platform thread pool, capped by its own size).
max-concurrent-requests: 512
# Jetty platform thread-pool sizing. With virtual-thread dispatch on (the default,
# above) this pool runs only Jetty's acceptors and selectors — not blocking
# application work — so the defaults suit most deployments; these knobs are for
# tuning a large or constrained instance. Defaults match Jetty's own QueuedThreadPool.
# A config with min > max is rejected at startup.
threads:
min: 8 # minimum (core) platform threads kept alive
max: 200 # maximum platform threads
idle-timeout-ms: 60000 # ms an idle thread above `min` is kept before being reaped
# Largest request body fogwall will accept, in bytes. Applies to both proxy modes.
# An over-size push is rejected with a git error before the body is read, so it
# costs no memory. Set to 0 to disable the check — note that "unlimited" is still
# bounded by heap in practice, and absolutely by ~2GiB, since the body is buffered
# as a single array.
# Raising this needs a matching increase in container memory: the real bound is heap
# divided by concurrent pushes. See "Sizing memory for pushes" in the Admin Guide.
max-push-bytes: 67108864 # 64MiB
# Largest decompressed size of any single object in a pushed pack, in bytes.
# Applies to both proxy modes, and to both server mode transports (HTTP and
# SSH). max-push-bytes above caps the compressed wire size, but a crafted pack can
# inflate to roughly 1000x its compressed size — this caps the inflated side, per
# object. A push containing a violating object is rejected during pack parsing.
#
# The default sits deliberately far above the binary-blob filter's 50MiB policy
# ceiling: binary-blob is the tunable policy layer for "how big may a file be",
# while this limit exists only to stop decompression bombs, and should be kept
# high enough that no legitimate push ever hits it. Set to 0 to disable.
max-object-size-bytes: 134217728 # 128MiB
# Base URL fogwall is externally reachable at — the bare host, WITHOUT a /dashboard suffix (fogwall appends
# /dashboard, /api, etc. itself where needed). Used in links sent to clients via sideband messages, and required
# for SCM OAuth account linking (#40) to build a correct redirect_uri — OAuth linking is disabled with a clear
# error if this is unset. No default — must be set explicitly.
#
# BREAKING CHANGE in v1.4.0: prior releases expected this to already include any path prefix your reverse proxy
# adds (e.g. https://fogwall.internal.example.com/dashboard), with fogwall concatenating routes directly onto it.
# As of v1.4.0 it must be the bare origin instead — drop a trailing /dashboard (or other prefix) from your existing
# value when upgrading, or push-record/profile links in sideband messages will point at the wrong path.
# service-url: https://fogwall.internal.example.com
# When false (default), any authenticated user may review any push they did not push
# themselves. Set to true to require an explicit REVIEW permission entry for the repo.
# Use true for deployments that need restricted approvers with formal sign-off.
require-review-permission: false
# Origins allowed to make cross-origin requests to the dashboard REST API.
# Required when the frontend is served from a different hostname than the backend
# (e.g. Vite dev server on port 5173, or a load-balanced dashboard behind a different host).
# Default (empty): same-origin only.
# allowed-origins:
# - http://localhost:5173
# - https://dashboard.example.com
# Whether the dashboard trusts Forwarded / X-Forwarded-* headers to resolve the external
# scheme, host and port. These drive OIDC login redirects, other absolute URLs, and the
# session cookie's Secure flag. Only the dashboard reads them; the git and SCM API
# listeners read none. Default true — existing ingress deployments depend on it.
# Precondition when true: the dashboard listener must be reachable only through the ingress
# that sets the headers. Set false when TLS terminates at fogwall, or whenever the listener
# is directly reachable; then also set service-url. See docs/admin/network-requirements.md.
# trust-forwarded-headers: true
# HTTP session persistence backend. Controls where authenticated sessions are stored.
# Options:
# none — in-memory (default); sessions lost on restart, not shared across pods
# jdbc — persisted to the configured JDBC database; zero new infrastructure required
# mongo — persisted to the configured MongoDB database; zero new infrastructure required
# redis — persisted to a Redis or Valkey instance; configure via server.redis.*
# Use jdbc or redis for multi-instance deployments so sessions survive pod restarts
# and remain valid across all replicas.
# session-store: none
# Redis connection — only required when session-store: redis
# redis:
# host: redis.cluster.local
# port: 6379
# password: ""
# ssl: false
Session persistence for multi-instance deployments
By default, authenticated sessions are stored in memory. This works for single-instance deployments but means:
- Sessions are lost when a pod restarts
- A user hitting a different pod after a load balancer switch will be logged out
Set server.session-store to persist sessions across restarts and share them between replicas.
JDBC (recommended — zero new infrastructure):
server:
session-store: jdbc
database:
type: postgres
url: jdbc:postgresql://db.internal:5432/fogwall
pool:
maximum-pool-size: 3
minimum-idle: 1
The session tables (SPRING_SESSION, SPRING_SESSION_ATTRIBUTES) are created automatically by the database migrator on
first startup. No manual DDL required.
Redis / Valkey:
server:
session-store: redis
redis:
host: redis.cluster.local # or valkey.cluster.local
port: 6379
password: "" # omit if no auth configured
ssl: false # set true for TLS-secured Redis
A minimal single-replica Redis or Valkey pod is sufficient — sessions are small and low-throughput. No persistence or clustering required for this use case.
MongoDB:
server:
session-store: mongo
database:
type: mongo
url: mongodb://fogwall:secret@mongo.internal:27017/fogwall
Sessions are stored in the proxy_sessions collection alongside the other proxy_* collections. A TTL index on
expireAt lets MongoDB expire idle sessions server-side — no background cleanup task runs in the proxy. The session
store reuses the same connection pool as the rest of the MongoDB-backed stores, so no extra configuration is needed.
Requires database.type: mongo.
Local mirror cache
To inspect push content, fogwall keeps a local bare mirror of each upstream repository. How much history that mirror
holds is a per-deployment tradeoff: a full mirror is the most correct basis for reachability checks, but a first clone
of a large repository (e.g. one with a large binary history) can be slow enough to exceed HTTP connection timeouts; a
shallow mirror clones cheaply but truncates history. The two modes are configured separately and only their defaults
differ — server mode defaults to full history, transparent proxy to a shallow clone. Either mode accepts either
knob: server mode is used against large repositories too, so shallow cloning is fully supported there — set
cache.server.shallow-since or cache.server.clone-depth to enable it.
cache:
proxy:
shallow-since: 90d # keep history back ~90 days (preferred)
# clone-depth: 100 # or a fixed commit depth; used only when shallow-since is unset
server:
clone-depth: 0 # 0 = full history (the default)
| Key | Default (proxy / server) | Meaning |
|---|---|---|
cache.<mode>.shallow-since | unset | Time-based boundary — 90d, 12h, 30m, or an ISO-8601 duration (PT48H). Takes precedence over depth. |
cache.<mode>.clone-depth | 100 / 0 | Commit depth for the shallow clone; 0 means full history. Used only when shallow-since is unset. |
Prefer shallow-since: a commit depth is a graph-distance bound, not a function of age, so which commits fall outside
depth=100 is unpredictable for a given repository — whereas “keep 90 days” is something an operator can reason about
and write against a retention requirement.
A shallow default is a clone-cost optimisation, not a correctness ceiling: checks that would get a wrong answer from a truncated mirror (reachability / hidden-commit detection) deepen it to full history on demand before deciding, so a release tag on a commit that predates the shallow boundary is still validated correctly.
TLS
Server HTTPS listener
By default fogwall listens on plain HTTP. To enable HTTPS, add a server.tls block.
PEM-based (preferred — no keytool required):
server:
tls:
port: 8443
certificate: /etc/fogwall/tls/server.pem # X.509 certificate or chain, PEM
key: /etc/fogwall/tls/server-key.pem # PKCS8 private key, unencrypted PEM
The private key must be in PKCS8 format. Convert a PKCS1 key with:
openssl pkcs8 -topk8 -nocrypt -in server.pem -out server-key.pem
Keystore-based (for shops with existing managed keystores):
server:
tls:
port: 8443
keystore:
path: /etc/fogwall/tls/keystore.p12
password: changeit
type: PKCS12 # or JKS
Plain HTTP on server.port remains active when HTTPS is configured — both listeners run concurrently.
Custom upstream CA trust
Enterprise PKIs typically issue certificates that Java’s built-in truststore doesn’t include, causing
SSLHandshakeException on upstream connections to internal GitLab/Bitbucket/Forgejo instances. fogwall supports
trusting a custom CA bundle without touching the JVM truststore or running keytool.
server:
tls:
trust-ca-bundle: /etc/fogwall/tls/internal-ca.pem
The PEM file may contain one or more -----BEGIN CERTIFICATE----- blocks (a full CA chain is fine). Custom CAs are
merged with the JVM’s built-in trust anchors — public hosts (GitHub, GitLab SaaS, Bitbucket Cloud) continue to work
without any changes.
This applies to both proxy modes:
- Transparent proxy — Jetty’s
HttpClientused for upstream forwarding - Server mode — JGit’s HTTP transport used for forwarding after local receipt
Outbound proxy
Available since v1.3.0.
For environments without direct internet access, fogwall can route its own outbound connections through a corporate HTTP
proxy. This covers all three places fogwall makes outbound connections: server mode upstream pushes (JGit Transport),
transparent-proxy forwarding (Jetty HttpClient), and provider REST API calls (identity resolution, SSH key listing).
server:
outbound-proxy:
https-proxy: http://proxy.example.com:8080
no-proxy: localhost,*.internal.example.com
auth:
type: none # none (default) | basic | kerberos
http-proxy, https-proxy, and no-proxy fall back to the HTTP_PROXY/HTTPS_PROXY/NO_PROXY environment variables
when left unset in YAML — an explicit YAML value always takes precedence. Leave auth.type at none (the default) when
the configured proxy doesn’t require fogwall to authenticate itself.
Proxy authentication
When the configured proxy requires authentication, two schemes are supported:
server:
outbound-proxy:
https-proxy: http://proxy.example.com:8080
auth:
type: basic
username: ${PROXY_USER}
password: ${PROXY_PASS}
server:
outbound-proxy:
https-proxy: http://proxy.example.com:8080
auth:
type: kerberos
# keytab-path and principal are both optional. Omitted (the default): fogwall authenticates using
# whatever Kerberos ticket is already in the OS's ticket cache — the common case on a machine that
# already has a live ticket from domain/SSO login. Set both only when fogwall should authenticate
# as its own service identity instead (e.g. a long-running headless deployment with no live session).
# keytab-path: /etc/fogwall/proxy.keytab
# principal: fogwall/host@CORP.EXAMPLE.COM
NTLM is intentionally not supported as a scheme fogwall speaks directly — it’s a deprecated protocol, and Jetty’s HTTP client (the transparent-proxy path) has no NTLM support at all and can’t cleanly add it, since the reference NTLM implementation (jcifs) is LGPL-licensed and Jetty won’t bundle it. Kerberos/Negotiate is the modern successor in Active Directory environments and is natively supported across all three outbound paths.
Database
database:
type: h2-mem # h2-mem | h2-file | postgres | mysql | mariadb | mongo
Database backends
| Type | Description | Extra keys |
|---|---|---|
h2-mem | H2 in-memory (default) | name (default: fogwall) |
h2-file | H2 persisted to disk | path (default: ./.data/fogwall) |
postgres | PostgreSQL | url or host, port, name, username, password |
mysql | MySQL 8.0+ | url or host, port (default 3306), name, username, password |
mariadb | MariaDB 10.5+ | url or host, port (default 3306), name, username, password |
mongo | MongoDB | url (required); name optional if the database is in the URI path |
SQLite is intentionally not supported — its single-writer file locking is unsuitable for a proxy that may run more than one instance against the same database.
For postgres, mysql, mariadb, and mongo, setting url to a full connection string is the recommended approach
when you need driver-specific options (TLS, SSL certificates, connection parameters) that are not exposed as individual
config fields.
MySQL and MariaDB use separate JDBC drivers (mysql-connector-j and mariadb-java-client, not one shared driver) —
pick the type matching the actual engine you’re running, even though the two are largely wire-compatible.
database.port defaults to PostgreSQL’s port (5432); for mysql/mariadb set it explicitly (typically 3306) unless
the server actually listens on 5432 — fogwall logs a startup warning if it detects the untouched default being used with
either type.
Connection pool tuning
Applies to all JDBC backends (h2-mem, h2-file, postgres, mysql, mariadb). The default pool size is
deliberately small — git push workloads are sequential per user, so a large pool buys nothing and drives up aggregate
connection counts when multiple instances share a database. The
HikariCP pool sizing guide covers this in depth.
database:
type: postgres
url: jdbc:postgresql://db.internal:5432/fogwall
pool:
maximum-pool-size: 3 # per-instance connections; multiply by instance count for total DB load
minimum-idle: 1 # release idle connections; omit to keep the full pool warm
connection-timeout: 30000 # ms; fail fast if the pool is exhausted
idle-timeout: 600000 # ms; retire connections after 10 min idle
max-lifetime: 1800000 # ms; rotate connections every 30 min
For deployments sharing a database across multiple instances (multiple environments, blue/green, canary), set
maximum-pool-size to the lowest value that keeps p99 push latency acceptable. For most proxy workloads, 2–5
connections per instance is sufficient.
# Postgres — individual fields
database:
type: postgres
host: db.internal
port: 5432
name: fogwall
username: fogwall
password: secret
# Postgres — connection string (use this for sslmode, certificates, etc.)
database:
type: postgres
url: jdbc:postgresql://db.internal:5432/fogwall?sslmode=verify-full&sslrootcert=/certs/ca.crt
username: fogwall
password: secret
# MySQL — individual fields
database:
type: mysql
host: db.internal
port: 3306
name: fogwall
username: fogwall
password: secret
# MySQL — connection string
database:
type: mysql
url: jdbc:mysql://db.internal:3306/fogwall?useSSL=true&requireSSL=true
username: fogwall
password: secret
# MariaDB — individual fields
database:
type: mariadb
host: db.internal
port: 3306
name: fogwall
username: fogwall
password: secret
# MariaDB — connection string
database:
type: mariadb
url: jdbc:mariadb://db.internal:3306/fogwall?useSsl=true
username: fogwall
password: secret
# Mongo — connection string (name extracted from URI path)
database:
type: mongo
url: mongodb://fogwall:secret@mongo.internal:27017/fogwall?tls=true&tlsCAFile=/certs/ca.crt
# Mongo — connection string with separate name field
database:
type: mongo
url: mongodb://fogwall:secret@mongo.internal:27017
name: fogwall
SCM token identity cache
When users are configured, fogwall caches successful token-to-username resolutions so that repeated pushes from the same PAT do not incur a provider API call every time. The cache is backed by the configured database (JDBC or MongoDB) and keyed on a SHA-512 digest of the token — the raw token is never stored.
| Variable | Default | Effect |
|---|---|---|
FOGWALL_SCM_CACHE_MAX_AGE_DAYS | 7 | Maximum age of a cache entry in days. Entries older than this are ignored on read and overwritten on the next successful resolution. |
Token rotation is handled automatically: a new PAT produces a new cache key, so the old entry simply ages out. There is no need to flush the cache manually when tokens are rotated.
MongoDB: coexisting with the upstream Node.js git-proxy
If you are migrating from finos/git-proxy (the Node.js implementation) and
pointing this proxy at a database that previously held its data, the two applications use incompatible document schemas.
The safest path is to provision a new MongoDB database (e.g. fogwall) and point database.url at it. This avoids
all collision risk and keeps indexes, backups, and ops tooling cleanly separated.
If provisioning a separate database is not feasible, this proxy now uses collection names that do not collide with the upstream Node.js implementation:
| Collection | Written by | Notes |
|---|---|---|
proxy_users | MongoUserStore | Renamed from users to avoid collision with upstream’s users. |
proxy_pushes | MongoPushStore | Renamed from pushes to avoid collision with upstream’s pushes. |
repo_permissions | MongoRepoPermissionStore | No upstream equivalent. |
access_rules | MongoUrlRuleRegistry | No upstream equivalent. |
fetch_records | MongoFetchStore | No upstream equivalent. |
This means you can point both apps at the same MongoDB database without corrupting each other’s data. We still recommend separate databases for operational clarity — shared databases make backups, restores, and index tuning harder to reason about — but it is no longer a correctness hazard. Starting with 1.0.0, these collection names are part of the project’s stability contract and will not be renamed without an in-place migration path.
Authentication
The dashboard supports four authentication providers, selected via auth.provider.
auth:
provider: local # local | ldap | ad | oidc (default: local)
# Maximum idle time before a session expires and the user must re-authenticate.
# Default: 86400 (24 hours). Tighten to 28800 (8 hours) or less for compliance environments.
session-timeout-seconds: 86400
Local (default)
Usernames and BCrypt password hashes are defined directly in the users: block. See
Provisioning users in the administrator guide.
LDAP
Authenticates users against a generic LDAP directory using a bind operation.
auth:
provider: ldap
ldap:
# LDAP server URL including base DN.
url: ldap://ldap.example.com:389/dc=example,dc=com
# User DN pattern — {0} is substituted with the login username.
user-dn-patterns: cn={0},ou=users
# Optional bind credentials for group search / attribute lookup.
bind-dn: cn=admin,dc=example,dc=com
bind-password: secret
# Base DN (relative to url base) to search for group membership.
# When set, group names are mapped to roles via auth.role-mappings below.
group-search-base: ou=groups
# LDAP filter for group membership. {0} = user full DN, {1} = username.
group-search-filter: "(member={0})"
# Map fogwall role names to lists of LDAP group CNs.
# When a user is a member of any listed group, the role is granted.
role-mappings:
ADMIN:
- git-admins
- security-team
Active Directory
Authenticates users against an on-premises Active Directory domain using UPN bind (user@domain.com). Unlike the
generic LDAP provider, no user-dn-patterns is required — Spring Security constructs the UPN automatically from the
domain and the submitted username.
auth:
provider: ad
ad:
# AD domain name — used to form user@domain UPN for bind.
domain: corp.example.com
# Domain controller URL. When omitted, Spring Security resolves the DC via DNS SRV records.
url: ldap://dc.corp.example.com:389
# Optional: base DN for group search. When set, group membership is used for role mapping.
group-search-base: DC=corp,DC=example,DC=com
# LDAP filter for group membership. {0} = user full DN.
group-search-filter: "(member={0})"
role-mappings:
ADMIN:
- CN=git-admins,OU=Groups,DC=corp,DC=example,DC=com
Tip
The AD provider understands Active Directory error sub-codes on bind failure 49 (expired passwords, locked accounts, etc.) and maps them to specific Spring Security exceptions.
OIDC
Authenticates users via OpenID Connect authorization code flow (Keycloak, Okta, Entra ID, Dex, etc.).
auth:
provider: oidc
oidc:
# OIDC issuer URI — Spring Security fetches {issuerUri}/.well-known/openid-configuration at startup.
issuer-uri: https://accounts.example.com
client-id: fogwall-client
client-secret: fogwall-secret
# Optional: path to a PKCS#8 PEM RSA private key for private_key_jwt client auth.
# When set, client-secret is not required.
# private-key-path: /run/secrets/fogwall-oidc-private-key.pem
# Optional: path to the X.509 certificate (PEM) matching private-key-path.
# Required for Entra ID — Entra matches registered certificates by x5t thumbprint, not kid.
# Without this, every token exchange fails with AADSTS700027.
# Generate: openssl req -new -x509 -key private.pem -out cert.pem -days 365
# cert-path: /run/secrets/fogwall-oidc-cert.pem
# Optional: explicit kid to embed in the private_key_jwt assertion header.
# Use this for providers that match the assertion against a registered JWKS by kid
# (Keycloak, Okta, Auth0, Dex). Without it, a random UUID kid is generated on each
# restart, which breaks authentication with those providers.
# Find the kid by inspecting your provider's JWKS endpoint and matching it to the
# public key you registered.
# Not needed when cert-path is set (Entra ID uses x5t instead of kid).
# key-id: my-registered-kid
# Claim used as the principal name (the fogwall username). Defaults to "sub" — the only
# claim the OIDC spec guarantees in every ID token. Claims like preferred_username and
# email are voluntary: point this at one only if your IdP actually sends it, otherwise
# login fails with a clear "claim not present" error.
# user-name-attribute: email
# Read all claims from the ID token and never call the UserInfo endpoint. Needed when
# user-name-attribute names a claim your IdP's UserInfo response omits (see the Entra ID
# section below); harmless otherwise.
# skip-user-info: true
# Endpoint overrides. OIDC discovery ALWAYS runs at startup against issuer-uri; these
# replace individual discovered endpoints for split-egress setups (e.g. an internal JWKS
# mirror). They have no effect on token validation.
# authorization-uri: ...
# token-uri: ...
# user-info-uri: ...
# jwk-set-uri: ...
# OIDC claim containing the user's group memberships. Defaults to "groups",
# which is standard for Keycloak, Okta, and most Entra ID configurations.
groups-claim: groups
# Map fogwall role names to lists of OIDC group values from the claim above.
role-mappings:
ADMIN:
- git-admins
Entra ID (Azure AD)
Entra ID works with standard OIDC discovery and full stock token validation — no bypass and no endpoint overrides. What
it does need is two settings that account for its unusual (but spec-conformant — all profile/email claims are voluntary,
only sub is guaranteed) claims behaviour:
user-name-attribute: email— with theemailoptional claim added to the app registration (checklist below). Don’t skip this one: the default (sub) logs in without any error, but Entra’ssubis an opaque generated string, so every downstream record — permissions, push history, the admin UI — ends up keyed to an unreadable identifier. Nothing breaks loudly; it is just miserable to operate.skip-user-info: true(recommended). Entra’s UserInfo endpoint is Microsoft Graph, which returns HTTP 200 with a minimal claim set and has historically broken Spring’s principal construction whenuser-name-attributenames a claim its response lacks. Skipping UserInfo reads all claims from the ID token and drops the runtime dependency on Graph (and itsUser.Readpermission) entirely.
auth:
provider: oidc
oidc:
# The /v2.0 suffix matters: it selects the v2 endpoints, and ID-token format follows the
# endpoint — v2 tokens carry iss=https://login.microsoftonline.com/{tenant-id}/v2.0, which
# matches discovery and validates cleanly.
issuer-uri: https://login.microsoftonline.com/{tenant-id}/v2.0
client-id: <app-registration-client-id>
client-secret: <client-secret>
skip-user-info: true
user-name-attribute: email
# Requires "Group claims" to be enabled in the app registration (Token configuration → Groups claim).
# Group values will be object IDs (GUIDs) unless "Group names" is selected in the manifest.
groups-claim: groups
role-mappings:
ADMIN:
- <object-id-of-admin-group>
Important
App registration checklist:
- Platform: Web — redirect URI
https://<your-host>/login/oauth2/code/fogwall- API permissions:
openid,profile,- Token configuration → add Groups claim → select “Security groups”
- Token configuration → add optional claim → ID token →
Legacy v1-token tenants. An app registration reached through the v1 endpoints issues v1-format tokens with
iss=https://sts.windows.net/{tenant-id}/, which fails validation against the v2 discovery issuer. The fix is to use
the v2 issuer URI above. If your tenant genuinely cannot, point issuer-uri directly at
https://sts.windows.net/{tenant-id}/ — it serves a self-consistent discovery document — and set user-name-attribute
to a claim v1-format tokens actually carry (check a decoded token; upn is the usual candidate).
To tell which case you are in, decode an ID token from a real login and check its ver claim ("1.0" or "2.0") —
that is authoritative. Don’t infer it from the app manifest: Microsoft documents requestedAccessTokenVersion as
applying to access tokens issued to your app when it acts as an API, and in deployments verified so far a tenant with
that setting unset still issued v2 ID tokens through the /v2.0 endpoints — but tenant configurations vary, so check
the token.
Role mappings
auth.role-mappings applies to LDAP, AD, and OIDC. Keys are role names (without the ROLE_ prefix); values are lists
of group names or claim values from the IdP.
| Role | Dashboard access |
|---|---|
USER | View and act on pushes awaiting approval |
ADMIN | All USER permissions + create/delete users, reset passwords, manage identities |
When role-mappings is empty, the operator has not configured group-based access control: ROLE_USER is granted to
every authenticated user (open mode). When role-mappings is non-empty, access is deny-by-default — a user whose
IdP groups don’t match any mapping authenticates successfully against the directory/IdP but is refused access by
fogwall.
auth:
role-mappings:
ADMIN:
- git-admins
# Deny-by-default is the correct posture for regulated environments and is the default.
# Set to false to treat the IdP purely as an authentication mechanism (SSO convenience): any
# user who authenticates successfully is granted ROLE_USER even if no group mapping matches.
# role-mappings (if present) then only grant additional roles on top. No-op when role-mappings
# is empty, since open mode is already the behaviour in that case.
require-role-mapping: true
Providers
Providers define the upstream Git hosting services the proxy routes to.
providers:
# Reserved names — provider type and default URI are built in
github:
enabled: true # → github.com
gitlab:
enabled: true # → gitlab.com
bitbucket:
enabled: true # → bitbucket.org
codeberg:
enabled: true # → codeberg.org
gitea:
enabled: true # → gitea.com
# Custom-named providers — 'type' and 'uri' are both required
my-internal-server:
enabled: true
type: github # uses GitHubProvider (identity resolution, GHES API path logic, etc.)
uri: https://github.corp.example.com
my-forgejo:
enabled: true
type: forgejo # ForgejoProvider; uri is required (forgejo has no canonical public host)
uri: https://forge.internal.example.com
acme-bitbucket:
enabled: true
type: bitbucket
uri: https://bitbucket.acme.com
Provider properties
| Property | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Whether the provider is active |
servlet-path | string | "" | Additional URL prefix for this provider |
uri | string | (built-in default) | Upstream base URI. Required for custom-named providers; omit for built-ins. |
type | string | (from name) | Provider implementation: github, gitlab, bitbucket, codeberg, forgejo, gitea. Required for any name that is not one of the five reserved names. |
api-uri | string | (derived from uri) | HTTP base URI for provider REST API calls (identity resolution, SSH key lookup). Only needed when the HTTP API port can’t be derived from uri — e.g. a self-hosted instance where the HTTP API runs on a non-standard port. |
api-token | string | (none) | PAT used when the provider’s SSH key listing API requires authentication (Forgejo/GitLab with REQUIRE_SIGNIN_VIEW=true). GitHub’s equivalent endpoint is public and needs no token. |
ssh | block | (disabled) | SSH transport for this provider — the same entry serves both HTTP and SSH. See Serving a provider over SSH. |
blocked-info-refs-status | int | 403 | HTTP status returned when a blocked /info/refs discovery request is denied. 403 is unambiguous; 404 obscures whether the repo exists (security by obscurity). |
serve-fetch | boolean | (inherits global) | Per-provider override for whether server mode serves clone/fetch from the local mirror (both transports). Omit to inherit the global server.serve-fetch; set true/false to override for this provider only. |
issues-enabled | boolean | false | Whether the dashboard issue form may create, edit, comment on and close/reopen issues on this provider on a user’s behalf. Off by default; needs no listener or port, but requires the user to have linked their account via OAuth. See Filing issues from the dashboard. |
The five reserved names (github, gitlab, bitbucket, codeberg, gitea) carry a built-in default URI and provider
type. Any other name is opaque — the name is never parsed for type hints — so type and uri must both be set. The
typed provider supplies API URL logic, identity resolution, and (for Bitbucket) credential rewriting; uri overrides
only the upstream address.
Bitbucket identity resolution
Bitbucket does not enforce the git push username — only the token is validated. To enable identity resolution (required for push permission checks and commit identity verification), the proxy adopts the convention that the HTTP Basic-auth username in the remote URL must be the user’s Bitbucket account email address.
Configure the remote URL like this:
https://<email>:<api-token>@bitbucket.org/<workspace>/<repo>.git
The proxy calls GET /2.0/user using those credentials to look up the user’s Bitbucket username (the auto-generated
URL-safe identifier, e.g. a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6). It then rewrites the outbound credentials to
username:token before forwarding the push to Bitbucket — this is necessary because Bitbucket’s git endpoint only
accepts the internal username, not an email address.
Required API token scopes: read:user:bitbucket and write:repository:bitbucket.
Tip
M&A / private server use case: This same mechanism works for self-hosted Bitbucket Data Center instances. Set
urito your internal Bitbucket URL and the proxy will route and rewrite credentials accordingly, making it straightforward to gate pushes to acquired-company repositories during an integration period.
SCM OAuth
Available since v1.4.0.
Lets a proxy user link their account to an upstream SCM identity (GitHub, GitLab, or a Forgejo/Gitea/Codeberg instance)
via OAuth from their profile page, instead of typing their SCM username into a free-text field. A successful link sets
verified = true on that identity, which scm-oauth.identity-mode: strict can then require for push authorization —
closing the gap where a manually entered SCM username is trusted with no proof the pusher actually controls it. Linking
also imports the SCM provider’s own verified emails and registered SSH public keys, so a developer doesn’t have to
re-enter data the provider already has confirmed.
scm-oauth:
# permissive (default): any linked SCM identity is usable for push authorization, verified or not — today's
# behaviour, unchanged.
# strict: only OAuth-verified identities count, on both HTTP and SSH push paths. On HTTP the account the token
# belongs to must itself be one OAuth verified — a verified identity on the same provider does not vouch for a
# hand-typed sibling, or for a user matched by email. On SSH the connecting key must be one OAuth linking
# imported — a key added by hand on the profile page no longer resolves an SCM identity, even if the provider
# would confirm it is registered there.
identity-mode: permissive
# Path to a file holding a base64-encoded 32-byte AES-256-GCM key, used to encrypt linked OAuth tokens at rest.
# If unset, a key is auto-generated under ./.data/ for local development only — a loud warning is logged on every
# startup when this happens. Production deployments MUST set this to a durable, backed-up location (see
# the administrator guide's production checklist); losing an auto-generated key just means every linked user has to
# re-link — push authorization itself is never affected by a token-encryption problem.
token-encryption-key-path: /run/secrets/fogwall-scm-oauth-key
# OAuth app registration is a property of the provider instance it belongs to, nested under that provider's own
# providers.<name>.oauth block below — not a separate map keyed by the same name. An operator running two separate
# GitHub OAuth apps at once (one for github.com/GHEC, a second for a GHEC-with-data-residency *.ghe.com tenant, or a
# self-managed GHES host) already needs two separate providers: entries for routing; each just carries its own
# oauth: block alongside its uri. The OAuth authorize/token/user-API host is always derived from that same provider
# instance's own `uri` — github.com/GHEC, *.ghe.com, and self-managed GHES are each detected automatically, so
# there's nothing to set for that under oauth: specifically. GHES's `api-uri` (the general provider setting, not
# scm-oauth-specific) is also auto-derived and only needs setting explicitly when the API isn't reachable on the
# standard host/port (e.g. local dev).
providers:
github:
enabled: true # github.com/GHEC
oauth:
enabled: true
client-id: Iv1.abc123
client-secret-path: /run/secrets/fogwall-github-oauth-secret
github-ghe:
enabled: true
type: github
uri: https://my-tenant.ghe.com
oauth:
enabled: true
client-id: Iv1.def456
client-secret-path: /run/secrets/fogwall-github-ghe-oauth-secret
gitlab:
enabled: true # gitlab.com
oauth:
enabled: true
client-id: abc123
client-secret-path: /run/secrets/fogwall-gitlab-oauth-secret
forgejo:
enabled: true
type: forgejo
uri: https://forgejo.example.internal # self-hosted
oauth:
enabled: true
client-id: abc123
client-secret-path: /run/secrets/fogwall-forgejo-oauth-secret
SCM OAuth properties
| Property | Type | Default | Description |
|---|---|---|---|
identity-mode | string | permissive | permissive or strict — see above. |
token-encryption-key-path | string | (none) | Path to the base64-encoded 32-byte AES-256-GCM key file. Auto-generated under ./.data/ for local dev if unset. |
providers.<name>.oauth.enabled | boolean | false | Whether “Link via OAuth” is offered for this provider. |
providers.<name>.oauth.client-id | string | "" | OAuth app/client ID. |
providers.<name>.oauth.client-secret-path | string | "" | Path to a file holding the OAuth app/client secret. |
Registering a GitHub App: account permissions needed are exactly Email addresses (read-only) and Git SSH keys
(read-only) — no others, and no private key (a GitHub App’s private key is for app/installation-level auth, which this
user-to-server linking flow never uses). Callback URL:
https://<server.service-url>/api/scm-oauth/<provider-name>/callback, where <provider-name> is the top-level
providers: key (e.g. github), not the provider type.
Registering a Forgejo/Gitea OAuth application: self-service OAuth2 application registration under the instance’s own
Settings → Applications page (works the same way on Codeberg, self-hosted Forgejo/Gitea, and org-owned applications),
requesting the read:user scope. Callback URL is the same shape as above.
SCM API
Available since v1.4.0, opt-in per provider — see docs/internals/scm-api-proxy.md.
Extends fogwall past git push into the rest of the contribution lifecycle: proxying gh’s issue/PR
create-edit-comment-review traffic and glab’s issue/MR equivalent, reusing fogwall’s existing identity resolution,
permission engine, and audit trail. See the administrator guide for the operational model
(BYO-token, egress assumption) and the user guide for how a developer points gh/glab at
it.
The providers use different wire formats under the hood — GitHub’s is GraphQL with an opaque node ID that must be
resolved to owner/repo; GitLab’s and Forgejo’s are REST with the repository addressed directly in the URL, so no
resolution step exists for them (node-id-cache-ttl below is a GitHub-only setting, inert for the others). See
docs/internals/scm-api-proxy.md for the per-provider wire-format detail. Every dialect
shares the same opt-in config shape — a global settings block, and a per-provider enabled flag that actually mounts
the path:
scm-api:
# TTL for GitHub's GraphQL node-ID -> owner/repo resolution cache (ISO-8601 duration). A SECURITY parameter, not
# just a perf knob: a GraphQL node ID can outlive a repo rename/transfer while what it resolves to changes
# underneath it. Conservative default; shorten further for a deployment where repo renames/transfers are unusually
# frequent. Has no effect on GitLab, which addresses its target directly in the URL.
node-id-cache-ttl: PT5M
providers:
github:
enabled: true
scm-api:
enabled: true # per-provider opt-in (default false)
port: 9443 # required when enabled — a dedicated listener, see below
gitlab:
enabled: true
scm-api:
enabled: true
port: 9444
gitea:
enabled: true
scm-api:
enabled: true
port: 9445
require-validated-head: false # relax the head-provenance check; default true
merge-enabled: true # allow merging PR/MRs through this provider; default false
Each enabled provider needs its own port, and fogwall fails to start if one is enabled without it. The dialect is
mounted at the root of that listener (/api/graphql, /api/v4/*, /api/v1/*) because that is the only place the CLIs
can reach it: gh and fj address the API from the host root and silently discard any path prefix. A single shared
listener isn’t an option either — every GitLab claims /api/v4 and every Gitea/Forgejo /api/v1, so two instances of
the same platform would collide. Clients are then pointed at a plain host and port (GH_HOST, GITLAB_HOST,
tea login add --url, fj -H). See docs/internals/scm-api-proxy.md for the per-CLI
evidence.
TLS is inherited from server.tls; there is no per-provider TLS block. When server.tls is set, every
SCM API listener serves HTTPS with the same certificate, on its own port. The CLIs only ever address a custom host over
HTTPS, so TLS has to terminate either at fogwall this way or at an ingress in front of it — with server.tls unset,
fogwall logs a warning naming each plaintext listener. See
TLS on the SCM API listeners.
The caller’s User-Agent and the CLI version it advertises are recorded on every audit record — the anchor for noticing
a CLI upgrade has changed its wire format, which otherwise surfaces only as an unexplained denial. It is not used to
gate: User-Agent is caller-controlled, so nothing branches on it.
require-validated-head refuses a pull/merge request create whose head commit fogwall has no push record for — closing
the gap where a contributor pushes straight to their fork, never touching fogwall, then opens the pull request through
it. The lookup is keyed on the commit SHA alone, not the repository, since a fork push and the upstream pull/merge
request are two different repositories.
It defaults on, so a pull/merge request’s head must trace to a push fogwall saw. Relax it (set false) where these
workflows are common: a rebase, amend, or force-push after pushing through fogwall changes the SHA a validated push
recorded, and a commit authored in the SCM’s own web UI never went through fogwall at all — each leaves the head with no
matching push record. A denial names the remedy — push the branch through fogwall, then reopen — rather than just
refusing.
merge-enabled allows merging a pull/merge request through this provider’s SCM API proxy (gh pr merge,
glab mr merge, tea/fj pr merge). It defaults off because merge is the highest-consequence operation on this path,
so exposing it is an explicit operator decision rather than a side effect of enabling the SCM API proxy. It is
independent of the MERGE grant, and both are required: the capability must be enabled here, and the caller must
hold the grant (see Permissions). With it off, a merge request is refused even for a caller who holds
MERGE.
Per-repo authorization for mutations (issue/PR create, edit, comment, review) goes through the existing
RepoPermission grants — see Permissions below — with a dedicated PROPOSE grant kept independent
from PUSH/REVIEW, so an operator can permission git-push and SCM API mutations separately. Merging a pull/merge
request is its own grant, MERGE, independent again from PROPOSE — see
the administrator guide’s merging section:
permissions:
- username: alice
provider: github
match:
value: /acme/widgets
grant: PROPOSE
Reads (GraphQL query traffic — an ordinary gh issue list, for example) are not gated by PROPOSE. They are
forwarded for any authenticated caller, which keeps the default read cost near pass-through — no allowlist, no node-ID
resolution, no extra round-trip.
SCM API properties
| Property | Type | Default | Description |
|---|---|---|---|
scm-api.node-id-cache-ttl | string | PT5M | ISO-8601 duration. See the security note above. |
providers.<name>.scm-api.enabled | boolean | false | Whether the SCM API proxy is mounted for this provider. |
providers.<name>.scm-api.port | int | — | Dedicated listener port. Required when enabled; startup fails without it. |
providers.<name>.scm-api.require-validated-head | boolean | true | Refuse a pull/merge request whose head commit has no fogwall push record. Relax where rebase/amend/force-push/web-UI commits are common. |
providers.<name>.scm-api.merge-enabled | boolean | false | Allow merging PR/MRs through this provider. Independent of the MERGE grant; both are required. |
Token model
The CLI carries a personal access token the user supplies. fogwall forwards it upstream unchanged after inspecting the request, and never mints or supplies a credential for this path.
SCM OAuth is a separate mechanism, for fogwall-managed operations rather than external tooling. It does not provide a token for a CLI. See the administrator guide for the egress assumption this relies on.
SSH transport
Available since v1.3.0.
An alternative to the HTTP push path — see SSH transport in the administrator guide for how identity verification and agent forwarding work. This section covers the listener config itself.
server:
ssh:
enabled: false # off by default
port: 2222 # non-standard on purpose - see note below
host-key-path: .ssh/fogwall_host_key # generated on first start if absent
| Property | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Whether the SSH listener starts |
port | int | 2222 | TCP port the SSH server binds |
host-key-path | string | .ssh/fogwall_host_key | Path to the SSH host key file (absolute or relative to the working directory) |
Note
Why port 2222, and why
git@host:pathshorthand doesn’t work out of the box: the default avoids clashing with a realsshdthat may already be running on the same host, and avoids the container needing root orCAP_NET_BIND_SERVICEjust to bind a port below 1024. The trade-off is that Git’s SCP-like shorthand (git@host:owner/repo.git, the syntax GitHub’sgit@github.com:...uses) has no field for a non-default port — only the explicitssh://host:port/pathform does. If you want the shorthand to work, put fogwall’s SSH port behind a plain TCP/L4 passthrough on your load balancer orService(external:22→ the pod’s:2222) rather than changing what the container itself binds — the same pattern most deployments already use for terminating TLS on 443 in front of the container’s plaintext 8080. The Helm chart’ssshService.*values do exactly this.
Serving a provider over SSH
The single-entry model below is available since v1.4.0.
The server.ssh block above starts the SSH listener; SSH transport is then turned on per provider via an ssh:
sub-block on that provider’s entry. A single provider entry serves both HTTP and SSH to the same upstream — there is no
need for a separate github-ssh entry, and an OAuth-linked identity applies to both transports automatically.
providers:
github:
enabled: true # github.com over HTTPS
ssh:
enabled: true # also serve SSH — the endpoint is derived as ssh://git@github.com
gitea-internal:
enabled: true
type: gitea
uri: https://gitea.corp.example.com # HTTP/API endpoint
ssh:
enabled: true
uri: ssh://git@gitea.corp.example.com:3022 # explicit endpoint for a non-standard SSH port
known-hosts:
- "[gitea.corp.example.com]:3022 ssh-ed25519 AAAA..." # pin this upstream's host key
# GitHub Enterprise Cloud with data residency uses the enterprise slug as the SSH username, not "git":
acme-ghe:
enabled: true
type: github
uri: https://acme.ghe.com
ssh:
enabled: true
uri: ssh://acme@acme.ghe.com
Property (under ssh:) | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Serve this provider over SSH. With no ssh.uri, the endpoint is derived as ssh://git@<host> from the provider’s HTTP uri (port 22). |
uri | string | (derived) | Explicit SSH endpoint. Required when the upstream uses a non-git SSH username (e.g. GHEC data residency) or a non-standard port. Must use the ssh:// scheme. |
known-hosts | list | (none) | Inline known_hosts lines pinning this upstream’s SSH host key(s). Merged with the global server.ssh.known-hosts-path / bundled defaults. Entries are host-keyed, so they scope to this upstream. |
known-hosts-path | string | (none) | Path to a known_hosts file whose lines pin this upstream’s host key(s). Read once at startup and merged like known-hosts. |
Note
The
urion a provider entry is always the HTTP/HTTPS endpoint (it is also the API base used for SSH-key identity resolution). A top-leveluriwith anssh://scheme no longer enables SSH — configure SSH through thessh:sub-block instead.
Commit validation
Per-commit checks (identity, email policy, message content, trailer policy) apply to both server mode and transparent proxy modes.
commit:
# Committer email policy — the committer is the employee who ran git commit or git rebase.
# This is the primary corporate control: enforce that your staff use their work identity.
# Rebased commits from external contributors still pass as long as the committer email is valid.
# Also applied to the tagger of an annotated tag push: git fills the tagger line from the same
# user.email as the committer line, so there is no separate tagger policy to configure.
committer:
email:
# Unified allow/block rules — see "Email policy rules" below.
rules:
- { action: allow, field: domain, match: regex, value: "corp\\.example\\.com$" }
- { action: block, field: local, match: regex, value: "^(noreply|no-reply|bot|nobody)$" }
# Author email policy — the author is whoever originally wrote the commit.
# Configure this only if you want to disallow rebasing external contributors' commits.
# When set, any commit whose author email is not permitted is blocked — developers must open
# PRs from the original fork rather than rebasing upstream changes. Omit to allow external
# author emails (the most common setup).
author:
email:
rules:
- { action: allow, field: domain, match: regex, value: "corp\\.example\\.com$" }
message:
block:
literals:
- "WIP"
- "DO NOT MERGE"
patterns:
- '(?i)(password|secret|token)\s*[=:]\s*\S+'
# Commit-trailer policy — DCO Signed-off-by and Co-authored-by. Both controls are enforce-or-off
# and apply to both transports. See "Trailer policy" below.
trailers:
signed-off-by:
require: false # true = every commit must carry a Signed-off-by trailer (DCO)
require-author-match: false # true = the Signed-off-by email must equal the commit author's
co-authored-by:
policy: off # off | ban | allowlist | require
# Under `allowlist`, each co-author email is checked against these rules (same shape as above):
email:
rules:
- { action: allow, field: domain, match: regex, value: "corp\\.example\\.com$" }
- { action: allow, field: address, match: literal, value: "noreply@anthropic.com" }
Email policy rules
author.email, committer.email, and co-authored-by.email all share one shape: an ordered list of rules. Each rule
is { action, field, match, value }:
| Key | Values | Meaning |
|---|---|---|
action | allow | block | Whether a match permits or rejects the email. |
field | domain | local | address | Which part of the address to test (local is before @, address is the full local@domain). |
match | literal | regex | literal is case-insensitive exact equality; regex uses find-semantics. Default regex. |
value | string | The literal string or regex source. |
Evaluation: a block match always rejects; then, if any allow rule is present, the email must match at least one
to pass. With no allow rule, anything not blocked is permitted. This lets you express, for example, “allow @corp.com
and the single bot address noreply@anthropic.com, but block the svc- service accounts”:
rules:
- { action: allow, field: domain, match: regex, value: "corp\\.example\\.com$" }
- { action: allow, field: address, match: literal, value: "noreply@anthropic.com" }
- { action: block, field: local, match: regex, value: "^svc-" }
Deprecated aliases. The older
email.domain.allowandemail.local.blocksingle-regex keys are still accepted for one minor release and are folded into equivalentallow domain regex/block local regexrules at startup (a deprecation warning is logged). Migrate to theruleslist — it can express blocks on domains, allows on local-parts, full-address matching, and literals, none of which the old keys could.
Trailer policy
Two independent, enforce-or-off controls over commit-message trailers (both apply to server mode and transparent proxy):
signed-off-by.require— reject any commit lacking aSigned-off-by:trailer (Developer Certificate of Origin). Withrequire-author-match: true, at least oneSigned-off-byemail must equal the commit’s own author email (the DCO “sign off your own work” rule).co-authored-by.policy— one of:off(default) — no restriction.ban— reject any commit that carries aCo-authored-by:trailer (e.g. legal teams requiring a single attributable author per commit).require— reject any commit that has noCo-authored-by:trailer.allowlist— reject aCo-authored-bywhose email is not permitted byco-authored-by.email.rules(the same email-policy shape above), e.g. to permit only approved internal bots.
Captured Signed-off-by and Co-authored-by trailers are also stored on the push record and shown per-commit in the
dashboard push detail, independent of any policy.
Diff scan
Push-level check applied once per push against the aggregate diff (all commits combined). Only added lines (+) are
scanned — deletions and context lines are ignored.
diff-scan:
block:
literals:
- "internal.corp.example.com"
patterns:
- '(?i)https?://[a-z0-9.-]*\.corp\.example\.com\b'
Secret scanning
Secret scanning via gitleaks (https://github.com/gitleaks/gitleaks). Applied once per push. The JAR ships with a bundled gitleaks binary so scanning works out of the box.
Binary resolution order (first match wins):
scanner-path— explicit path, bypasses everything elseversion+auto-install: true— downloads and caches that version on startup- Bundled JAR binary (default version, always present)
- System
PATH
secret-scan:
enabled: false
# version: 8.22.0
# auto-install: true
# install-dir: ~/.cache/fogwall/gitleaks
# scanner-path: /usr/local/bin/gitleaks
# External TOML rules file. Ignored when inline-config is set.
# config-file: /app/conf/.gitleaks.toml
# Inline TOML config — takes precedence over config-file. Hot-reloadable via
# POST /api/config/reload?section=secret-scan. Content must be valid gitleaks TOML:
# inline-config: |
# title = "my-org"
# [extend]
# useDefault = true
# [[rules]]
# id = "my-org-api-key"
# regex = '''MY_ORG_[A-Z0-9]{32}'''
# timeout-seconds: 30
Binary blob detection
Available since v1.3.0.
Push-level check applied once per push against the aggregate diff (all commits combined) — same scope as diff scan. Flags added/modified blobs that exceed a size threshold or match a denied MIME type. ENFORCE-only: a match always blocks the push (there is no advisory/WARN mode yet).
MIME type classification sniffs the first few bytes of each blob’s content against a built-in table of magic-byte
signatures (the same technique tools like file/libmagic use, at a much smaller scale) — file extensions are never
consulted, since they’re trivially renamed and unreliable across operating systems. Only a small, bounded header is read
per blob; blob content is never fully loaded. Note that ZIP-based Office formats (.docx/.xlsx/.pptx) share the
same container signature as plain .zip/.jar archives and cannot be distinguished from magic bytes alone — all are
classified as application/zip.
binary-blob:
enabled: true # on by default — the size threshold alone is a useful safety net out of the box
max-size-bytes: 52428800 # 50MiB; 0 = no size limit
deny-mime-types: # PDF and ZIP-family denied by default; further types are a policy choice
- application/pdf
- application/zip
# - application/x-executable
# - application/x-msdownload
Detectable MIME types
deny-mime-types entries must match one of the values below exactly — these are the only content types the built-in
magic-byte signature table can identify. Anything not in this list is never denied by MIME type (though it may still be
denied by max-size-bytes).
| MIME type | Matches |
|---|---|
application/pdf | PDF documents |
application/zip | ZIP archives, JARs, and OOXML Office docs (.docx/.xlsx/.pptx — indistinguishable from plain zip at the magic-byte level) |
application/gzip | gzip-compressed files (.gz, .tar.gz) |
application/x-7z-compressed | 7-Zip archives |
application/vnd.rar | RAR archives |
application/x-executable | ELF binaries (Linux executables/shared objects) |
application/x-msdownload | Windows PE binaries (.exe, .dll) |
application/vnd.sqlite3 | SQLite database files |
application/java-vm | Compiled Java class files |
application/wasm | WebAssembly binaries |
application/zstd | Zstandard-compressed files |
application/x-xz | XZ-compressed files |
application/x-bzip2 | Bzip2-compressed files |
application/vnd.apache.parquet | Apache Parquet columnar data files |
application/x-java-keystore | Java Keystore (.jks) |
application/x-java-jce-keystore | Java Cryptography Extension Keystore (.jceks) |
application/x-qemu-disk | QEMU/QCOW2 virtual disk images |
application/x-hdf5 | HDF5 data files (e.g. Keras model checkpoints) |
Content-pattern scanning
Available since v1.3.0.
Push-level check applied against both the aggregate diff and every pushed commit’s message. Flags structured identifier content (national ID numbers, IBANs, credit card numbers, crypto wallet addresses, etc.) using fogwall’s built-in pattern bundles — distinct from secret scanning, which targets credential-shaped content (API keys, tokens). Every bundle here requires a structural checksum or Presidio-derived regex match, not free-text PII detection (names, addresses, emails) — that class needs NLP/NER to keep an acceptable false-positive rate, which is out of scope; see Design notes below.
WARN-only on the push path — a match is recorded as a WARN step for the reviewer to see, it never blocks the push.
These patterns have a real false-positive rate (a bare regex match on a short numeric identifier can’t be fully
disambiguated from unrelated numbers), and there’s currently no override path for a wrongly-blocked push. Since every
push already requires a human reviewer to look at it, WARN gets the finding in front of them without the downside of
blocking on a false positive.
Blocking on the SCM API path — that reasoning depends on the reviewer, and an SCM API entity has none: it is
forwarded to the upstream or refused, with no held state to annotate. A warning recorded against a merge request
description that is already published is not a control, so a match there refuses the request (REJECTED) the same way a
blocked term or a detected secret does. See Content inspection.
Bundle content (regexes, context keywords, structural validators like Luhn/IBAN/Base58Check checksums) is hand-ported
from data-privacy-stack/presidio (MIT licensed) where noted below —
see PROVENANCE.md alongside the bundle resources in the fogwall source tree for exact provenance, including the small
number of validators (US bank routing, Bitcoin SegWit/Taproot, Ethereum) that aren’t from Presidio. fogwall does not run
Presidio itself; only the matching logic is translated to Java.
content-patterns:
enabled: true
bundles:
- national-id-all-geos # every national-id-<cc> bundle below
- generic-iban
- generic-crypto-wallet
# generic-credit-card and generic-us-bank-routing use a Luhn-strength (mod-10) checksum - noisier than the
# above, so they're opt-in only; add them individually, or use the generic-all alias for all four generic
# bundles at once.
scan-diff: true # set false to skip scanning the push diff
scan-commit-messages: true # set false to skip scanning commit messages
scan-scm-api: true # set false to skip scanning SCM API titles, descriptions and comments
scan-diff/scan-commit-messages/scan-scm-api independently gate the three content sources - all default true, so
selecting a bundle covers every surface it can appear on. An operator who considers commit messages low-risk (or wants
to reduce push-summary noise) can disable that half without affecting diff scanning, and vice versa. This is distinct
from disabling a bundle: the bundle selection still applies to whichever source(s) remain enabled.
Available bundles
Two tiers: national-id-<cc> bundles (ISO 3166-1 alpha-2 country codes) each cover a single country’s
national-identity-registry number — not that country’s full PII catalog. generic-<type> bundles cover a data type that
isn’t tied to a jurisdiction. Two group aliases are always available: national-id-all-geos (every national-id-*
bundle) and generic-all (every generic-* bundle).
National ID bundles
| Bundle | Jurisdiction | Detects |
|---|---|---|
national-id-ca | Canada | Social Insurance Number (SIN) |
national-id-us | United States | Social Security Number (SSN) |
national-id-gb | United Kingdom | National Insurance Number (NINO); NHS Number |
national-id-au | Australia | Tax File Number (TFN); Australian Business Number (ABN); Australian Company Number (ACN) |
national-id-de | Germany | Rentenversicherungsnummer |
national-id-in | India | Aadhaar Number |
national-id-sg | Singapore | NRIC/FIN Number |
national-id-za | South Africa | South African ID Number |
national-id-es | Spain | NIF Number |
national-id-se | Sweden | Personnummer |
national-id-tr | Turkey | National ID Number (TC Kimlik No) |
national-id-fi | Finland | Personal Identity Code (Henkilötunnus) |
national-id-it | Italy | Fiscal Code (Codice Fiscale) |
national-id-kr | South Korea | Resident Registration Number |
national-id-ng | Nigeria | National Identification Number |
national-id-ph | Philippines | UMID Number |
national-id-th | Thailand | National ID Number |
Generic bundles
| Bundle | Enabled by default | Detects | Checksum strength |
|---|---|---|---|
generic-iban | Yes | IBAN | ISO 7064 mod-97-10 (strong) |
generic-crypto-wallet | Yes | Bitcoin (legacy Base58Check, SegWit/Taproot Bech32); Ethereum (EIP-55, checksum-cased addresses only) | SHA-256d / BCH / Keccak-256 (strong) |
generic-credit-card | No — opt in | Credit card number | Luhn (mod-10, weak) |
generic-us-bank-routing | No — opt in | US bank routing number (ABA) | ABA weighted mod-10 (weak) |
Design notes
Presidio’s recognizer catalog goes well beyond what fogwall ports: unstructured categories like names, physical
addresses, phone numbers, and email addresses rely on Presidio’s NLP/NER pipeline to keep an acceptable false-positive
rate, not a checksum. fogwall has no NLP pipeline (that’s the dependency this feature deliberately avoided — see
PROVENANCE.md), so only checksum-or-strict-structural-regex data types are in scope. If a data type doesn’t have a
real checksum, it isn’t a good fit for this WARN-only, regex-based mechanism.
Hot reload
Selected config sections can be reloaded at runtime without restarting the server. Two reload sources are supported:
- File watch — monitors a local YAML file; triggers automatically on modification
- Git source — periodically pulls a git repository and reads a YAML overlay from it
reload:
file:
enabled: false
path: /app/conf/fogwall-local.yml # watched for modifications
git:
enabled: false
url: https://github.com/myorg/config.git
branch: main
file-path: fogwall.yml
interval-seconds: 300 # 0 = manual trigger only
Git source authentication
For private repositories, set these environment variables — no config file changes needed:
FOGWALL_RELOAD_GIT_AUTH_USERNAME=<username or token placeholder>
FOGWALL_RELOAD_GIT_AUTH_PASSWORD=<personal access token or password>
Both variables must be set together; if only one is present a warning is logged and the clone/pull proceeds without
credentials. For token-only auth (GitHub, GitLab, Gitea PATs) the username can be any non-empty string — git or
x-token are common placeholders.
Reloadable sections
| Section | YAML key | What changes take effect |
|---|---|---|
commit | commit: | Author email rules, message block lists, commit attribution policy mode |
diff-scan | diff-scan: | Diff content block literals and patterns |
secret-scan | secret-scan: | All gitleaks settings including inline-config |
binary-blob | binary-blob: | Blob size limit and denied MIME types |
rules | rules: | URL access control allow/deny rules |
permissions | permissions: | Config-sourced user→repo permission grants |
attestations | attestations: | Dashboard approval form questions |
Provider, server, database and scm-oauth sections always require a restart — they describe how the deployment is set
up rather than policy that changes over time.
Manual trigger
POST /api/config/reload # reload all sections
POST /api/config/reload?section=commit # commit rules only
POST /api/config/reload?section=diff-scan # diff scan only
POST /api/config/reload?section=secret-scan # gitleaks config only
POST /api/config/reload?section=binary-blob # binary blob detection only
POST /api/config/reload?section=rules # URL rules only
POST /api/config/reload?section=permissions # permissions only
POST /api/config/reload?section=attestations # attestation questions only
The dashboard admin panel also provides a section dropdown for manual triggers.
Commit attribution policy
Formerly
commit.identity-verification. The old key still binds (with a deprecation warning) but will be removed in a future release — rename it tocommit.attribution-policy.
commit:
attribution-policy:
committer: warn # warn | strict | off (default: warn)
author: off # warn | strict | off (default: off)
For every push, the proxy runs two checks:
-
SCM login check — calls the upstream provider’s user API with the token supplied in the git credentials (the HTTP Basic-auth password). The returned login (e.g. GitHub
login, GitLabusername) is matched against the authenticated fogwall user’sscm-identities. This check is always enforced regardless of theattribution-policymode — a push from a token that cannot be matched to a registered proxy user is always blocked. -
Commit email check — every author and committer email in the pushed commits is checked against the authenticated fogwall user’s
emailslist. These emails are populated independently of the SCM: they come from the IdP on LDAP/OIDC login, or from additional associations added via the dashboard. This is what ties commit attribution back to a verified real person. Theattribution-policymode controls this check only.
Note
The HTTP Basic-auth username in the remote URL is not used for identity resolution. It is ignored by all providers (except Bitbucket). Configure your remote URL with any username —
git,me, your actual name — it makes no difference.
Modes
attribution-policy controls the commit email check only, independently for committer and author. The SCM login
check is always enforced.
| Mode | Behaviour | Use when |
|---|---|---|
strict | Blocks the push if any commit email cannot be matched to the authenticated fogwall user | Production — enforces that every commit is attributed to the person who pushed |
warn | Allows the push through but emits a sideband warning to the git client and records the mismatch | Rolling out to an existing team — lets you observe mismatches before enforcing |
off | Commit email check is disabled entirely | Migrations or environments where email data is not yet populated |
Caution
warnis not a security control. Pushes succeed regardless of the email check outcome. Onlystrictblocks mismatched commits. The default iswarnto avoid breaking existing deployments on first install.Committer vs author: the two are checked independently.
committerdefaults towarn— the committer is who last touched the commit object, i.e. the pusher on their own work.authordefaults tooffbecause rebased or cherry-picked commits legitimately preserve a different original author, so blocking on it would reject valid workflows. Enableauthor: strictonly on closed boundaries (private-to-private, M&A integration) where every commit must be authored by the pusher; leave itofffor open-source contribution flows.
Token scope requirements
The SCM login check calls GET /user (or equivalent) on the upstream SCM using the pusher’s token. The token must carry
at least the following scope:
| Provider | API endpoint | Additional scope |
|---|---|---|
| GitHub | GET https://api.github.com/user | No additional scopes required for either classic or fine-grained PATs. |
| GitLab | GET {uri}/api/v4/user | read_user or api (not recommended) |
| Codeberg | GET https://codeberg.org/api/v1/user | read:user |
| Gitea | GET https://gitea.com/api/v1/user | read:user |
If the token is missing the required scope or cannot be resolved to a registered proxy user, the push is blocked
regardless of attribution-policy mode.
Prerequisites
Both checks require the user record to be populated before a push. A push from a token that cannot be matched to any
registered proxy user is always blocked. Use attribution-policy with committer: warn during rollout to allow pushes
through while users register their commit emails; the SCM identity must be registered before any push can proceed.
users:
- username: alice
password-hash: "{bcrypt}$2a$12$..."
roles:
- ADMIN # optional; defaults to [USER] if omitted
emails:
- alice@example.com
# push-usernames: HTTP Basic-auth usernames accepted for this user when pushing.
# The proxy username is always implicitly valid; these are additional aliases.
# Useful when git clients send a fixed username (e.g. "git") that differs from
# the proxy username. Stored internally as SCM identities under the "proxy" provider.
push-usernames:
- git
- alice-bot
scm-identities:
- provider: github
username: alice-gh
- provider: gitlab
username: alice
URL rules
URL rules control which repositories are accessible through the proxy. fogwall is default-deny: if no allow rules are configured for a provider, all pushes and fetches to that provider are rejected. At least one allow rule must match for a request to proceed.
Rules use a unified match block that specifies what to match against (target), the pattern string (value), and how
to interpret it (type). Evaluation is first-match-wins by order — identical to iptables/firewall rule semantics.
order is optional for YAML-configured rules. When omitted, it’s inferred from the entry’s position within its
allow[]/deny[] array — the first entry gets 0, the second 100, and so on, leaving gaps for later insertion. An
explicit order always takes precedence over the inferred position. Rules created via the dashboard or REST API have no
array position to infer from, so they use a fixed default (100) unless an explicit order is set.
rules:
allow:
# Specific repo by exact slug — both operations, scoped to one provider
- enabled: true
order: 110
operation: BOTH
provider: github
match:
target: SLUG
value: /RBC/fogwall
type: LITERAL
# All repos under an owner — fetch only, any provider
- enabled: true
order: 120
operation: FETCH
match:
target: OWNER
value: finos
type: GLOB
deny:
# Block a specific repo across all operations
- enabled: true
order: 100
match:
target: SLUG
value: /myorg/forbidden-repo
type: LITERAL
To allow all repositories on a provider (open mode):
rules:
allow:
- enabled: true
order: 110
operation: BOTH
provider: internal-github
match:
target: OWNER
value: "*"
type: GLOB
URL rule properties
| Property | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Whether this entry is active |
order | int | (position) | Evaluation order (lower = earlier; first match wins). Optional — see below |
operation | string | BOTH | FETCH, PUSH, or BOTH — which git operation this entry matches |
provider | string | (all) | Provider name to scope this entry to; omit or leave blank for all |
match | object | — | Repository match criteria — see below |
match.target | enum | SLUG | What to match: SLUG (the full repository path), OWNER, or NAME |
match.value | string | — | The pattern to match against the chosen target |
match.type | enum | GLOB | How to interpret the pattern: LITERAL, GLOB, or REGEX |
Pattern matching
Each rule matches exactly one thing — the match block selects what URL part to test (target) and how to test it
(type). To express an AND condition (e.g. specific owner AND specific name prefix), use target: SLUG and write a
single glob or regex that covers both parts.
Values are compared to the request path verbatim, for every match type. These are URL rules, so a SLUG value
carries the leading / the path has — write /acme/repo, not acme/repo. OWNER and NAME are path segments and
carry no slash. fogwall does not normalise either way: a value whose leading / disagrees with its target can never
match, and is named in a startup warning rather than silently matching nothing.
Case is the one exception, and is ignored for every match type. Every supported provider resolves owner/repo
case-insensitively, so /acme/widgets and /Acme/Widgets are the same repository upstream — a rule written in one
casing covers both. This matters most for DENY: rules are first-match-wins, so a deny that missed on casing would hand
the request to whatever broader allow sits below it.
Nested namespaces
The repository name is the last path segment and the owner is everything before it, so a repository path is not
limited to two segments. For a GitLab subgroup project /group/subgroup/project:
| Target | Value matched against |
|---|---|
SLUG | /group/subgroup/project |
OWNER | group/subgroup |
NAME | project |
GitLab is the only supported provider that produces such a path — GitHub owners are a single segment, and Forgejo/Gitea and Bitbucket address owner and repository as two plain segments. The same derivation applies to every path fogwall serves: both proxy modes, and both of server mode’s transports.
Two consequences when writing rules for subgroup projects:
- An
OWNERvalue must name the whole namespace (group/subgroup), not just the top-level group. AGLOBofgroup/*matches one level of nesting;group/**matches any depth. - A
SLUGvalue must name every segment. ALITERALof/group/subgroupdoes not admit the projects inside that subgroup — write/group/subgroup/*as aGLOBfor that.
LITERAL
Exact string match, ignoring case. The path shape is not normalised — /acme/repo matches the slug /acme/repo and
/ACME/Repo, while acme/repo matches nothing.
GLOB
Wildcard matching using * (any characters) and ? (single character), ignoring case. Both the pattern and the
candidate are folded before matching, so GLOB behaves the same on every host — without that it would inherit the
filesystem’s own case rules and differ between Linux and macOS.
| Target | * behaviour |
|---|---|
SLUG | Does not cross / — use /acme/* for one level, /acme/** for any depth |
OWNER | Does not cross / — a nested namespace needs group/* or group/** (see above) |
NAME | Repo names cannot contain / — * matches any valid name |
| Pattern (GLOB, target=SLUG) | Matches | Does NOT match |
|---|---|---|
/acme/repo (LITERAL) | /acme/repo | /acme/other |
/acme/* | /acme/repo, /acme/my-service | /other/repo |
/acme/service-* | /acme/service-api, /acme/service-worker | /acme/repo |
/acme/repo-? | /acme/repo-1, /acme/repo-a | /acme/repo-12 |
REGEX
Full Java regular expression. A few things to know before writing regex rules:
- Full-string match: the pattern must match the entire candidate string — there are no implicit anchors, but
matches()semantics apply. A pattern ofacmedoes not match/acme/repo; write/acme/.*or.*acme.*. /does not need escaping: Java regex uses strings, not a/pattern/literal syntax. Write/acme/.*not\/acme\/.*.- Case-insensitive already: patterns are compiled with
CASE_INSENSITIVE. An inline(?i)stays valid and is harmless, but is no longer needed. - Anchoring: explicit
^and$are redundant withmatches()but harmless if included.
| Pattern (REGEX, target=SLUG) | Matches | Does NOT match |
|---|---|---|
/acme/.* | /acme/repo, /ACME/Repo | /other/repo |
/(acme|partner)/.* | /acme/repo, /partner/repo | /other/repo |
/acme/service-[0-9]+ | /acme/service-1, /acme/service-42 | /acme/service-api |
| Pattern (REGEX, target=NAME) | Matches | Does NOT match |
|---|---|---|
(?i)(^|-)secret(-|$).* | secret-config, my-secret | secretariat |
migrate-.* | migrate-app, migrate-db | old-migrate |
rules:
deny:
# Block any repo whose name contains "secret" as a distinct word segment (case-insensitive)
- enabled: true
order: 50
operation: PUSH
match:
target: NAME
value: "(?i)(^|-)secret(-|$).*"
type: REGEX
# Block repos matching multiple owner orgs using alternation
- enabled: true
order: 51
operation: BOTH
match:
target: OWNER
value: "(blocked-org|suspended-org)"
type: REGEX
Real-world URL rule examples
Gateway for a specific SCM — allow all push and fetch:
rules:
allow:
- enabled: true
order: 110
operation: BOTH
provider: internal-github
match:
target: OWNER
value: "*"
type: GLOB
Allow repos under a set of known owner orgs, identified by a name prefix:
rules:
allow:
- enabled: true
order: 110
operation: BOTH
provider: internal-github
match:
target: OWNER
value: "team-(alpha|beta|gamma)"
type: REGEX
Allow push only for repos whose name starts with a known prefix:
When repos are identified by a project code followed by a hyphen, match on NAME so the rule applies regardless of
which org the repo lives under.
rules:
allow:
- enabled: true
order: 110
operation: PUSH
provider: internal-github
match:
target: NAME
value: "proj0-*"
type: GLOB
# Multiple project prefixes — one rule per prefix, or combine with REGEX alternation:
allow:
- enabled: true
order: 111
operation: PUSH
provider: internal-github
match:
target: NAME
value: "(proj0|proj1|shared)-.*"
type: REGEX
Combine owner and name matching (AND condition):
Use target: SLUG with a glob or regex — the slug is the full repository path, so a single pattern can constrain both
parts.
rules:
allow:
# Allow fetch from the source SCM for a specific org + name prefix (glob AND)
- enabled: true
order: 110
operation: FETCH
provider: source-github
match:
target: SLUG
value: "acquired-org/migrate-*"
type: GLOB
# Allow push to the destination SCM — stricter control with regex
- enabled: true
order: 120
operation: PUSH
provider: dest-gitlab
match:
target: SLUG
value: "/migrated-org/migrate-.*"
type: REGEX
Permissions
Permissions control which proxy users can push to or review pushes from specific repositories. They are checked after URL rules: a push that is blocked by a deny rule never reaches the permission check.
Permissions are hot-reloadable (see Reloadable sections).
permissions:
# LITERAL (default): exact /owner/repo match
- username: alice
provider: github
match:
target: SLUG
value: /myorg/myrepo
type: LITERAL
grant: PUSH
# GLOB: wildcard repo name under a specific owner
- username: bob
provider: gitlab
match:
target: SLUG
value: /myorg/*
type: GLOB
grant: PUSH_AND_REVIEW
# OWNER target: grant access to all repos under an org
- username: carol
provider: github
match:
target: OWNER
value: myorg
type: GLOB
grant: REVIEW
# REGEX on SLUG: match repos under multiple orgs
- username: dave
provider: github
match:
target: SLUG
value: "/team-(alpha|beta)/.*"
type: REGEX
grant: PUSH_AND_REVIEW
# SELF_CERTIFY: trusted contributor who can approve their own clean pushes.
# Requires both this permission entry AND the SELF_CERTIFY role on the user.
- username: trusted
provider: github
match:
target: SLUG
value: /myorg/myrepo
type: LITERAL
grant: SELF_CERTIFY
Permission properties
| Property | Type | Default | Description |
|---|---|---|---|
username | string | — | Proxy username (must match a users: entry or a DB user) |
provider | string | — | Provider name as defined in providers: config |
match | object | — | Repository match criteria — see below |
match.target | enum | SLUG | What to match: SLUG (the full repository path), OWNER, or NAME |
match.value | string | — | The pattern to match against the chosen target |
match.type | enum | GLOB | How to interpret the pattern: LITERAL, GLOB, or REGEX |
grant | enum | PUSH_AND_REVIEW | What the user may do: PUSH, REVIEW, PUSH_AND_REVIEW, SELF_CERTIFY, ISSUE, PROPOSE, MERGE, MAINTAIN. ISSUE/PROPOSE/MERGE (v1.4.0+) are independent of PUSH/REVIEW; ISSUE gates the dashboard issue form (the narrow floor under PROPOSE), PROPOSE/MERGE the SCM API proxy. MAINTAIN bundles PUSH, PROPOSE and MERGE for a sole maintainer. |
Pattern matching
Permissions support the same three match types as URL rules (LITERAL, GLOB, REGEX) applied to the same three targets (SLUG, OWNER, NAME), matched the same way — including case-insensitively, so a grant covers the repository it names whatever casing the push uses. See Pattern matching above for full semantics including regex behaviour.
GLOB on target: SLUG follows slug path conventions:
| Pattern (GLOB, target=SLUG) | Matches | Does NOT match |
|---|---|---|
/acme/repo | /acme/repo | /acme/other |
/acme/* | /acme/repo, /acme/my-service | /other/repo |
/acme/service-* | /acme/service-api, /acme/service-worker | /acme/repo |
/*/proj0-* | /acme/proj0-api, /other/proj0-db | /acme/other |
Note
Conflict detection: At config load time and when saving via the dashboard API, fogwall rejects any new permission entry whose pattern overlaps with an existing entry for the same user and provider. Two entries overlap when they are equal ignoring case, or when one is a GLOB/REGEX pattern that would match the other’s value. This prevents silent misconfiguration where the effective permission depends on evaluation order.
Real-world permission examples
Allow a user to push any repo on a specific provider:
permissions:
- username: alice
provider: internal-github
match:
target: OWNER
value: "*"
type: GLOB
grant: PUSH_AND_REVIEW
Allow push to repos whose name starts with a project code:
permissions:
- username: alice
provider: internal-github
match:
target: NAME
value: "proj0-*"
type: GLOB
grant: PUSH
# Or match the same prefix across any owner using SLUG:
- username: alice
provider: internal-github
match:
target: SLUG
value: "/*/proj0-*"
type: GLOB
grant: PUSH
Regex — match repos under multiple owner orgs:
permissions:
- username: alice
provider: internal-github
match:
target: SLUG
value: "/team-(alpha|beta)/.*"
type: REGEX
grant: PUSH_AND_REVIEW
Self-certify for a trusted committer scoped to a prefix:
A trusted committer needs both entries: PUSH_AND_REVIEW to be able to push, and SELF_CERTIFY to bypass the peer
review requirement. These cover separate code paths and are not treated as conflicting.
permissions:
# Push and review access
- username: trusted-dev
provider: internal-github
match:
target: NAME
value: "proj0-*"
type: GLOB
grant: PUSH_AND_REVIEW
# Self-certify on the same scope (requires SELF_CERTIFY role on the user too)
- username: trusted-dev
provider: internal-github
match:
target: NAME
value: "proj0-*"
type: GLOB
grant: SELF_CERTIFY
Grant
| Value | Effect |
|---|---|
PUSH | User may push to matching repositories |
REVIEW | User may approve or reject pushes submitted by others |
PUSH_AND_REVIEW | Shorthand for both PUSH and REVIEW; does not include SELF_CERTIFY |
SELF_CERTIFY | Trusted contributor: may approve their own clean pushes without a peer reviewer. Requires the SELF_CERTIFY role as well |
ISSUE | User may file and follow up on issues through the dashboard issue form — the narrow floor under PROPOSE, independent of PUSH/REVIEW |
PROPOSE | User may open and edit pull/merge requests and issues through the SCM API proxy — independent of PUSH/REVIEW |
MERGE | User may merge a pull/merge request through the SCM API proxy’s maintainer path — independent of PUSH/REVIEW/PROPOSE |
MAINTAIN | Sole-maintainer bundle: PUSH + PROPOSE + MERGE in one entry. Deliberately excludes SELF_CERTIFY — that stays a separate grant |
Important
SELF_CERTIFYis a two-key lock: the user must have both aSELF_CERTIFYpermission entry for the repository and theSELF_CERTIFYrole (set viausers[].rolesorauth.role-mappings). Either alone is not sufficient.
Groups
Available since v1.3.0.
Named groups of users that share a common set of permission grants — an alternative to repeating the same permissions:
entry for every member individually. A user assigned to a group inherits all of the group’s grants in addition to any
directly-assigned permissions.
groups:
- name: team-alpha
description: "Alpha team push access"
members:
- alice
- bob
grants:
- provider: github
match:
target: SLUG
value: /myorg/**
type: GLOB
grant: PUSH
Group properties
| Property | Type | Default | Description |
|---|---|---|---|
name | string | — | Group name, shown in the dashboard |
description | string | "" | Free-text description |
members | list | [] | Usernames belonging to this group (must match a users: entry or a DB user) |
grants | list | [] | Permission grants applied to every member — same shape as permissions: entries, minus username |
grants[].provider | string | — | Provider name as defined in providers: config |
grants[].match | object | — | Repository match criteria — same semantics as Permissions |
grants[].grant | enum | PUSH | PUSH, REVIEW, PUSH_AND_REVIEW, SELF_CERTIFY, ISSUE, PROPOSE, MERGE, or MAINTAIN — see Grant |
Note
Groups defined here are CONFIG-sourced and read-only from the dashboard — editing or deleting a config-sourced group via the UI/REST API is rejected. Groups created through the dashboard instead are DB-sourced and fully editable there. This mirrors how CONFIG-sourced
permissions:/rules:entries behave.
Attestations
Attestation questions are presented to reviewers in the dashboard approval form. All configured questions must be answered before the reviewer can submit an approval. Attestations are hot-reloadable.
attestations:
- id: reviewed-content
type: checkbox
label: "I have reviewed the diff and it contains no sensitive or proprietary information"
required: true
- id: policy-compliance
type: checkbox
label: "This push complies with our open source contribution policy"
required: true
- id: ticket-ref
type: text
label: "Internal ticket or justification reference"
required: false
- id: risk-level
type: dropdown
label: "Estimated risk level for this change"
options:
- Low
- Medium
- High
required: true
tooltip: "Select the risk level based on the scope and nature of the change"
- id: policy-review
type: checkbox
label: "I have reviewed the applicable policy"
required: true
links:
- text: "Open source contribution policy"
url: "https://policy.example.com/open-source"
- text: "Data classification guide"
url: "https://policy.example.com/data-classification"
Attestation properties
| Property | Type | Default | Description |
|---|---|---|---|
id | string | — | Unique key used to store the reviewer’s answer in the push record |
type | string | checkbox | Input type: checkbox, text, or dropdown |
label | string | — | Question text shown in the review form |
required | boolean | false | Whether the question must be answered before the reviewer can submit |
links | list | [] | Policy/reference links (text + url each) rendered below the question |
options | list | (empty) | Choices for dropdown type; ignored for other types |
tooltip | string | (none) | Optional help text shown alongside the question |
Set attestations: [] (or omit the key) to disable attestations entirely.
Running and logging
Running
# Proxy only (no dashboard):
./gradlew :fogwall-server:run
# Proxy + dashboard + REST API:
./gradlew :fogwall-dashboard:run
# Override port via environment variable:
FOGWALL_SERVER_PORT=9090 ./gradlew :fogwall-server:run
Logs: fogwall-server/logs/application.log
Logging
fogwall uses Log4j2 for logging. To override the bundled config without rebuilding the image, mount a custom
log4j2.xml and point the JVM at it:
# Local run
JAVA_TOOL_OPTIONS=-Dlog4j2.configurationFile=/path/to/log4j2.xml ./gradlew :fogwall-dashboard:run
# Docker — mount your config and set the env var
volumes:
- ./my-log4j2.xml:/app/conf/log4j2.xml:ro
environment:
JAVA_TOOL_OPTIONS: -Dlog4j2.configurationFile=/app/conf/log4j2.xml
JAVA_TOOL_OPTIONS is read directly by the JVM, so it works regardless of how the application is launched.
A ready-made debug config (docker/log4j2-debug.xml) is included for diagnosing OIDC and Spring Security issues — it
enables DEBUG on org.springframework.security and org.springframework.web.client. See the comments in that file
for how to activate it.
Git client output
fogwall sends validation results and status messages to the git client via sideband (the remote: lines visible during
a push). Two environment variables control the formatting of these messages:
| Variable | Effect |
|---|---|
NO_COLOR | Disables ANSI colour in sideband output. Follows the no-color.org convention — set to any value to disable. |
FOGWALL_NO_EMOJI | Replaces emoji symbols (✅ ❌ ⛔ 🔑 etc.) with plain ASCII equivalents. Useful when pushing through terminals or CI systems that do not render Unicode correctly. |
Both are read at runtime from the server’s environment — no restart is required if set before the process starts, but they cannot be changed while the server is running.
# Docker Compose — add to the fogwall service environment block
environment:
NO_COLOR: "1"
FOGWALL_NO_EMOJI: "1"
Architecture
fogwall is a Git push proxy that sits between developers and upstream Git hosting providers (GitHub, GitLab, Bitbucket, Forgejo, etc.). Every push travels through a validation and approval pipeline before reaching the upstream remote. Fetch/clone traffic is audited but not blocked.
If you’re familiar with finos/git-proxy, the Java rewrite shares the same conceptual model: an ordered chain of steps that inspect and act on each push, a push store for audit and approval state, and pluggable providers for different Git hosts. The main structural difference is that fogwall offers two distinct proxy modes with different tradeoffs.
Contents
- Project structure — the Gradle modules and how they depend on each other
- Two proxy modes — server mode, transparent proxy, and the SCM API listeners
- Request flow — what happens to a push, step by step, in each mode
- Validation pipeline — the ordered chain of checks a push runs through
- Core abstractions — provider, push store, approval gateway, user store
- Deployment modes — proxy only, proxy plus dashboard, and Docker
- Advanced use cases — private-to-private proxying and credential rewriting
- What this architecture enables — the use cases server mode’s full pack ownership opens up
Project structure
The codebase is a multi-module Gradle build. Dependencies flow upward — core is depended on by server, server is
depended on by dashboard.
fogwall-core
Shared library. Contains all validation logic (hooks + filters), the push store, provider
model, identity resolution, approval abstraction, and database migrations (Flyway). Both
proxy modes are implemented here. No application entry point — this is a library.
fogwall-server
Standalone Jetty application (FogwallJettyApplication). Registers both proxy modes for
every configured provider, loads YAML config via Gestalt, and starts a plain Jetty server.
No Spring, no dashboard, no REST API. This module also owns the shared servlet registrar
(FogwallServletRegistrar) and configuration builder (JettyConfigurationBuilder) used by
the dashboard module.
fogwall-dashboard
Full application (FogwallDashboardApplication). Depends on both core and server.
Adds Spring MVC (DispatcherServlet at /*), Spring Security, a REST API (/api/*), and a
React SPA (built with Vite, bundled into the JAR as static resources). Approval workflow
is always UI-driven in this mode.
The server module defines a FogwallContext record that bundles all runtime singletons (push store, user store,
approval gateway, identity resolver, repository caches, TLS config). Both application entry points build this context
from config and pass it to FogwallServletRegistrar, which registers the same servlets and filters regardless of
whether the dashboard is present.
Two proxy modes
Server mode (/server/<provider>/<owner>/<repo>.git)
The upstream repository is cloned locally on first access. When a developer pushes, JGit’s ReceivePack receives the
entire pack locally before anything is forwarded. Pre-receive hooks validate the push; if it passes (and any required
approval is granted), a post-receive hook forwards it to upstream using the developer’s credentials.
This mode can stream progress messages to the git client in real time via JGit sideband packets — so the developer sees
remote: [step] author email OK lines as each validation step completes.
Naming: this mode was formerly called store-and-forward. It is served under the canonical
/server/…prefix as of 1.4.0; the legacy/push/…prefix still routes to it as a deprecated alias, so existing git remotes keep working.
Transparent proxy (/proxy/<provider>/<owner>/<repo>.git)
An HTTP reverse proxy forwards the git protocol directly to upstream. A servlet filter chain inspects the pack data
before it reaches upstream. Validation results are collected and, if anything fails, a single error response is sent.
The developer’s git client is talking to a forwarding proxy, not a JGit endpoint, through a single HTTP request/response
cycle. A temporary local clone is still used to unpack the pack data and walk the commit range for validation, but the
push is forwarded via HTTP proxy rather than a JGit push command.
This mode cannot stream incremental feedback. The reason is structural: an HTTP response is a single buffered reply. The
filter chain runs to completion inside one request/response cycle — there is no mechanism to flush partial output to the
git client mid-chain. Validation filters accumulate their results; ValidationSummaryFilter and PushFinalizerFilter
collect everything and write one response at the end. Server mode avoids this constraint entirely because JGit’s
ReceivePack owns the connection and can call sendMessage() at any point, streaming sideband packets to the client as
each hook completes.
Choosing a mode
| Concern | Server mode | Transparent proxy |
|---|---|---|
| Live progress feedback | Yes — per-step sideband messages | No — single terminal response |
| Local storage required | Yes — receives the push into a local clone | Yes — clone needed for pack inspection |
| Approval workflow | Blocks git session until approved | Records push, polls for approval (requires second push) |
| Pack inspection | Via JGit ReceivePack APIs | Pack unpacked into local clone for inspection, then HTTP-proxied upstream |
| Resumable push after approval | Same session | New push to /proxy/ re-run detects prior approval |
Both modes share the same validation logic and push store. Both are always active for every configured provider — there is currently no per-provider toggle to disable one mode.
SCM API (a dedicated listener per provider)
Available since v1.4.0, opt-in per provider — see docs/internals/scm-api-proxy.md.
A third HTTP surface, opt-in per provider (providers.<name>.scm-api.enabled), for SCM CLI tools rather than git itself
— proxying gh’s issue/PR, glab’s issue/MR, and tea/fj’s issue/PR create-edit-comment-review traffic instead of a
git push. Unlike the two modes above, it does not touch a local repository clone; it inspects and relays a small
request/response pair.
Unlike server mode and the transparent proxy, which share the main port under path prefixes, each enabled provider
gets its own listener (providers.<name>.scm-api.port) with its dialect mounted at that listener’s root —
/api/graphql, /api/v4/*, /api/v1/*. This is forced by the clients: gh and fj address the API from the host
root and discard any path prefix, and a single shared root listener would collide between two instances of the same
platform. registerScmApiListeners binds each context to its connector using Jetty’s "@connectorName" virtual-host
form, and relaxes URI compliance on the GitLab and Gitea/Forgejo connectors so an encoded separator isn’t rejected as an
ambiguous path separator — GitLab names a project as one owner%2Frepo segment, and Gitea encodes a repository-relative
file path into one segment of its blob endpoints. The GitHub connector keeps the strict default. The relaxation only
gets those requests past the parser; ScmApiRestPathPolicy decides where a %2F is actually permitted, per dialect.
The three platforms use genuinely different wire formats, so each gets its own filter chain rather than one shared
pipeline forced to fit all — the chains are plain jakarta.servlet.Filters (not FogwallFilters — the git-specific
GitRequestDetails/PushStep request model doesn’t apply here), registered by FogwallServletRegistrar:
GitHub (GraphQL), via registerScmApiProxy:
ScmApiAuditFilter (outermost, try/finally — one record per mutation, plus refusals of authenticated callers)
└─ ScmApiAuthenticateFilter (token → fogwall identity, via the same PushIdentityResolver git push uses)
└─ ScmApiGitHubGateFilter (parse → allowlist/resolve/authorize a mutation, or provider-level-gate a read)
└─ ScmApiContentInspectionFilter (blocked terms, secrets, PII bundles over the whole payload)
└─ ScmApiGraphQlForwardServlet (relays to the GraphQL endpoint with the caller's own token)
GitLab (REST), via registerScmApiProxyGitLab:
ScmApiAuditFilter (same as above)
└─ ScmApiAuthenticateFilter (same as above)
└─ ScmApiGitLabGateFilter (allowlist method+path → authorize using owner/repo straight from the URL)
└─ ScmApiContentInspectionFilter (same as above)
└─ ScmApiRestForwardServlet (relays the sub-path/query/body to the provider's REST API base URL)
Gitea/Forgejo (REST), via registerScmApiProxyForgejo:
ScmApiAuditFilter (same as above)
└─ ScmApiAuthenticateFilter (same as above)
└─ ScmApiUserAgentFilter (classify + audit the client; optionally refuse non-CLI callers)
└─ ScmApiForgejoGateFilter (same shape as GitLab's, different allowlist table)
└─ ScmApiContentInspectionFilter (same as above)
└─ ScmApiRestForwardServlet (shared with the GitLab dialect)
(ScmApiUserAgentFilter sits in all three chains; it is shown once, above, to keep the other two readable.)
Mechanics that carry the actual security decisions:
- AST-based mutation allowlisting (GitHub). The GraphQL request body is parsed (
graphql-java) into a real AST; the allowlist matches on the parsed mutation’s schema field name, never a substring of the raw query text — a client alias or a string literal containing a mutation name can’t spoof the check. - Opaque node-ID resolution (GitHub only). A GraphQL mutation references its target only by an opaque node ID, never
owner/repo—GitHubNodeIdResolverresolves it (cached, with a TTL that is a security parameter, not just a perf knob: a node ID can outlive a repo rename/transfer) beforeRepoPermissionService.isAllowedToProposecan run. GitLab has no equivalent step: its REST calls carryowner/repodirectly in the URL (verified from liveglabcaptures — see docs/internals/scm-api-proxy.md), soScmApiGitLabGateFilterreads the authorization target straight off the matched path viaGitLabRestAllowlist. - One dialect for
teaandfj. The two Gitea/Forgejo CLIs speak the same server API and differ only in which subset they use, soForgejoRestAllowlistis the union of both. The union is load-bearing:tea pr closesendsPATCH /pulls/{n}whilefj pr closesendsPATCH /issues/{n}, so allowlisting one form silently breaks the other CLI. They are deliberately not told apart byUser-Agent— that header is caller-controlled, so branching authorization on it would let a caller select the looser rule set. User-Agentis evidence, never an input to a decision.ScmApiUserAgentFilterrecords the raw header (each CLI advertises its version, the anchor for spotting a wire-format change after an upgrade) and can optionally refuse unrecognised client types. It is strictly subtractive: enabling it only ever denies more, so a forged header buys nothing beyond the baseline the allowlist and permission engine already enforce.- Allowlists match the raw, undecoded URI.
getPathInfo()is decoded by the container, which would split GitLab’sacme%2Fwidgetsinto two segments — turning everyglabmutation into a fail-closed denial, and in principle letting an encoded slash shift which repository is authorized.ScmApiRestPathreadsgetRequestURI()instead. - The upstream response is read, not just relayed. A push response is an acknowledgement; a client-server API’s
response is the authoritative statement of what now exists, and fogwall already holds it. The forwarders relay a
mutation’s response to the client while keeping a bounded copy, and
EntityRegistrarthen reads it through the dialect’sEntityResponseReader— number, URL, node ID and state — into thescm_api_entitiesregistry, a mutable current-state table that the append-onlyscm_api_action_recordspoint at viaentity_id. A read is still streamed and never kept. The registry write happens after the client has its response and never throws into the request, so a registry failure costs the link, not the mutation. - Content inspection covers the payload, not a field list.
ScmContentInspectorrunsscm-api.block, gitleaks and the content-pattern bundles over the raw bytes, every JSON key and scalar at any depth, the query string in both forms, and — for GitHub — the GraphQL query’s own literals. It fails closed: unlike the push path, a pull/merge request or issue that cannot be scanned is refused, because a forwarded one has already published its text upstream. For the same reason the PII bundles block here rather than warning as they do on a push — a warning needs a reviewer, and this path holds nothing for one to look at. - Reads stay cheap in all dialects. No dialect resolves or permission-checks reads individually — an authenticated caller’s reads are forwarded, keeping the default read cost near pass-through.
- No URL rule layer.
UrlRuleRegistrygates the git path because a fetch of a public repository arrives with no credential, leaving the URL as the only thing to match on. Every request here is authenticated —ScmApiAuthenticateFilterrefuses a missing token and one that resolves to no fogwall user — so authorization runs against the caller directly, throughRepoPermissionService.
Request flow
Server mode push
git push → /server/<provider>/<owner>/<repo>.git
│
▼
ServerRepositoryResolver
• clone/fetch upstream repo locally
• extract credentials from Authorization header
│
▼
ServerReceivePackFactory
• assemble hook chain (see below)
│
┌─────┴──────────────────────────────────┐
│ Pre-receive hooks (ordered) │
│ 1. PushStorePersistenceHook │ record RECEIVED
│ 2. Validation hooks (see below) │ emit per-step sideband messages
│ 3. PushStorePersistenceHook │ record PENDING or BLOCKED
│ 4. ApprovalPreReceiveHook │ block until approved / auto-approve
└─────┬──────────────────────────────────┘
│ (if approved)
┌─────┴──────────────────────────────────┐
│ Post-receive hooks │
│ 1. ForwardingPostReceiveHook │ push to upstream with dev's credentials
│ 2. PushStorePersistenceHook │ record FORWARDED or ERROR
└─────────────────────────────────────────┘
Authenticating a server mode request
Server mode cannot forward a request it has no credentials for, and a git client only sends credentials after a 401
challenge — so BasicAuthChallengeFilter has to decide, before the servlet runs, whether this request needs one.
Push is unambiguous: the push is forwarded upstream using the developer’s own token, so it is always challenged.
Fetch is not. The mirror is cloned from upstream on every open, so a fetch of a private repository must be able to carry credentials — but challenging every fetch makes public repositories unclonable by anyone who has no credential to offer, and a client that answers the challenge with an unrelated or expired token is rejected by providers such as GitHub even on a repository they would have served anonymously. Guessing in either direction breaks a real workflow.
So fogwall asks upstream instead. UpstreamAuthProbe issues the git advertisement itself —
GET <repo>/info/refs?service=git-upload-pack, no credentials — and reads the answer: 200 means anonymous reads are
served, anything else means they are not. That is provider-agnostic, needs no REST API and no per-provider visibility
field, and follows the repository’s real visibility rather than a configured assumption.
Two properties keep it cheap and safe. Only an unauthenticated fetch probes at all — a request already carrying an
Authorization header is passed straight through — and verdicts are cached per repository, so a burst of anonymous
clones costs one upstream round trip. Any unclear answer (timeout, 404, 5xx) is treated as “credentials required”,
because a probe that cannot reach upstream must never be the reason a repository becomes anonymously readable.
The transparent proxy needs none of this: it forwards to upstream directly, so upstream issues its own challenge. SSH authenticates by key before any git command runs.
Repository path derivation
Owner, name and slug all come from one place, RepoPath — the transparent proxy’s ParseGitRequestFilter, server
mode’s ServerReceivePackFactory and RepositoryUrlRuleHook, the SSH transport’s route resolution, and
RepoPermissionService’s OWNER/NAME targets each parse the request path through it rather than splitting the path
themselves. A URL rule and a permission check evaluated for the same request have to compare against the same strings; a
per-call-site split is how they drift apart, and a rule that silently matches a different repository than the permission
check did is a containment failure rather than a cosmetic inconsistency.
The rule is that the repository name is the last path segment and the owner is everything before it, so a path is
not capped at two segments — a GitLab subgroup project /group/subgroup/project has owner group/subgroup. Splitting
at the first separator, or keeping only the first two segments, reads such a path as the subgroup itself. Every segment
is validated, not just the first and last, because a nested owner is several segments and traversal in any one of them
must not reach an upstream URL or a cache key. A path that does not parse yields nothing at all and each caller rejects:
a partial reading of a repository path is never safe to authorize against.
RepoPathMatching is the companion half: RepoPath decides which part of a path a pattern is compared against, and
RepoPathMatching decides how that comparison is made. Both the URL-rule evaluator and the permission service route
through it, so neither can drift on the answer. Its one job today is case folding — every provider fogwall speaks to
resolves owner/repo case-insensitively, so comparing case-sensitively let a recased path miss a DENY and fall
through to a broader allow beneath it. Folding also takes GLOB off the host filesystem’s case rules, which otherwise
made the same rule behave differently on Linux and macOS. Case is the only thing folded: the path’s shape — its leading
slash and its segment boundaries — is still compared verbatim, because those are what the operator wrote in a URL rule.
Transparent proxy push
git push → /proxy/<provider>/<owner>/<repo>.git
│
▼
Servlet filter chain (ordered)
ParseGitRequestFilter extract pack metadata from packet lines
EnrichPushCommitsFilter clone/fetch upstream repo; unpack inflight pack into a per-request quarantine; walk commit range
AllowApprovedPushFilter prior-approved? skip validation, proxy directly
UrlRuleAggregateFilter evaluate ALLOW/DENY rules
CheckUserPushPermissionFilter resolve identity; check repo permissions
CommitAttributionPolicyFilter verify commit author/committer email
[content validation filters — see below]
ValidationSummaryFilter collect all issues
PushFinalizerFilter save push record; wait for approval if required
│
▼
FogwallServlet (Jetty AsyncProxyServlet)
• HTTP proxy pass-through to upstream
• on response: update push record → FORWARDED or ERROR
Per-request object quarantine
Validation has to read a push’s objects before it can decide anything about them, but the mirror behind
/proxy/<provider>/... is shared by every request for that repository. Unpacking straight into it means a rejected push
leaves its content there permanently — including the content policy just refused.
QuarantineObjectStore gives each push its own scratch object store instead:
- the quarantine
Repositoryshares the mirror’s git directory, so it sees the mirror’s refs and can still answer “what does this push actually introduce”; - its object directory is a temporary directory, so every write lands there;
- the mirror’s object directory is registered as an alternate, which is what lets thin-pack deltas resolve against objects the mirror already has.
Downstream filters receive the quarantine as GitRequestDetails.localRepository, so they see the union: mirror contents
plus this push. EnrichPushCommitsFilter wraps the rest of the chain in try-finally and deletes the quarantine when the
request ends.
In this mode nothing is ever promoted back into the mirror. An accepted push’s objects reach it the same way everything else does — by being fetched from upstream once they exist there — which keeps the mirror a reflection of upstream rather than an accumulation of everything anyone attempted. (Server mode differs; see below.) If a quarantine cannot be created the filter logs a warning and falls back to the mirror: the loss is disk hygiene, not a validation result, so it is not worth failing a push over.
This is the same shape as git’s own receive-pack quarantine (tmp_objdir, exposed to hooks as GIT_QUARANTINE_PATH):
temporary object directory, real one as an alternate, hooks run against that view. JGit has no equivalent, hence the
local implementation. Note it is roughly the inverse of a worktree — a worktree shares the object database and
isolates the index and HEAD, whereas this shares refs and isolates objects, so a worktree would not help here.
The one deliberate departure from git’s version is the last step: git migrates objects into the real store on success, because for git the receive is authoritative and those objects have nowhere else to come from. Here the mirror is a cache of upstream, so not promoting is both simpler and a stronger guarantee.
Server mode quarantines too, with one difference. There JGit’s ReceivePack applies the ref updates to the shared git
directory once the pre-receive hooks pass, so the objects those refs name have to be in the mirror by then — discarding
them would leave the mirror pointing at objects that no longer exist. QuarantinePromotionHook runs as the last
pre-receive hook and moves the objects across only when nothing has been rejected; if it fails, it rejects the push,
because a half-promoted push is worse than a refused one. The HTTP path’s quarantine is torn down by
QuarantineCleanupFilter on the server-mode mapping; the SSH path scopes it to SshGitReceiveCommand, which has no
servlet request to hang it off.
So both modes discard a rejected push’s objects. The transparent proxy additionally never promotes, because it never
applies ref updates. There, JGit’s ReceivePack owns the inserter and writes into the mirror before the pre-receive
hooks run, so the same guarantee needs a different mechanism.
Both mirrors — server mode’s and the transparent proxy’s — are held by a LocalRepositoryCache, and the dashboard
exposes each one for operator inspection and manual invalidation over /api/admin/cache (ROLE_ADMIN). The cache is
in-memory and per-pod, so this is a per-pod operational view, not distributed state; invalidating an entry deletes its
local clone (keeping the cache root) so the next access re-clones from upstream — the recovery path for a stale or
poisoned mirror without a restart.
Concurrency. A mirror is shared across concurrent requests, so the cache coordinates access at three points. First
clones are serialized on a per-repository lock (keyed on the cache key): threads racing on the first access to the
same repo dedupe to one clone, while first clones of different repos run in parallel. Upstream refreshes for a repo
are serialized on that repo’s own lock so two fetches never write the same bare repo at once. The serve path is
deliberately left lock-free, though: a refresh (writer) may run while an upload/receive or content inspection (reader)
uses the same mirror, and readers are not blocked. This is safe because a git fetch is additive — it never deletes the
objects a concurrent reader is serving — so the worst normal outcome is that the reader sees a slightly stale snapshot
and the client re-fetches. A read/write lock was rejected because, to be safe, it would either starve refreshes under
sustained fetch traffic or tax the hot serve path; the full rationale (and the shallow-mirror cloneDepth=0 escape
hatch for the one transient edge case) lives on LocalRepositoryCache. Separately, JGit’s process-global pack-window
cache is tuned once at startup for a many-mirror server (larger packedGitLimit/open-file budget, mmap off) rather than
its desktop-git defaults; these are engine internals, intentionally not fogwall config keys.
Validation pipeline
Both modes run equivalent validation logic. The filter/hook names differ, but they check the same things in the same order.
| Order | What it checks |
|---|---|
| 50–199 | URL allow/deny rules (config + DB-sourced) |
| 150 | User identity — developer must have a proxy account and push permission for this repo |
| 160 | Author attribution — git commit author must match the authenticated proxy user |
| 210 | Non-empty push — at least one new commit |
| 220 | Hidden commits — pack must not contain commits outside the declared push range |
| 250–260 | Author email and commit message patterns (allow/block regex) |
| 265 | Content pattern scan — commit messages (national ID/PII bundles) — WARN-only |
| 290 | Binary blob detection — magic-byte signature sniffing, with MIME-type allow/deny |
| 300 | Diff content scan (blocked literals and patterns) |
| 320 | GPG commit signature validation |
| 340 | Secret scanning (gitleaks) |
| 345 | Content pattern scan — diff (national ID/PII bundles) — WARN-only |
Each step records a PushStep in the push record with a StepStatus of PASS, WARN, FAIL, BLOCKED, or
SKIPPED. WARN is a first-class outcome, not a lesser form of FAIL — a WARN step never blocks the push, it only
surfaces a finding on the push record for the reviewer’s attention. The content-pattern (PII/national-ID) filters are
WARN-only by design on this pipeline, where a reviewer sees the finding; the SCM API surface runs the same bundles as a
blocking check, having no reviewer to show a warning to. CommitAttributionPolicyFilter (order 160, commit-email
attribution) can also run in warn mode via commit.attribution-policy. All steps always run (fail-fast is
configurable); issues accumulate and are reported together.
Core abstractions
Build stamp (BuildInfo)
BuildInfo names the version and commit the running process was built from. It lives in fogwall-core so both
applications read the same two values from one file, fogwall-build.properties, which Gradle expands at build time. The
dashboard’s version.properties could not serve this purpose: it is absent from the standalone server’s distribution,
and a second resource of that name would shadow it rather than supplement it.
The commit is supplied to the build rather than discovered by it — the container builder image has no git binary, so
-PbuildCommit (or BUILD_COMMIT) carries it in, falling back to a local git rev-parse for a developer build. Both
values degrade to unknown rather than failing a build, and an unexpanded ${...} placeholder is treated as unknown
too, since that is what a classpath assembled without processResources yields. The expanded values are declared as
processResources inputs, without which the task goes UP-TO-DATE and the jar keeps shipping the previous build’s
stamp.
Provider (FogwallProvider)
A provider represents one upstream Git hosting service. It carries the upstream HTTP base URI, the URL path prefix the proxy listens on, and optional API calls for identity resolution.
Built-in providers: github, gitlab, bitbucket, forgejo/gitea, codeberg. Custom generic providers can be
declared in config with an arbitrary name and URI.
Transport is a property of the provider, not a separate entry. A single provider entry can serve HTTP (its uri),
SSH (an ssh: sub-block exposing getSshUri()), or both — the FogwallServletRegistrar registers the HTTP servlets
for any provider with an HTTP URI, and the SshServerRegistrar registers an SSH route for any provider whose
getSshUri() is present, both keyed by the same servletPath(). Because both transports resolve to one provider name,
identity resolution, permissions, and OAuth links apply uniformly across them — there is no github / github-ssh
duplication.
Providers that implement TokenIdentityProvider can resolve an SCM username from a push token by calling the hosting
service’s API (e.g. GET /user for GitHub). This is how the proxy maps a credential to a known identity without
requiring the developer to use their SCM username as the HTTP Basic username. These mappings are cached in the database
for performance & to avoid excess API calls to respect rate limits. The cache expires entries on the order of 7 days by
default - PAT tokens have a configurable lifespan, so this strikes a balance between keeping up with token changes and
minimizing API calls.
Push store (PushStore)
Every push attempt produces a PushRecord. The record tracks the full lifecycle:
RECEIVED → PENDING → APPROVED → FORWARDED, or RECEIVED → BLOCKED, or RECEIVED → PENDING → REJECTED. It embeds an
ordered list of PushStep entries (one per validation step) and a list of commits.
The push store is the integration point for the approval workflow: the dashboard reads push records from it, writes approvals/rejections to it, and the proxy polls it.
Backends: H2 (dev), PostgreSQL, MySQL, MariaDB, MongoDB, in-memory (testing).
Approval gateway (ApprovalGateway)
Decouples the proxy from the approval mechanism. Two implementations today, with the interface designed for external integrations:
AutoApprovalGateway— clean pushes are approved immediately (no human review)UiApprovalGateway— proxy writes the push record and polls the store; a reviewer approves or rejects via the dashboard REST API
The ApprovalGateway interface is the extension point for external approval workflows — for example, a
ServiceNowApprovalGateway (planned) that would create a request ticket and wait for external approval before
forwarding the push.
User store and identity
The proxy maintains its own user registry, separate from any upstream SCM accounts.
UserEntry (proxy user)
├── username + password hash (BCrypt / {noop} in dev/local auth modes)
├── emails[] claimed email addresses (used for author attribution)
├── scmIdentities[] links to upstream SCM accounts
│ ├── provider e.g. "github", "gitlab"
│ └── username the developer's SCM login
└── roles[] USER, ADMIN
When a developer pushes with Authorization: Basic <token>, the proxy:
- Calls the provider API with the token to get the developer’s SCM username.
- Looks up a proxy user whose
scmIdentitieshas a matching(provider, scmUsername)entry. - Uses the resolved
UserEntryfor permission checks and author attribution. - Records that SCM login on the push record, so the audit trail names the account the token belongs to. A user may hold several identities on one provider, and the match may have been made on email, in which case no identity on file carries the login at all.
Resolution results are cached in the database (7-day TTL by default).
Backends: static YAML list, JDBC (H2/Postgres), MongoDB, or a composite that checks both.
Deployment modes
Proxy only (fogwall-server)
FogwallJettyApplication boots a plain Jetty server. It loads YAML config (base fogwall.yml + profile overlays +
environment variable overrides), builds the FogwallContext, and registers both proxy modes for every provider. There
is no Spring context, no dashboard, and no REST API — just the git servlets on /server/* and /proxy/*.
The approval gateway defaults to AutoApprovalGateway — clean pushes go straight through with no human review. A
LiveConfigLoader watches the config file and hot-reloads commit validation rules (email patterns, message patterns,
diff scan rules) without restarting the server.
Everything is configured upfront in YAML: users, permissions, URL allow/deny rules, and validation settings. The standalone server has no REST API, so there is no way to create or modify users, permissions, or rules at runtime. This makes it well-suited for enforcement-only deployments where configuration is managed as code — CI pipelines, automated environments, or setups where an external system like ServiceNow handles approval.
./gradlew :fogwall-server:run # start (FOGWALL_CONFIG_PROFILES=local by default)
./gradlew :fogwall-server:stop # stop via PID file
Proxy + dashboard (fogwall-dashboard)
FogwallDashboardApplication builds the same FogwallContext and calls the same FogwallServletRegistrar, then layers
on a Spring MVC DispatcherServlet at /*. Jetty’s servlet path-matching rules give the more-specific git paths
(/server/*, /proxy/*) precedence, so the Spring servlet only handles /api/*, /dashboard/*, /login, and static
assets.
This is Spring MVC and Spring Security directly on a Jetty Server we construct and configure ourselves — not Spring
Boot. Boot’s auto-configuration assumes it owns the embedded servlet container: it wants to build the Server, wire the
connectors, and register its own default servlet mappings. fogwall needs the opposite — the JGit ReceivePack servlets
and the git-protocol filter chain must be registered on that same Jetty instance with precise path and order control
(see Two proxy modes above), and fogwall-server needs to run the identical servlet setup with zero
Spring on the classpath at all. Wiring Spring MVC onto a Jetty server we already built is straightforward; carving a
Boot application apart to let something else own the container is fighting the framework. So the dashboard module adds
Spring as a set of servlets/filters registered onto fogwall’s Jetty server, not the other way around.
Spring Security is registered as a filter chain on a narrow set of paths (/api/**, /login, /logout, /,
/oauth2/**) — deliberately not on git paths, to avoid interfering with async streaming. Four auth providers are
supported: local (BCrypt from YAML), LDAP, Active Directory, and OIDC (authorization code flow). When using an IdP
(LDAP/AD/OIDC), users are automatically provisioned in the database on first login.
The approval gateway is always UiApprovalGateway in this mode, regardless of config. Pushes that pass validation land
in PENDING status; a reviewer approves or rejects via the dashboard UI, and the proxy polls the push store for the
decision.
The dashboard adds runtime management that the standalone server does not have: user and permission CRUD, URL rule management, push history queries, and the approval workflow UI. This is the recommended mode for operational deployments where administrators need to manage users, review pushes, and adjust policies without redeploying.
The React frontend is built by Vite at Gradle build time and copied into the JAR as static resources. For local
development, Vite’s dev server can run separately and proxy /api calls to the backend.
./gradlew :fogwall-dashboard:run # start (dashboard at http://localhost:8080/)
./gradlew :fogwall-dashboard:stop # stop via PID file
Docker
The primary production distribution is a Docker image. The Dockerfile builds the dashboard module’s distribution
(including the frontend), producing a self-contained image with a Temurin JRE. Config overrides are mounted at
/app/conf/fogwall-local.yml.
Advanced use cases
Private-to-private proxying
The provider uri does not have to be a public SaaS host. Any Git HTTP server works:
providers:
internal-github:
type: github
uri: https://github.mycompany.com
acquired-gitlab:
type: gitlab
uri: https://git.acquiredco.internal
Pushes to /server/internal-github/... and /server/acquired-gitlab/... go through the same validation pipeline. The
proxy validates identity, author email, commit messages, and diff content before forwarding to the appropriate internal
host. This is useful for enforcing consistent push policy across multiple internally-hosted Git services.
Credential rewriting (planned)
A planned extension is proxy-level credential substitution: the developer authenticates to the proxy with their own identity, but the forwarded push uses a proxy-managed service account credential for the upstream.
Motivating scenario: an acquired company (Org A) has developers with credentials for Org A’s Git host, but they need to push to shared repositories on the acquiring company’s Git host (Org B). Org A developers don’t have Org B credentials. The proxy can:
- Accept the Org A developer’s push (authenticated against their proxy user record).
- Validate author attribution, commit messages, and diff content normally — the developer’s identity is still enforced.
- Forward the push to Org B’s Git host using a proxy-managed service account that has write access there.
This separates authentication (who you are, proven by your token against Org A’s API) from forwarding credentials (what
gets sent upstream). All existing validation steps remain active — the credential rewrite only changes what appears in
the Authorization header on the forwarded request.
What this architecture enables
The transparent proxy mode replicates what finos/git-proxy does today: intercept, inspect, and forward. The server mode — where the proxy owns the full pack lifecycle via JGit — opens up use cases that are not possible with a pass-through HTTP proxy:
-
Deferred forwarding — the developer’s push is received and acknowledged immediately. The pack is stored locally while an approval process runs (hours, days); forwarding happens asynchronously once approved. This eliminates the problem of holding a git client session open during a long review window. Note: the current implementation forwards within the same session using the client’s in-memory credentials (see Credential flow); true async deferred forwarding would require a separate credential design and is tracked as a backlog item.
-
Multi-upstream push — a single received pack can be forwarded to more than one upstream remote, keeping shared repositories (CI workflows, shared libraries) in sync across separate Git hosts without requiring the developer to push to each one individually.
-
Upstream buffering — when an upstream SCM is slow or unavailable, the proxy can hold received packs and retry with backoff rather than failing the developer’s push immediately.
-
Checkpoint resumption — because each validation step is persisted as a
PushStep, a re-push of the same commits can skip steps that already passed. This matters most when the chain includes expensive external calls (secret scanning, external policy engines) — the developer gets credit for work already done rather than waiting through the full chain again. -
Streaming LLM analysis — the sideband channel in server mode can stream an LLM’s advisory review of the diff back to the developer’s terminal in real time, giving immediate feedback alongside the existing rule-based checks.
These are tracked as individual issues in the backlog; the architecture is designed to support them incrementally.
Internals
Working notes for contributors. These are not user or operator documentation — they record git and provider behaviour that fogwall has to accommodate, and the reasoning behind how the code accommodates it. Read them when changing a filter, a hook, or the SCM API proxy; skip them otherwise.
For how the system is put together, see Architecture.
Contents
- Git internals — git and JGit behaviour that constrains how filters and hooks are written
- JGit infrastructure — how fogwall drives JGit’s server-side APIs to implement server mode
- SCM API proxy — the wire formats each SCM CLI uses, captured from live traffic
Git internals reference
Notes on git/JGit behaviour that inform how filters and hooks are written. Add a section here when you hit a non-obvious edge case so the next person doesn’t have to rediscover it.
For an overview of how fogwall uses JGit’s server-side APIs (ReceivePackFactory, hook chain, forwarding, credential flow), see JGit infrastructure.
Tag objects
Lightweight vs annotated tags
Git has two kinds of tags, and they behave very differently at the object level.
Lightweight tag — just a named pointer, stored as a ref file. The ref value is the SHA of the commit it points to directly. There is no tag object in the object store.
refs/tags/v1.0 → a3f9c1... (commit)
Annotated tag — a first-class git object of type tag. The ref points to the tag object SHA, not the commit SHA.
The tag object contains metadata (tagger, date, message) and a pointer to the tagged commit.
refs/tags/v1.0 → b7d2e4... (tag object)
└─→ a3f9c1... (commit)
The key consequence: cmd.getNewId() for an annotated tag push returns the tag object SHA, not a commit SHA. Any
code that calls RevWalk.parseCommit(cmd.getNewId()) directly will throw IncorrectObjectTypeException for annotated
tags.
The ^{commit} dereference
Both git and JGit support a peeling suffix to follow any chain of tag objects to the final commit:
// Safe for both lightweight and annotated tags:
ObjectId commitId = repository.resolve(sha + "^{commit}");
For a lightweight tag, sha is already a commit SHA — ^{commit} is a no-op. For an annotated tag, JGit follows the
tag → commit chain. For a chain of tags (a tag of a tag), it follows all the way down.
^{tree} works the same way but stops at a tree object instead of a commit.
resolve() returns null when the peel fails — for example when a tag points to a blob or tree rather than a commit
(legal but extremely rare). Always null-check the result.
What git sends over the wire for a tag push
When you run git push origin refs/tags/v1.0:
- The packet line header is
<oldOid> <newOid> refs/tags/v1.0(same format as a branch push). - For a lightweight tag to an already-upstream commit, git sends a thin pack with zero objects because the
commit already exists at the remote. Trying to read a pack entry from an empty pack produces garbage or a
DataFormatException— this is normal, not a corruption. - For an annotated tag, git sends a pack containing the tag object (type 4,
OBJ_TAG). The tagged commit is not included if it already exists upstream. commitFrom(oldOid) is all-zeros for a new tag — the same value used for a new branch. Code that usescommitFrom == zerosas a signal for “new branch” must also account for new tags.
How each hook/filter handles tags
server mode hooks (CheckEmptyBranchHook, CheckHiddenCommitsHook)
Tags push commits that already exist upstream. CommitInspectionService.getCommitRange() returns an empty list — the
commit at the tag tip is already reachable from existing heads, so it is not “new”.
CheckEmptyBranchHook — an empty commit range on a zero-oldId ref would normally mean the branch has no new commits
(a reject condition). For tags this is always the case and is legitimate, so the hook skips any ref whose name starts
with refs/tags/.
CheckHiddenCommitsHook — calls walk.parseCommit() on cmd.getNewId(). For an annotated tag this throws. Fix:
resolve through ^{commit} first.
ObjectId commitId = repo.resolve(cmd.getNewId().name() + "^{commit}");
if (commitId == null) continue;
walk.markStart(walk.parseCommit(commitId));
All other server mode hooks (AuthorEmailValidationHook, CommitMessageValidationHook, etc.) delegate to
CommitInspectionService.getCommitDetails() or getCommitRange(), both of which use ^{commit}. They are safe
transitively.
Proxy-mode filters
The proxy pipeline sees the same two objects as the server mode hooks — the packet line SHAs and the pack data — but
runs as servlet filters without JGit’s ReceivePack infrastructure.
ParseGitRequestFilter — extracts branch, commitFrom, commitTo from the packet line, then tries to parse the
first pack object as a commit. For a tag push this fails (the pack contains a tag object or no new objects). The parse
exception is caught; requestDetails.commit is left null. requestDetails.branch is set to the full ref name (e.g.
refs/tags/v1.0), so GitRequestDetails.isTagPush() works correctly downstream.
CheckUserPushPermissionFilter — uses the commit author email to identify the pushing user. Null commit → null
email → rejects with “Unknown User”. Fix: skip the email check for tag pushes; the user is already verified by HTTP
basic auth.
CheckEmptyBranchFilter — empty pushedCommits + zero commitFrom looks like an empty branch push. Fix: skip for
tag refs, same reasoning as the server mode hook.
CheckHiddenCommitsFilter — calls walk.parseCommit(repo.resolve(toCommit)) where toCommit is the tag object
SHA. Fix: use repo.resolve(toCommit + "^{commit}"), consistent with all other CommitInspectionService callers.
EnrichPushCommitsFilter — unpacks the pack objects into the local repo clone using JGit’s PackParser, which
handles tag objects fine. Then calls CommitInspectionService.getCommitRange() (fixed via ^{commit}), which returns
empty for a tag on an existing commit. Normal behaviour.
ScanDiffFilter — calls getDiff(repo, fromCommit, toCommit). toCommit + "^{tree}" peels through the tag chain
to the tree; this works correctly. For a new tag (fromCommit == zeros) the diff base falls through to
findNewBranchBase(), which also uses ^{commit} and returns null (no new commits), so the diff is against the empty
tree. This produces a full-snapshot diff of the tagged commit, which is harmless for typical content checks.
SecretScanningFilter — passes commitFrom/commitTo to gitleaks git. Gitleaks calls native git log, which
peels tags natively. No special handling needed.
Branches and refs
What the proxy sees on the wire
Every git push sends one or more packet lines before the pack data. Each line has the format:
<oldOid> <newOid> <refName>\0<capabilities>
| Field | Meaning |
|---|---|
oldOid | The SHA the client believes the ref currently points to on the remote. All-zeros (0000…) for a new ref. |
newOid | The SHA the client wants the ref to point to after the push. All-zeros for a ref deletion. |
refName | Full ref path: refs/heads/main, refs/tags/v1.0, etc. |
The null byte \0 separates the ref triple from the capability string (e.g. report-status side-band-64k). Only the
first packet line carries capabilities; subsequent lines omit the \0… suffix.
GitReceivePackParser.parsePush() splits this line and populates PushInfo (proxy mode) or JGit’s ReceiveCommand
carries the same triple (server mode).
Determining the push type from the packet line
The packet line SHAs encode what kind of ref update is happening:
oldOid | newOid | refName | Meaning |
|---|---|---|---|
000…0 | abc123 | refs/heads/feature | New branch — create pointing at abc123 |
abc123 | def456 | refs/heads/feature | Branch update — FF or force push from abc123 |
abc123 | 000…0 | refs/heads/feature | Branch deletion — remove the ref |
000…0 | abc123 | refs/tags/v1.0 | New tag — see “Tag objects” section |
In server mode, JGit’s ReceiveCommand.Type enum maps these directly: CREATE, UPDATE, UPDATE_NONFASTFORWARD,
DELETE.
In proxy mode, GitRequestDetails exposes helper methods:
isRefDeletion()—commitTois all-zerosisTagPush()—branchstarts withrefs/tags/
There is no explicit isNewBranch() helper; filters check commitFrom.matches("^0+$") directly.
New branches — what makes them tricky
A new-branch push (oldOid = zeros) doesn’t tell you which commits are “new”. The pack may contain many commits, but
some of them may already exist on the remote under a different branch. Only the commits not reachable from any
existing ref are genuinely new in this push.
Both modes solve this the same way — via CommitInspectionService.getCommitRange():
// New branch path (fromId is null or zero):
var logCmd = git.log().add(toId);
for (Ref ref : repository.getRefDatabase().getRefsByPrefix("refs/heads/")) {
if (ref.getObjectId() != null) logCmd.not(ref.getObjectId());
}
This walks backward from the pushed tip, excluding anything reachable from existing branch heads. The result is only the commits that are genuinely new.
server mode: JGit’s ReceivePack has already unpacked the objects into its own repository, so getCommitRange()
works against that repo directly.
Proxy mode: EnrichPushCommitsFilter must first clone/fetch the upstream and unpack the push’s pack data into the
local clone (see “How proxy mode gets a repository” below), then getCommitRange() can walk the combined object store.
Branch updates — the commit range
For an existing branch update (oldOid is a real SHA), the commit range is straightforward:
git log oldOid..newOid
CommitInspectionService.getCommitRange() uses git.log().addRange(fromId, toId), which is JGit’s equivalent. This
returns exactly the commits introduced by this push.
Force pushes (non-fast-forward)
A force push rewrites history. oldOid is no longer an ancestor of newOid.
In server mode, JGit classifies this as ReceiveCommand.Type.UPDATE_NONFASTFORWARD.
ForwardingPostReceiveHook.buildRefUpdates() sets force=true for these so the upstream accepts the rewrite.
In proxy mode, the request is forwarded as-is — the upstream git server decides whether to accept the force push based
on its own configuration. The proxy’s filter chain still runs validation on the new commits, but getCommitRange() may
behave unexpectedly: addRange(oldId, newOid) only returns commits reachable from newOid but not oldOid. If the
branches diverged, commits on the old branch that were dropped are not included — the range shows only what was
added, not what was removed.
Ref deletions
When newOid is all-zeros, the client is deleting a ref. There are no objects in the pack and no commits to validate.
server mode: ReceiveCommand.Type.DELETE. Hooks that iterate commands skip DELETE types explicitly (e.g.
CheckEmptyBranchHook, CheckHiddenCommitsHook, DiffGenerationHook). ForwardingPostReceiveHook handles deletion by
creating a RemoteRefUpdate with a null source ref — JGit translates this to a delete on the upstream.
Proxy mode: GitReceivePackParser.parsePush() checks newCommit.equals(ZERO_OID) and skips pack parsing entirely
(there’s nothing to parse). GitRequestDetails will have commitTo = zeros, commit = null, pushedCommits = empty.
isRefDeletion() returns true, and filters should check this early and skip.
How the proxy gets commit data
The two proxy modes obtain commit metadata very differently.
server mode: JGit ReceivePack
JGit’s ReceivePack handles the entire git protocol server-side. When the client pushes, JGit:
- Receives the pack data and unpacks objects into the local repository
- Creates
ReceiveCommandentries for each ref update - Calls the pre-receive hook chain with access to the full
Repository
Hooks can call any JGit API — RevWalk, DiffFormatter, git.log() — because the objects are already in the local
object store. No special setup required.
The repository is a bare repo managed by the server mode servlet, one per provider+repo combination.
Proxy mode: clone + unpack
Proxy-mode filters run as servlet filters on an HTTP request. They don’t have a local repository by default — the request is just bytes on the wire being forwarded to the upstream.
EnrichPushCommitsFilter bridges this gap:
-
Clone/fetch:
LocalRepositoryCache.getOrClone(remoteUrl)maintains a bare clone of each upstream repository. First push triggers agit clone --bare --depth 100; subsequent pushes dogit fetch --depth 100. The cache is keyed byowner_reponame(derived from the URL). -
Unpack push data: The push’s pack data (from the HTTP request body) is fed into JGit’s
PackParser, which inserts the objects into the local clone’s object store. This is the equivalent of whatReceivePackdoes internally in server mode. -
Walk commits: With objects now in the local clone,
CommitInspectionServicecan walk the commit range, generate diffs, etc.
The local clone is published on GitRequestDetails.localRepository so all downstream filters can use it.
Shallow clone implications
The default clone depth is 100 commits. This means:
getCommitRange()for a new branch will only walk back 100 commits. Commits beyond that depth are not in the local clone and won’t appear in the range.getDiff()for a new branch usesfindNewBranchBase()to diff against the parent of the oldest new commit. If the oldest new commit’s parent is beyond the shallow boundary,resolve(parentSha + "^{tree}")returns null and the diff falls back to the empty tree (full-snapshot diff).- Secret scanning via gitleaks is passed
commitFrom..commitToand runsgit lognatively — it respects the shallow boundary silently.
For most pushes this is fine. A push with more than 100 new commits on a new branch is unusual, and the shallow clone
can be deepened via configuration (cloneDepth).
Diff generation
Where diffs are generated
Diffs are generated in both modes but through different code paths:
| Mode | Component | When | What |
|---|---|---|---|
| server mode | DiffGenerationHook (order 280) | Pre-receive, post-validation | Push diff + optional default-branch diff |
| Proxy | ScanDiffFilter (order 300) | After EnrichPushCommitsFilter | Push diff only |
Both ultimately call CommitInspectionService.getFormattedDiff(repo, fromCommit, toCommit).
How diffs are computed
CommitInspectionService.getDiff() resolves both sides to tree objects, then runs JGit’s DiffFormatter:
ObjectId oldId = isNullCommit(fromCommit)
? findNewBranchBase(repository, toCommit) // new branch: diff against merge base
: repository.resolve(fromCommit + "^{tree}"); // existing branch: diff against old tip
ObjectId newId = repository.resolve(toCommit + "^{tree}");
The ^{tree} peel works for both commits and annotated tags — it follows the chain down to the commit, then to its
tree.
New branch diff base (findNewBranchBase)
For a new-branch push, diffing against the empty tree would show the entire repo snapshot — useless for review and would trigger false-positive secret scan findings on existing files.
Instead, findNewBranchBase() finds the oldest new commit (same “exclude existing refs” walk as getCommitRange()),
then returns the tree of that commit’s first parent. This means the diff shows only the changes introduced by the
new commits, not the entire history they’re built on.
If the oldest new commit is a root commit (no parent), the base is null, and the diff does fall back to the empty tree — but this only happens for genuinely new repositories.
Default-branch diff (server mode only)
DiffGenerationHook generates a second diff when pushing to a non-default branch: the total diff of
defaultBranch..commitTo. This helps reviewers see the full scope of a feature branch without having to check it out.
The default branch is resolved from HEAD (which in a bare clone is a symbolic ref to the remote’s default branch),
falling back to refs/heads/main or refs/heads/master.
This diff is stored as a separate PushStep with step name diff:default-branch and tagged as
type: auto:default-branch so the dashboard UI can label it appropriately.
Hidden commits detection
The “hidden commits” check exists in both modes (CheckHiddenCommitsHook / CheckHiddenCommitsFilter) and catches a
subtle attack vector: a developer could create a branch from unapproved commits that haven’t been pushed yet. Git’s pack
protocol bundles all objects needed by the receiving side, including ancestor commits that the remote doesn’t have.
The algorithm is:
- introduced = commits from
getCommitRange(oldId, newId)— the explicit push range - allNew =
RevWalkfromnewId, marking all existing refs as uninteresting - hidden =
allNewminusintroduced
If hidden is non-empty, the push is rejected. The developer needs to get the hidden commits approved and pushed first, then retry.
Pack data parsing
What GitReceivePackParser does (proxy mode only)
In proxy mode, ParseGitRequestFilter needs to extract commit metadata from the raw HTTP request body before JGit ever
touches it. The request body contains:
- Packet lines (ref updates + capabilities)
- A flush packet (
0000) - Pack data (the
PACKsignature followed by pack objects)
GitReceivePackParser.parsePush() reads the packet line via JGit’s PacketLineIn, then parses the first object from
the pack data manually:
- Scans for the
PACKsignature (4 bytes:P,A,C,K) - Skips the 12-byte pack header (signature + version + object count)
- Reads the first pack entry’s type+size header (variable-length encoding)
- Inflates the zlib-compressed object data
- If the type is
OBJ_COMMIT(1), parses the raw commit content for author, committer, parent, message, and GPG signature
This is a best-effort parse of the first object only. It handles the common case (a commit push where the tip commit is the first pack entry) but intentionally does not handle:
- Delta objects (
OBJ_OFS_DELTA,OBJ_REF_DELTA) — logged as a warning - Tag objects (
OBJ_TAG, type 4) — throws “No commit object found” - Packs where the commit is not the first entry
- Empty packs (lightweight tag pointing to an existing commit)
These failures are caught by the try/catch in parsePush(), and PushInfo.commit is left null.
EnrichPushCommitsFilter downstream recovers full commit data from the local clone anyway — the pack-parsed commit is
just an early-availability optimization for ParseGitRequestFilter.
Large pushes and chunked transfer encoding
The problem
When a push’s pack data exceeds the git client’s http.postBuffer (default 1 MiB), git switches from a single
Content-Length POST to Transfer-Encoding: chunked. The body content is identical — pkt-line ref updates + flush +
PACK data — but the HTTP framing changes from “here’s N bytes” to “here are chunks of variable size, terminated by a
zero-length chunk.”
Many reverse proxies deployed in front of fogwall do not faithfully forward chunked request bodies. Observed failure modes (confirmed on HAProxy-based OpenShift Routes):
- Early termination: the proxy forwards the first HTTP chunk (a few bytes of pkt-line data), then sends the chunked
terminator. The server receives a valid but tiny body — just the pkt-line length prefix — and
ParseGitRequestFilterfails withEOFException: Short read of block. - Request splitting: the proxy dechunks the body, buffers the remainder, and forwards it as a separate
Content-Lengthrequest. The server sees two requests: one with a few bytes of pkt-line data, and a second with raw PACK binary (no pkt-line prefix). The second request fails withInvalid packet line headerbecause the body starts mid-stream. - Keepalive contamination: when the server doesn’t consume the full body of the truncated first request, leftover bytes bleed into the next request on the same TCP connection. The next request’s body starts with binary PACK data instead of pkt-line headers.
Small pushes (< 1 MiB) use Content-Length and are unaffected — the proxy forwards the body as a single unit.
This is not a Jetty bug or a fogwall bug. The same issue affects any HTTP backend behind a proxy that doesn’t support chunked request forwarding. GitHub, GitLab, and Gitea avoid this because they either terminate HTTP at the edge (no generic proxy in the path) or explicitly configure their proxy layer for streaming uploads.
Server-side mitigation: BlockingContentHandler
BlockingContentHandler is a Jetty Handler.Wrapper that reads the full request body at the core Handler level before
the servlet layer sees it. It uses Jetty 12’s Content.Source.read() / Content.Source.demand() cycle directly on the
Request object:
- Call
read()— returns aContent.Chunkornull - If
null: calldemand()with aCountDownLatchcallback, thenawait()until more data arrives from the network - If a chunk: copy its bytes into a
ByteArrayOutputStream, release the chunk - Repeat until a chunk with
isLast()=true
The accumulated body is wrapped in a BufferedBodyRequest (a Request.Wrapper that overrides the Content.Source
methods) so the servlet layer’s HttpInput reads from the buffered copy. Both the transparent proxy filter chain
(RequestBodyWrapper.readAllBytes()) and the server mode path (JGit’s ReceivePack) get the complete body without
touching the network.
GET requests pass through without buffering.
Why not use Jetty’s EagerContentHandler?
EagerContentHandler was the first attempted fix. It is designed to eagerly buffer the full body before dispatching to
the servlet. However, its internal RetainedContentLoader.getInvocationType() returns NON_BLOCKING, which causes
doHandle() to be called synchronously on Jetty 12’s EPC (Execute-Produce-Consume) reserved thread — where blocking I/O
does not work. The body was still truncated in production.
Why not just dispatch to a blocking thread?
The second attempt used a simple Handler.Wrapper that called request.getContext().execute(...) to move servlet
execution to a QueuedThreadPool worker thread, then relied on the servlet layer’s HttpInput.readAllBytes(). This
also failed — HttpInput returned -1 prematurely even on a blocking thread.
The third attempt used Content.Source.asInputStream(request).readAllBytes() at the Handler level, bypassing
HttpInput. This also returned truncated data — ContentSourceInputStream wraps the same Content.Source and
exhibited the same premature EOF behaviour.
The working approach reads from Content.Source directly using the read()/demand() loop, which is the lowest-level
API available and handles partial delivery correctly regardless of transfer encoding or proxy reframing.
Client-side workaround
Increasing the git client’s post buffer avoids chunked encoding entirely:
git config --global http.postBuffer 524288000
This forces git to buffer the entire pack in memory and send it as a single Content-Length POST, which all proxies
handle correctly. The tradeoff is higher client memory usage for large pushes.
Proxy-side fixes
If you control the reverse proxy, configure it to buffer the full request before forwarding to the backend:
nginx:
proxy_request_buffering on; # buffer the full request before forwarding (default)
client_max_body_size 500m; # allow large pack uploads
HAProxy:
option http-buffer-request # buffer the full request before forwarding
timeout http-request 300s # allow time for large chunked uploads to complete
Consult your proxy’s documentation for equivalent settings if you use a different load balancer.
Why the pack parser exists alongside EnrichPushCommitsFilter
ParseGitRequestFilter runs at order MIN_VALUE + 1 — it’s the first filter. It needs to populate GitRequestDetails
before any other filter runs. EnrichPushCommitsFilter runs at MIN_VALUE + 2 — immediately after — but requires a
network clone/fetch which may fail.
The pack parser gives ParseGitRequestFilter a synchronous, no-network way to extract the head commit’s metadata. If it
succeeds, requestDetails.commit is available immediately. If it fails (tag push, delta-only pack, etc.), the commit is
null and filters that need it wait for EnrichPushCommitsFilter to populate pushedCommits from the local clone.
JGit infrastructure
How fogwall uses Eclipse JGit to implement the server mode proxy mode and to support commit inspection in the transparent proxy mode.
For low-level details on git wire-protocol behaviour and how individual hooks/filters handle edge cases (tags, new branches, force pushes, deletions, etc.), see Git internals.
Server mode architecture
The server mode path (/server/<host>/owner/repo.git) uses JGit’s built-in HTTP git server. Three JGit SPIs are plugged
in to turn a vanilla GitServlet into a validating, forwarding proxy:
| SPI | Implementation | Role |
|---|---|---|
RepositoryResolver | ServerRepositoryResolver | Resolves the upstream repo into a local bare clone; extracts client credentials |
ReceivePackFactory | ServerReceivePackFactory | Creates a ReceivePack per request; assembles the pre/post-receive hook chain |
UploadPackFactory | ServerUploadPackFactory | Creates UploadPack for fetch requests |
Registration happens in FogwallServletRegistrar:
var gitServlet = new GitServlet();
gitServlet.setRepositoryResolver(new ServerRepositoryResolver(cache, provider));
gitServlet.setReceivePackFactory(new ServerReceivePackFactory(...));
gitServlet.setUploadPackFactory(new ServerUploadPackFactory());
context.addServlet(new ServletHolder(gitServlet), "/server/" + provider.servletPath() + "/*");
Push lifecycle
Client: git push http://proxy:8080/server/github.com/owner/repo.git
|
v
ServerRepositoryResolver.open()
- Extract credentials from Authorization header (in-memory only)
- Clone/fetch upstream WITHOUT credentials (public repos only)
- Store upstream URL in repo config (fogwall.upstreamUrl)
|
v
JGit GitServlet receives pack data
- Parses pack, writes objects to local bare repository
- Creates ReceiveCommand entries for each ref update
|
v
ServerReceivePackFactory.create()
- Retrieve credentials from request attribute
- Create ValidationContext + PushContext (shared across hooks)
- Assemble and sort the hook chain
|
v
PRE-RECEIVE HOOK CHAIN (with heartbeat keepalive)
|
+-- PushStorePersistenceHook.preReceiveHook() [pinned: first]
| Save initial RECEIVED record to database
|
+-- RepositoryWhitelistHook [order 100]
+-- CheckUserPushPermissionHook [order 150]
+-- CheckEmptyBranchHook [order 210]
+-- CheckHiddenCommitsHook [order 220]
+-- AuthorEmailValidationHook [order 250]
+-- CommitMessageValidationHook [order 260]
+-- ProxyPreReceiveHook [order 270]
+-- DiffGenerationHook [order 280]
+-- DiffScanningHook [order 300]
+-- GpgSignatureHook [order 320]
+-- SecretScanningHook [order 340]
|
+-- PushStorePersistenceHook.validationResultHook() [pinned: after validation]
| Collect all issues from ValidationContext
| Record REJECTED (failed) or BLOCKED (clean, pending review)
| Send validation summary to client via sideband
|
+-- ApprovalPreReceiveHook [pinned: last]
If clean: poll ApprovalGateway (auto-approve or wait for human)
If failed: reject all commands immediately
|
v
JGit updates local refs (only if all pre-receive hooks passed)
|
v
POST-RECEIVE HOOKS
|
+-- ForwardingPostReceiveHook
| Open JGit Transport to upstream
| Build RemoteRefUpdate for each accepted command
| Push with client's credentials
| Stream coloured status to client via sideband
|
+-- PushStorePersistenceHook.postReceiveHook()
Record FORWARDED or ERROR
|
v
Client receives result
RepositoryResolver
ServerRepositoryResolver does two things per request:
-
Local mirror — calls
LocalRepositoryCache.getOrClone(upstreamUrl)to maintain a bare clone. First access triggersgit clone --bare --depth 100; subsequent requests dogit fetch --depth 100. The clone uses no credentials so this only works for public repositories. Private repos fail with a clear error directing the user to the/proxy/path. -
Credential extraction — reads HTTP Basic auth (or URL userinfo) and stores it as a request attribute (
com.rbc.fogwall.credentials). Credentials live in memory only for the duration of the request and are never written to disk or repo config.
The upstream URL is stored in the repository’s git config (fogwall.upstreamUrl) so downstream hooks can read it
without access to the HTTP request.
ReceivePackFactory
ServerReceivePackFactory creates a fresh ReceivePack for each push request. Its main job is assembling the hook
chain:
- Orderable validation hooks implement
FogwallHookand are sorted bygetOrder(). Two ranges are used:- Authorization (0–199): whitelist check, user permission
- Content filtering (200–399): empty branch, hidden commits, email/message validation, diffs, GPG, secret scanning
- Lifecycle hooks are pinned at fixed positions around the validation hooks: persistence (before/after) and approval (after).
The factory also:
- Extracts credentials from the request attribute (set by the resolver) or falls back to re-reading the Authorization header
- Creates per-request
ValidationContextandPushContextinstances shared across all hooks - Sets
setBiDirectionalPipe(false)since this is HTTP, not SSH
Pre-receive hook chain
All pre-receive hooks are chained by chainPreReceiveHooks():
try (HeartbeatSender hb = new HeartbeatSender(rp, heartbeatInterval)) {
hb.start();
for (PreReceiveHook hook : hooks) {
hook.onPreReceive(rp, commands);
rp.getMessageOutputStream().flush(); // stream sideband in real time
if (anyCommandRejected) return; // stop on first rejection
}
}
Key points:
- After each hook, the sideband stream is flushed so messages appear immediately in the client terminal (JGit’s
sendMessage()doesn’t auto-flush). - The chain short-circuits as soon as any
ReceiveCommandresult is set to anything other thanNOT_ATTEMPTED. - A
HeartbeatSenderruns on a background daemon thread, sending"."on sideband every N seconds (default 10) to prevent idle-timeout disconnects during long steps like secret scanning or approval polling.
Hook inventory
| Order | Hook | Purpose |
|---|---|---|
| — | PushStorePersistenceHook.preReceive | Record initial RECEIVED state in database |
| 100 | RepositoryWhitelistHook | Record whitelist pass (resolver already validated) |
| 150 | CheckUserPushPermissionHook | Validate push user via UserAuthorizationService |
| 210 | CheckEmptyBranchHook | Reject if push range has no commits (skips tags) |
| 220 | CheckHiddenCommitsHook | Detect unreferenced commits smuggled in via pack |
| 250 | AuthorEmailValidationHook | Check author emails against allow/block patterns |
| 260 | CommitMessageValidationHook | Check commit messages against blocked literals/patterns |
| 270 | ProxyPreReceiveHook | Log commit inspection details (sha, author, message snippet) |
| 280 | DiffGenerationHook | Generate unified diffs (push diff + default-branch diff) |
| 300 | DiffScanningHook | Scan diff added-lines for blocked content patterns |
| 320 | GpgSignatureHook | Validate GPG signatures via BouncyCastle PGP |
| 340 | SecretScanningHook | Pipe diff to gitleaks CLI for secret detection |
| — | PushStorePersistenceHook.validationResult | Collect issues; record REJECTED or BLOCKED |
| — | ApprovalPreReceiveHook | Gate: auto-approve or poll for human approval |
Post-receive hooks
Post-receive hooks run only for commands with Result.OK (refs that were successfully updated locally).
ForwardingPostReceiveHook
The “forward” half of server mode. For each accepted ReceiveCommand:
- Opens a JGit
Transportto the upstream URL (read fromfogwall.upstreamUrlin repo config) - Sets the
CredentialsProviderextracted from the original client request - Builds
RemoteRefUpdateobjects:CREATE/UPDATE— same source and destination refUPDATE_NONFASTFORWARD— same, but withforce=trueDELETE— null source ref (JGit translates this to a ref deletion)
- Calls
transport.push()and streams per-ref status to the client with colour-coded sideband messages
PushStorePersistenceHook.postReceiveHook
Records the final outcome: FORWARDED if all refs pushed successfully, ERROR otherwise.
Shared contexts
Two objects are created per request and threaded through all hooks:
ValidationContext
Collects validation issues without rejecting commands directly. This allows the user to see all problems in a single push attempt rather than fixing them one at a time.
validationContext.addIssue("hookName", "summary", "detail");
Issues are collected and reported together by PushStorePersistenceHook.validationResultHook().
PushContext
Accumulates PushStep records (diffs, scan results, forwarding status) that are persisted to the database as part of
the push audit trail.
Credential flow
Credentials are handled carefully to avoid writing secrets to disk:
ServerRepositoryResolver.open()— extracts from Authorization header or URL userinfo. Stores as request attributecom.rbc.fogwall.credentials. Never used for cloning.ServerReceivePackFactory.create()— reads from request attribute (or re-extracts from header). CreatesUsernamePasswordCredentialsProvider.ForwardingPostReceiveHook.pushToUpstream()— sets theCredentialsProvideron JGit’sTransportbefore callingpush().
The local clone/fetch is always unauthenticated. Credentials exist only in memory for the request duration.
HeartbeatSender
Prevents idle-timeout disconnects during long-running hooks (approval polling, secret scanning subprocess waits).
- Single daemon thread via
ScheduledExecutorService - Fires every N seconds (default 10, configurable via
server.heartbeat-interval-seconds) - Sends
"."on sideband-2 and flushes - No-op if interval is zero or negative
- Implements
AutoCloseable— used in try-with-resources around the hook chain
Thread safety: JGit’s sideband stream is not thread-safe. The race window between heartbeat and hook writes is benign because heartbeat fires only during silent gaps.
LocalRepositoryCache
Manages bare clones used by both proxy modes:
- server mode:
ServerRepositoryResolvercallsgetOrClone()on each push. Objects from the client’s pack are already in the repo (JGit’sReceivePackunpacked them), so hooks can useRevWalk,DiffFormatter, etc. directly. - Proxy mode:
EnrichPushCommitsFiltercallsgetOrClone()to get a local repo, then feeds the push’s pack data through JGit’sPackParserto insert objects. This bridges the gap between raw HTTP bytes and the JGit API.
Cache characteristics:
- Keyed by
owner_reponamederived from the URL - First access:
git clone --bare --depth 100 - Subsequent:
git fetch --depth 100 - Stored in temp directory, cleaned up on JVM shutdown
- Thread-safe with synchronized cloning
See Shallow clone implications for how the depth limit affects commit walks and diffs.
CommitInspectionService
Utility class used by both modes for extracting commit data via JGit:
| Method | What it does | JGit API |
|---|---|---|
getCommitDetails(repo, sha) | Single commit metadata (author, message, signature, trailers) | RevWalk.parseCommit() |
getCommitRange(repo, from, to) | Commits introduced by a push | git.log().addRange() or git.log().add(to).not(existingRefs) for new branches |
getDiff(repo, from, to) | Diff entries between two commits | git.diff() with tree iterators |
getFormattedDiff(repo, from, to) | Unified diff as string | DiffFormatter writing to ByteArrayOutputStream |
findNewBranchBase(repo, to) | Oldest new commit’s parent tree (for diffing new branches) | RevWalk excluding existing refs |
All methods use ^{commit} peeling to handle annotated tags transparently. See
Tag objects for details.
Key JGit APIs used
| API | Where | Purpose |
|---|---|---|
GitServlet | FogwallServletRegistrar | HTTP git server implementation |
ReceivePack | ServerReceivePackFactory | Receives pack data, runs hook chain |
Transport | ForwardingPostReceiveHook | Pushes to upstream with credentials |
RevWalk | CommitInspectionService, CheckHiddenCommitsHook | Walks commit graph |
DiffFormatter | CommitInspectionService, DiffGenerationHook | Generates unified diffs |
PackParser | EnrichPushCommitsFilter (proxy mode) | Inserts pack objects into local repo |
PacketLineIn | GitReceivePackParser (proxy mode) | Reads packet-line protocol from raw bytes |
Repository.resolve() | Throughout | SHA resolution with ^{commit} / ^{tree} peeling |
CredentialsProvider | Factory, resolver, forwarding hook | In-memory credential transport |
SCM API Proxy — wire formats and capture notes
How each SCM CLI talks to its provider, and what fogwall has to match to sit in the middle. This is the reverse-engineering record: request shapes, endpoint maps, where each dialect hides its authorization target, and the per-CLI quirks that constrain the implementation.
For how the proxy is built — listeners, filter chains, where each decision is made — see Architecture.
Everything below is from live traffic unless marked otherwise. Versions captured: gh 2.98.0 (GH_DEBUG=api), glab
v1.116.0 (GLAB_DEBUG_HTTP=true), tea 0.15.1 (tea --debug, gitea.dev/sdk v1.2.0). fj v0.6.0 emits no HTTP debug
output at all, so its rows come from reading forgejo-cli and the generated forgejo-api 0.11.0 crate.
What the CLIs constrain
None of them accepts a path
Each binary was pointed at a local listener configured as http://127.0.0.1:8099/scm-api/<provider>, and the request
that actually arrived was recorded:
| CLI | sub-path mount | request observed |
|---|---|---|
gh | discarded | POST /api/graphql — GH_HOST is a hostname; a path cannot be expressed |
glab | preserved | GET /scm-api/gitlab/api/v4/projects/foo%2Fbar/issues |
tea | preserved | GET /scm-api/gitea/api/v1/user |
fj | discarded | GET /api/v1/user |
tea concatenates (c.url + "/api/v1" + path), so a base path survives. fj resolves base.join("/api/v1/..."), and
RFC 3986 makes an absolute reference replace the entire base path — silently, with no error. gh never had a path to
begin with: GH_HOST holds a hostname, optionally with a port.
This is what forces a listener per provider rather than a shared prefix.
Each sends a different credential header
| CLI | header |
|---|---|
gh | Authorization: token <pat> |
glab | PRIVATE-TOKEN: <pat> for a PAT; Authorization: Bearer for OAuth |
tea | Authorization: token <pat> |
fj | Authorization: token <pat> |
glab is the awkward one: which header it sends depends on how the developer authenticated. A personal access token
goes in PRIVATE-TOKEN with no Authorization header at all; a token obtained through OAuth login goes in
Authorization: Bearer. The two are not interchangeable — GitLab rejects a PAT presented as a bearer token.
So fogwall has to recognise both headers to resolve identity, and forward whichever the caller actually sent, unchanged.
Reading only Authorization rejects every PAT-authenticated glab request with a 401 from fogwall rather than from the
upstream. Rewriting one into the other makes fogwall’s answer differ from what the CLI would have got talking to the
provider directly.
Each advertises its version
GitHub CLI 2.98.0 ..., glab/v1.116.0 (linux, amd64), tea/0.15.1 (linux/amd64) go-sdk/v1.2.0,
forgejo-cli/0.6.0 (https://codeberg.org/forgejo-contrib/forgejo-cli/). A CLI release that changes its wire format
shows up here first.
Encoded separators appear inside single path segments
GitLab addresses a project as one owner%2Frepo segment. Gitea encodes a repository-relative file path into one segment
of its blob endpoints, and a branch name into the {base}...{head} segment of compare — fj reads a pull request
template from /repos/{o}/{r}/raw/.forgejo%2Fpull_request_template.md and then fetches
/repos/{o}/{r}/compare/main...feature%2Fx before creating a pull request. All of these must survive to fogwall
undecoded, or the segment splits and the repository the request names changes.
GitHub (gh)
Transport
Issue and PR CRUD is entirely GraphQL — every create, edit, comment, review and close is a POST to the GraphQL
endpoint (/graphql on github.com, /api/graphql on GHES). The one REST call found anywhere in this matrix is
pr close --delete-branch, which sends DELETE /repos/{o}/{r}/git/refs/heads%2F{branch} — see “What the flags reach”
below.
Every command is a 2–3 request fan-out: one or more read querys, then one mutation.
The proxied path needs a classic PAT with the repo scope. GitHub has no classic scope covering issues or pull
requests alone, so repo is the minimum, and it grants full read/write across every repository the user can reach. That
breadth is a property of GitHub’s scopes, not of anything fogwall does — the permission engine, not the token, is what
bounds a caller here.
Request headers seen include X-Github-Api-Version: 2022-11-28 and Graphql-Features: merge_queue.
Mutation → node-ID map
Each mutation references its target by an opaque global node ID, and the input key holding that ID differs per mutation — there is no single field name to look for:
gh command | schema mutation field | gh operationName | input node-ID key | node type |
|---|---|---|---|---|
issue create | createIssue | IssueCreate | input.repositoryId | Repository (R_) |
pr create | createPullRequest | PullRequestCreate | input.repositoryId | Repository (R_) |
issue edit | updateIssue | IssueUpdate | input.id | Issue (I_) |
issue close | closeIssue | IssueClose | input.issueId | Issue (I_) |
issue/pr comment | addComment | CommentCreate | input.subjectId | Issue or PR |
pr edit | updatePullRequest | PullRequestUpdate | input.pullRequestId | PullRequest (PR_) |
pr review† | addPullRequestReview | PullRequestReviewAdd | input.pullRequestId | PullRequest (PR_) |
pr close | closePullRequest | PullRequestClose | input.pullRequestId | PullRequest (PR_) |
Attribute changes are their own mutations, not fields on the ones above, and each names its target through the generic capability it acts on rather than a concrete type:
| flag | schema mutation field | gh operationName | input node-ID key | node type |
|---|---|---|---|---|
--assignee, --add-assignee, --remove-assignee | replaceActorsForAssignable | ReplaceActorsForAssignable | input.assignableId | Issue or PR |
--add-label | addLabelsToLabelable | LabelAdd | input.labelableId | Issue or PR |
--remove-label | removeLabelsFromLabelable | LabelRemove | input.labelableId | Issue or PR |
--reviewer, --add-reviewer, --remove-reviewer | requestReviewsByLogin | RequestReviewsByLogin | input.pullRequestId | PullRequest |
† Recorded for completeness; not allowlisted.
Three things follow:
- Allowlisting matches the schema mutation field parsed from the AST, not
gh’soperationName(which isgh-specific and can change) and not a substring of the query text. - The mutation carries only the node ID. Resolution to
owner/repois mandatory before any permission check. - The resolver handles three node types —
Repository(R_…),Issue(I_…),PullRequest(PR_…):
node(id: $id) {
... on Repository { name owner { login } }
... on Issue { repository { name owner { login } } }
... on PullRequest { repository { name owner { login } } }
}
Fork PRs address the upstream
input.repositoryId names the base repository — the one the request is opened on, and the one to authorize against.
Captured from a real fork PR (fork RBC/coopernetes-test-repo → upstream coopernetes/test-repo):
{
"input": {
"repositoryId": "R_kgDOKPRwrA",
"headRefName": "RBC:test/fork-pr-1788671018",
"baseRefName": "main",
"title": "…",
"body": "…"
}
}
R_kgDOKPRwrA is the upstream; the fork’s own ID (R_kgDON5qHaA) appears nowhere. The fork is named only inside
headRefName, in the owner:branch form — the same shape Gitea uses. So the resolver reads the correct repository with
no extra work, and none of GitLab’s target_project_id handling is needed.
The schema has a separate input.headRepositoryId for the head repository, but gh does not send it, relying on the
namespaced headRefName instead. It is worth knowing it exists: reading it as the target would authorize the repository
the contributor already owns.
Subject IDs are safer to cache than repository IDs. A GitHub issue transfer mints a new node ID in the destination and
leaves the old one as a redirect, so issueId → repo has no rename staleness. repositoryId → owner/name is the
mapping that needs a conservative TTL.
Fan-out, and what could seed the cache
Each mutation is preceded by a read query carrying owner/repo(/number) in its variables and returning the target
node ID in its response — so the cache can be seeded from the caller’s own traffic before the mutation arrives:
issue create→query IssueRepositoryInfo($owner,$name){ repository{ id … } }. The response’sdata.repository.idequals thecreateIssuemutation’sinput.repositoryId.issue edit/comment/close→query IssueByNumber($owner,$repo,$number){ … issue{ id } }.pr edit/comment/review/close→query PullRequestByNumber($owner,$repo,$pr_number){ … pullRequest{ id } }.pr createadditionally firesquery RepositoryInfoandquery PullRequestForBranch(an existing-PR check).
All lookups are query type. Some commands fire extra reads (PullRequestProjectItems), also queries.
What the flags reach
Every table above was captured from the bare command. The flagged variants split three ways, and the split is not where it looks:
- Inlined into the create or update.
issue create --label(aslabelIds),pr create --draft, andpr edit --base --milestone --title --body— all four of the last land in oneupdatePullRequestinput.ghalso firesupdatePullRequestunder the operation namePullRequestCreateMetadataimmediately aftercreatePullRequest, to attach labels named onpr create. - A separate mutation. Assignees in every command, labels on an edit, reviewers even on create. These are the four rows in the second table above.
- REST.
pr close --delete-branchsendsDELETE /repos/{o}/{r}/git/refs/heads%2F{branch}. The dialect carries GraphQL only, so this has nowhere to go. Ref deletion is a git operation with a git path through fogwall; it is not SCM API content, and it stays out.
The follow-ups run after the create or update has already succeeded, so denying one leaves the issue or PR created and the attribute unset — half-applied rather than refused. That asymmetry is the reason they are allowlisted rather than left out as “metadata”.
Allowlist
createIssue, updateIssue, closeIssue,
createPullRequest, updatePullRequest, closePullRequest, mergePullRequest,
addComment,
replaceActorsForAssignable, addLabelsToLabelable, removeLabelsFromLabelable,
requestReviewsByLogin
requestReviewsByLogin requests a review; addPullRequestReview submits one and is absent. Asking a colleague to look
is part of proposing a change — the verdict is not.
Merging a pull request
Captured live against a real PR (GH_DEBUG=api gh pr merge <n> --merge), the same way as every other row in this
section except fj’s. The merge mutation carries far less than the create/update ones do:
{ "input": { "pullRequestId": "PR_kwDOKPRwrM8AAAABCwcIng", "mergeMethod": "MERGE" } }
Two things follow, both the opposite of what reading gh’s source alone would have suggested:
- No head-SHA field at all.
MergePullRequestInputhas anexpectedHeadOid, butghdoes not send it — onlypullRequestIdandmergeMethod(--subject/--bodyaddcommitHeadline/commitBody; nothing else appears with the bare command). Provenance at merge time therefore cannot read a field off this mutation the way create-time validation readsheadRefName; it has to ask the PR’s own node for its current head directly (node(id:){ ... on PullRequest { headRefOid } }, the caller’s own token, same node ID the mutation already targets) — seeGitHubHeadShaResolver.resolvePullRequestHeadSha. - The response selects nothing beyond
clientMutationId.ghdoes not ask for the merge commit’s SHA, its own state, or anything elsemergePullRequest’s schema could return — the captured response body was exactly{"data":{"mergePullRequest":{"clientMutationId":null}}}. So while a 2xx response confirms the merge happened (recorded asMERGED), there is no merge commit SHA anywhere in whatghitself asks for or gets back.
GitLab (glab)
Transport
Issue and MR CRUD is entirely REST v4 — plain GET/POST/PUT against /api/v4/... with a JSON body. GitLab has a
GraphQL surface; glab does not use it for this command set.
The project is addressed by URL-encoded owner/repo path rather than a numeric ID, so the authorization target is read
straight off the URL and none of GitHub’s node-ID machinery applies. Every mutating command is preceded by a GET on
the same path, so the path is self-describing whether the cache is warm or cold.
Operation → REST endpoint map
glab command | Method | Path | Target ID source |
|---|---|---|---|
issue create | POST | /projects/:path/issues | path only |
issue update | PUT | /projects/:path/issues/:iid | path + iid (from CLI arg, not a preceding lookup) |
issue note | POST | /projects/:path/issues/:iid/notes | path + iid |
issue close | PUT | /projects/:path/issues/:iid | body {"state_event":"close"} |
mr create | POST | /projects/:path/merge_requests | path (+ numeric target_project_id, from a preceding GET /projects/:path) |
mr update | PUT | /projects/:path/merge_requests/:iid | path + iid |
mr note | POST | /projects/:path/merge_requests/:iid/notes | path + iid |
mr approve† | POST | /projects/:path/merge_requests/:iid/approve | path + iid |
mr close | PUT | /projects/:path/merge_requests/:iid | body {"state_event":"close"} |
mr merge | PUT | /projects/:path/merge_requests/:iid/merge | path + iid |
:path is the URL-encoded owner%2Frepo segment; :iid is the project-scoped issue/MR number (not a global ID),
always supplied by the CLI caller from the command-line argument or a preceding GET.
† Recorded for completeness; approval is a review operation and is not allowlisted.
Merging a merge request
Captured live against a real MR (GLAB_DEBUG_HTTP=true glab mr merge <iid>). The preceding
GET /projects/:path/merge_requests/:iid — the same mergeability/pipeline check the appendix’s capture methodology
exists to catch — returns the MR object including its current head, sha. The merge call itself:
PUT /api/v4/projects/coopernetes%2Ftest-repo-gitlab/merge_requests/11/merge
{}
An empty body. No sha, no squash, no message fields — glab mr merge with no flags sends nothing at all,
contrary to what GitLab’s own REST documentation for the endpoint’s optional parameters would suggest a client might
send. So, like GitHub, provenance at merge time cannot read a field off the mutation itself; it has to ask the merge
request directly for its current head (GET /projects/:path/merge_requests/:iid, the caller’s own credential — see
GitLabHeadShaResolver.resolveMergeRequestHeadSha).
The response, unlike GitHub’s, is the full updated MR object — "state":"merged" and, notably, "merge_commit_sha"
with a real value. That field is the one place across all three dialects’ merge responses where fogwall can record what
the merge actually produced.
Flags need no extra endpoints, but do need extra reads
GitLab is the one dialect where every flag lands inline.
mr create --assignee --reviewer --label --squash-before-merge --remove-source-branch is a single POST, and
mr update --label --assignee --reviewer --ready --lock-discussion a single PUT, because GitLab accepts
assignee_ids, reviewer_ids, labels and milestone_id as ordinary fields. There is no follow-up call to allowlist.
What the flags do add is reads, and those carry query parameters the write path never does:
GET /projects/:path?license=true&with_custom_attributes=true before every create
GET /users?per_page=30&username=<login> once per --assignee/--reviewer login
glab resolves each login to a numeric ID before it can build the mutation, so refusing username stops the command
before the write is ever attempted. The query-parameter allowlist has to carry all three names or the mutation allowlist
never gets a say.
Fork MRs address the source project
mr create is the one operation whose URL does not name the repository fogwall must authorize against. Captured from a
real fork MR (fork id 86130652 → upstream id 53539888, same namespace):
POST /api/v4/projects/coopernetes%2Ftest-repo-gitlab-fork/merge_requests
{"title":"…","source_branch":"fork-feature","target_branch":"main","target_project_id":53539888}
The URL segment is the fork. The upstream appears only as the numeric target_project_id in the body, and the response
confirms the split — source_project_id: 86130652, target_project_id: 53539888, MR created on the upstream.
Since authorization targets the repository the MR is opened on, a path-only matcher reads the wrong project here:
- Authorize on
target_project_idwhen the body carries it. - Fall back to the URL’s project when it does not — a same-project MR, where the two are identical.
- If
target_project_idis present but cannot be resolved to a path, deny.
Every other GitLab operation in scope is unaffected: mr update and mr note address the MR by iid, which is scoped
to the target project, so the URL already names the upstream.
Resolution is cheap. glab fires GET /projects/:path for both projects immediately before the POST, and each response
carries id alongside path_with_namespace — the same seeding opportunity as GitHub’s node IDs.
Capture hazard.
GLAB_DEBUG_HTTPredactsAuthorizationbut not response bodies, andGET /projects/:pathreturnsrunners_tokenin plaintext for a project owner. Scrub captures before sharing them.
Forgejo and Gitea — fj and tea
Gitea and Forgejo share one REST API — Forgejo forked Gitea’s — and tea and fj are two CLIs against it.
Transport
100% REST v1, no GraphQL in either CLI. The repository is two ordinary path segments,
/api/v1/repos/{owner}/{repo}/..., each URL-encoded independently, so the authorization target comes off the path as it
does for GitLab. The issue/PR index is a plain project-scoped integer supplied by the caller.
The two CLIs reach the same operations by different endpoints
| operation | tea | fj |
|---|---|---|
| list PRs | GET /repos/{o}/{r}/pulls | GET /repos/{o}/{r}/issues?type=pulls |
| close PR | PATCH /repos/{o}/{r}/pulls/{n} | PATCH /repos/{o}/{r}/issues/{n} |
| comment on a PR | POST /repos/{o}/{r}/issues/{n}/comments | same |
Forgejo models a pull request as an issue, and fj routes through that model (fj pr close calls
crate::issues::close_issue). Allowlisting only the /pulls form silently breaks fj; only the /issues form
silently breaks tea. One dialect covers both, with the allowlist as the union.
Operation → REST endpoint map
Paths are shown below the /api/v1 mount point. {n} is the project-scoped index.
| operation | Method | Path | tea | fj |
|---|---|---|---|---|
| issue create | POST | /repos/{o}/{r}/issues | yes | yes |
| issue update/close | PATCH | /repos/{o}/{r}/issues/{n} | yes | yes (also pr close) |
| issue/PR comment | POST | /repos/{o}/{r}/issues/{n}/comments | yes | yes |
| comment update | PATCH | /repos/{o}/{r}/issues/comments/{n} | yes | yes |
| PR create | POST | /repos/{o}/{r}/pulls | yes | yes |
| PR update/close | PATCH | /repos/{o}/{r}/pulls/{n} | yes | no |
| PR merge | POST | /repos/{o}/{r}/pulls/{n}/merge | yes | yes |
| PR review (approve)† | POST | /repos/{o}/{r}/pulls/{n}/reviews | yes | no |
| add labels | POST | /repos/{o}/{r}/issues/{n}/labels | yes | — |
| add assignees | POST | /repos/{o}/{r}/issues/{n}/assignees | yes | — |
| remove assignees | DELETE | /repos/{o}/{r}/issues/{n}/assignees | yes | — |
† Recorded for completeness; not allowlisted.
Merging a pull request — head-commit provenance differs between tea and fj
tea pr merge was captured live (tea --debug, against a real PR on a disposable test repo) after an earlier attempt
in a different environment hit an interactive credential prompt and was abandoned rather than pushed through unattended.
With a saved non-interactive login the capture is a normal three-request fan-out: GET /repos/{o}/{r}/pulls/{n}
(mergeability), GET /version (not repo-scoped, harmless), then:
POST /repos/{o}/{r}/pulls/{n}/merge
{"Do":"merge","MergeCommitID":"","MergeTitleField":"","MergeMessageField":"","force_merge":false,
"head_commit_id":"<the PR's actual head SHA>","merge_when_checks_succeed":false}
tea populates head_commit_id with the real head SHA — confirmed from the capture, not assumed from source. This
is an optimistic-concurrency field on the Gitea/Forgejo side: a mismatch against the PR’s actual current head answers
409 head out of date (pull_service.IsErrSHADoesNotMatch, from services/forms/repo_form.go and
routers/api/v1/repo/pull.go), and fogwall’s provenance check reuses the same field as its head-SHA source when
require-validated-head is on.
fj never sends it. fj emits no HTTP debug output (see “What the CLIs constrain” above), so this comes from
source: merge_pr in forgejo-cli’s src/prs.rs builds its MergePullRequestOption with head_commit_id: None
hardcoded, with no flag or code path that ever sets it. fogwall’s provenance check denies a merge request that omits the
field when require-validated-head is on, so a fj pr merge is unconditionally refused under that setting today, the
same fail-closed posture an unresolvable head ref already gets on the create path. Closing this needs a change in
forgejo-cli upstream — noted for the maintainer to file by hand.
The response, per the capture, is a bare 200 OK with an empty body — so, as with GitHub, no merge commit SHA is
available to record from what a successful merge returns.
The last three are what tea issue edit reaches for --add-labels, --add-assignees/--set-assignees and
--remove-assignees; a create sets both inline on POST /issues instead. The remove is a DELETE carrying a body —
the logins to drop are in the entity, so forwarding the method without it asks the upstream to remove nobody.
--remove-labels fires no HTTP request at all: tea resolves the label list and then no-ops, so there is nothing to
allowlist.
Two quirks constrain what the allowlist can express:
fjcannot approve a pull request.repo_create_pull_reviewexists inforgejo-api0.11.0, butfjnever calls it —fj pr reviewonly lists. A capability gap in the CLI, not a gap in the capture.teasends a full-object PATCH.tea pr closeemits every field alongside"state":"closed"({"title":"","base":"", … ,"state":"closed", …}), so on the wire it is indistinguishable fromtea pr edit. Rule granularity is method plus path, never intent.
Endpoints the CLIs can also reach — tracked time, dependencies, blocking, releases — are absent from the allowlist and therefore denied.
Fork PRs address the upstream
Unlike GitLab, Gitea and Forgejo name the repository fogwall must authorize against directly in the URL, even for a PR
opened from a fork. Captured with tea --debug:
POST https://gitea.com/api/v1/repos/coopernetes/test-repo/pulls
{"head":"someotheruser:some-fork-branch","base":"main","title":"…","body":"…"}
The path segment is the upstream — whatever --repo names — and the fork appears only in the body as
head: "<user>:<branch>", the same shape GitHub uses. No target_project_id handling is needed.
fj behaves the same way by construction: --head is forwarded verbatim (prs.rs, Some(head) => Some(head)) and the
repo comes from -r/--repo into repo_create_pull_request(owner, repo, …).
tea also fires non-repo-scoped reads around the create (GET /orgs/{name}, GET /repos/{o}/{r}/labels), so a
path-based matcher has to tolerate paths carrying no owner/repo.
What the upstream answers
Read by the dialect’s EntityResponseReader after the response has gone to the client, so the registry can name what a
mutation created. Captured from the same CLI runs as the request shapes above.
| dialect | create returns | edit / close return | comment returns |
|---|---|---|---|
| GitHub | data.<field>.{issue,pullRequest}.{id,url} — only what gh selects | clientMutationId alone; the target is the node ID in the input | nothing naming the target beyond the input node ID |
| GitLab | the issue / MR: iid, web_url, state, title | the same object, state updated | the note, with noteable_iid |
| Forgejo | the issue / PR: number, html_url, state, title (+ merged) | the same object; via /issues/{n} it carries pull_request | the comment, with issue_url |
Consequences:
- GitHub’s number comes off the trailing segment of
url; state on a create isOPEN, on a close it is implied by the mutation. An edit or close of a pull/merge request or issue fogwall did not see created cannot be registered — nothing in the response names it beyond the node ID. tea pr closeandtea pr editgo throughPATCH /issues/{n}, so the response, not the path, decides whether the target is a pull request.- GitLab reports
opened/closed/merged/locked; Forgejoopen/closedplus amergedboolean. - A label or assignee write returns the labels or the issue; the number falls back to the request path.
Credential model
ID resolution uses the caller’s own token, never an app-level one. fogwall resolves only what the caller can already see, so an opaque ID they cannot read is a denial rather than a lookup. That never wrongly blocks: the caller is about to operate on that target, and a token that cannot read it cannot operate on it either.
The id → owner/repo mapping is an objective fact rather than a per-user one, so the cache is shared across users. Only
the authorization decision is per-user, and it is never cached.
A developer’s token is always far broader than the operations fogwall proxies with it. Neither provider has a scope that
means “may open issues and pull requests” — GitHub’s repo and GitLab’s api are both full read/write to everything
the user can already reach. GitLab has granular scopes for runners, the registry and observability, but nothing for core
API resources.
The token stays bounded by the user’s own role on the provider, so this is an over-broad credential at rest rather than an escalation. Narrowing it to specific repositories and operations is the permission engine’s job, not the token’s.
Appendix: capturing a new provider
The goal is a per-command log of every request and response, bodies included, for the full CRUD matrix, with credentials scrubbed.
- Use a throwaway repo and a rotatable token. Never capture against production data.
- Enable the CLI’s API debug output and tee each command to its own file:
gh:GH_DEBUG=api gh <cmd> … > cmd.log 2>&1— prints the request and response details.glab:GLAB_DEBUG_HTTP=true glab <cmd> … > cmd.log 2>&1— prints the request and response details.tea:tea <cmd> … --debug— prints method, URL, the headersteasets, and the request body. Response bodies show only a Go pointer, andAuthorization/User-Agentare added lower in the SDK so they never appear.fj: no HTTP debug exists. Read the CLI source and its generated API crate instead — that is how the Gitea/Forgejo tables above were produced, and it enumerates every endpoint the CLI can reach rather than only the ones one session exercised.fjhonoursHTTPS_PROXYand links OpenSSL, so mitmproxy with its CA in the system trust store works if raw bytes are genuinely needed.
- Exercise the full matrix, recording the ordered fan-out per command: issue create → edit → comment; pr/mr create → edit → comment → review; then close both. Reuse an existing branch for the PR/MR head so nothing needs pushing.
- Scrub tokens from every log before analysis —
Authorization:lines and anygh[posur]_…,github_pat_…or provider token patterns. CLI debug output usually redactsAuthorization; scrub anyway. - Extract per command: method and path (REST) or mutation field and variables (GraphQL), where the target ID
appears, and whether a preceding lookup already carries
owner/repo. - Check what the client does with a base path. Point the CLI at a local listener configured with a sub-path and see
whether the prefix survives. A
BaseHTTPRequestHandlerthat logs the request line is enough, and needs no valid credential.
Read the CLI’s source alongside any capture. A capture proves what one session did; the source enumerates everything the CLI can send, which is what an allowlist has to cover.