Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. The proxy URL — something like https://fogwall.corp.example.com or http://localhost:8080 for local development.
  2. A proxy user account — username and password for the fogwall dashboard. This is separate from your upstream SCM credentials.
  3. A personal access token (PAT) for the upstream SCM — the proxy forwards your token to authenticate with GitHub/GitLab/etc. on your behalf.
  4. Push permission on the target repo — the administrator must grant you PUSH permission for the specific repository you want to push to.
  5. 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

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 under fogwall.corp.example.com instead. 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. Run git config --global credential.https://fogwall.corp.example.com.useHttpPath true to 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:

ProviderMinimum scope
GitHubNo additional scopes required (classic or fine-grained PATs both work)
GitLabread_user
Bitbucketread:user:bitbucket and write:repository:bitbucket
Codeberg / Gitearead: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

  1. 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.

  2. 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.

  3. Enable agent forwarding. The proxy needs your SSH agent to authenticate outbound connections to the upstream SCM. Add a ForwardAgent yes entry in your ~/.ssh/config:

    Host <proxy-host>
      ForwardAgent yes
    

    Or pass -A on the command line: GIT_SSH_COMMAND="ssh -A" git push.

  4. 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>.git
    

    For 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 main
    

    Check 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.git shorthand 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 (2222 by default) rather than 22. 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 :22 mapping. 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:

  1. Verifies your SSH key against the fogwall user database (MINA public-key auth).
  2. Calls the upstream SCM API to fetch the SSH public keys registered on your linked SCM identity.
  3. 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 worksThe proxy receives your push locally, validates it, then forwards to upstreamThe proxy forwards HTTP requests directly to upstream while inspecting them inline
Terminal feedbackLive streaming — each validation step prints as it runsSilent until the end — one response after all checks complete
Approval workflowPush stays open waiting for approval; same git push command completes once approvedPush is blocked and you must run git push again after a reviewer approves — the second push is matched to the existing push record
Push recordEvery push is persisted with a full event historyEvery push is persisted; the re-push after approval references the same record
Local disk usageClones each repo to ephemeral pod storage for diff inspection — proportional to repo history sizeNone — git bytes stream directly through the proxy with no local storage
RecommendationUse this for most workflowsUse 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

StateMeaning
RECEIVEDPush has arrived and is being processed
PENDINGValidation passed; awaiting a reviewer’s decision
APPROVEDApproved by a reviewer (or self-certified) — will be forwarded
FORWARDEDSuccessfully sent to the upstream SCM
REJECTEDReviewer declined the push
BLOCKEDValidation failed — push will not be forwarded
CANCELEDCanceled 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:

MessageFix
author email '...' is not allowedYour 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 patternReword the commit message (git commit --amend or git rebase -i) to remove the blocked string.
diff contains blocked contentThe 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 gitleaksA 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 AllowedThe 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 DeniedThe repository is explicitly blocked by a deny rule. Contact your administrator.
Push Blocked - UnauthorizedThe 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 resolvedYour 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 same user.email as 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.email for a tagger block), then re-create the tag (git tag -d <tag> then git 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

  1. 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.
  2. 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 — in warn mode mismatches are logged but the push proceeds; in strict mode 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:

  1. Public-key auth (connection gate): your SSH key must be registered in your proxy profile.
  2. 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) or locked (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:

    deploymentwhat you configure
    a port per providerfogwall.corp.example.com:9443
    a hostname per providerfogwall-github-api.corp.example.com

    Either 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, not GH_TOKEN. gh reserves GH_TOKEN for 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_TOKEN works too.
  • gh auth login does not work against fogwall. Set the environment variables above instead.

Supported commands

commandpermission needed
gh issue create/edit/close/commentPROPOSE
gh pr create/edit/close/commentPROPOSE
gh pr mergeMERGE
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, so git push or git fetch through it will fail. Keep using your normal remote for all git operations.

Supported commands

commandpermission needed
glab issue create/update/note/closePROPOSE
glab mr create/update/note/closePROPOSE
glab mr mergeMERGE
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

commandpermission needed
issue create/edit/close/comment (tea/fj)PROPOSE
PR create/edit/close/comment (tea/fj)PROPOSE
tea pr merge/fj pr mergeMERGE
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 fetchcontributions (PR/MR and issue operations)
site-wide gaterules.allow / rules.deny, per repositorynone — the surface is on or off
your permissionPUSH grant on that repositoryPROPOSE 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

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 ~/.gitconfig form (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 in git 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-url so 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. Setting service-url is 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

Backendauth.providerWhen to use
Local (static)localDev / demo only. Passwords in YAML config.
LDAPldapGeneric LDAP directory (OpenLDAP, 389 DS, etc.)
Active DirectoryadOn-premises AD domain. UPN bind, no user-dn-patterns needed.
OIDCoidcKeycloak, 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:

RoleWhat it grants
USER (default)View push records; approve or reject pushes they have REVIEW permission on; manage their own profile (emails, SCM identities)
ADMINEverything USER can do, plus: create/delete users, reset passwords, manage any user’s profile, view all push records
SELF_CERTIFYGrants 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_CERTIFY to auth.role-mappings and map it to the appropriate IdP group.
  • Local auth (config user): add SELF_CERTIFY to roles: in the user’s users: 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_CERTIFY role or permissions. If no user holds SELF_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 mail attribute 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 email claim 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

ValueWhat it grants
PUSHUser can submit pushes to this repo for validation and review
REVIEWUser can approve or reject pushes to this repo submitted by others
PUSH_AND_REVIEWShorthand for both PUSH and REVIEW
SELF_CERTIFYPer-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.
ISSUEUser 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.
PROPOSEUser 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.
MERGEUser 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.
MAINTAINSole-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:

  1. The SELF_CERTIFY role (capability gate) — granted via auth.role-mappings or roles: [SELF_CERTIFY] in local config. This is the org-level attestation that the user is trusted to self-certify at all.
  2. A SELF_CERTIFY permission 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
ModeBehaviour
autoClean 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.
uiEvery 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

EnvironmentLog output
./gradlew runfogwall-server/logs/application.log + console
Docker / productionconsole 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:

VariableEffect
NO_COLORDisables ANSI colour. Follows the no-color.org convention — set to any non-empty value.
FOGWALL_NO_EMOJIReplaces 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):

PrioritySourceMechanism
1 (lowest)fogwall.ymlBundled in the JAR — base defaults
2Profile YAMLs named in FOGWALL_CONFIG_PROFILESClasspath lookup (see below)
3FOGWALL_* environment variablesStrip 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 in FOGWALL_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

PathLibraryDestination
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 forwardingJetty HttpClient (HTTPS)SCM provider git endpoint
SCM identity resolution (PAT verification)Apache HttpClient 5SCM provider REST API
SSH fingerprint lookupApache HttpClient 5SCM 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:

  1. 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.
  2. 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.
  3. 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:

  1. Git probe — sends GET /info/refs?service=git-upload-pack and GET /info/refs?service=git-receive-pack with a User-Agent: git/2.x.x header. 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:

SettingDefaultBounds
server.max-push-bytes64 MiBhow large one push may be
server.max-concurrent-requests512how 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:

SettingWhy it cannot work here
server.approval-mode: uiA held push waits for a decision over the REST API, which this image does not serve.
scm-oauth.identity-mode: strictOnly 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:

  1. 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.
  2. 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 scmUsername is 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

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:

  1. 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.sh when a provider rotates its key.

  2. Pinned in config (recommended for custom providers). Pin a private/internal SCM’s host key with a standard known_hosts line — globally under server.ssh.extra-known-hosts, or per-provider under that provider’s ssh.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
    
  3. Operator-supplied file. Point server.ssh.known-hosts-path at a known_hosts file. 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.

  4. Trust on first use (opt-in). server.ssh.trust-on-first-use: true pins 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 is false (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.

  1. Create the app under your org’s GitHub settings (or a personal account, for testing).
  2. Account permissions — grant exactly:
    • Email addresses: Read-only
    • Git SSH keys: Read-only
  3. Callback URL: https://<your-fogwall-host>/api/scm-oauth/<provider-name>/callback, where <provider-name> is the top-level providers: key this app’s oauth: 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.com tenant, 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 exactly server.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).
  4. 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.
  5. 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.

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.tls and 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:

permitteddenied
issue create, edit, close, commentsubmitting a review, approving
PR/MR create, edit, close, comment, mergebranch deletion, auto-merge, merge queues
label, assignee and reviewer-request changesrelease, 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 --reviewer is 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 (under scm-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 MERGE grant, standalone from PROPOSE: a contributor who can open a pull/merge request cannot merge one with the same grant, and a maintainer given only MERGE cannot open one. Grant it the same way as PROPOSE (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 merge is refused whenever require-validated-head is onfj sends 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:

  1. Is the repo in rules.allow? Verify the slug matches exactly (including leading /).
  2. Does the user have a permissions entry for this provider + path with grant: 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_CERTIFY permission 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:

  1. Does the user’s profile have an scm-identities entry for the correct provider?
  2. 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:

  1. The key is in fogwall (ssh-keys) but not on the upstream SCM account. Have the user add it in their SCM account settings.
  2. The linked scm-identities entry 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).
  3. The api-token is missing or has expired, causing the key lookup to return an empty list. Check the server log for Failed 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 strict mode 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 warn mode the push goes through but the mismatch is logged and visible in the push record. Switch to strict once 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

  1. Enable the Spring Security debug profile (docker/log4j2-debug.xml) — see Debug profiles.
  2. Check the redirect URI registered in the IdP matches https://<your-host>/login/oauth2/code/fogwall exactly.
  3. For Entra ID: make sure issuer-uri ends in /v2.0 and skip-user-info: true is set — see the Entra ID section of the configuration reference. jwk-set-uri is 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:

VersionRedirect URI sent to IdP
< 1.2.0https://<host>/login/oauth2/code/gitproxy
≥ 1.2.0https://<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:

ErrorLikely cause
Repository not found / 404The 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 ForbiddenThe token has repo access but lacks the required write scope (e.g. a fine-grained PAT missing Contents: write).
pre-receive hook declinedThe 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 branchThe 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 (lowest → highest priority)

LayerSourceWhen loaded
1fogwall.ymlAlways — base defaults bundled in the jar
2fogwall-{profile}.ymlFor each profile listed in FOGWALL_CONFIG_PROFILES
3Environment 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 nameFilePurpose
localfogwall-local.ymlLocal development: dev users, Vite CORS, test allow rules
docker-defaultfogwall-docker-default.ymlDocker base: admin user, Gitea provider, validation rules
ldapfogwall-ldap.ymlLDAP authentication config (used with docker-default)
oidcfogwall-oidc.ymlOIDC authentication config (used with docker-default)

When running via ./gradlew run, FOGWALL_CONFIG_PROFILES=local is 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 VariableConfig pathExample
FOGWALL_CONFIG_PROFILES(meta — not a config key)docker-default,ldap
FOGWALL_SERVER_PORTserver.port9090
FOGWALL_SERVER_APPROVAL_MODEserver.approvalModeui
FOGWALL_SERVER_SERVICE_URLserver.serviceUrlhttps://fogwall.example.com/dashboard
FOGWALL_DATABASE_TYPEdatabase.typepostgres
FOGWALL_DATABASE_URLdatabase.urljdbc:postgresql://...
FOGWALL_DATABASE_HOSTdatabase.hostdb.internal
FOGWALL_DATABASE_POOL_MAXIMUMPOOLSIZEdatabase.pool.maximum-pool-size3
FOGWALL_DATABASE_POOL_MINIMUMIDLEdatabase.pool.minimum-idle1
FOGWALL_DATABASE_POOL_CONNECTIONTIMEOUTdatabase.pool.connection-timeout30000
FOGWALL_SERVER_SESSIONSTOREserver.session-storejdbc
FOGWALL_SERVER_REDIS_HOSTserver.redis.hostredis.cluster.local
FOGWALL_SERVER_REDIS_PORTserver.redis.port6379
FOGWALL_SERVER_ALLOWEDORIGINSserver.allowed-originshttps://dashboard.example.com
FOGWALL_PROVIDERS_GITHUB_ENABLEDproviders.github.enabledfalse
FOGWALL_PROVIDERS_<NAME>_URIproviders.<name>.urihttps://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 VariableConfig path
FOGWALL_PROVIDERS__GITEA_SSH__API_TOKENproviders.gitea-ssh.api-token
FOGWALL_SERVER__SESSION_STOREserver.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)
KeyDefault (proxy / server)Meaning
cache.<mode>.shallow-sinceunsetTime-based boundary — 90d, 12h, 30m, or an ISO-8601 duration (PT48H). Takes precedence over depth.
cache.<mode>.clone-depth100 / 0Commit 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 HttpClient used 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

TypeDescriptionExtra keys
h2-memH2 in-memory (default)name (default: fogwall)
h2-fileH2 persisted to diskpath (default: ./.data/fogwall)
postgresPostgreSQLurl or host, port, name, username, password
mysqlMySQL 8.0+url or host, port (default 3306), name, username, password
mariadbMariaDB 10.5+url or host, port (default 3306), name, username, password
mongoMongoDBurl (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.

VariableDefaultEffect
FOGWALL_SCM_CACHE_MAX_AGE_DAYS7Maximum 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:

CollectionWritten byNotes
proxy_usersMongoUserStoreRenamed from users to avoid collision with upstream’s users.
proxy_pushesMongoPushStoreRenamed from pushes to avoid collision with upstream’s pushes.
repo_permissionsMongoRepoPermissionStoreNo upstream equivalent.
access_rulesMongoUrlRuleRegistryNo upstream equivalent.
fetch_recordsMongoFetchStoreNo 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 the email optional claim added to the app registration (checklist below). Don’t skip this one: the default (sub) logs in without any error, but Entra’s sub is 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 when user-name-attribute names a claim its response lacks. Skipping UserInfo reads all claims from the ID token and drops the runtime dependency on Graph (and its User.Read permission) 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:

  1. Platform: Web — redirect URI https://<your-host>/login/oauth2/code/fogwall
  2. API permissions: openid, profile, email (delegated)
  3. Token configuration → add Groups claim → select “Security groups”
  4. Token configuration → add optional claim → ID token → email (feeds fogwall’s locked-email provisioning)

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.

RoleDashboard access
USERView and act on pushes awaiting approval
ADMINAll 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

PropertyTypeDefaultDescription
enabledbooleantrueWhether the provider is active
servlet-pathstring""Additional URL prefix for this provider
uristring(built-in default)Upstream base URI. Required for custom-named providers; omit for built-ins.
typestring(from name)Provider implementation: github, gitlab, bitbucket, codeberg, forgejo, gitea. Required for any name that is not one of the five reserved names.
api-uristring(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-tokenstring(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.
sshblock(disabled)SSH transport for this provider — the same entry serves both HTTP and SSH. See Serving a provider over SSH.
blocked-info-refs-statusint403HTTP status returned when a blocked /info/refs discovery request is denied. 403 is unambiguous; 404 obscures whether the repo exists (security by obscurity).
serve-fetchboolean(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-enabledbooleanfalseWhether 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 uri to 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

PropertyTypeDefaultDescription
identity-modestringpermissivepermissive or strict — see above.
token-encryption-key-pathstring(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.enabledbooleanfalseWhether “Link via OAuth” is offered for this provider.
providers.<name>.oauth.client-idstring""OAuth app/client ID.
providers.<name>.oauth.client-secret-pathstring""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

PropertyTypeDefaultDescription
scm-api.node-id-cache-ttlstringPT5MISO-8601 duration. See the security note above.
providers.<name>.scm-api.enabledbooleanfalseWhether the SCM API proxy is mounted for this provider.
providers.<name>.scm-api.portintDedicated listener port. Required when enabled; startup fails without it.
providers.<name>.scm-api.require-validated-headbooleantrueRefuse 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-enabledbooleanfalseAllow 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
PropertyTypeDefaultDescription
enabledbooleanfalseWhether the SSH listener starts
portint2222TCP port the SSH server binds
host-key-pathstring.ssh/fogwall_host_keyPath to the SSH host key file (absolute or relative to the working directory)

Note

Why port 2222, and why git@host:path shorthand doesn’t work out of the box: the default avoids clashing with a real sshd that may already be running on the same host, and avoids the container needing root or CAP_NET_BIND_SERVICE just to bind a port below 1024. The trade-off is that Git’s SCP-like shorthand (git@host:owner/repo.git, the syntax GitHub’s git@github.com:... uses) has no field for a non-default port — only the explicit ssh://host:port/path form does. If you want the shorthand to work, put fogwall’s SSH port behind a plain TCP/L4 passthrough on your load balancer or Service (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’s sshService.* 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:)TypeDefaultDescription
enabledbooleanfalseServe this provider over SSH. With no ssh.uri, the endpoint is derived as ssh://git@<host> from the provider’s HTTP uri (port 22).
uristring(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-hostslist(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-pathstring(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 uri on a provider entry is always the HTTP/HTTPS endpoint (it is also the API base used for SSH-key identity resolution). A top-level uri with an ssh:// scheme no longer enables SSH — configure SSH through the ssh: 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 }:

KeyValuesMeaning
actionallow | blockWhether a match permits or rejects the email.
fielddomain | local | addressWhich part of the address to test (local is before @, address is the full local@domain).
matchliteral | regexliteral is case-insensitive exact equality; regex uses find-semantics. Default regex.
valuestringThe 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.allow and email.local.block single-regex keys are still accepted for one minor release and are folded into equivalent allow domain regex / block local regex rules at startup (a deprecation warning is logged). Migrate to the rules list — 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 a Signed-off-by: trailer (Developer Certificate of Origin). With require-author-match: true, at least one Signed-off-by email 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 a Co-authored-by: trailer (e.g. legal teams requiring a single attributable author per commit).
    • require — reject any commit that has no Co-authored-by: trailer.
    • allowlist — reject a Co-authored-by whose email is not permitted by co-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):

  1. scanner-path — explicit path, bypasses everything else
  2. version + auto-install: true — downloads and caches that version on startup
  3. Bundled JAR binary (default version, always present)
  4. 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 typeMatches
application/pdfPDF documents
application/zipZIP archives, JARs, and OOXML Office docs (.docx/.xlsx/.pptx — indistinguishable from plain zip at the magic-byte level)
application/gzipgzip-compressed files (.gz, .tar.gz)
application/x-7z-compressed7-Zip archives
application/vnd.rarRAR archives
application/x-executableELF binaries (Linux executables/shared objects)
application/x-msdownloadWindows PE binaries (.exe, .dll)
application/vnd.sqlite3SQLite database files
application/java-vmCompiled Java class files
application/wasmWebAssembly binaries
application/zstdZstandard-compressed files
application/x-xzXZ-compressed files
application/x-bzip2Bzip2-compressed files
application/vnd.apache.parquetApache Parquet columnar data files
application/x-java-keystoreJava Keystore (.jks)
application/x-java-jce-keystoreJava Cryptography Extension Keystore (.jceks)
application/x-qemu-diskQEMU/QCOW2 virtual disk images
application/x-hdf5HDF5 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

BundleJurisdictionDetects
national-id-caCanadaSocial Insurance Number (SIN)
national-id-usUnited StatesSocial Security Number (SSN)
national-id-gbUnited KingdomNational Insurance Number (NINO); NHS Number
national-id-auAustraliaTax File Number (TFN); Australian Business Number (ABN); Australian Company Number (ACN)
national-id-deGermanyRentenversicherungsnummer
national-id-inIndiaAadhaar Number
national-id-sgSingaporeNRIC/FIN Number
national-id-zaSouth AfricaSouth African ID Number
national-id-esSpainNIF Number
national-id-seSwedenPersonnummer
national-id-trTurkeyNational ID Number (TC Kimlik No)
national-id-fiFinlandPersonal Identity Code (Henkilötunnus)
national-id-itItalyFiscal Code (Codice Fiscale)
national-id-krSouth KoreaResident Registration Number
national-id-ngNigeriaNational Identification Number
national-id-phPhilippinesUMID Number
national-id-thThailandNational ID Number

Generic bundles

BundleEnabled by defaultDetectsChecksum strength
generic-ibanYesIBANISO 7064 mod-97-10 (strong)
generic-crypto-walletYesBitcoin (legacy Base58Check, SegWit/Taproot Bech32); Ethereum (EIP-55, checksum-cased addresses only)SHA-256d / BCH / Keccak-256 (strong)
generic-credit-cardNo — opt inCredit card numberLuhn (mod-10, weak)
generic-us-bank-routingNo — opt inUS 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

SectionYAML keyWhat changes take effect
commitcommit:Author email rules, message block lists, commit attribution policy mode
diff-scandiff-scan:Diff content block literals and patterns
secret-scansecret-scan:All gitleaks settings including inline-config
binary-blobbinary-blob:Blob size limit and denied MIME types
rulesrules:URL access control allow/deny rules
permissionspermissions:Config-sourced user→repo permission grants
attestationsattestations: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 to commit.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:

  1. 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, GitLab username) is matched against the authenticated fogwall user’s scm-identities. This check is always enforced regardless of the attribution-policy mode — a push from a token that cannot be matched to a registered proxy user is always blocked.

  2. Commit email check — every author and committer email in the pushed commits is checked against the authenticated fogwall user’s emails list. 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. The attribution-policy mode 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.

ModeBehaviourUse when
strictBlocks the push if any commit email cannot be matched to the authenticated fogwall userProduction — enforces that every commit is attributed to the person who pushed
warnAllows the push through but emits a sideband warning to the git client and records the mismatchRolling out to an existing team — lets you observe mismatches before enforcing
offCommit email check is disabled entirelyMigrations or environments where email data is not yet populated

Caution

warn is not a security control. Pushes succeed regardless of the email check outcome. Only strict blocks mismatched commits. The default is warn to avoid breaking existing deployments on first install.

Committer vs author: the two are checked independently. committer defaults to warn — the committer is who last touched the commit object, i.e. the pusher on their own work. author defaults to off because rebased or cherry-picked commits legitimately preserve a different original author, so blocking on it would reject valid workflows. Enable author: strict only on closed boundaries (private-to-private, M&A integration) where every commit must be authored by the pusher; leave it off for 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:

ProviderAPI endpointAdditional scope
GitHubGET https://api.github.com/userNo additional scopes required for either classic or fine-grained PATs.
GitLabGET {uri}/api/v4/userread_user or api (not recommended)
CodebergGET https://codeberg.org/api/v1/userread:user
GiteaGET https://gitea.com/api/v1/userread: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

PropertyTypeDefaultDescription
enabledbooleantrueWhether this entry is active
orderint(position)Evaluation order (lower = earlier; first match wins). Optional — see below
operationstringBOTHFETCH, PUSH, or BOTH — which git operation this entry matches
providerstring(all)Provider name to scope this entry to; omit or leave blank for all
matchobjectRepository match criteria — see below
match.targetenumSLUGWhat to match: SLUG (the full repository path), OWNER, or NAME
match.valuestringThe pattern to match against the chosen target
match.typeenumGLOBHow 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:

TargetValue matched against
SLUG/group/subgroup/project
OWNERgroup/subgroup
NAMEproject

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 OWNER value must name the whole namespace (group/subgroup), not just the top-level group. A GLOB of group/* matches one level of nesting; group/** matches any depth.
  • A SLUG value must name every segment. A LITERAL of /group/subgroup does not admit the projects inside that subgroup — write /group/subgroup/* as a GLOB for 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
SLUGDoes not cross / — use /acme/* for one level, /acme/** for any depth
OWNERDoes not cross / — a nested namespace needs group/* or group/** (see above)
NAMERepo names cannot contain /* matches any valid name
Pattern (GLOB, target=SLUG)MatchesDoes 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 of acme does 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 with matches() but harmless if included.
Pattern (REGEX, target=SLUG)MatchesDoes 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)MatchesDoes NOT match
(?i)(^&#124;-)secret(-&#124;$).*secret-config, my-secretsecretariat
migrate-.*migrate-app, migrate-dbold-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

PropertyTypeDefaultDescription
usernamestringProxy username (must match a users: entry or a DB user)
providerstringProvider name as defined in providers: config
matchobjectRepository match criteria — see below
match.targetenumSLUGWhat to match: SLUG (the full repository path), OWNER, or NAME
match.valuestringThe pattern to match against the chosen target
match.typeenumGLOBHow to interpret the pattern: LITERAL, GLOB, or REGEX
grantenumPUSH_AND_REVIEWWhat 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)MatchesDoes 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

ValueEffect
PUSHUser may push to matching repositories
REVIEWUser may approve or reject pushes submitted by others
PUSH_AND_REVIEWShorthand for both PUSH and REVIEW; does not include SELF_CERTIFY
SELF_CERTIFYTrusted contributor: may approve their own clean pushes without a peer reviewer. Requires the SELF_CERTIFY role as well
ISSUEUser may file and follow up on issues through the dashboard issue form — the narrow floor under PROPOSE, independent of PUSH/REVIEW
PROPOSEUser may open and edit pull/merge requests and issues through the SCM API proxy — independent of PUSH/REVIEW
MERGEUser may merge a pull/merge request through the SCM API proxy’s maintainer path — independent of PUSH/REVIEW/PROPOSE
MAINTAINSole-maintainer bundle: PUSH + PROPOSE + MERGE in one entry. Deliberately excludes SELF_CERTIFY — that stays a separate grant

Important

SELF_CERTIFY is a two-key lock: the user must have both a SELF_CERTIFY permission entry for the repository and the SELF_CERTIFY role (set via users[].roles or auth.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

PropertyTypeDefaultDescription
namestringGroup name, shown in the dashboard
descriptionstring""Free-text description
memberslist[]Usernames belonging to this group (must match a users: entry or a DB user)
grantslist[]Permission grants applied to every member — same shape as permissions: entries, minus username
grants[].providerstringProvider name as defined in providers: config
grants[].matchobjectRepository match criteria — same semantics as Permissions
grants[].grantenumPUSHPUSH, 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

PropertyTypeDefaultDescription
idstringUnique key used to store the reviewer’s answer in the push record
typestringcheckboxInput type: checkbox, text, or dropdown
labelstringQuestion text shown in the review form
requiredbooleanfalseWhether the question must be answered before the reviewer can submit
linkslist[]Policy/reference links (text + url each) rendered below the question
optionslist(empty)Choices for dropdown type; ignored for other types
tooltipstring(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:

VariableEffect
NO_COLORDisables ANSI colour in sideband output. Follows the no-color.org convention — set to any value to disable.
FOGWALL_NO_EMOJIReplaces 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 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

ConcernServer modeTransparent proxy
Live progress feedbackYes — per-step sideband messagesNo — single terminal response
Local storage requiredYes — receives the push into a local cloneYes — clone needed for pack inspection
Approval workflowBlocks git session until approvedRecords push, polls for approval (requires second push)
Pack inspectionVia JGit ReceivePack APIsPack unpacked into local clone for inspection, then HTTP-proxied upstream
Resumable push after approvalSame sessionNew 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/repoGitHubNodeIdResolver resolves it (cached, with a TTL that is a security parameter, not just a perf knob: a node ID can outlive a repo rename/transfer) before RepoPermissionService.isAllowedToPropose can run. GitLab has no equivalent step: its REST calls carry owner/repo directly in the URL (verified from live glab captures — see docs/internals/scm-api-proxy.md), so ScmApiGitLabGateFilter reads the authorization target straight off the matched path via GitLabRestAllowlist.
  • One dialect for tea and fj. The two Gitea/Forgejo CLIs speak the same server API and differ only in which subset they use, so ForgejoRestAllowlist is the union of both. The union is load-bearing: tea pr close sends PATCH /pulls/{n} while fj pr close sends PATCH /issues/{n}, so allowlisting one form silently breaks the other CLI. They are deliberately not told apart by User-Agent — that header is caller-controlled, so branching authorization on it would let a caller select the looser rule set.
  • User-Agent is evidence, never an input to a decision. ScmApiUserAgentFilter records 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’s acme%2Fwidgets into two segments — turning every glab mutation into a fail-closed denial, and in principle letting an encoded slash shift which repository is authorized. ScmApiRestPath reads getRequestURI() 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 EntityRegistrar then reads it through the dialect’s EntityResponseReader — number, URL, node ID and state — into the scm_api_entities registry, a mutable current-state table that the append-only scm_api_action_records point at via entity_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. ScmContentInspector runs scm-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. UrlRuleRegistry gates 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 — ScmApiAuthenticateFilter refuses a missing token and one that resolves to no fogwall user — so authorization runs against the caller directly, through RepoPermissionService.

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 Repository shares 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.

OrderWhat it checks
50–199URL allow/deny rules (config + DB-sourced)
150User identity — developer must have a proxy account and push permission for this repo
160Author attribution — git commit author must match the authenticated proxy user
210Non-empty push — at least one new commit
220Hidden commits — pack must not contain commits outside the declared push range
250–260Author email and commit message patterns (allow/block regex)
265Content pattern scan — commit messages (national ID/PII bundles) — WARN-only
290Binary blob detection — magic-byte signature sniffing, with MIME-type allow/deny
300Diff content scan (blocked literals and patterns)
320GPG commit signature validation
340Secret scanning (gitleaks)
345Content 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:

  1. Calls the provider API with the token to get the developer’s SCM username.
  2. Looks up a proxy user whose scmIdentities has a matching (provider, scmUsername) entry.
  3. Uses the resolved UserEntry for permission checks and author attribution.
  4. 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:

  1. Accept the Org A developer’s push (authenticated against their proxy user record).
  2. Validate author attribution, commit messages, and diff content normally — the developer’s identity is still enforced.
  3. 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 uses commitFrom == zeros as 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>
FieldMeaning
oldOidThe SHA the client believes the ref currently points to on the remote. All-zeros (0000…) for a new ref.
newOidThe SHA the client wants the ref to point to after the push. All-zeros for a ref deletion.
refNameFull 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:

oldOidnewOidrefNameMeaning
000…0abc123refs/heads/featureNew branch — create pointing at abc123
abc123def456refs/heads/featureBranch update — FF or force push from abc123
abc123000…0refs/heads/featureBranch deletion — remove the ref
000…0abc123refs/tags/v1.0New 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()commitTo is all-zeros
  • isTagPush()branch starts with refs/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:

  1. Receives the pack data and unpacks objects into the local repository
  2. Creates ReceiveCommand entries for each ref update
  3. 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:

  1. Clone/fetch: LocalRepositoryCache.getOrClone(remoteUrl) maintains a bare clone of each upstream repository. First push triggers a git clone --bare --depth 100; subsequent pushes do git fetch --depth 100. The cache is keyed by owner_reponame (derived from the URL).

  2. 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 what ReceivePack does internally in server mode.

  3. Walk commits: With objects now in the local clone, CommitInspectionService can 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 uses findNewBranchBase() 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..commitTo and runs git log natively — 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:

ModeComponentWhenWhat
server modeDiffGenerationHook (order 280)Pre-receive, post-validationPush diff + optional default-branch diff
ProxyScanDiffFilter (order 300)After EnrichPushCommitsFilterPush 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:

  1. introduced = commits from getCommitRange(oldId, newId) — the explicit push range
  2. allNew = RevWalk from newId, marking all existing refs as uninteresting
  3. hidden = allNew minus introduced

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:

  1. Packet lines (ref updates + capabilities)
  2. A flush packet (0000)
  3. Pack data (the PACK signature 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 PACK signature (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 ParseGitRequestFilter fails with EOFException: Short read of block.
  • Request splitting: the proxy dechunks the body, buffers the remainder, and forwards it as a separate Content-Length request. 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 with Invalid packet line header because 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:

  1. Call read() — returns a Content.Chunk or null
  2. If null: call demand() with a CountDownLatch callback, then await() until more data arrives from the network
  3. If a chunk: copy its bytes into a ByteArrayOutputStream, release the chunk
  4. 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:

SPIImplementationRole
RepositoryResolverServerRepositoryResolverResolves the upstream repo into a local bare clone; extracts client credentials
ReceivePackFactoryServerReceivePackFactoryCreates a ReceivePack per request; assembles the pre/post-receive hook chain
UploadPackFactoryServerUploadPackFactoryCreates 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:

  1. Local mirror — calls LocalRepositoryCache.getOrClone(upstreamUrl) to maintain a bare clone. First access triggers git clone --bare --depth 100; subsequent requests do git 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.

  2. 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 FogwallHook and are sorted by getOrder(). 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 ValidationContext and PushContext instances 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 ReceiveCommand result is set to anything other than NOT_ATTEMPTED.
  • A HeartbeatSender runs 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

OrderHookPurpose
PushStorePersistenceHook.preReceiveRecord initial RECEIVED state in database
100RepositoryWhitelistHookRecord whitelist pass (resolver already validated)
150CheckUserPushPermissionHookValidate push user via UserAuthorizationService
210CheckEmptyBranchHookReject if push range has no commits (skips tags)
220CheckHiddenCommitsHookDetect unreferenced commits smuggled in via pack
250AuthorEmailValidationHookCheck author emails against allow/block patterns
260CommitMessageValidationHookCheck commit messages against blocked literals/patterns
270ProxyPreReceiveHookLog commit inspection details (sha, author, message snippet)
280DiffGenerationHookGenerate unified diffs (push diff + default-branch diff)
300DiffScanningHookScan diff added-lines for blocked content patterns
320GpgSignatureHookValidate GPG signatures via BouncyCastle PGP
340SecretScanningHookPipe diff to gitleaks CLI for secret detection
PushStorePersistenceHook.validationResultCollect issues; record REJECTED or BLOCKED
ApprovalPreReceiveHookGate: 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:

  1. Opens a JGit Transport to the upstream URL (read from fogwall.upstreamUrl in repo config)
  2. Sets the CredentialsProvider extracted from the original client request
  3. Builds RemoteRefUpdate objects:
    • CREATE / UPDATE — same source and destination ref
    • UPDATE_NONFASTFORWARD — same, but with force=true
    • DELETE — null source ref (JGit translates this to a ref deletion)
  4. 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:

  1. ServerRepositoryResolver.open() — extracts from Authorization header or URL userinfo. Stores as request attribute com.rbc.fogwall.credentials. Never used for cloning.
  2. ServerReceivePackFactory.create() — reads from request attribute (or re-extracts from header). Creates UsernamePasswordCredentialsProvider.
  3. ForwardingPostReceiveHook.pushToUpstream() — sets the CredentialsProvider on JGit’s Transport before calling push().

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: ServerRepositoryResolver calls getOrClone() on each push. Objects from the client’s pack are already in the repo (JGit’s ReceivePack unpacked them), so hooks can use RevWalk, DiffFormatter, etc. directly.
  • Proxy mode: EnrichPushCommitsFilter calls getOrClone() to get a local repo, then feeds the push’s pack data through JGit’s PackParser to insert objects. This bridges the gap between raw HTTP bytes and the JGit API.

Cache characteristics:

  • Keyed by owner_reponame derived 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:

MethodWhat it doesJGit API
getCommitDetails(repo, sha)Single commit metadata (author, message, signature, trailers)RevWalk.parseCommit()
getCommitRange(repo, from, to)Commits introduced by a pushgit.log().addRange() or git.log().add(to).not(existingRefs) for new branches
getDiff(repo, from, to)Diff entries between two commitsgit.diff() with tree iterators
getFormattedDiff(repo, from, to)Unified diff as stringDiffFormatter 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

APIWherePurpose
GitServletFogwallServletRegistrarHTTP git server implementation
ReceivePackServerReceivePackFactoryReceives pack data, runs hook chain
TransportForwardingPostReceiveHookPushes to upstream with credentials
RevWalkCommitInspectionService, CheckHiddenCommitsHookWalks commit graph
DiffFormatterCommitInspectionService, DiffGenerationHookGenerates unified diffs
PackParserEnrichPushCommitsFilter (proxy mode)Inserts pack objects into local repo
PacketLineInGitReceivePackParser (proxy mode)Reads packet-line protocol from raw bytes
Repository.resolve()ThroughoutSHA resolution with ^{commit} / ^{tree} peeling
CredentialsProviderFactory, resolver, forwarding hookIn-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:

CLIsub-path mountrequest observed
ghdiscardedPOST /api/graphqlGH_HOST is a hostname; a path cannot be expressed
glabpreservedGET /scm-api/gitlab/api/v4/projects/foo%2Fbar/issues
teapreservedGET /scm-api/gitea/api/v1/user
fjdiscardedGET /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

CLIheader
ghAuthorization: token <pat>
glabPRIVATE-TOKEN: <pat> for a PAT; Authorization: Bearer for OAuth
teaAuthorization: token <pat>
fjAuthorization: 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 comparefj 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 commandschema mutation fieldgh operationNameinput node-ID keynode type
issue createcreateIssueIssueCreateinput.repositoryIdRepository (R_)
pr createcreatePullRequestPullRequestCreateinput.repositoryIdRepository (R_)
issue editupdateIssueIssueUpdateinput.idIssue (I_)
issue closecloseIssueIssueCloseinput.issueIdIssue (I_)
issue/pr commentaddCommentCommentCreateinput.subjectIdIssue or PR
pr editupdatePullRequestPullRequestUpdateinput.pullRequestIdPullRequest (PR_)
pr reviewaddPullRequestReviewPullRequestReviewAddinput.pullRequestIdPullRequest (PR_)
pr closeclosePullRequestPullRequestCloseinput.pullRequestIdPullRequest (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:

flagschema mutation fieldgh operationNameinput node-ID keynode type
--assignee, --add-assignee, --remove-assigneereplaceActorsForAssignableReplaceActorsForAssignableinput.assignableIdIssue or PR
--add-labeladdLabelsToLabelableLabelAddinput.labelableIdIssue or PR
--remove-labelremoveLabelsFromLabelableLabelRemoveinput.labelableIdIssue or PR
--reviewer, --add-reviewer, --remove-reviewerrequestReviewsByLoginRequestReviewsByLogininput.pullRequestIdPullRequest

† Recorded for completeness; not allowlisted.

Three things follow:

  1. Allowlisting matches the schema mutation field parsed from the AST, not gh’s operationName (which is gh-specific and can change) and not a substring of the query text.
  2. The mutation carries only the node ID. Resolution to owner/repo is mandatory before any permission check.
  3. 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 createquery IssueRepositoryInfo($owner,$name){ repository{ id … } }. The response’s data.repository.id equals the createIssue mutation’s input.repositoryId.
  • issue edit/comment/closequery IssueByNumber($owner,$repo,$number){ … issue{ id } }.
  • pr edit/comment/review/closequery PullRequestByNumber($owner,$repo,$pr_number){ … pullRequest{ id } }.
  • pr create additionally fires query RepositoryInfo and query 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 (as labelIds), pr create --draft, and pr edit --base --milestone --title --body — all four of the last land in one updatePullRequest input. gh also fires updatePullRequest under the operation name PullRequestCreateMetadata immediately after createPullRequest, to attach labels named on pr 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-branch sends DELETE /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. MergePullRequestInput has an expectedHeadOid, but gh does not send it — only pullRequestId and mergeMethod (--subject/--body add commitHeadline/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 reads headRefName; 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) — see GitHubHeadShaResolver.resolvePullRequestHeadSha.
  • The response selects nothing beyond clientMutationId. gh does not ask for the merge commit’s SHA, its own state, or anything else mergePullRequest’s schema could return — the captured response body was exactly {"data":{"mergePullRequest":{"clientMutationId":null}}}. So while a 2xx response confirms the merge happened (recorded as MERGED), there is no merge commit SHA anywhere in what gh itself 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 commandMethodPathTarget ID source
issue createPOST/projects/:path/issuespath only
issue updatePUT/projects/:path/issues/:iidpath + iid (from CLI arg, not a preceding lookup)
issue notePOST/projects/:path/issues/:iid/notespath + iid
issue closePUT/projects/:path/issues/:iidbody {"state_event":"close"}
mr createPOST/projects/:path/merge_requestspath (+ numeric target_project_id, from a preceding GET /projects/:path)
mr updatePUT/projects/:path/merge_requests/:iidpath + iid
mr notePOST/projects/:path/merge_requests/:iid/notespath + iid
mr approvePOST/projects/:path/merge_requests/:iid/approvepath + iid
mr closePUT/projects/:path/merge_requests/:iidbody {"state_event":"close"}
mr mergePUT/projects/:path/merge_requests/:iid/mergepath + 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_id when 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_id is 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_HTTP redacts Authorization but not response bodies, and GET /projects/:path returns runners_token in 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

operationteafj
list PRsGET /repos/{o}/{r}/pullsGET /repos/{o}/{r}/issues?type=pulls
close PRPATCH /repos/{o}/{r}/pulls/{n}PATCH /repos/{o}/{r}/issues/{n}
comment on a PRPOST /repos/{o}/{r}/issues/{n}/commentssame

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.

operationMethodPathteafj
issue createPOST/repos/{o}/{r}/issuesyesyes
issue update/closePATCH/repos/{o}/{r}/issues/{n}yesyes (also pr close)
issue/PR commentPOST/repos/{o}/{r}/issues/{n}/commentsyesyes
comment updatePATCH/repos/{o}/{r}/issues/comments/{n}yesyes
PR createPOST/repos/{o}/{r}/pullsyesyes
PR update/closePATCH/repos/{o}/{r}/pulls/{n}yesno
PR mergePOST/repos/{o}/{r}/pulls/{n}/mergeyesyes
PR review (approve)†POST/repos/{o}/{r}/pulls/{n}/reviewsyesno
add labelsPOST/repos/{o}/{r}/issues/{n}/labelsyes
add assigneesPOST/repos/{o}/{r}/issues/{n}/assigneesyes
remove assigneesDELETE/repos/{o}/{r}/issues/{n}/assigneesyes

† 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:

  • fj cannot approve a pull request. repo_create_pull_review exists in forgejo-api 0.11.0, but fj never calls it — fj pr review only lists. A capability gap in the CLI, not a gap in the capture.
  • tea sends a full-object PATCH. tea pr close emits every field alongside "state":"closed" ({"title":"","base":"", … ,"state":"closed", …}), so on the wire it is indistinguishable from tea 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.

dialectcreate returnsedit / close returncomment returns
GitHubdata.<field>.{issue,pullRequest}.{id,url} — only what gh selectsclientMutationId alone; the target is the node ID in the inputnothing naming the target beyond the input node ID
GitLabthe issue / MR: iid, web_url, state, titlethe same object, state updatedthe note, with noteable_iid
Forgejothe issue / PR: number, html_url, state, title (+ merged)the same object; via /issues/{n} it carries pull_requestthe comment, with issue_url

Consequences:

  • GitHub’s number comes off the trailing segment of url; state on a create is OPEN, 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 close and tea pr edit go through PATCH /issues/{n}, so the response, not the path, decides whether the target is a pull request.
  • GitLab reports opened / closed / merged / locked; Forgejo open / closed plus a merged boolean.
  • 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.

  1. Use a throwaway repo and a rotatable token. Never capture against production data.
  2. 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 headers tea sets, and the request body. Response bodies show only a Go pointer, and Authorization/User-Agent are 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. fj honours HTTPS_PROXY and links OpenSSL, so mitmproxy with its CA in the system trust store works if raw bytes are genuinely needed.
  3. 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.
  4. Scrub tokens from every log before analysis — Authorization: lines and any gh[posur]_…, github_pat_… or provider token patterns. CLI debug output usually redacts Authorization; scrub anyway.
  5. 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.
  6. 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 BaseHTTPRequestHandler that 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.