Skip to content

Commit 4e9cd82

Browse files
authored
feat: require explicit admin override for self-approval; fix :run PID file (#216)
## Summary - Admins reviewing their own pushes now follow the same path as regular users — `ROLE_ADMIN` alone no longer bypasses the self-approval identity check. Admins either self-certify (via `ROLE_SELF_CERTIFY` + repo permission, same as any user) or must explicitly activate an **Enable admin override** toggle in the dashboard UI for break-glass situations. - The admin override toggle is hidden when self-certify is already available, so the aggressive red warning only surfaces when it is actually needed. - `selfApproval=true` is only recorded in the audit log when the override flag is explicitly used — admin self-certify approvals are no longer incorrectly flagged. - Fix `:run` PID file: `applicationDefaultJvmArgs` only applies to generated distribution scripts, not the Gradle `JavaExec` run task. Added `-Dgitproxyjava.pidfile` explicitly to `tasks.named('run')` in both `build.gradle` files so `./gradlew :stop` works correctly after `ctrl+c`. - Local dev config: grant `thomas-cooper` `ROLE_SELF_CERTIFY` so the self-certify path is exercisable without needing the admin override. ## Test plan - [ ] Log in as an admin user who pushed a commit — confirm blue self-certify banner appears (not red override warning) - [ ] Log in as an admin without self-certify permission — confirm amber "not permitted" banner and "Enable admin override" link - [ ] Click "Enable admin override" — confirm red warning appears and approve button becomes active - [ ] Approve via override — confirm audit log entry has `selfApproval: true` - [ ] Approve via self-certify — confirm audit log entry has `selfApproval: false` - [ ] Admin approving someone else's push — confirm no change in behaviour (bypass still applies) - [ ] `./gradlew :git-proxy-java-dashboard:run` then `ctrl+c` then `./gradlew :git-proxy-java-dashboard:stop` — confirm "Stopping … (PID: …)" rather than "No PID file found" - [ ] `./gradlew :git-proxy-java-dashboard:test` passes
2 parents d2e08c7 + 18dffe1 commit 4e9cd82

7 files changed

Lines changed: 104 additions & 45 deletions

File tree

git-proxy-java-dashboard/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ tasks.named('run') {
116116
// Default keeps the dev experience simple; override in shell for a stricter key.
117117
environment 'GITPROXY_API_KEY', System.getenv('GITPROXY_API_KEY') ?: 'change-me-in-production'
118118
jvmArgs "-Dlog4j2.configurationFile=${rootDir}/git-proxy-java-server/src/main/resources/log4j2-local.xml"
119+
jvmArgs "-Dgitproxyjava.pidfile=${buildDir}/git-proxy-java.pid"
119120
}
120121

121122
tasks.register('stop') {

git-proxy-java-dashboard/frontend/src/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export async function approvePush(
9898
reviewerEmail: string
9999
reason: string
100100
attestations?: Record<string, string>
101+
adminOverride?: boolean
101102
},
102103
) {
103104
const res = await apiFetch(`/api/push/${id}/authorise`, {

git-proxy-java-dashboard/frontend/src/pages/PushDetail.tsx

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,7 @@ export function PushDetail({ currentUser }: PushDetailProps) {
449449
const [canceling, setCanceling] = useState(false)
450450
const [attestationQuestions, setAttestationQuestions] = useState<AttestationQuestion[]>([])
451451
const [attestationAnswers, setAttestationAnswers] = useState<Record<string, string>>({})
452+
const [adminOverrideEnabled, setAdminOverrideEnabled] = useState(false)
452453

453454
// Diff state — loaded separately so it never blocks the main page render
454455
const [diffContent, setDiffContent] = useState<string | null>(null)
@@ -470,6 +471,7 @@ export function PushDetail({ currentUser }: PushDetailProps) {
470471
setActionError('')
471472
setOpenSteps({})
472473
setAttestationAnswers({})
474+
setAdminOverrideEnabled(false)
473475
setDiffContent(null)
474476
setDiffLines(0)
475477
try {
@@ -550,6 +552,7 @@ export function PushDetail({ currentUser }: PushDetailProps) {
550552
reviewerEmail: currentUser?.emails[0]?.email ?? '',
551553
reason: reviewReason,
552554
attestations: attestationQuestions.length > 0 ? attestationAnswers : undefined,
555+
adminOverride: adminOverrideEnabled || undefined,
553556
})
554557
await load(record.id)
555558
} catch (e) {
@@ -887,15 +890,15 @@ export function PushDetail({ currentUser }: PushDetailProps) {
887890
const isAdmin = currentUser?.authorities?.includes('ROLE_ADMIN') ?? false
888891
// canCurrentUserSelfCertify is computed server-side and requires BOTH the
889892
// ROLE_SELF_CERTIFY authority AND a SELF_CERTIFY repo permission row for this push's
890-
// path. A user with the role but no per-repo permission gets `false` here, so the
891-
// self-certify banner stays hidden and the approve button is gated behind the
892-
// standard self-review block.
893+
// path. A user with the role but no per-repo permission gets `false` here.
893894
const canSelfCertify = record.canCurrentUserSelfCertify ?? false
894895
const isPusher =
895896
!!currentUser?.username &&
896897
!!record.resolvedUser &&
897898
currentUser.username === record.resolvedUser
898-
const isSelfReview = isPusher && !isAdmin && !canSelfCertify
899+
// Admins are treated the same as regular users for their own pushes: they need
900+
// self-certify permissions or must explicitly activate the admin override.
901+
const isSelfReview = isPusher && !canSelfCertify
899902
const canCancel = isAdmin || isPusher
900903
const attestationsComplete = attestationQuestions
901904
.filter((q) => q.required)
@@ -924,7 +927,7 @@ export function PushDetail({ currentUser }: PushDetailProps) {
924927
</span>
925928
)}
926929
</div>
927-
{isSelfReview && (
930+
{isSelfReview && !adminOverrideEnabled && (
928931
<div className="flex gap-2 text-sm text-amber-800 bg-amber-50 border border-amber-200 rounded px-3 py-2 mb-3">
929932
<span></span>
930933
<span>
@@ -933,16 +936,24 @@ export function PushDetail({ currentUser }: PushDetailProps) {
933936
</span>
934937
</div>
935938
)}
936-
{canSelfCertify && isPusher && !isAdmin && (
939+
{canSelfCertify && isPusher && (
937940
<div className="flex gap-2 text-sm text-blue-800 bg-blue-50 border border-blue-200 rounded px-3 py-2 mb-3">
938-
<span></span>
941+
<span>{'\u2139\uFE0F'}</span>
939942
<span>
940943
You are self-certifying your own push. This approval will be permanently
941944
recorded in the audit log.
942945
</span>
943946
</div>
944947
)}
945-
{isAdmin && isPusher && (
948+
{isAdmin && isPusher && !canSelfCertify && !adminOverrideEnabled && (
949+
<button
950+
onClick={() => setAdminOverrideEnabled(true)}
951+
className="w-full text-left text-xs text-gray-500 hover:text-gray-700 underline mb-3"
952+
>
953+
Enable admin override
954+
</button>
955+
)}
956+
{isAdmin && isPusher && adminOverrideEnabled && (
946957
<div className="flex gap-2 text-sm text-red-800 bg-red-50 border border-red-200 rounded px-3 py-2 mb-3">
947958
<span></span>
948959
<span>
@@ -962,7 +973,7 @@ export function PushDetail({ currentUser }: PushDetailProps) {
962973
key={q.id}
963974
question={q}
964975
value={attestationAnswers[q.id] ?? ''}
965-
disabled={isSelfReview}
976+
disabled={isSelfReview && !adminOverrideEnabled}
966977
onChange={(val) =>
967978
setAttestationAnswers((prev) => ({ ...prev, [q.id]: val }))
968979
}
@@ -977,15 +988,15 @@ export function PushDetail({ currentUser }: PushDetailProps) {
977988
placeholder={
978989
'Reason (required for both approve and reject)\nDescribe the basis for your decision...'
979990
}
980-
disabled={isSelfReview}
991+
disabled={isSelfReview && !adminOverrideEnabled}
981992
className="w-full border border-gray-300 rounded px-3 py-2 text-sm mb-3 resize-none focus:outline-none focus:ring-2 focus:ring-blue-300 disabled:bg-gray-50 disabled:text-gray-400"
982993
/>
983994
{actionError && <div className="text-red-600 text-sm mb-3">{actionError}</div>}
984995
<div className="flex gap-3">
985996
<button
986997
onClick={handleApprove}
987998
disabled={
988-
isSelfReview ||
999+
(isSelfReview && !adminOverrideEnabled) ||
9891000
saving ||
9901001
canceling ||
9911002
!reviewReason.trim() ||
@@ -997,7 +1008,12 @@ export function PushDetail({ currentUser }: PushDetailProps) {
9971008
</button>
9981009
<button
9991010
onClick={handleReject}
1000-
disabled={isSelfReview || saving || canceling || !reviewReason.trim()}
1011+
disabled={
1012+
(isSelfReview && !adminOverrideEnabled) ||
1013+
saving ||
1014+
canceling ||
1015+
!reviewReason.trim()
1016+
}
10011017
className="px-4 py-2 text-sm font-medium rounded bg-red-600 text-white hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed disabled:bg-gray-400"
10021018
>
10031019
✗ Reject

git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/controller/PushController.java

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,11 @@ public ResponseEntity<PushRecord> getById(@PathVariable String id) {
138138
}
139139

140140
/**
141-
* Computes whether the currently authenticated user is permitted to self-approve this specific push. Mirrors the
142-
* server-side enforcement in {@link #checkReviewerIdentity}: the user must be the resolved pusher, hold the
143-
* {@code ROLE_SELF_CERTIFY} authority (capability gate), and have a matching {@code SELF_CERTIFY} repo permission
144-
* row (per-repo entitlement). Admins do not need self-certify — they can approve anything via the regular path.
141+
* Computes whether the currently authenticated user is permitted to self-approve this specific push via the
142+
* self-certify path. The user must be the resolved pusher, hold the {@code ROLE_SELF_CERTIFY} authority (capability
143+
* gate), and have a matching {@code SELF_CERTIFY} repo permission row (per-repo entitlement). Applies to both
144+
* regular users and admins — admins who hold self-certify permissions follow this path rather than the admin
145+
* override path.
145146
*/
146147
private boolean computeCanCurrentUserSelfCertify(PushRecord record) {
147148
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
@@ -190,7 +191,11 @@ private static void stripDiffContent(PushRecord record) {
190191
* question answers keyed by question ID.
191192
*/
192193
public record ApproveBody(
193-
String reviewerUsername, String reviewerEmail, String reason, Map<String, String> attestations) {}
194+
String reviewerUsername,
195+
String reviewerEmail,
196+
String reason,
197+
Map<String, String> attestations,
198+
boolean adminOverride) {}
194199

195200
/**
196201
* Approve a push. Body: { "reviewerUsername": "...", "reviewerEmail": "...", "reason": "...", "attestations": {
@@ -206,7 +211,7 @@ public ResponseEntity<?> approve(@PathVariable String id, @RequestBody ApproveBo
206211
return ResponseEntity.badRequest()
207212
.body(Map.of("error", "Push is not in PENDING status: " + record.getStatus()));
208213
}
209-
ResponseEntity<?> identityError = checkReviewerIdentity(record);
214+
ResponseEntity<?> identityError = checkReviewerIdentity(record, body.adminOverride());
210215
if (identityError != null) return identityError;
211216

212217
// Validate required attestation questions are answered
@@ -220,7 +225,7 @@ public ResponseEntity<?> approve(@PathVariable String id, @RequestBody ApproveBo
220225
.reviewerUsername(resolveReviewerFromApproveBody(body, auth))
221226
.reviewerEmail(body.reviewerEmail())
222227
.reason(body.reason())
223-
.selfApproval(isSelfApproval(record, auth))
228+
.selfApproval(isSelfApproval(record, auth, body.adminOverride()))
224229
.answers(body.attestations())
225230
.build();
226231
var updated = pushStore.approve(id, attestation);
@@ -277,7 +282,7 @@ public ResponseEntity<?> reject(@PathVariable String id, @RequestBody Map<String
277282
return ResponseEntity.badRequest()
278283
.body(Map.of("error", "Push is not in PENDING status: " + record.getStatus()));
279284
}
280-
ResponseEntity<?> identityError = checkReviewerIdentity(record);
285+
ResponseEntity<?> identityError = checkReviewerIdentity(record, true);
281286
if (identityError != null) return identityError;
282287
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
283288
var attestation = Attestation.builder()
@@ -286,7 +291,7 @@ public ResponseEntity<?> reject(@PathVariable String id, @RequestBody Map<String
286291
.reviewerUsername(resolveReviewer(body))
287292
.reviewerEmail(body.get("reviewerEmail"))
288293
.reason(reason)
289-
.selfApproval(isSelfApproval(record, auth))
294+
.selfApproval(isSelfApproval(record, auth, true))
290295
.build();
291296
var updated = pushStore.reject(id, attestation);
292297
return ResponseEntity.ok(updated);
@@ -298,25 +303,37 @@ public ResponseEntity<?> reject(@PathVariable String id, @RequestBody Map<String
298303
* Validates that the current session user may review the given push record:
299304
*
300305
* <ol>
301-
* <li>ROLE_ADMIN bypasses all identity checks — admins may approve/reject any push.
302-
* <li>The pusher must have been resolved to a proxy user — if not, we cannot guarantee identity.
303-
* <li>Self-review: allowed only when the reviewer has both {@code ROLE_SELF_CERTIFY} (the capability, attested by
304-
* the org's IdP/IAM via {@code auth.role-mappings} or the local {@code users[].roles} block) and a
306+
* <li>ROLE_ADMIN reviewing someone else's push: always permitted.
307+
* <li>ROLE_ADMIN self-review with {@code adminOverride=true}: permitted; recorded as an admin override in the
308+
* audit log.
309+
* <li>Self-review (admin or otherwise) without override: allowed only when the reviewer has both
310+
* {@code ROLE_SELF_CERTIFY} (the capability, attested by the org's IdP/IAM via {@code auth.role-mappings} or
311+
* the local {@code users[].roles} block) and a
305312
* {@link org.finos.gitproxy.permission.RepoPermission.Operations#SELF_CERTIFY} repo permission entry for this
306-
* specific repository (the per-repo entitlement). Both must be present.
313+
* specific repository. Both must be present.
314+
* <li>The pusher must have been resolved to a proxy user — if not, we cannot guarantee identity.
307315
* <li>Non-self reviewer: by default any authenticated user may review. When
308316
* {@code server.require-review-permission: true}, the user must have a REVIEW (or PUSH_AND_REVIEW) permission
309317
* for the repo.
310318
* </ol>
311319
*
320+
* @param adminOverride {@code true} when the caller has explicitly activated the admin override on the approve
321+
* endpoint; ignored for non-admin users and for admins reviewing someone else's push
312322
* @return a 403 response if the check fails, {@code null} if the reviewer is permitted to proceed
313323
*/
314-
private ResponseEntity<?> checkReviewerIdentity(PushRecord record) {
324+
private ResponseEntity<?> checkReviewerIdentity(PushRecord record, boolean adminOverride) {
315325
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
316-
if (isAdmin(auth)) return null;
317-
318326
String reviewer = auth != null ? auth.getName() : null;
319327
String pusherProxyUser = record.getResolvedUser();
328+
boolean isSelfReview = pusherProxyUser != null && pusherProxyUser.equals(reviewer);
329+
330+
// Admins reviewing someone else's push always bypass identity checks.
331+
if (isAdmin(auth) && !isSelfReview) return null;
332+
333+
// Admins reviewing their own push: permitted only when the override flag is explicit.
334+
// Without it they fall through to the same self-certify check as a regular user, so
335+
// having ROLE_ADMIN alone does not bypass the two-gate self-approval requirement.
336+
if (isAdmin(auth) && adminOverride) return null;
320337

321338
if (pusherProxyUser == null) {
322339
return ResponseEntity.status(HttpStatus.FORBIDDEN)
@@ -326,11 +343,9 @@ private ResponseEntity<?> checkReviewerIdentity(PushRecord record) {
326343
"Pusher identity has not been resolved to a proxy user; approval requires verified identity"));
327344
}
328345

329-
if (pusherProxyUser.equals(reviewer)) {
346+
if (isSelfReview) {
330347
// Self-review requires two independent checks:
331348
// 1. ROLE_SELF_CERTIFY — the capability, granted via auth.role-mappings or users[].roles in config.
332-
// This is the pre-requisite gate: it must be attested by the org's IdP/IAM before any per-repo
333-
// entitlement can take effect.
334349
// 2. A SELF_CERTIFY repo permission entry for this specific repo — the per-repo entitlement.
335350
boolean hasSelfCertifyRole = auth != null
336351
&& auth.getAuthorities().stream().anyMatch(a -> "ROLE_SELF_CERTIFY".equals(a.getAuthority()));
@@ -400,12 +415,10 @@ private static boolean isAdmin(Authentication auth) {
400415
return auth != null && auth.getAuthorities().stream().anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority()));
401416
}
402417

403-
private static boolean isSelfApproval(PushRecord record, Authentication auth) {
404-
if (!isAdmin(auth)) {
405-
// Non-admin self-reviews are permitted only via an explicit SELF_CERTIFY grant —
406-
// that is expected behaviour, not an admin override.
407-
return false;
408-
}
418+
private static boolean isSelfApproval(PushRecord record, Authentication auth, boolean adminOverride) {
419+
// Only flag as an admin override when the admin explicitly activated it.
420+
// Admin self-reviews via SELF_CERTIFY and all non-admin self-reviews are expected behaviour, not overrides.
421+
if (!isAdmin(auth) || !adminOverride) return false;
409422
String pusher = record.getResolvedUser();
410423
String reviewer = auth != null ? auth.getName() : null;
411424
return pusher != null && pusher.equals(reviewer);

git-proxy-java-dashboard/src/test/java/org/finos/gitproxy/dashboard/controller/PushControllerTest.java

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,11 @@ class PushControllerTest {
5252

5353
/** Empty approve body — no attestations, mirrors previous Map.of() usage. */
5454
private static PushController.ApproveBody approveBody() {
55-
return new PushController.ApproveBody(null, null, null, null);
55+
return new PushController.ApproveBody(null, null, null, null, false);
56+
}
57+
58+
private static PushController.ApproveBody approveBodyWithAdminOverride() {
59+
return new PushController.ApproveBody(null, null, null, null, true);
5660
}
5761

5862
@AfterEach
@@ -288,25 +292,47 @@ void unresolvedPusher_returns403() {
288292
}
289293

290294
@Test
291-
void admin_bypassesIdentityChecks_returns200() {
295+
void admin_selfApproval_withoutOverride_returns403() {
296+
when(pushStore.findById("p1")).thenReturn(Optional.of(blockedPush("p1", "alice")));
297+
loginAs("alice", true); // same user as pusher, no adminOverride flag
298+
299+
assertEquals(
300+
HttpStatus.FORBIDDEN,
301+
controller.approve("p1", approveBody()).getStatusCode());
302+
}
303+
304+
@Test
305+
void admin_selfApproval_withOverride_returns200() {
292306
when(pushStore.findById("p1")).thenReturn(Optional.of(blockedPush("p1", "alice")));
293307
when(pushStore.approve(eq("p1"), any())).thenReturn(approvedPush("p1"));
294-
loginAs("alice", true); // same user as pusher — admin bypass
308+
loginAs("alice", true);
295309

296-
assertEquals(HttpStatus.OK, controller.approve("p1", approveBody()).getStatusCode());
310+
assertEquals(
311+
HttpStatus.OK,
312+
controller.approve("p1", approveBodyWithAdminOverride()).getStatusCode());
297313
}
298314

299315
@Test
300-
void admin_selfApproval_flaggedInAttestation() {
316+
void admin_selfApproval_withOverride_flaggedInAttestation() {
301317
when(pushStore.findById("p1")).thenReturn(Optional.of(blockedPush("p1", "alice")));
302318
when(pushStore.approve(eq("p1"), any())).thenReturn(approvedPush("p1"));
303319
loginAs("alice", true);
304320

305-
controller.approve("p1", approveBody());
321+
controller.approve("p1", approveBodyWithAdminOverride());
306322

307323
verify(pushStore).approve(eq("p1"), argThat(a -> a.isSelfApproval()));
308324
}
309325

326+
@Test
327+
void admin_selfApproval_withoutOverride_notFlaggedAsAdminOverride() {
328+
when(pushStore.findById("p1")).thenReturn(Optional.of(blockedPush("p1", "alice")));
329+
loginAs("alice", true);
330+
331+
// Returns 403 — no interaction with pushStore at all
332+
controller.approve("p1", approveBody());
333+
verify(pushStore, org.mockito.Mockito.never()).approve(any(), any());
334+
}
335+
310336
@Test
311337
void selfCertify_nonAdmin_notFlaggedAsAdminOverride() throws Exception {
312338
when(pushStore.findById("p1")).thenReturn(Optional.of(blockedPush("p1", "alice")));

0 commit comments

Comments
 (0)