Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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));

// ---------------------------------------------------------------------------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,26 @@ public void upsertUser(String username, List<String> 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<UserEntry> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserEntry> findByApiKey(String keyHash) {
List<Map<String, Object>> 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<String> 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<String> emails = jdbc.queryForList(
"SELECT email FROM user_emails WHERE username = :u ORDER BY email",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,33 @@ public void upsertUser(String username, List<String> 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<UserEntry> 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -73,6 +74,20 @@ default void upsertUser(String username, List<String> 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<UserEntry> 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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE proxy_users ADD COLUMN api_key_hash VARCHAR(128);
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
11 changes: 11 additions & 0 deletions git-proxy-java-dashboard/frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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)}`,
Expand Down
97 changes: 97 additions & 0 deletions git-proxy-java-dashboard/frontend/src/pages/Profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -121,6 +123,38 @@ export function Profile() {
}
}

const [generatedKey, setGeneratedKey] = useState<string | null>(null)
const [apiKeyBusy, setApiKeyBusy] = useState(false)
const [apiKeyError, setApiKeyError] = useState<string | null>(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 <div className="max-w-2xl mx-auto px-4 py-16 text-center text-gray-400">Loading…</div>
if (error)
Expand Down Expand Up @@ -297,6 +331,69 @@ export function Profile() {
</div>
)}

{/* API Key section — SELF_CERTIFY users only */}
{profile.authorities.includes('ROLE_SELF_CERTIFY') && (
<div className="space-y-3 border-t border-gray-100 pt-6">
<div>
<h3 className="text-sm font-semibold text-gray-700">API Key</h3>
<p className="text-xs text-gray-500 mt-0.5">
Use this key with the <code className="font-mono">X-Api-Key</code> header to
authenticate API calls (e.g. self-certify) from automated pipelines.
</p>
</div>

{generatedKey ? (
<div className="space-y-2">
<p className="text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2">
Copy this key now — it will not be shown again.
</p>
<div className="flex gap-2">
<input
readOnly
value={generatedKey}
className="flex-1 font-mono text-xs rounded border border-gray-300 px-3 py-2 bg-gray-50 select-all"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
onClick={() => navigator.clipboard.writeText(generatedKey)}
className="px-3 py-2 rounded border border-gray-300 text-xs text-gray-600 hover:bg-gray-50"
>
Copy
</button>
</div>
<button
onClick={handleRevokeApiKey}
disabled={apiKeyBusy}
className="text-xs text-red-600 hover:underline disabled:opacity-50"
>
Revoke key
</button>
</div>
) : profile.hasApiKey ? (
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">API key active</span>
<button
onClick={handleRevokeApiKey}
disabled={apiKeyBusy}
className="text-xs text-red-600 hover:underline disabled:opacity-50"
>
Revoke
</button>
</div>
) : (
<button
onClick={handleGenerateApiKey}
disabled={apiKeyBusy}
className="px-4 py-2 rounded bg-slate-700 text-white text-sm hover:bg-slate-600 disabled:opacity-50 transition-colors"
>
{apiKeyBusy ? 'Generating…' : 'Generate API Key'}
</button>
)}

{apiKeyError && <p className="text-sm text-red-600">{apiKeyError}</p>}
</div>
)}

{/* SCM Identities tab */}
{tab === 'identities' && (
<div className="space-y-4">
Expand Down
1 change: 1 addition & 0 deletions git-proxy-java-dashboard/frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export interface CurrentUser {
emails: EmailEntry[]
scmIdentities: ScmIdentity[]
authorities: string[]
hasApiKey: boolean
}

export interface UserSummary {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> allowedOrigins = gitProxyConfig.getServer().getAllowedOrigins();
if (!allowedOrigins.isEmpty()) {
log.info("CORS enabled for origins: {}", allowedOrigins);
Expand Down
Loading
Loading