diff --git a/git-proxy-java-core/src/main/java/org/finos/gitproxy/db/model/PushRecord.java b/git-proxy-java-core/src/main/java/org/finos/gitproxy/db/model/PushRecord.java
index 37cda572..161726ca 100644
--- a/git-proxy-java-core/src/main/java/org/finos/gitproxy/db/model/PushRecord.java
+++ b/git-proxy-java-core/src/main/java/org/finos/gitproxy/db/model/PushRecord.java
@@ -114,4 +114,13 @@ public class PushRecord {
/** Attestation record if the push was manually reviewed. */
private Attestation attestation;
+
+ /**
+ * Transient flag computed by the dashboard {@code PushController#getById} endpoint indicating whether the currently
+ * authenticated user is permitted to self-approve this specific push (i.e. they are the resolved pusher, have
+ * {@code ROLE_SELF_CERTIFY}, and have a {@code SELF_CERTIFY} repo permission for this push's path). Not persisted
+ * to the database. Used by the frontend to gate the self-certify banner and approve button.
+ */
+ @Builder.Default
+ private boolean canCurrentUserSelfCertify = false;
}
diff --git a/git-proxy-java-core/src/main/java/org/finos/gitproxy/git/ApprovalPreReceiveHook.java b/git-proxy-java-core/src/main/java/org/finos/gitproxy/git/ApprovalPreReceiveHook.java
index 8305c18c..c4c9141c 100644
--- a/git-proxy-java-core/src/main/java/org/finos/gitproxy/git/ApprovalPreReceiveHook.java
+++ b/git-proxy-java-core/src/main/java/org/finos/gitproxy/git/ApprovalPreReceiveHook.java
@@ -103,6 +103,12 @@ var record = pushStore.findById(validationRecordId).orElse(null);
// Safety net: already approved before this hook ran (race condition or re-push)
if (record.getStatus() == PushStatus.APPROVED) {
+ if (!verifySelfApprovalEntitled(record)) {
+ String reason = "Self-approved push rejected: no SELF_CERTIFY permission for this repository";
+ sendAndFlush(rp, msgOut, color(RED, "" + sym(CROSS_MARK) + " " + reason));
+ rejectAll(commands, reason);
+ return;
+ }
sendAndFlush(rp, msgOut, color(GREEN, "" + sym(HEAVY_CHECK_MARK) + " Push already approved - forwarding"));
return;
}
@@ -115,34 +121,6 @@ var record = pushStore.findById(validationRecordId).orElse(null);
return;
}
- // Trusted contributor bypass: auto-approve without human review
- if (repoPermissionService != null) {
- String username = record.getUser();
- String provider = record.getProvider();
- String path = record.getUrl();
- if (username != null
- && provider != null
- && path != null
- && repoPermissionService.isBypassReviewAllowed(username, provider, path)) {
- pushStore.approve(
- validationRecordId,
- Attestation.builder()
- .pushId(validationRecordId)
- .type(Attestation.Type.APPROVAL)
- .reviewerUsername(username)
- .reason("Self-certified by " + username)
- .automated(true)
- .build());
- sendAndFlush(
- rp,
- msgOut,
- color(
- GREEN,
- "" + sym(HEAVY_CHECK_MARK) + " Self-certified — push approved automatically"));
- return;
- }
- }
-
sendAndFlush(
rp, msgOut, color(YELLOW, "" + sym(WARNING) + " Push requires review. Waiting for approval..."));
sendAndFlush(rp, msgOut, color(CYAN, "" + sym(KEY) + " Push ID: " + validationRecordId));
@@ -172,8 +150,16 @@ var record = pushStore.findById(validationRecordId).orElse(null);
}
switch (result) {
- case APPROVED ->
+ case APPROVED -> {
+ var approvedRecord = pushStore.findById(validationRecordId).orElse(null);
+ if (approvedRecord != null && !verifySelfApprovalEntitled(approvedRecord)) {
+ String reason = "Self-approved push rejected: no SELF_CERTIFY permission for this repository";
+ sendAndFlush(rp, msgOut, color(RED, "" + sym(CROSS_MARK) + " " + reason));
+ rejectAll(commands, reason);
+ return;
+ }
sendAndFlush(rp, msgOut, color(GREEN, "" + sym(HEAVY_CHECK_MARK) + " Push approved by reviewer"));
+ }
case REJECTED -> {
var updated = pushStore.findById(validationRecordId).orElse(null);
String reason = updated != null && updated.getAttestation() != null
@@ -200,6 +186,39 @@ var record = pushStore.findById(validationRecordId).orElse(null);
}
}
+ /**
+ * Defense in depth: if the approver is the pusher, re-verify that a {@code SELF_CERTIFY} repo permission still
+ * exists for the pusher on this push's path. {@link org.finos.gitproxy.dashboard.controller.PushController#approve}
+ * already enforces this at approval time, but re-checking here protects against future code paths or bugs that mark
+ * a record APPROVED without going through that gate.
+ *
+ *
The {@code ROLE_SELF_CERTIFY} role check is intentionally NOT performed at the hook layer — it requires Spring
+ * Security context (only available in the dashboard) and may live in IdP-derived authorities that aren't persisted
+ * to the user store. The hook re-verifies only the per-repo permission, which is the more granular authoritative
+ * gate.
+ *
+ * @return {@code true} if the push may proceed; {@code false} if the approver was the pusher and no
+ * {@code SELF_CERTIFY} permission row exists.
+ */
+ private boolean verifySelfApprovalEntitled(org.finos.gitproxy.db.model.PushRecord record) {
+ if (repoPermissionService == null) return true;
+ Attestation att = record.getAttestation();
+ if (att == null) return true;
+ String pusher = record.getResolvedUser();
+ String approver = att.getReviewerUsername();
+ if (pusher == null || approver == null || !pusher.equals(approver)) return true;
+ if (record.getProvider() == null || record.getUrl() == null) return true;
+ boolean entitled = repoPermissionService.isBypassReviewAllowed(pusher, record.getProvider(), record.getUrl());
+ if (!entitled) {
+ log.warn(
+ "Self-approval rejected at hook: pusher={} provider={} path={} has no SELF_CERTIFY permission",
+ pusher,
+ record.getProvider(),
+ record.getUrl());
+ }
+ return entitled;
+ }
+
private void rejectAll(Collection commands, String reason) {
for (ReceiveCommand cmd : commands) {
if (cmd.getResult() == ReceiveCommand.Result.NOT_ATTEMPTED) {
diff --git a/git-proxy-java-core/src/main/java/org/finos/gitproxy/git/LocalRepositoryCache.java b/git-proxy-java-core/src/main/java/org/finos/gitproxy/git/LocalRepositoryCache.java
index 2a80e8b6..22214a8c 100644
--- a/git-proxy-java-core/src/main/java/org/finos/gitproxy/git/LocalRepositoryCache.java
+++ b/git-proxy-java-core/src/main/java/org/finos/gitproxy/git/LocalRepositoryCache.java
@@ -122,9 +122,7 @@ public Repository getOrClone(String remoteUrl, CredentialsProvider credentials)
CachedRepository cached = cache.get(cacheKey);
if (cached != null && cached.isValid()) {
log.debug("Using cached repository for: {}", remoteUrl);
- if (credentials != null) {
- refreshIfStale(cached, cacheKey, credentials);
- }
+ refreshIfStale(cached, cacheKey, credentials);
cached.repository.incrementOpen();
return cached.repository;
}
diff --git a/git-proxy-java-core/src/main/java/org/finos/gitproxy/servlet/GitProxyServlet.java b/git-proxy-java-core/src/main/java/org/finos/gitproxy/servlet/GitProxyServlet.java
index 06ffab45..bd0d1ed0 100644
--- a/git-proxy-java-core/src/main/java/org/finos/gitproxy/servlet/GitProxyServlet.java
+++ b/git-proxy-java-core/src/main/java/org/finos/gitproxy/servlet/GitProxyServlet.java
@@ -23,11 +23,6 @@ public class GitProxyServlet extends AsyncProxyServlet.Transparent {
public static final String GIT_REQUEST_ATTR = "gitproxy.gitRequest";
public static final String ERROR_ATTR = "gitproxy.error";
public static final String PRE_APPROVED_ATTR = "gitproxy.preApproved";
- /**
- * Request attribute set by {@code PushFinalizerFilter} when a self-certify grant was applied. Value is the resolved
- * proxy username.
- */
- public static final String SELF_CERTIFY_USER_ATTR = "gitproxy.selfCertifyUser";
/** Request attribute holding the UUID of the original APPROVED push record for a transparent-proxy re-push. */
public static final String APPROVED_PUSH_ID_ATTR = "gitproxy.approvedPushId";
diff --git a/git-proxy-java-core/src/main/java/org/finos/gitproxy/servlet/filter/CheckHiddenCommitsFilter.java b/git-proxy-java-core/src/main/java/org/finos/gitproxy/servlet/filter/CheckHiddenCommitsFilter.java
index e856ae28..c0499acc 100644
--- a/git-proxy-java-core/src/main/java/org/finos/gitproxy/servlet/filter/CheckHiddenCommitsFilter.java
+++ b/git-proxy-java-core/src/main/java/org/finos/gitproxy/servlet/filter/CheckHiddenCommitsFilter.java
@@ -85,7 +85,7 @@ public void doHttpFilter(HttpServletRequest request, HttpServletResponse respons
return;
}
- Set allNew = collectAllNewCommits(repository, toCommit);
+ Set allNew = collectAllNewCommits(repository, toCommit, requestDetails.getCommitFrom());
Set hidden = new HashSet<>(allNew);
hidden.removeAll(introduced);
@@ -115,9 +115,14 @@ public void doHttpFilter(HttpServletRequest request, HttpServletResponse respons
/**
* Collect all commits reachable from {@code toCommit} that are not reachable from any existing ref in the upstream
- * clone. These correspond to the commits that were new to the upstream in this pack.
+ * clone or from {@code fromCommit} (the old branch tip). These correspond to the commits that were genuinely new
+ * and not already present at the upstream.
+ *
+ *
{@code fromCommit} is marked uninteresting explicitly because the local clone's refs may be stale (e.g. after
+ * a recently forwarded push the remote ref has advanced but the cache hasn't been re-fetched). Without this,
+ * commits reachable only from the old tip — but not from any cached ref — would be falsely flagged as hidden.
*/
- private Set collectAllNewCommits(Repository repo, String toCommit) throws IOException {
+ private Set collectAllNewCommits(Repository repo, String toCommit, String fromCommit) throws IOException {
Set result = new HashSet<>();
try (RevWalk walk = new RevWalk(repo)) {
@@ -139,6 +144,19 @@ private Set collectAllNewCommits(Repository repo, String toCommit) throw
}
}
+ // Always mark commitFrom (old branch tip) as uninteresting — it is already at the
+ // upstream by definition, even when the local clone's refs are stale.
+ if (fromCommit != null && !fromCommit.isBlank() && !fromCommit.matches("^0+$")) {
+ try {
+ ObjectId fromId = repo.resolve(fromCommit);
+ if (fromId != null) {
+ walk.markUninteresting(walk.parseCommit(fromId));
+ }
+ } catch (Exception e) {
+ log.debug("Could not resolve commitFrom {} as uninteresting boundary — skipping", fromCommit);
+ }
+ }
+
for (RevCommit commit : walk) {
result.add(commit.getName());
}
diff --git a/git-proxy-java-core/src/main/java/org/finos/gitproxy/servlet/filter/PushFinalizerFilter.java b/git-proxy-java-core/src/main/java/org/finos/gitproxy/servlet/filter/PushFinalizerFilter.java
index ded96fc3..05a29196 100644
--- a/git-proxy-java-core/src/main/java/org/finos/gitproxy/servlet/filter/PushFinalizerFilter.java
+++ b/git-proxy-java-core/src/main/java/org/finos/gitproxy/servlet/filter/PushFinalizerFilter.java
@@ -20,7 +20,6 @@
import org.finos.gitproxy.approval.ApprovalGateway;
import org.finos.gitproxy.git.GitRequestDetails;
import org.finos.gitproxy.git.HttpOperation;
-import org.finos.gitproxy.permission.RepoPermissionService;
/**
* Terminal filter for push operations that determines the final result:
@@ -47,18 +46,11 @@ public class PushFinalizerFilter extends AbstractGitProxyFilter {
private final String serviceUrl;
private final ApprovalGateway approvalGateway;
- private final RepoPermissionService repoPermissionService;
public PushFinalizerFilter(String serviceUrl, ApprovalGateway approvalGateway) {
- this(serviceUrl, approvalGateway, null);
- }
-
- public PushFinalizerFilter(
- String serviceUrl, ApprovalGateway approvalGateway, RepoPermissionService repoPermissionService) {
super(ORDER, Set.of(HttpOperation.PUSH));
this.serviceUrl = serviceUrl;
this.approvalGateway = approvalGateway;
- this.repoPermissionService = repoPermissionService;
}
/**
@@ -123,23 +115,12 @@ public void doHttpFilter(HttpServletRequest request, HttpServletResponse respons
return;
}
- // Trusted contributor bypass: auto-approve without human review
- if (repoPermissionService != null) {
- String username = details.getResolvedUser();
- String provider =
- details.getProvider() != null ? details.getProvider().getProviderId() : null;
- String path = details.getRepoRef() != null ? details.getRepoRef().getSlug() : null;
- if (username != null
- && provider != null
- && path != null
- && repoPermissionService.isBypassReviewAllowed(username, provider, path)) {
- request.setAttribute(SELF_CERTIFY_USER_ATTR, username);
- details.setResult(GitRequestDetails.GitResult.ALLOWED);
- return;
- }
- }
-
- // First push that passed validation - block pending review (dashboard/ServiceNow mode)
+ // First push that passed validation - block pending review (dashboard/ServiceNow mode).
+ // Self-certify is intentionally NOT enforced here: the role check requires Spring Security context
+ // which the proxy filter chain does not have. Self-approval is gated entirely in the dashboard
+ // (PushController.checkReviewerIdentity), where both ROLE_SELF_CERTIFY and the SELF_CERTIFY repo
+ // permission are required. The pre-receive hook re-verifies the per-repo permission as defense in
+ // depth before forwarding an approved self-review.
details.setResult(GitRequestDetails.GitResult.REVIEW);
String pushId = details.getId().toString();
String summary = buildValidationSummary(details.getSteps());
diff --git a/git-proxy-java-core/src/main/java/org/finos/gitproxy/servlet/filter/PushStoreAuditFilter.java b/git-proxy-java-core/src/main/java/org/finos/gitproxy/servlet/filter/PushStoreAuditFilter.java
index 6c3df12f..77662ba9 100644
--- a/git-proxy-java-core/src/main/java/org/finos/gitproxy/servlet/filter/PushStoreAuditFilter.java
+++ b/git-proxy-java-core/src/main/java/org/finos/gitproxy/servlet/filter/PushStoreAuditFilter.java
@@ -2,7 +2,6 @@
import static org.finos.gitproxy.servlet.GitProxyServlet.GIT_REQUEST_ATTR;
import static org.finos.gitproxy.servlet.GitProxyServlet.PRE_APPROVED_ATTR;
-import static org.finos.gitproxy.servlet.GitProxyServlet.SELF_CERTIFY_USER_ATTR;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
@@ -11,7 +10,6 @@
import lombok.extern.slf4j.Slf4j;
import org.finos.gitproxy.db.PushRecordMapper;
import org.finos.gitproxy.db.PushStore;
-import org.finos.gitproxy.db.model.Attestation;
import org.finos.gitproxy.db.model.PushRecord;
import org.finos.gitproxy.git.GitRequestDetails;
import org.finos.gitproxy.git.HttpOperation;
@@ -56,22 +54,6 @@ private void persistIfPush(ServletRequest request, ServletResponse response) {
PushRecord record = PushRecordMapper.fromRequestDetails(requestDetails);
pushStore.save(record);
- // If a self-certify grant was applied, stamp the automated attestation now that
- // the record exists. PushFinalizerFilter already set the result to ALLOWED so the
- // saved status is APPROVED.
- String bypassUser = (String) httpRequest.getAttribute(SELF_CERTIFY_USER_ATTR);
- if (bypassUser != null) {
- pushStore.approve(
- record.getId(),
- Attestation.builder()
- .pushId(record.getId())
- .type(Attestation.Type.APPROVAL)
- .reviewerUsername(bypassUser)
- .reason("Self-certified by " + bypassUser)
- .automated(true)
- .build());
- }
-
log.info(
"Persisted push record: id={}, repo={}, status={}",
record.getId(),
diff --git a/git-proxy-java-core/src/test/java/org/finos/gitproxy/git/ApprovalPreReceiveHookTest.java b/git-proxy-java-core/src/test/java/org/finos/gitproxy/git/ApprovalPreReceiveHookTest.java
index 3a5422c5..ac0ae805 100644
--- a/git-proxy-java-core/src/test/java/org/finos/gitproxy/git/ApprovalPreReceiveHookTest.java
+++ b/git-proxy-java-core/src/test/java/org/finos/gitproxy/git/ApprovalPreReceiveHookTest.java
@@ -20,8 +20,10 @@
import org.finos.gitproxy.approval.ApprovalGateway;
import org.finos.gitproxy.approval.ApprovalResult;
import org.finos.gitproxy.db.PushStore;
+import org.finos.gitproxy.db.model.Attestation;
import org.finos.gitproxy.db.model.PushRecord;
import org.finos.gitproxy.db.model.PushStatus;
+import org.finos.gitproxy.permission.RepoPermissionService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -153,6 +155,157 @@ void blockedPush_gatewayRejects_commandRejected() throws Exception {
assertEquals(ReceiveCommand.Result.REJECTED_OTHER_REASON, cmd.getResult());
}
+ // ── Defense-in-depth: hook re-verifies SELF_CERTIFY perm when approver == pusher ─────────────
+
+ @Test
+ void selfApproved_alreadyApprovedAtHookStart_noPerm_rejected() throws Exception {
+ String recordId = UUID.randomUUID().toString();
+ repo.getConfig().setString("gitproxy", null, "validationRecordId", recordId);
+ repo.getConfig().save();
+ Attestation att = Attestation.builder()
+ .pushId(recordId)
+ .type(Attestation.Type.APPROVAL)
+ .reviewerUsername("alice")
+ .build();
+ PushRecord record = PushRecord.builder()
+ .id(recordId)
+ .status(PushStatus.APPROVED)
+ .resolvedUser("alice")
+ .provider("github/github.com")
+ .url("/owner/repo")
+ .attestation(att)
+ .build();
+ when(pushStore.findById(recordId)).thenReturn(Optional.of(record));
+ RepoPermissionService perms = mock(RepoPermissionService.class);
+ when(perms.isBypassReviewAllowed("alice", "github/github.com", "/owner/repo"))
+ .thenReturn(false);
+
+ RevCommit c1 = createCommit("init");
+ RevCommit c2 = createCommit("second");
+ ReceivePack rp = new ReceivePack(repo);
+ ReceiveCommand cmd = new ReceiveCommand(c1.getId(), c2.getId(), "refs/heads/main", ReceiveCommand.Type.UPDATE);
+
+ new ApprovalPreReceiveHook(pushStore, approvalGateway, Duration.ofSeconds(5), null, perms)
+ .onPreReceive(rp, List.of(cmd));
+
+ assertEquals(ReceiveCommand.Result.REJECTED_OTHER_REASON, cmd.getResult());
+ verify(perms).isBypassReviewAllowed("alice", "github/github.com", "/owner/repo");
+ }
+
+ @Test
+ void selfApproved_alreadyApprovedAtHookStart_withPerm_passes() throws Exception {
+ String recordId = UUID.randomUUID().toString();
+ repo.getConfig().setString("gitproxy", null, "validationRecordId", recordId);
+ repo.getConfig().save();
+ Attestation att = Attestation.builder()
+ .pushId(recordId)
+ .type(Attestation.Type.APPROVAL)
+ .reviewerUsername("alice")
+ .build();
+ PushRecord record = PushRecord.builder()
+ .id(recordId)
+ .status(PushStatus.APPROVED)
+ .resolvedUser("alice")
+ .provider("github/github.com")
+ .url("/owner/repo")
+ .attestation(att)
+ .build();
+ when(pushStore.findById(recordId)).thenReturn(Optional.of(record));
+ RepoPermissionService perms = mock(RepoPermissionService.class);
+ when(perms.isBypassReviewAllowed("alice", "github/github.com", "/owner/repo"))
+ .thenReturn(true);
+
+ RevCommit c1 = createCommit("init");
+ RevCommit c2 = createCommit("second");
+ ReceivePack rp = new ReceivePack(repo);
+ ReceiveCommand cmd = new ReceiveCommand(c1.getId(), c2.getId(), "refs/heads/main", ReceiveCommand.Type.UPDATE);
+
+ new ApprovalPreReceiveHook(pushStore, approvalGateway, Duration.ofSeconds(5), null, perms)
+ .onPreReceive(rp, List.of(cmd));
+
+ assertEquals(ReceiveCommand.Result.NOT_ATTEMPTED, cmd.getResult());
+ }
+
+ @Test
+ void selfApproved_viaWaitForApproval_noPerm_rejected() throws Exception {
+ String recordId = UUID.randomUUID().toString();
+ repo.getConfig().setString("gitproxy", null, "validationRecordId", recordId);
+ repo.getConfig().save();
+ // Initial fetch returns PENDING (no attestation yet); after approval, returns APPROVED with attestation
+ // showing the pusher self-approved.
+ PushRecord pending = PushRecord.builder()
+ .id(recordId)
+ .status(PushStatus.PENDING)
+ .resolvedUser("alice")
+ .provider("github/github.com")
+ .url("/owner/repo")
+ .build();
+ Attestation att = Attestation.builder()
+ .pushId(recordId)
+ .type(Attestation.Type.APPROVAL)
+ .reviewerUsername("alice")
+ .build();
+ PushRecord approved = PushRecord.builder()
+ .id(recordId)
+ .status(PushStatus.APPROVED)
+ .resolvedUser("alice")
+ .provider("github/github.com")
+ .url("/owner/repo")
+ .attestation(att)
+ .build();
+ when(pushStore.findById(recordId)).thenReturn(Optional.of(pending)).thenReturn(Optional.of(approved));
+ when(approvalGateway.waitForApproval(eq(recordId), any(), any(Duration.class)))
+ .thenReturn(ApprovalResult.APPROVED);
+ RepoPermissionService perms = mock(RepoPermissionService.class);
+ when(perms.isBypassReviewAllowed("alice", "github/github.com", "/owner/repo"))
+ .thenReturn(false);
+
+ RevCommit c1 = createCommit("init");
+ RevCommit c2 = createCommit("second");
+ ReceivePack rp = new ReceivePack(repo);
+ ReceiveCommand cmd = new ReceiveCommand(c1.getId(), c2.getId(), "refs/heads/main", ReceiveCommand.Type.UPDATE);
+
+ new ApprovalPreReceiveHook(pushStore, approvalGateway, Duration.ofSeconds(5), null, perms)
+ .onPreReceive(rp, List.of(cmd));
+
+ assertEquals(ReceiveCommand.Result.REJECTED_OTHER_REASON, cmd.getResult());
+ verify(perms).isBypassReviewAllowed("alice", "github/github.com", "/owner/repo");
+ }
+
+ @Test
+ void differentApproverThanPusher_noReVerifyNeeded() throws Exception {
+ // Approver != pusher → defense-in-depth check skipped; push is forwarded.
+ String recordId = UUID.randomUUID().toString();
+ repo.getConfig().setString("gitproxy", null, "validationRecordId", recordId);
+ repo.getConfig().save();
+ Attestation att = Attestation.builder()
+ .pushId(recordId)
+ .type(Attestation.Type.APPROVAL)
+ .reviewerUsername("bob")
+ .build();
+ PushRecord record = PushRecord.builder()
+ .id(recordId)
+ .status(PushStatus.APPROVED)
+ .resolvedUser("alice")
+ .provider("github/github.com")
+ .url("/owner/repo")
+ .attestation(att)
+ .build();
+ when(pushStore.findById(recordId)).thenReturn(Optional.of(record));
+ RepoPermissionService perms = mock(RepoPermissionService.class);
+
+ RevCommit c1 = createCommit("init");
+ RevCommit c2 = createCommit("second");
+ ReceivePack rp = new ReceivePack(repo);
+ ReceiveCommand cmd = new ReceiveCommand(c1.getId(), c2.getId(), "refs/heads/main", ReceiveCommand.Type.UPDATE);
+
+ new ApprovalPreReceiveHook(pushStore, approvalGateway, Duration.ofSeconds(5), null, perms)
+ .onPreReceive(rp, List.of(cmd));
+
+ assertEquals(ReceiveCommand.Result.NOT_ATTEMPTED, cmd.getResult());
+ verifyNoInteractions(perms);
+ }
+
@Test
void blockedPush_gatewayTimesOut_commandRejected() throws Exception {
String recordId = UUID.randomUUID().toString();
diff --git a/git-proxy-java-core/src/test/java/org/finos/gitproxy/servlet/filter/PushFinalizerFilterTest.java b/git-proxy-java-core/src/test/java/org/finos/gitproxy/servlet/filter/PushFinalizerFilterTest.java
index 5befa05d..205bd99c 100644
--- a/git-proxy-java-core/src/test/java/org/finos/gitproxy/servlet/filter/PushFinalizerFilterTest.java
+++ b/git-proxy-java-core/src/test/java/org/finos/gitproxy/servlet/filter/PushFinalizerFilterTest.java
@@ -3,7 +3,6 @@
import static org.finos.gitproxy.git.GitClientUtils.ZERO_OID;
import static org.finos.gitproxy.servlet.GitProxyServlet.GIT_REQUEST_ATTR;
import static org.finos.gitproxy.servlet.GitProxyServlet.PRE_APPROVED_ATTR;
-import static org.finos.gitproxy.servlet.GitProxyServlet.SELF_CERTIFY_USER_ATTR;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@@ -26,7 +25,6 @@
import org.finos.gitproxy.db.PushStoreFactory;
import org.finos.gitproxy.git.GitRequestDetails;
import org.finos.gitproxy.git.HttpOperation;
-import org.finos.gitproxy.permission.RepoPermissionService;
import org.finos.gitproxy.provider.GitProxyProvider;
import org.junit.jupiter.api.Test;
@@ -196,31 +194,13 @@ void nullDetails_doesNotThrow() throws Exception {
}
@Test
- void selfCertify_granted_allowsPushAndSetsAttribute() throws Exception {
+ void selfCertify_perm_doesNotBypassReview() throws Exception {
+ // The push-time bypass for SELF_CERTIFY was removed: self-certify is enforced exclusively in the dashboard
+ // (PushController.checkReviewerIdentity). The pre-receive hook re-verifies the per-repo permission as defense
+ // in depth before forwarding an approved self-review. From the proxy filter chain's perspective, every clean
+ // push with no prior approval blocks pending review regardless of any SELF_CERTIFY grants.
GitRequestDetails details = pendingPushDetailsWithUser("alice", "github");
- RepoPermissionService perms = mock(RepoPermissionService.class);
- when(perms.isBypassReviewAllowed("alice", "github/github.com", "/owner/repo"))
- .thenReturn(true);
- PushFinalizerFilter filter =
- new PushFinalizerFilter("http://localhost:8080", mock(ApprovalGateway.class), perms);
- HttpServletRequest req = mockPushRequest(details);
- FakeResponse fakeResponse = new FakeResponse();
-
- filter.doHttpFilter(req, fakeResponse.mock);
-
- assertEquals(GitRequestDetails.GitResult.ALLOWED, details.getResult());
- verify(req).setAttribute(SELF_CERTIFY_USER_ATTR, "alice");
- assertFalse(fakeResponse.committed.get(), "Self-certify must not send a git error to the client");
- }
-
- @Test
- void selfCertify_notGranted_blocksPendingReview() throws Exception {
- GitRequestDetails details = pendingPushDetailsWithUser("alice", "github");
- RepoPermissionService perms = mock(RepoPermissionService.class);
- when(perms.isBypassReviewAllowed("alice", "github/github.com", "/owner/repo"))
- .thenReturn(false);
- PushFinalizerFilter filter =
- new PushFinalizerFilter("http://localhost:8080", mock(ApprovalGateway.class), perms);
+ PushFinalizerFilter filter = new PushFinalizerFilter("http://localhost:8080", mock(ApprovalGateway.class));
FakeResponse fakeResponse = new FakeResponse();
filter.doHttpFilter(mockPushRequest(details), fakeResponse.mock);
@@ -229,49 +209,6 @@ void selfCertify_notGranted_blocksPendingReview() throws Exception {
assertTrue(fakeResponse.committed.get());
}
- @Test
- void selfCertify_nullProvider_fallsThroughToReview() throws Exception {
- // repoPermissionService non-null, resolvedUser set, but no provider → provider ternary takes null branch
- // compound condition: username!=null && provider!=null → false → bypass skipped
- GitRequestDetails details = pendingPushDetails();
- details.setResolvedUser("alice"); // so username != null; provider is still null
- RepoPermissionService perms = mock(RepoPermissionService.class);
- PushFinalizerFilter filter =
- new PushFinalizerFilter("http://localhost:8080", mock(ApprovalGateway.class), perms);
- FakeResponse fakeResponse = new FakeResponse();
-
- filter.doHttpFilter(mockPushRequest(details), fakeResponse.mock);
-
- assertEquals(GitRequestDetails.GitResult.REVIEW, details.getResult());
- verifyNoInteractions(perms);
- }
-
- @Test
- void selfCertify_nullRepoRef_fallsThroughToReview() throws Exception {
- // repoPermissionService non-null, provider set, but repoRef is null → path ternary takes null branch → bypass
- // skipped
- GitRequestDetails details = new GitRequestDetails();
- details.setOperation(HttpOperation.PUSH);
- details.setCommitTo("abc123");
- details.setResolvedUser("alice");
- GitProxyProvider provider = mock(GitProxyProvider.class);
- when(provider.getName()).thenReturn("github");
- when(provider.getType()).thenReturn("github");
- when(provider.getUri()).thenReturn(java.net.URI.create("https://github.com"));
- when(provider.getProviderId()).thenReturn("github/github.com");
- details.setProvider(provider);
- // repoRef intentionally not set
- RepoPermissionService perms = mock(RepoPermissionService.class);
- PushFinalizerFilter filter =
- new PushFinalizerFilter("http://localhost:8080", mock(ApprovalGateway.class), perms);
- FakeResponse fakeResponse = new FakeResponse();
-
- filter.doHttpFilter(mockPushRequest(details), fakeResponse.mock);
-
- assertEquals(GitRequestDetails.GitResult.REVIEW, details.getResult());
- verifyNoInteractions(perms);
- }
-
@Test
void autoApprovalGateway_allowsPushWithoutBlocking() throws Exception {
GitRequestDetails details = pendingPushDetails();
diff --git a/git-proxy-java-core/src/test/java/org/finos/gitproxy/servlet/filter/PushStoreAuditFilterTest.java b/git-proxy-java-core/src/test/java/org/finos/gitproxy/servlet/filter/PushStoreAuditFilterTest.java
index fd61f76b..17e720d5 100644
--- a/git-proxy-java-core/src/test/java/org/finos/gitproxy/servlet/filter/PushStoreAuditFilterTest.java
+++ b/git-proxy-java-core/src/test/java/org/finos/gitproxy/servlet/filter/PushStoreAuditFilterTest.java
@@ -2,7 +2,6 @@
import static org.finos.gitproxy.servlet.GitProxyServlet.GIT_REQUEST_ATTR;
import static org.finos.gitproxy.servlet.GitProxyServlet.PRE_APPROVED_ATTR;
-import static org.finos.gitproxy.servlet.GitProxyServlet.SELF_CERTIFY_USER_ATTR;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@@ -12,7 +11,6 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.finos.gitproxy.db.PushStore;
-import org.finos.gitproxy.db.model.Attestation;
import org.finos.gitproxy.db.model.PushRecord;
import org.finos.gitproxy.git.GitRequestDetails;
import org.finos.gitproxy.git.HttpOperation;
@@ -141,38 +139,6 @@ void preApprovedRePush_notPersisted() throws Exception {
verifyNoInteractions(pushStore);
}
- // ---- self-certify bypass stamps automated attestation ----
-
- @Test
- void selfCertifyBypass_stampsAutomatedAttestation() throws Exception {
- FilterChain chain = mock(FilterChain.class);
- HttpServletRequest req = pushRequest(pushDetails());
- when(req.getAttribute(SELF_CERTIFY_USER_ATTR)).thenReturn("alice");
-
- filter.doFilter(req, mock(HttpServletResponse.class), chain);
-
- verify(pushStore).save(any(PushRecord.class));
- verify(pushStore)
- .approve(
- any(),
- argThat((Attestation a) -> a.isAutomated()
- && "alice".equals(a.getReviewerUsername())
- && a.getReason() != null
- && a.getReason().contains("alice")));
- }
-
- @Test
- void noBypasAttribute_noApproveCall() throws Exception {
- FilterChain chain = mock(FilterChain.class);
- HttpServletRequest req = pushRequest(pushDetails());
- when(req.getAttribute(SELF_CERTIFY_USER_ATTR)).thenReturn(null);
-
- filter.doFilter(req, mock(HttpServletResponse.class), chain);
-
- verify(pushStore).save(any(PushRecord.class));
- verify(pushStore, never()).approve(any(), any());
- }
-
// ---- store failure does not propagate (audit must not break pushes) ----
@Test
diff --git a/git-proxy-java-dashboard/frontend/src/pages/Admin.tsx b/git-proxy-java-dashboard/frontend/src/pages/Admin.tsx
index f29d41ff..778a2db9 100644
--- a/git-proxy-java-dashboard/frontend/src/pages/Admin.tsx
+++ b/git-proxy-java-dashboard/frontend/src/pages/Admin.tsx
@@ -62,9 +62,7 @@ function HttpBadge({ http }: { http: HttpResult | null }) {
function GitProbeBadge({ label, result }: { label: string; result: GitProbeResult }) {
const ok = result.status === 'ok'
- const detail = ok
- ? `${result.httpStatus} ${ms(result.durationMs)}`
- : (result.error ?? 'ERROR')
+ const detail = ok ? `${result.httpStatus} ${ms(result.durationMs)}` : (result.error ?? 'ERROR')
return (
)}
- {result.gitProbe.receivePack.status === 'ok' && result.gitProbe.receivePack.contentType && (
-
ℹ
diff --git a/git-proxy-java-dashboard/frontend/src/types.ts b/git-proxy-java-dashboard/frontend/src/types.ts
index cb0276bd..6ce9abee 100644
--- a/git-proxy-java-dashboard/frontend/src/types.ts
+++ b/git-proxy-java-dashboard/frontend/src/types.ts
@@ -71,6 +71,12 @@ export interface PushRecord {
attestation?: Attestation
commits?: Commit[]
steps?: Step[]
+ /**
+ * Server-computed flag (only set on GET /api/push/{id}): the current authenticated user is the resolved pusher,
+ * holds ROLE_SELF_CERTIFY, AND has a SELF_CERTIFY repo permission for this push's path. Gates the self-certify
+ * banner and approve button in the UI.
+ */
+ canCurrentUserSelfCertify?: boolean
}
export interface Provider {
diff --git a/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/controller/ConnectivityController.java b/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/controller/ConnectivityController.java
index 7236f076..1a335368 100644
--- a/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/controller/ConnectivityController.java
+++ b/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/controller/ConnectivityController.java
@@ -45,10 +45,9 @@
*
TLS — complete the TLS handshake (HTTPS providers only). Reports negotiated protocol and cipher, or the
* specific exception class for certificate / SNI failures.
*
HTTP — send {@code GET /} and record the status code and response time.
- *
Git probe (targeted check only) — send {@code GET /info/refs?service=git-upload-pack} and
- * {@code GET /info/refs?service=git-receive-pack} with {@code User-Agent: git/2.x.x} to a specific repo URL.
- * Distinguishes appliances that pass generic HTTP but block git-specific URL patterns, query strings, or
- * user-agent strings.
+ *
Git probe (targeted check only) — send {@code GET /info/refs?service=git-upload-pack} and {@code GET
+ * /info/refs?service=git-receive-pack} with {@code User-Agent: git/2.x.x} to a specific repo URL. Distinguishes
+ * appliances that pass generic HTTP but block git-specific URL patterns, query strings, or user-agent strings.
*
*
*
Every step is logged at INFO level. Targeted checks additionally return a structured {@code steps} log in the API
@@ -73,8 +72,8 @@ public class ConnectivityController {
* optional git probe step.
*
* @param providerName optional — name of the provider to target; if absent all providers are checked
- * @param repoPath optional — repo path (e.g. {@code /owner/repo.git}) appended to the provider base URI for
- * the git probe step; requires {@code provider}
+ * @param repoPath optional — repo path (e.g. {@code /owner/repo.git}) appended to the provider base URI for the git
+ * probe step; requires {@code provider}
*/
@GetMapping("/api/admin/connectivity")
public Map check(
@@ -92,8 +91,8 @@ public Map check(
GitProxyProvider provider = providers.getProviders().stream()
.filter(p -> p.getName().equals(providerName))
.findFirst()
- .orElseThrow(() -> new ResponseStatusException(
- HttpStatus.BAD_REQUEST, "Unknown provider: " + providerName));
+ .orElseThrow(() ->
+ new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown provider: " + providerName));
log.info("--- Provider: {} ({}) [targeted] ---", provider.getName(), provider.getUri());
List