Skip to content

Commit 9249caf

Browse files
coopernetesclaude
andcommitted
feat: add push identity resolution, IdentityVerificationHook, and Bitbucket credential rewriting
Implements push identity resolution across all supported providers (#41): - New `TokenIdentityProvider` interface with `fetchScmIdentity(pushUsername, token)` — GitHub, GitLab, and Codeberg ignore the username; Bitbucket uses it as the account email for Basic auth. - `TokenPushIdentityResolver` resolves a push user via the provider API then matches against `user_scm_identities` (with email fallback). - `IdentityVerificationHook` (store-and-forward) and `IdentityVerificationFilter` (transparent proxy) check that every pushed commit's author/committer email is registered to the push user. Configurable as STRICT / WARN / OFF via `commit.identity-verification`. Bitbucket identity resolution and credential rewriting: - Bitbucket does not enforce the git push username, so the proxy convention is that the HTTP Basic-auth username must be the user's Bitbucket account email. The API returns an auto-generated `username` that Bitbucket's git endpoint requires; the proxy rewrites outbound credentials from `email:token` to `username:token` before forwarding. - Transparent proxy: `BitbucketIdentityFilter` (order 148) resolves the upstream username and stores it on `GitRequestDetails`; `PushFinalizerFilter` rewrites the `Authorization` header via `HttpServletRequestWrapper` on allowed pushes. - Store-and-forward: `BitbucketCredentialRewriteHook` (order 148) writes the resolved username to the in-memory JGit repo config; `ForwardingPostReceiveHook` reads it and opens the upstream transport with rewritten credentials. Also includes filter chain cleanup, user store refactors, and additional filter tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8f5a655 commit 9249caf

60 files changed

Lines changed: 2409 additions & 534 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ ext {
1212
jgitVersion = '7.6.0.202603022253-r'
1313

1414
// HTTP
15-
apacheHttpVersion = '4.5.14'
15+
apacheHttpClientVersion = '5.6'
1616

1717
// Jetty / Servlet
1818
jettyVersion = '12.1.8'

docs/CONFIGURATION.md

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,16 +98,28 @@ providers:
9898
bitbucket:
9999
enabled: true
100100
101-
# Custom provider — requires explicit URI
102-
internal-gitlab:
101+
# Self-hosted GitHub Enterprise Server — name contains "github" so type is inferred
102+
internal-github:
103103
enabled: true
104-
servlet-path: /enterprise
105-
uri: https://gitlab.internal.example.com
104+
uri: https://github.corp.example.com
106105
107-
debian-gitlab:
106+
# Self-hosted instance with an arbitrary name — use 'type' to select the provider implementation
107+
my-internal-server:
108108
enabled: true
109-
servlet-path: /debian
110-
uri: https://salsa.debian.org/
109+
type: github # uses GitHubProvider (identity resolution, GHES API path logic, etc.)
110+
uri: https://github.corp.example.com
111+
112+
# Self-hosted Forgejo instance
113+
my-forgejo:
114+
enabled: true
115+
type: forgejo # also accepts: codeberg
116+
uri: https://forge.internal.example.com
117+
118+
# Bitbucket Data Center (self-hosted)
119+
acme-bitbucket:
120+
enabled: true
121+
type: bitbucket
122+
uri: https://bitbucket.acme.com
111123
```
112124

113125
### Provider properties
@@ -117,6 +129,25 @@ providers:
117129
| `enabled` | boolean | `true` | Whether the provider is active |
118130
| `servlet-path` | string | `""` | Additional URL prefix for this provider |
119131
| `uri` | string | _(built-in default)_ | Upstream base URI (required for custom providers) |
132+
| `type` | string | _(inferred from name)_ | Provider implementation to use: `github`, `gitlab`, `bitbucket`, `codeberg`, `forgejo`. Set this when the provider name does not contain the type keyword. |
133+
134+
When `type` (or the provider name) matches a known implementation, the full typed provider is used — including its API URL logic, identity resolution, and (for Bitbucket) credential rewriting. A custom `uri` overrides only the upstream address; all other behaviour is inherited from the provider type.
135+
136+
### Bitbucket identity resolution
137+
138+
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**.
139+
140+
Configure the remote URL like this:
141+
142+
```
143+
https://<email>:<api-token>@bitbucket.org/<workspace>/<repo>.git
144+
```
145+
146+
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.
147+
148+
**Required API token scopes:** `read:user:bitbucket` and `write:repository:bitbucket`.
149+
150+
> **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.
120151

121152
## Commit validation
122153

jgit-proxy-core/build.gradle

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,7 @@ dependencies {
5151
api "org.eclipse.jgit:org.eclipse.jgit.http.server:${jgitVersion}"
5252

5353
// HTTP Client
54-
implementation "org.apache.httpcomponents:httpclient:${apacheHttpVersion}"
55-
54+
implementation "org.apache.httpcomponents.client5:httpclient5-fluent:${apacheHttpClientVersion}"
5655

5756
// Jackson for JSON — BOM pins all jackson-* modules to the same version
5857
api platform("tools.jackson:jackson-bom:${jacksonBomVersion}")

jgit-proxy-core/src/main/java/org/finos/gitproxy/config/CommitConfig.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,33 @@
1111
@Builder
1212
public class CommitConfig {
1313

14+
/** Controls whether commit identity is verified against the authenticated push user. */
15+
public enum IdentityVerificationMode {
16+
/** Block the push when any commit author/committer email is not registered to the push user. */
17+
STRICT,
18+
/** Warn the push user but allow the push through. Default. */
19+
WARN,
20+
/** Skip identity verification entirely. */
21+
OFF;
22+
23+
public static IdentityVerificationMode fromString(String value) {
24+
if (value == null) return WARN;
25+
return switch (value.trim().toLowerCase()) {
26+
case "strict" -> STRICT;
27+
case "off" -> OFF;
28+
default -> WARN;
29+
};
30+
}
31+
}
32+
33+
/**
34+
* Whether to verify that commit author/committer emails are registered to the authenticated push user. When users
35+
* are configured, {@code WARN} is the default — mismatches produce a warning but do not block. {@code STRICT}
36+
* blocks the push. {@code OFF} skips the check entirely.
37+
*/
38+
@Builder.Default
39+
private IdentityVerificationMode identityVerification = IdentityVerificationMode.WARN;
40+
1441
/** Configuration for author email validation. */
1542
@Builder.Default
1643
private AuthorConfig author = AuthorConfig.builder().build();
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package org.finos.gitproxy.git;
2+
3+
import java.util.Collection;
4+
import java.util.Optional;
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.eclipse.jgit.transport.ReceiveCommand;
8+
import org.eclipse.jgit.transport.ReceivePack;
9+
import org.finos.gitproxy.provider.BitbucketProvider;
10+
import org.finos.gitproxy.provider.client.ScmUserInfo;
11+
12+
/**
13+
* Pre-receive hook that resolves the Bitbucket push user's upstream username and stores it in the repository config
14+
* ({@code gitproxy.upstreamUser}) for use by {@link ForwardingPostReceiveHook}.
15+
*
16+
* <p>Bitbucket does not validate the HTTP Basic-auth username on git pushes — only the token is checked. The proxy
17+
* convention is that the username field carries the user's Bitbucket account email. This hook calls {@code GET
18+
* /2.0/user} using that email and the push token to retrieve the auto-generated {@code username} field that Bitbucket
19+
* recognises for git authentication.
20+
*
21+
* <p>{@link ForwardingPostReceiveHook} reads {@code gitproxy.upstreamUser} and rewrites the outbound credentials from
22+
* {@code email:token} to {@code username:token} before pushing to Bitbucket upstream.
23+
*
24+
* <p>Runs at order 148, before {@link CheckUserPushPermissionHook} (150).
25+
*/
26+
@Slf4j
27+
@RequiredArgsConstructor
28+
public class BitbucketCredentialRewriteHook implements GitProxyHook {
29+
30+
private static final int ORDER = 148;
31+
32+
private final BitbucketProvider provider;
33+
34+
@Override
35+
public int getOrder() {
36+
return ORDER;
37+
}
38+
39+
@Override
40+
public String getName() {
41+
return "bitbucketCredentialRewrite";
42+
}
43+
44+
@Override
45+
public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
46+
var repo = rp.getRepository();
47+
String pushEmail = repo.getConfig().getString("gitproxy", null, "pushUser");
48+
String pushToken = repo.getConfig().getString("gitproxy", null, "pushToken");
49+
50+
if (pushEmail == null || pushToken == null) {
51+
log.debug("No push credentials in repo config — skipping Bitbucket upstream username resolution");
52+
return;
53+
}
54+
55+
Optional<ScmUserInfo> identity = provider.fetchScmIdentity(pushEmail, pushToken);
56+
if (identity.isEmpty()) {
57+
log.debug(
58+
"Could not resolve Bitbucket upstream username for '{}' — credentials will be forwarded as-is",
59+
pushEmail);
60+
return;
61+
}
62+
63+
String upstreamUser = identity.get().login();
64+
repo.getConfig().setString("gitproxy", null, "upstreamUser", upstreamUser);
65+
log.debug("Stored Bitbucket upstream username '{}' for push email '{}'", upstreamUser, pushEmail);
66+
}
67+
}

jgit-proxy-core/src/main/java/org/finos/gitproxy/git/CheckUserPushPermissionHook.java

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import org.eclipse.jgit.transport.ReceivePack;
1313
import org.finos.gitproxy.db.model.PushStep;
1414
import org.finos.gitproxy.db.model.StepStatus;
15+
import org.finos.gitproxy.provider.GitProxyProvider;
1516
import org.finos.gitproxy.service.PushIdentityResolver;
1617
import org.finos.gitproxy.service.UserAuthorizationService;
1718
import org.finos.gitproxy.user.UserEntry;
@@ -32,27 +33,27 @@ public class CheckUserPushPermissionHook implements GitProxyHook {
3233
private final UserAuthorizationService userAuthorizationService;
3334
private final ValidationContext validationContext;
3435
private final PushContext pushContext;
35-
private final String providerName;
36+
private final GitProxyProvider provider;
3637

3738
public CheckUserPushPermissionHook(
3839
PushIdentityResolver identityResolver,
3940
UserAuthorizationService userAuthorizationService,
4041
ValidationContext validationContext,
4142
PushContext pushContext) {
42-
this(identityResolver, userAuthorizationService, validationContext, pushContext, "");
43+
this(identityResolver, userAuthorizationService, validationContext, pushContext, (GitProxyProvider) null);
4344
}
4445

4546
public CheckUserPushPermissionHook(
4647
PushIdentityResolver identityResolver,
4748
UserAuthorizationService userAuthorizationService,
4849
ValidationContext validationContext,
4950
PushContext pushContext,
50-
String providerName) {
51+
GitProxyProvider provider) {
5152
this.identityResolver = identityResolver;
5253
this.userAuthorizationService = userAuthorizationService;
5354
this.validationContext = validationContext;
5455
this.pushContext = pushContext;
55-
this.providerName = providerName != null ? providerName : "";
56+
this.provider = provider;
5657
}
5758

5859
@Override
@@ -82,9 +83,8 @@ public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
8283
}
8384

8485
// Resolve identity: who is the person behind these credentials?
85-
Optional<UserEntry> resolved = identityResolver != null
86-
? identityResolver.resolve(providerName, pushUser, pushToken)
87-
: Optional.empty();
86+
Optional<UserEntry> resolved =
87+
identityResolver != null ? identityResolver.resolve(provider, pushUser, pushToken) : Optional.empty();
8888

8989
if (resolved.isEmpty()) {
9090
log.warn("Push user '{}' could not be resolved to a registered proxy user", pushUser);

jgit-proxy-core/src/main/java/org/finos/gitproxy/git/ForwardingPostReceiveHook.java

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ public void onPostReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
4949
}
5050

5151
Repository repo = rp.getRepository();
52+
53+
// If BitbucketCredentialRewriteHook resolved an upstream username, use it instead of the push email.
54+
String upstreamUser = repo.getConfig().getString("gitproxy", null, "upstreamUser");
55+
CredentialsProvider effectiveCreds = credentials;
56+
if (upstreamUser != null) {
57+
String pushToken = repo.getConfig().getString("gitproxy", null, "pushToken");
58+
effectiveCreds = new UsernamePasswordCredentialsProvider(upstreamUser, pushToken != null ? pushToken : "");
59+
log.debug("Using Bitbucket upstream username '{}' for forwarding credentials", upstreamUser);
60+
}
61+
5262
String upstreamUrl = repo.getConfig().getString("gitproxy", null, "upstreamUrl");
5363

5464
if (upstreamUrl == null) {
@@ -72,7 +82,7 @@ public void onPostReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
7282

7383
try {
7484
URIish upstream = new URIish(upstreamUrl);
75-
forwardFailed = pushToUpstream(rp, repo, upstream, accepted, logs);
85+
forwardFailed = pushToUpstream(rp, repo, upstream, accepted, logs, effectiveCreds);
7686
} catch (Exception e) {
7787
rp.sendMessage(color(RED, sym(CROSS_MARK) + " ERROR forwarding to upstream: " + e.getMessage()));
7888
log.error("Failed to push to upstream {}", upstreamUrl, e);
@@ -91,13 +101,18 @@ public void onPostReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
91101

92102
/** Returns true if any ref failed to forward. */
93103
private boolean pushToUpstream(
94-
ReceivePack rp, Repository repo, URIish upstream, List<ReceiveCommand> commands, List<String> logs)
104+
ReceivePack rp,
105+
Repository repo,
106+
URIish upstream,
107+
List<ReceiveCommand> commands,
108+
List<String> logs,
109+
CredentialsProvider creds)
95110
throws Exception {
96111

97112
boolean anyFailed = false;
98113
try (Transport transport = Transport.open(repo, upstream)) {
99-
if (credentials != null) {
100-
transport.setCredentialsProvider(credentials);
114+
if (creds != null) {
115+
transport.setCredentialsProvider(creds);
101116
}
102117

103118
List<RemoteRefUpdate> updates = buildRefUpdates(repo, commands);

jgit-proxy-core/src/main/java/org/finos/gitproxy/git/GitRequestDetails.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,14 @@ public class GitRequestDetails {
2929
private String commitFrom; // Old ref SHA from the packet line (the push range start)
3030
private String commitTo; // New ref SHA from the packet line (the push range end)
3131
private List<Commit> pushedCommits = new ArrayList<>(); // All commits received in this push
32-
private GitProxyProvider provider;
32+
private GitProxyProvider provider; // this should never be null
33+
/**
34+
* Provider-specific upstream username to use when forwarding the push. Set by {@code BitbucketIdentityFilter}
35+
* (transparent proxy) or {@code BitbucketCredentialRewriteHook} (store-and-forward) when the push username needs
36+
* rewriting before forwarding. {@code null} for all non-Bitbucket providers.
37+
*/
38+
private String upstreamUsername;
39+
3340
private List<GitProxyFilter> filters = new ArrayList<>();
3441
private List<PushStep> steps = new ArrayList<>(); // Filter/hook results for audit trail
3542
private GitResult result = GitResult.PENDING;

0 commit comments

Comments
 (0)