Skip to content

Commit 0e98bb8

Browse files
coopernetesclaude
andcommitted
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 <noreply@anthropic.com>
1 parent fa4af73 commit 0e98bb8

16 files changed

Lines changed: 404 additions & 7 deletions

File tree

git-proxy-java-core/src/main/java/org/finos/gitproxy/db/jdbc/DatabaseMigrator.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ private record Migration(String version, String description, String resource, bo
4141
"2.1", "widen provider columns", "db/migration-postgresql/V2_1__widen_provider_columns.sql", true),
4242
new Migration("3", "email unique constraint", "db/migration/V3__email_unique.sql", false),
4343
new Migration("4", "spring session tables", "db/migration/V4__spring_session.sql", false),
44-
new Migration("5", "unified rule shape", "db/migration/V5__unified_rule_shape.sql", false));
44+
new Migration("5", "unified rule shape", "db/migration/V5__unified_rule_shape.sql", false),
45+
new Migration("6", "api key hash", "db/migration/V6__api_key.sql", false));
4546

4647
// ---------------------------------------------------------------------------
4748

git-proxy-java-core/src/main/java/org/finos/gitproxy/user/CompositeUserStore.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,26 @@ public void upsertUser(String username, List<String> roles) {
189189
mutableStore.upsertUser(username, roles);
190190
}
191191

192+
@Override
193+
public void setApiKey(String username, String keyHash) {
194+
mutableStore.setApiKey(username, keyHash);
195+
}
196+
197+
@Override
198+
public void revokeApiKey(String username) {
199+
mutableStore.revokeApiKey(username);
200+
}
201+
202+
@Override
203+
public Optional<UserEntry> findByApiKey(String keyHash) {
204+
return mutableStore.findByApiKey(keyHash);
205+
}
206+
207+
@Override
208+
public boolean hasApiKey(String username) {
209+
return mutableStore.hasApiKey(username);
210+
}
211+
192212
@Override
193213
public void upsertLockedEmail(String username, String email, String authSource) {
194214
mutableStore.upsertLockedEmail(username, email, authSource);

git-proxy-java-core/src/main/java/org/finos/gitproxy/user/JdbcUserStore.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,42 @@ public void upsertLockedEmail(String username, String email, String authSource)
301301
log.debug("Upserted locked email '{}' ({}) for user '{}'", email, authSource, username);
302302
}
303303

304+
@Override
305+
public void setApiKey(String username, String keyHash) {
306+
int updated = jdbc.update(
307+
"UPDATE proxy_users SET api_key_hash = :hash WHERE username = :u",
308+
Map.of("u", username, "hash", keyHash));
309+
if (updated == 0) throw new IllegalArgumentException("User not found: " + username);
310+
log.debug("Set API key for user '{}'", username);
311+
}
312+
313+
@Override
314+
public void revokeApiKey(String username) {
315+
jdbc.update("UPDATE proxy_users SET api_key_hash = NULL WHERE username = :u", Map.of("u", username));
316+
log.debug("Revoked API key for user '{}'", username);
317+
}
318+
319+
@Override
320+
public Optional<UserEntry> findByApiKey(String keyHash) {
321+
List<Map<String, Object>> rows = jdbc.queryForList(
322+
"SELECT username, password_hash, roles FROM proxy_users WHERE api_key_hash = :hash",
323+
Map.of("hash", keyHash));
324+
if (rows.isEmpty()) return Optional.empty();
325+
String username = (String) rows.get(0).get("username");
326+
String hash = (String) rows.get(0).get("password_hash");
327+
String rolesStr = (String) rows.get(0).get("roles");
328+
return Optional.of(buildEntry(username, hash, rolesStr));
329+
}
330+
331+
@Override
332+
public boolean hasApiKey(String username) {
333+
List<String> rows = jdbc.queryForList(
334+
"SELECT api_key_hash FROM proxy_users WHERE username = :u AND api_key_hash IS NOT NULL",
335+
Map.of("u", username),
336+
String.class);
337+
return !rows.isEmpty();
338+
}
339+
304340
private UserEntry buildEntry(String username, String passwordHash, String rolesStr) {
305341
List<String> emails = jdbc.queryForList(
306342
"SELECT email FROM user_emails WHERE username = :u ORDER BY email",

git-proxy-java-core/src/main/java/org/finos/gitproxy/user/MongoUserStore.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,33 @@ public void upsertUser(String username, List<String> roles) {
177177
}
178178
}
179179

180+
@Override
181+
public void setApiKey(String username, String keyHash) {
182+
getCollection().updateOne(Filters.eq("_id", username), Updates.set("apiKeyHash", keyHash));
183+
log.debug("Set API key for user '{}'", username);
184+
}
185+
186+
@Override
187+
public void revokeApiKey(String username) {
188+
getCollection().updateOne(Filters.eq("_id", username), Updates.unset("apiKeyHash"));
189+
log.debug("Revoked API key for user '{}'", username);
190+
}
191+
192+
@Override
193+
public Optional<UserEntry> findByApiKey(String keyHash) {
194+
Document doc = getCollection().find(Filters.eq("apiKeyHash", keyHash)).first();
195+
if (doc == null) return Optional.empty();
196+
return findByUsername(doc.getString("_id"));
197+
}
198+
199+
@Override
200+
public boolean hasApiKey(String username) {
201+
return getCollection()
202+
.find(Filters.and(Filters.eq("_id", username), Filters.exists("apiKeyHash")))
203+
.first()
204+
!= null;
205+
}
206+
180207
@Override
181208
public void addEmail(String username, String email) {
182209
String normalized = email.toLowerCase();

git-proxy-java-core/src/main/java/org/finos/gitproxy/user/UserStore.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.util.List;
44
import java.util.Map;
5+
import java.util.Optional;
56

67
/**
78
* 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<String> roles) {
7374
/** Inserts or updates an email for a user as locked (owned by the identity provider). */
7475
void upsertLockedEmail(String username, String email, String authSource);
7576

77+
// ── API key management ────────────────────────────────────────────────────
78+
79+
/** Stores the SHA-256 hash of the user's API key. Replaces any existing key. */
80+
void setApiKey(String username, String keyHash);
81+
82+
/** Removes the API key for the given user. No-op if no key is set. */
83+
void revokeApiKey(String username);
84+
85+
/** Returns the user whose {@code api_key_hash} matches, or empty if none. */
86+
Optional<UserEntry> findByApiKey(String keyHash);
87+
88+
/** Returns {@code true} if the user currently has an active API key. */
89+
boolean hasApiKey(String username);
90+
7691
// ── enriched queries (for admin UI) ──────────────────────────────────────────
7792

7893
/** Returns all email entries for a user with their verified, locked, and source status. */
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE proxy_users ADD COLUMN api_key_hash VARCHAR(128);

git-proxy-java-core/src/test/java/org/finos/gitproxy/user/JdbcUserStoreIntegrationTest.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,4 +355,49 @@ void addScmIdentity_differentUser_throwsConflict() {
355355
ScmIdentityConflictException.class, () -> store.addScmIdentity("bob", "github", "shared-handle"));
356356
assertEquals("alice", ex.getOwner());
357357
}
358+
359+
// ---- API key management ----
360+
361+
@Test
362+
void setApiKey_storesHash_andFindByApiKeyReturnsUser() {
363+
store.upsertUser("alice");
364+
store.setApiKey("alice", "deadbeef");
365+
366+
var result = store.findByApiKey("deadbeef");
367+
assertTrue(result.isPresent());
368+
assertEquals("alice", result.get().getUsername());
369+
}
370+
371+
@Test
372+
void findByApiKey_unknownHash_returnsEmpty() {
373+
assertFalse(store.findByApiKey("notahash").isPresent());
374+
}
375+
376+
@Test
377+
void hasApiKey_returnsTrueAfterSet_falseAfterRevoke() {
378+
store.upsertUser("alice");
379+
assertFalse(store.hasApiKey("alice"));
380+
381+
store.setApiKey("alice", "deadbeef");
382+
assertTrue(store.hasApiKey("alice"));
383+
384+
store.revokeApiKey("alice");
385+
assertFalse(store.hasApiKey("alice"));
386+
}
387+
388+
@Test
389+
void revokeApiKey_noKeySet_isNoOp() {
390+
store.upsertUser("alice");
391+
assertDoesNotThrow(() -> store.revokeApiKey("alice"));
392+
}
393+
394+
@Test
395+
void setApiKey_replacesExistingKey() {
396+
store.upsertUser("alice");
397+
store.setApiKey("alice", "oldhash");
398+
store.setApiKey("alice", "newhash");
399+
400+
assertFalse(store.findByApiKey("oldhash").isPresent());
401+
assertTrue(store.findByApiKey("newhash").isPresent());
402+
}
358403
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,17 @@ export async function addScmIdentity(provider: string, username: string) {
155155
return res.json()
156156
}
157157

158+
export async function generateApiKey(): Promise<{ key: string }> {
159+
const res = await apiFetch('/api/me/api-key', { method: 'POST' })
160+
if (!res.ok) await parseErrorResponse(res, 'Failed to generate API key')
161+
return res.json()
162+
}
163+
164+
export async function revokeApiKey(): Promise<void> {
165+
const res = await apiFetch('/api/me/api-key', { method: 'DELETE' })
166+
if (!res.ok) await parseErrorResponse(res, 'Failed to revoke API key')
167+
}
168+
158169
export async function removeScmIdentity(provider: string, scmUsername: string) {
159170
const res = await apiFetch(
160171
`/api/me/identities/${encodeURIComponent(provider)}/${encodeURIComponent(scmUsername)}`,

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

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import {
44
addScmIdentity,
55
fetchMe,
66
fetchProviders,
7+
generateApiKey,
78
removeEmail,
89
removeScmIdentity,
10+
revokeApiKey,
911
} from '../api'
1012
import { OperationsBadge, PathTypeBadge } from '../components/PermissionBadges'
1113
import type { CurrentUser, EmailEntry, RepoPermission, ScmIdentity } from '../types'
@@ -121,6 +123,38 @@ export function Profile() {
121123
}
122124
}
123125

126+
const [generatedKey, setGeneratedKey] = useState<string | null>(null)
127+
const [apiKeyBusy, setApiKeyBusy] = useState(false)
128+
const [apiKeyError, setApiKeyError] = useState<string | null>(null)
129+
130+
async function handleGenerateApiKey() {
131+
setApiKeyBusy(true)
132+
setApiKeyError(null)
133+
try {
134+
const { key } = await generateApiKey()
135+
setGeneratedKey(key)
136+
setProfile((p) => p && { ...p, hasApiKey: true })
137+
} catch (err: unknown) {
138+
setApiKeyError(err instanceof Error ? err.message : 'Failed to generate API key')
139+
} finally {
140+
setApiKeyBusy(false)
141+
}
142+
}
143+
144+
async function handleRevokeApiKey() {
145+
setApiKeyBusy(true)
146+
setApiKeyError(null)
147+
try {
148+
await revokeApiKey()
149+
setGeneratedKey(null)
150+
setProfile((p) => p && { ...p, hasApiKey: false })
151+
} catch (err: unknown) {
152+
setApiKeyError(err instanceof Error ? err.message : 'Failed to revoke API key')
153+
} finally {
154+
setApiKeyBusy(false)
155+
}
156+
}
157+
124158
if (loading)
125159
return <div className="max-w-2xl mx-auto px-4 py-16 text-center text-gray-400">Loading…</div>
126160
if (error)
@@ -297,6 +331,69 @@ export function Profile() {
297331
</div>
298332
)}
299333

334+
{/* API Key section — SELF_CERTIFY users only */}
335+
{profile.authorities.includes('ROLE_SELF_CERTIFY') && (
336+
<div className="space-y-3 border-t border-gray-100 pt-6">
337+
<div>
338+
<h3 className="text-sm font-semibold text-gray-700">API Key</h3>
339+
<p className="text-xs text-gray-500 mt-0.5">
340+
Use this key with the <code className="font-mono">X-Api-Key</code> header to
341+
authenticate API calls (e.g. self-certify) from automated pipelines.
342+
</p>
343+
</div>
344+
345+
{generatedKey ? (
346+
<div className="space-y-2">
347+
<p className="text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2">
348+
Copy this key now — it will not be shown again.
349+
</p>
350+
<div className="flex gap-2">
351+
<input
352+
readOnly
353+
value={generatedKey}
354+
className="flex-1 font-mono text-xs rounded border border-gray-300 px-3 py-2 bg-gray-50 select-all"
355+
onClick={(e) => (e.target as HTMLInputElement).select()}
356+
/>
357+
<button
358+
onClick={() => navigator.clipboard.writeText(generatedKey)}
359+
className="px-3 py-2 rounded border border-gray-300 text-xs text-gray-600 hover:bg-gray-50"
360+
>
361+
Copy
362+
</button>
363+
</div>
364+
<button
365+
onClick={handleRevokeApiKey}
366+
disabled={apiKeyBusy}
367+
className="text-xs text-red-600 hover:underline disabled:opacity-50"
368+
>
369+
Revoke key
370+
</button>
371+
</div>
372+
) : profile.hasApiKey ? (
373+
<div className="flex items-center gap-4">
374+
<span className="text-sm text-gray-600">API key active</span>
375+
<button
376+
onClick={handleRevokeApiKey}
377+
disabled={apiKeyBusy}
378+
className="text-xs text-red-600 hover:underline disabled:opacity-50"
379+
>
380+
Revoke
381+
</button>
382+
</div>
383+
) : (
384+
<button
385+
onClick={handleGenerateApiKey}
386+
disabled={apiKeyBusy}
387+
className="px-4 py-2 rounded bg-slate-700 text-white text-sm hover:bg-slate-600 disabled:opacity-50 transition-colors"
388+
>
389+
{apiKeyBusy ? 'Generating…' : 'Generate API Key'}
390+
</button>
391+
)}
392+
393+
{apiKeyError && <p className="text-sm text-red-600">{apiKeyError}</p>}
394+
</div>
395+
)}
396+
300397
{/* SCM Identities tab */}
301398
{tab === 'identities' && (
302399
<div className="space-y-4">

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export interface CurrentUser {
108108
emails: EmailEntry[]
109109
scmIdentities: ScmIdentity[]
110110
authorities: string[]
111+
hasApiKey: boolean
111112
}
112113

113114
export interface UserSummary {

0 commit comments

Comments
 (0)