From 0e98bb81408b69910bb8824d5cd528831bb8deb2 Mon Sep 17 00:00:00 2001 From: Thomas Cooper Date: Wed, 13 May 2026 01:13:32 -0400 Subject: [PATCH] feat: per-user API key for REST API authentication - Add api_key_hash column to proxy_users (V6 migration) - Add setApiKey/revokeApiKey/findByApiKey/hasApiKey to UserStore (JDBC, Mongo, and Composite implementations) - Add UserApiKeyAuthFilter: resolves X-Api-Key header to a DB user via SHA-256 hash, sets full Spring Authentication with actual roles - Register filter before UsernamePasswordAuthenticationFilter so it works with local, LDAP, and OIDC auth providers - Add POST /api/me/api-key and DELETE /api/me/api-key endpoints (key generation gated on ROLE_SELF_CERTIFY; shown once on creation) - Add hasApiKey flag to GET /api/me response - Rename operator-key principal from "api-key" to "operator-api-key" for clearer audit records - Resolve reviewerEmail server-side in PushController: prefer locked (IdP-sourced) email, fall back to any registered email, null for local-auth-no-email and operator key - Frontend: API key section in Profile, visible only to SELF_CERTIFY users; three states: no key, just generated (show once), key active closes #185 Co-Authored-By: Claude Sonnet 4.6 --- .../gitproxy/db/jdbc/DatabaseMigrator.java | 3 +- .../gitproxy/user/CompositeUserStore.java | 20 ++++ .../finos/gitproxy/user/JdbcUserStore.java | 36 +++++++ .../finos/gitproxy/user/MongoUserStore.java | 27 ++++++ .../org/finos/gitproxy/user/UserStore.java | 15 +++ .../resources/db/migration/V6__api_key.sql | 1 + .../user/JdbcUserStoreIntegrationTest.java | 45 +++++++++ git-proxy-java-dashboard/frontend/src/api.ts | 11 +++ .../frontend/src/pages/Profile.tsx | 97 +++++++++++++++++++ .../frontend/src/types.ts | 1 + .../gitproxy/dashboard/ApiKeyAuthFilter.java | 2 +- .../gitproxy/dashboard/SecurityConfig.java | 4 + .../dashboard/UserApiKeyAuthFilter.java | 62 ++++++++++++ .../dashboard/controller/AuthController.java | 5 +- .../controller/ProfileController.java | 42 ++++++++ .../dashboard/controller/PushController.java | 40 +++++++- 16 files changed, 404 insertions(+), 7 deletions(-) create mode 100644 git-proxy-java-core/src/main/resources/db/migration/V6__api_key.sql create mode 100644 git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/UserApiKeyAuthFilter.java 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 3a6fb8f3..8c51ebb1 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 a3cdbdfd..1673b9f5 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 d4176f77..2db36062 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 6b379a80..1a26ba43 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 abee8b51..c98526e3 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 00000000..4f568824 --- /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 18241ef0..56ee0e81 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 dd4ed343..8d17bd82 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 f38b0cfc..73c4361d 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 b1deaa31..1f5b7df1 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 f23ea967..b4de5661 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 2c31d614..dd741c52 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 00000000..a6b3aadf --- /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 1db2f1e2..d8a0fb41 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 cce4ae77..1e53ef5c 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 a488d66c..28022dec 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();