diff --git a/git-proxy-java-core/src/main/java/org/finos/gitproxy/db/jdbc/DatabaseMigrator.java b/git-proxy-java-core/src/main/java/org/finos/gitproxy/db/jdbc/DatabaseMigrator.java index 3a6fb8f..8c51ebb 100644 --- a/git-proxy-java-core/src/main/java/org/finos/gitproxy/db/jdbc/DatabaseMigrator.java +++ b/git-proxy-java-core/src/main/java/org/finos/gitproxy/db/jdbc/DatabaseMigrator.java @@ -41,7 +41,8 @@ private record Migration(String version, String description, String resource, bo "2.1", "widen provider columns", "db/migration-postgresql/V2_1__widen_provider_columns.sql", true), new Migration("3", "email unique constraint", "db/migration/V3__email_unique.sql", false), new Migration("4", "spring session tables", "db/migration/V4__spring_session.sql", false), - new Migration("5", "unified rule shape", "db/migration/V5__unified_rule_shape.sql", false)); + new Migration("5", "unified rule shape", "db/migration/V5__unified_rule_shape.sql", false), + new Migration("6", "api key hash", "db/migration/V6__api_key.sql", false)); // --------------------------------------------------------------------------- diff --git a/git-proxy-java-core/src/main/java/org/finos/gitproxy/user/CompositeUserStore.java b/git-proxy-java-core/src/main/java/org/finos/gitproxy/user/CompositeUserStore.java index a3cdbdf..1673b9f 100644 --- a/git-proxy-java-core/src/main/java/org/finos/gitproxy/user/CompositeUserStore.java +++ b/git-proxy-java-core/src/main/java/org/finos/gitproxy/user/CompositeUserStore.java @@ -189,6 +189,26 @@ public void upsertUser(String username, List roles) { mutableStore.upsertUser(username, roles); } + @Override + public void setApiKey(String username, String keyHash) { + mutableStore.setApiKey(username, keyHash); + } + + @Override + public void revokeApiKey(String username) { + mutableStore.revokeApiKey(username); + } + + @Override + public Optional findByApiKey(String keyHash) { + return mutableStore.findByApiKey(keyHash); + } + + @Override + public boolean hasApiKey(String username) { + return mutableStore.hasApiKey(username); + } + @Override public void upsertLockedEmail(String username, String email, String authSource) { mutableStore.upsertLockedEmail(username, email, authSource); diff --git a/git-proxy-java-core/src/main/java/org/finos/gitproxy/user/JdbcUserStore.java b/git-proxy-java-core/src/main/java/org/finos/gitproxy/user/JdbcUserStore.java index d4176f7..2db3606 100644 --- a/git-proxy-java-core/src/main/java/org/finos/gitproxy/user/JdbcUserStore.java +++ b/git-proxy-java-core/src/main/java/org/finos/gitproxy/user/JdbcUserStore.java @@ -301,6 +301,42 @@ public void upsertLockedEmail(String username, String email, String authSource) log.debug("Upserted locked email '{}' ({}) for user '{}'", email, authSource, username); } + @Override + public void setApiKey(String username, String keyHash) { + int updated = jdbc.update( + "UPDATE proxy_users SET api_key_hash = :hash WHERE username = :u", + Map.of("u", username, "hash", keyHash)); + if (updated == 0) throw new IllegalArgumentException("User not found: " + username); + log.debug("Set API key for user '{}'", username); + } + + @Override + public void revokeApiKey(String username) { + jdbc.update("UPDATE proxy_users SET api_key_hash = NULL WHERE username = :u", Map.of("u", username)); + log.debug("Revoked API key for user '{}'", username); + } + + @Override + public Optional findByApiKey(String keyHash) { + List> rows = jdbc.queryForList( + "SELECT username, password_hash, roles FROM proxy_users WHERE api_key_hash = :hash", + Map.of("hash", keyHash)); + if (rows.isEmpty()) return Optional.empty(); + String username = (String) rows.get(0).get("username"); + String hash = (String) rows.get(0).get("password_hash"); + String rolesStr = (String) rows.get(0).get("roles"); + return Optional.of(buildEntry(username, hash, rolesStr)); + } + + @Override + public boolean hasApiKey(String username) { + List rows = jdbc.queryForList( + "SELECT api_key_hash FROM proxy_users WHERE username = :u AND api_key_hash IS NOT NULL", + Map.of("u", username), + String.class); + return !rows.isEmpty(); + } + private UserEntry buildEntry(String username, String passwordHash, String rolesStr) { List emails = jdbc.queryForList( "SELECT email FROM user_emails WHERE username = :u ORDER BY email", diff --git a/git-proxy-java-core/src/main/java/org/finos/gitproxy/user/MongoUserStore.java b/git-proxy-java-core/src/main/java/org/finos/gitproxy/user/MongoUserStore.java index 6b379a8..1a26ba4 100644 --- a/git-proxy-java-core/src/main/java/org/finos/gitproxy/user/MongoUserStore.java +++ b/git-proxy-java-core/src/main/java/org/finos/gitproxy/user/MongoUserStore.java @@ -177,6 +177,33 @@ public void upsertUser(String username, List roles) { } } + @Override + public void setApiKey(String username, String keyHash) { + getCollection().updateOne(Filters.eq("_id", username), Updates.set("apiKeyHash", keyHash)); + log.debug("Set API key for user '{}'", username); + } + + @Override + public void revokeApiKey(String username) { + getCollection().updateOne(Filters.eq("_id", username), Updates.unset("apiKeyHash")); + log.debug("Revoked API key for user '{}'", username); + } + + @Override + public Optional findByApiKey(String keyHash) { + Document doc = getCollection().find(Filters.eq("apiKeyHash", keyHash)).first(); + if (doc == null) return Optional.empty(); + return findByUsername(doc.getString("_id")); + } + + @Override + public boolean hasApiKey(String username) { + return getCollection() + .find(Filters.and(Filters.eq("_id", username), Filters.exists("apiKeyHash"))) + .first() + != null; + } + @Override public void addEmail(String username, String email) { String normalized = email.toLowerCase(); diff --git a/git-proxy-java-core/src/main/java/org/finos/gitproxy/user/UserStore.java b/git-proxy-java-core/src/main/java/org/finos/gitproxy/user/UserStore.java index abee8b5..c98526e 100644 --- a/git-proxy-java-core/src/main/java/org/finos/gitproxy/user/UserStore.java +++ b/git-proxy-java-core/src/main/java/org/finos/gitproxy/user/UserStore.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Map; +import java.util.Optional; /** * Full user store interface: extends read access with write operations for user, email, and SCM identity management. @@ -73,6 +74,20 @@ default void upsertUser(String username, List roles) { /** Inserts or updates an email for a user as locked (owned by the identity provider). */ void upsertLockedEmail(String username, String email, String authSource); + // ── API key management ──────────────────────────────────────────────────── + + /** Stores the SHA-256 hash of the user's API key. Replaces any existing key. */ + void setApiKey(String username, String keyHash); + + /** Removes the API key for the given user. No-op if no key is set. */ + void revokeApiKey(String username); + + /** Returns the user whose {@code api_key_hash} matches, or empty if none. */ + Optional findByApiKey(String keyHash); + + /** Returns {@code true} if the user currently has an active API key. */ + boolean hasApiKey(String username); + // ── enriched queries (for admin UI) ────────────────────────────────────────── /** Returns all email entries for a user with their verified, locked, and source status. */ diff --git a/git-proxy-java-core/src/main/resources/db/migration/V6__api_key.sql b/git-proxy-java-core/src/main/resources/db/migration/V6__api_key.sql new file mode 100644 index 0000000..4f56882 --- /dev/null +++ b/git-proxy-java-core/src/main/resources/db/migration/V6__api_key.sql @@ -0,0 +1 @@ +ALTER TABLE proxy_users ADD COLUMN api_key_hash VARCHAR(128); diff --git a/git-proxy-java-core/src/test/java/org/finos/gitproxy/user/JdbcUserStoreIntegrationTest.java b/git-proxy-java-core/src/test/java/org/finos/gitproxy/user/JdbcUserStoreIntegrationTest.java index 18241ef..56ee0e8 100644 --- a/git-proxy-java-core/src/test/java/org/finos/gitproxy/user/JdbcUserStoreIntegrationTest.java +++ b/git-proxy-java-core/src/test/java/org/finos/gitproxy/user/JdbcUserStoreIntegrationTest.java @@ -355,4 +355,49 @@ void addScmIdentity_differentUser_throwsConflict() { ScmIdentityConflictException.class, () -> store.addScmIdentity("bob", "github", "shared-handle")); assertEquals("alice", ex.getOwner()); } + + // ---- API key management ---- + + @Test + void setApiKey_storesHash_andFindByApiKeyReturnsUser() { + store.upsertUser("alice"); + store.setApiKey("alice", "deadbeef"); + + var result = store.findByApiKey("deadbeef"); + assertTrue(result.isPresent()); + assertEquals("alice", result.get().getUsername()); + } + + @Test + void findByApiKey_unknownHash_returnsEmpty() { + assertFalse(store.findByApiKey("notahash").isPresent()); + } + + @Test + void hasApiKey_returnsTrueAfterSet_falseAfterRevoke() { + store.upsertUser("alice"); + assertFalse(store.hasApiKey("alice")); + + store.setApiKey("alice", "deadbeef"); + assertTrue(store.hasApiKey("alice")); + + store.revokeApiKey("alice"); + assertFalse(store.hasApiKey("alice")); + } + + @Test + void revokeApiKey_noKeySet_isNoOp() { + store.upsertUser("alice"); + assertDoesNotThrow(() -> store.revokeApiKey("alice")); + } + + @Test + void setApiKey_replacesExistingKey() { + store.upsertUser("alice"); + store.setApiKey("alice", "oldhash"); + store.setApiKey("alice", "newhash"); + + assertFalse(store.findByApiKey("oldhash").isPresent()); + assertTrue(store.findByApiKey("newhash").isPresent()); + } } diff --git a/git-proxy-java-dashboard/frontend/src/api.ts b/git-proxy-java-dashboard/frontend/src/api.ts index dd4ed34..8d17bd8 100644 --- a/git-proxy-java-dashboard/frontend/src/api.ts +++ b/git-proxy-java-dashboard/frontend/src/api.ts @@ -155,6 +155,17 @@ export async function addScmIdentity(provider: string, username: string) { return res.json() } +export async function generateApiKey(): Promise<{ key: string }> { + const res = await apiFetch('/api/me/api-key', { method: 'POST' }) + if (!res.ok) await parseErrorResponse(res, 'Failed to generate API key') + return res.json() +} + +export async function revokeApiKey(): Promise { + const res = await apiFetch('/api/me/api-key', { method: 'DELETE' }) + if (!res.ok) await parseErrorResponse(res, 'Failed to revoke API key') +} + export async function removeScmIdentity(provider: string, scmUsername: string) { const res = await apiFetch( `/api/me/identities/${encodeURIComponent(provider)}/${encodeURIComponent(scmUsername)}`, diff --git a/git-proxy-java-dashboard/frontend/src/pages/Profile.tsx b/git-proxy-java-dashboard/frontend/src/pages/Profile.tsx index f38b0cf..73c4361 100644 --- a/git-proxy-java-dashboard/frontend/src/pages/Profile.tsx +++ b/git-proxy-java-dashboard/frontend/src/pages/Profile.tsx @@ -4,8 +4,10 @@ import { addScmIdentity, fetchMe, fetchProviders, + generateApiKey, removeEmail, removeScmIdentity, + revokeApiKey, } from '../api' import { OperationsBadge, PathTypeBadge } from '../components/PermissionBadges' import type { CurrentUser, EmailEntry, RepoPermission, ScmIdentity } from '../types' @@ -121,6 +123,38 @@ export function Profile() { } } + const [generatedKey, setGeneratedKey] = useState(null) + const [apiKeyBusy, setApiKeyBusy] = useState(false) + const [apiKeyError, setApiKeyError] = useState(null) + + async function handleGenerateApiKey() { + setApiKeyBusy(true) + setApiKeyError(null) + try { + const { key } = await generateApiKey() + setGeneratedKey(key) + setProfile((p) => p && { ...p, hasApiKey: true }) + } catch (err: unknown) { + setApiKeyError(err instanceof Error ? err.message : 'Failed to generate API key') + } finally { + setApiKeyBusy(false) + } + } + + async function handleRevokeApiKey() { + setApiKeyBusy(true) + setApiKeyError(null) + try { + await revokeApiKey() + setGeneratedKey(null) + setProfile((p) => p && { ...p, hasApiKey: false }) + } catch (err: unknown) { + setApiKeyError(err instanceof Error ? err.message : 'Failed to revoke API key') + } finally { + setApiKeyBusy(false) + } + } + if (loading) return
Loading…
if (error) @@ -297,6 +331,69 @@ export function Profile() { )} + {/* API Key section — SELF_CERTIFY users only */} + {profile.authorities.includes('ROLE_SELF_CERTIFY') && ( +
+
+

API Key

+

+ Use this key with the X-Api-Key header to + authenticate API calls (e.g. self-certify) from automated pipelines. +

+
+ + {generatedKey ? ( +
+

+ Copy this key now — it will not be shown again. +

+
+ (e.target as HTMLInputElement).select()} + /> + +
+ +
+ ) : profile.hasApiKey ? ( +
+ API key active + +
+ ) : ( + + )} + + {apiKeyError &&

{apiKeyError}

} +
+ )} + {/* SCM Identities tab */} {tab === 'identities' && (
diff --git a/git-proxy-java-dashboard/frontend/src/types.ts b/git-proxy-java-dashboard/frontend/src/types.ts index b1deaa3..1f5b7df 100644 --- a/git-proxy-java-dashboard/frontend/src/types.ts +++ b/git-proxy-java-dashboard/frontend/src/types.ts @@ -108,6 +108,7 @@ export interface CurrentUser { emails: EmailEntry[] scmIdentities: ScmIdentity[] authorities: string[] + hasApiKey: boolean } export interface UserSummary { diff --git a/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/ApiKeyAuthFilter.java b/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/ApiKeyAuthFilter.java index f23ea96..b4de566 100644 --- a/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/ApiKeyAuthFilter.java +++ b/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/ApiKeyAuthFilter.java @@ -40,7 +40,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse && MessageDigest.isEqual(expectedKey.getBytes(), provided.getBytes()) && SecurityContextHolder.getContext().getAuthentication() == null) { var auth = new UsernamePasswordAuthenticationToken( - "api-key", + "operator-api-key", null, List.of(new SimpleGrantedAuthority("ROLE_USER"), new SimpleGrantedAuthority("ROLE_ADMIN"))); SecurityContextHolder.getContext().setAuthentication(auth); diff --git a/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/SecurityConfig.java b/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/SecurityConfig.java index 2c31d61..dd741c5 100644 --- a/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/SecurityConfig.java +++ b/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/SecurityConfig.java @@ -133,6 +133,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http.addFilterBefore(new ApiKeyAuthFilter(generated), UsernamePasswordAuthenticationFilter.class); } + if (userStore instanceof UserStore mutableStore) { + http.addFilterBefore(new UserApiKeyAuthFilter(mutableStore), UsernamePasswordAuthenticationFilter.class); + } + List allowedOrigins = gitProxyConfig.getServer().getAllowedOrigins(); if (!allowedOrigins.isEmpty()) { log.info("CORS enabled for origins: {}", allowedOrigins); diff --git a/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/UserApiKeyAuthFilter.java b/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/UserApiKeyAuthFilter.java new file mode 100644 index 0000000..a6b3aad --- /dev/null +++ b/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/UserApiKeyAuthFilter.java @@ -0,0 +1,62 @@ +package org.finos.gitproxy.dashboard; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.finos.gitproxy.user.UserStore; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Authenticates REST API requests that carry an {@code X-Api-Key} header matching a per-user proxy API key. Resolves + * the user from the stored SHA-256 hash and sets up their full Spring {@link Authentication} with their actual roles, + * so all downstream permission checks behave identically to session-based auth. + * + *

Only activates when no session authentication is already present. Runs after the operator-level + * {@link ApiKeyAuthFilter} so the break-glass key takes precedence. + */ +@Slf4j +@RequiredArgsConstructor +public class UserApiKeyAuthFilter extends OncePerRequestFilter { + + private final UserStore userStore; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + String provided = request.getHeader(ApiKeyAuthFilter.HEADER); + if (provided != null && SecurityContextHolder.getContext().getAuthentication() == null) { + String hash = sha256(provided); + userStore.findByApiKey(hash).ifPresent(user -> { + List authorities = user.getRoles().stream() + .map(r -> new SimpleGrantedAuthority("ROLE_" + r)) + .toList(); + var auth = new UsernamePasswordAuthenticationToken(user.getUsername(), null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + log.debug("Authenticated API request for user '{}' via user API key", user.getUsername()); + }); + } + chain.doFilter(request, response); + } + + private static String sha256(String input) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } +} diff --git a/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/controller/AuthController.java b/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/controller/AuthController.java index 1db2f1e..d8a0fb4 100644 --- a/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/controller/AuthController.java +++ b/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/controller/AuthController.java @@ -71,11 +71,14 @@ public Map me() { ? repoPermissionService.findByUsername(username) : List.of(); + boolean hasApiKey = userStore instanceof UserStore jdbc && user != null && jdbc.hasApiKey(username); + return Map.of( "username", username != null ? username : "", "emails", emails, "scmIdentities", scmIdentities, "authorities", authorities, - "permissions", permissions); + "permissions", permissions, + "hasApiKey", hasApiKey); } } diff --git a/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/controller/ProfileController.java b/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/controller/ProfileController.java index cce4ae7..1e53ef5 100644 --- a/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/controller/ProfileController.java +++ b/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/controller/ProfileController.java @@ -2,6 +2,11 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.HexFormat; import java.util.Map; import org.finos.gitproxy.user.EmailConflictException; import org.finos.gitproxy.user.LockedByConfigException; @@ -13,6 +18,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -120,8 +126,44 @@ public ResponseEntity removeScmIdentity(@PathVariable String provider, @PathV return ResponseEntity.noContent().build(); } + // ---- API key management ---- + + @Operation(operationId = "generateApiKey", summary = "Generate a proxy-native API key for the current user") + @PostMapping("/api-key") + public ResponseEntity generateApiKey() { + if (!(userStore instanceof UserStore mutable)) return NOT_MUTABLE; + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_SELF_CERTIFY"))) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("error", "API key generation requires the SELF_CERTIFY role")); + } + byte[] raw = new byte[32]; + new SecureRandom().nextBytes(raw); + String key = "gp_" + HexFormat.of().formatHex(raw); + mutable.setApiKey(currentUsername(), sha256(key)); + return ResponseEntity.ok(Map.of("key", key)); + } + + @Operation(operationId = "revokeApiKey", summary = "Revoke the current user's proxy API key") + @DeleteMapping("/api-key") + public ResponseEntity revokeApiKey() { + if (!(userStore instanceof UserStore mutable)) return NOT_MUTABLE; + mutable.revokeApiKey(currentUsername()); + return ResponseEntity.noContent().build(); + } + private String currentUsername() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); return auth != null ? auth.getName() : null; } + + private static String sha256(String input) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } } diff --git a/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/controller/PushController.java b/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/controller/PushController.java index a488d66..28022de 100644 --- a/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/controller/PushController.java +++ b/git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/controller/PushController.java @@ -15,6 +15,8 @@ import org.finos.gitproxy.jetty.config.GitProxyConfig; import org.finos.gitproxy.jetty.reload.ConfigHolder; import org.finos.gitproxy.permission.RepoPermissionService; +import org.finos.gitproxy.user.ReadOnlyUserStore; +import org.finos.gitproxy.user.UserStore; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -40,6 +42,9 @@ public class PushController { @Autowired private ConfigHolder configHolder; + @Autowired(required = false) + private ReadOnlyUserStore userStore; + /** Returns the authenticated username, falling back to {@code body.reviewerUsername}, then {@code "system"}. */ private static String resolveReviewer(Map body) { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); @@ -220,11 +225,12 @@ public ResponseEntity approve(@PathVariable String id, @RequestBody ApproveBo if (attestationError != null) return attestationError; Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String reviewer = resolveReviewerFromApproveBody(body, auth); var attestation = Attestation.builder() .pushId(id) .type(Attestation.Type.APPROVAL) - .reviewerUsername(resolveReviewerFromApproveBody(body, auth)) - .reviewerEmail(body.reviewerEmail()) + .reviewerUsername(reviewer) + .reviewerEmail(resolveReviewerEmail(reviewer)) .reason(body.reason()) .selfApproval(isSelfApproval(record, auth, adminOverride)) .answers(body.attestations()) @@ -268,6 +274,31 @@ private static String resolveReviewerFromApproveBody(ApproveBody body, Authentic return body != null && body.reviewerUsername() != null ? body.reviewerUsername() : "system"; } + /** + * Resolves the reviewer's email from the user store. Prefers the IdP-locked email when available (most reliable + * across OIDC/LDAP), falls back to any registered email, and returns {@code null} for local users with no email or + * when the user store is unavailable (e.g. operator-api-key). + */ + private String resolveReviewerEmail(String username) { + if (username == null || userStore == null) return null; + if (userStore instanceof UserStore jdbc) { + return jdbc.findEmailsWithVerified(username).stream() + .filter(e -> Boolean.TRUE.equals(e.get("locked"))) + .map(e -> (String) e.get("email")) + .findFirst() + .orElseGet(() -> userStore + .findByUsername(username) + .filter(u -> !u.getEmails().isEmpty()) + .map(u -> u.getEmails().get(0)) + .orElse(null)); + } + return userStore + .findByUsername(username) + .filter(u -> !u.getEmails().isEmpty()) + .map(u -> u.getEmails().get(0)) + .orElse(null); + } + /** Reject a push. Body: { "reviewerUsername": "...", "reviewerEmail": "...", "reason": "..." } (reason required) */ @Operation(operationId = "rejectPush", summary = "Reject a push") @PostMapping("/{id}/reject") @@ -286,11 +317,12 @@ public ResponseEntity reject(@PathVariable String id, @RequestBody Map identityError = checkReviewerIdentity(record, true); if (identityError != null) return identityError; Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String reviewer = resolveReviewer(body); var attestation = Attestation.builder() .pushId(id) .type(Attestation.Type.REJECTION) - .reviewerUsername(resolveReviewer(body)) - .reviewerEmail(body.get("reviewerEmail")) + .reviewerUsername(reviewer) + .reviewerEmail(resolveReviewerEmail(reviewer)) .reason(reason) .selfApproval(isSelfApproval(record, auth, true)) .build();