Add API key access levels and processing-only key boundary

This commit is contained in:
Anthony Stirling
2026-07-11 19:30:20 +01:00
parent f1332ebe4f
commit 7c8a46255b
35 changed files with 625 additions and 92 deletions
@@ -2,6 +2,7 @@ package stirling.software.proprietary.model.api.apikey;
/**
* Create-key request body from the portal. {@code scope} is "personal", "team-lead", or
* "team-members"; team scoping resolves to the caller's own team server-side.
* "team-members"; team scoping resolves to the caller's own team server-side. {@code access} is
* "full" or "processing"; a team-scoped key must be processing-only (full access can't be shared).
*/
public record CreateApiKeyRequest(String name, String scope) {}
public record CreateApiKeyRequest(String name, String scope, String access) {}
@@ -13,6 +13,8 @@ public record PortalApiKeyDto(
String prefix,
/** "personal" | "team-lead" | "team-members". */
String scope,
/** "full" (acts as owner) | "processing" (file/PDF endpoints only). */
String access,
/** Team name for a team-scoped key, else null. */
String teamName,
String created,
@@ -6,6 +6,7 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.filter.ApiKeyProcessingScopeInterceptor;
import stirling.software.proprietary.security.filter.ParticipantRateLimitInterceptor;
@Configuration
@@ -13,10 +14,13 @@ import stirling.software.proprietary.security.filter.ParticipantRateLimitInterce
public class ProprietaryWebMvcConfig implements WebMvcConfigurer {
private final ParticipantRateLimitInterceptor participantRateLimitInterceptor;
private final ApiKeyProcessingScopeInterceptor apiKeyProcessingScopeInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(participantRateLimitInterceptor)
.addPathPatterns("/api/v1/workflow/participant/**");
// Confine processing-only (and every shared team) API key to the file/PDF endpoints.
registry.addInterceptor(apiKeyProcessingScopeInterceptor).addPathPatterns("/api/**");
}
}
@@ -57,6 +57,7 @@ import stirling.software.proprietary.security.oauth2.TauriAuthorizationRequestRe
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuccessHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2ResponseAuthenticationConverter;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.CustomOAuth2UserService;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
@@ -484,12 +485,14 @@ public class SecurityConfiguration {
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
public JwtAuthenticationFilter jwtAuthenticationFilter(
ApiKeyAuthenticationService apiKeyAuthenticationService) {
return new JwtAuthenticationFilter(
jwtService,
userService,
userDetailsService,
jwtAuthenticationEntryPoint,
securityProperties);
securityProperties,
apiKeyAuthenticationService);
}
}
@@ -0,0 +1,85 @@
package stirling.software.proprietary.security.filter;
import java.util.Set;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
/**
* Confines a processing-only API key to the file/PDF endpoints. A key the creator marked {@code
* PROCESSING} (and every shared team key is processing-only) may call the tool namespaces below and
* nothing else - any account, team, admin or portal-management request is rejected with 403.
*
* <p>This is the single, additive boundary the whole shared-key model rests on: because a shared
* key can never leave the data plane, it can never carry the owner's account powers no matter which
* filter authenticated it or what role the owner holds. Full-access and legacy keys are unaffected.
*/
@Slf4j
@Component
public class ApiKeyProcessingScopeInterceptor implements HandlerInterceptor {
/**
* The file/PDF processing namespaces a processing-only key may reach. Deliberately an allowlist
* (default-deny): a new management endpoint is blocked automatically, and the worst a missing
* tool prefix can do is 403 a legitimate processing call - never widen access.
*/
private static final Set<String> ALLOWED_PREFIXES =
Set.of(
"/api/v1/general",
"/api/v1/convert",
"/api/v1/misc",
"/api/v1/filter",
"/api/v1/analysis",
"/api/v1/pipeline",
"/api/v1/security",
"/api/v1/form",
"/api/v1/info",
"/api/v1/mobile-scanner");
@Override
public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (!(auth instanceof ApiKeyAuthenticationToken token) || !token.isProcessingOnly()) {
return true;
}
String path = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
path = path.substring(contextPath.length());
}
if (isProcessingPath(path)) {
return true;
}
log.debug("Processing-only API key blocked from non-tool endpoint {}", path);
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json");
response.getWriter()
.write(
"{\"error\":\"Forbidden\",\"message\":\"This API key is limited to"
+ " file/PDF processing endpoints.\",\"status\":403}");
return false;
}
private static boolean isProcessingPath(String path) {
for (String prefix : ALLOWED_PREFIXES) {
if (path.equals(prefix) || path.startsWith(prefix + "/")) {
return true;
}
}
return false;
}
}
@@ -33,8 +33,9 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.UserService;
@@ -48,6 +49,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final CustomUserDetailsService userDetailsService;
private final AuthenticationEntryPoint authenticationEntryPoint;
private final ApplicationProperties.Security securityProperties;
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
@Override
protected void doFilterInternal(
@@ -131,9 +133,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
if (apiKey != null && !apiKey.isBlank()) {
try {
Optional<User> user = userService.getUserByApiKey(apiKey);
// Resolve through the shared service so a team/processing key gets its capped
// authorities and access flag - resolving via the owner here would hand a
// shared
// key the owner's full (admin) rights and bypass the processing-scope boundary.
Optional<ApiKeyAuthentication> resolved =
apiKeyAuthenticationService.authenticate(apiKey);
if (user.isEmpty()) {
if (resolved.isEmpty()) {
handleAuthenticationFailure(
request,
response,
@@ -143,7 +150,10 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
authentication =
new ApiKeyAuthenticationToken(
user.get(), apiKey, user.get().getAuthorities());
resolved.get().user(),
apiKey,
resolved.get().authorities(),
resolved.get().access());
SecurityContextHolder.getContext().setAuthentication(authentication);
return true;
} catch (AuthenticationException e) {
@@ -116,7 +116,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
user,
apiKey,
resolved.get().authorities(),
resolved.get().teamScoped());
resolved.get().access());
SecurityContextHolder.getContext().setAuthentication(authentication);
if (resolved.get().auditLabel() != null) {
MDC.put(API_KEY_LABEL_MDC, resolved.get().auditLabel());
@@ -62,6 +62,15 @@ public class ApiKey implements Serializable {
@Column(name = "scope", nullable = false)
private ApiKeyScope scope;
/**
* How much power the key carries; a shared (team) key is always {@link
* ApiKeyAccess#PROCESSING}.
*/
@Enumerated(EnumType.STRING)
@Column(name = "access", nullable = false)
@Builder.Default
private ApiKeyAccess access = ApiKeyAccess.FULL;
@Column(name = "enabled", nullable = false)
private boolean enabled;
@@ -0,0 +1,22 @@
package stirling.software.proprietary.security.model;
/**
* How much power an {@link ApiKey} carries when it authenticates - chosen by the creator.
*
* <ul>
* <li>{@code FULL} - acts as its owner with the owner's full authorities, including account and
* admin endpoints. A full-access key is the owner's own credential and must never be shared,
* so it is always PERSONAL (never team-scoped).
* <li>{@code PROCESSING} - restricted to the file/PDF processing endpoints; blocked from account,
* team, admin and portal management by {@code ApiKeyProcessingScopeInterceptor}. Safe to
* share regardless of the owner's role, so every team-scoped key is PROCESSING.
* </ul>
*/
public enum ApiKeyAccess {
FULL,
PROCESSING;
public boolean isProcessingOnly() {
return this == PROCESSING;
}
}
@@ -9,38 +9,51 @@ public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal;
private Object credentials;
// True when the resolving key is team-scoped (shared). Such a key must never confer team-leader
// powers - it acts at the level of the least-privileged member who can use it.
private final boolean teamScoped;
// How much power the resolving key carries. A PROCESSING key is restricted to the file/PDF
// endpoints (enforced by ApiKeyProcessingScopeInterceptor) and never confers team-leader
// powers,
// regardless of the owner's role - so a shared key is safe.
private final ApiKeyAccess access;
public ApiKeyAuthenticationToken(String apiKey) {
super((Collection<? extends GrantedAuthority>) null);
this.principal = null;
this.credentials = apiKey;
this.teamScoped = false;
this.access = ApiKeyAccess.FULL;
setAuthenticated(false);
}
public ApiKeyAuthenticationToken(
Object principal, String apiKey, Collection<? extends GrantedAuthority> authorities) {
this(principal, apiKey, authorities, false);
this(principal, apiKey, authorities, ApiKeyAccess.FULL);
}
public ApiKeyAuthenticationToken(
Object principal,
String apiKey,
Collection<? extends GrantedAuthority> authorities,
boolean teamScoped) {
ApiKeyAccess access) {
super(authorities);
this.principal = principal; // principal can be a UserDetails object
this.credentials = apiKey;
this.teamScoped = teamScoped;
this.access = access == null ? ApiKeyAccess.FULL : access;
super.setAuthenticated(true); // this authentication is trusted
}
/** Whether this token came from a shared team key (never grants team-leader authority). */
public boolean isTeamScoped() {
return teamScoped;
/**
* How much power this key carries (FULL acts as owner; PROCESSING is file/PDF endpoints only).
*/
public ApiKeyAccess getAccess() {
return access;
}
/**
* Whether this key is restricted to file/PDF processing endpoints - true for every shared team
* key and for any personal key the owner chose to limit. Such a key never confers team-leader
* or admin powers.
*/
public boolean isProcessingOnly() {
return access != null && access.isProcessingOnly();
}
@Override
@@ -16,7 +16,7 @@ import lombok.RequiredArgsConstructor;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKey;
import stirling.software.proprietary.security.model.ApiKeyScope;
import stirling.software.proprietary.security.model.ApiKeyAccess;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.ApiKeyRepository;
@@ -72,29 +72,28 @@ public class ApiKeyAuthenticationService {
usageRecorder.record(key.getId());
return Optional.of(
new ApiKeyAuthentication(
owner,
auditLabel(key),
authoritiesFor(owner, key),
key.getScope().isTeamScoped()));
owner, auditLabel(key), authoritiesFor(owner, key), key.getAccess()));
}
// Legacy single per-user key: keep working, always personal to its user.
// Legacy single per-user key: keep working, always a full-access personal key for its user.
return userRepository
.findByApiKey(rawKey)
.filter(User::isEnabled)
.map(user -> new ApiKeyAuthentication(user, null, user.getAuthorities(), false));
.map(
user ->
new ApiKeyAuthentication(
user, null, user.getAuthorities(), ApiKeyAccess.FULL));
}
/**
* Authorities the resolved key authenticates with. A PERSONAL key is the owner using their own
* credential, so it carries the owner's authorities unchanged. A TEAM key is a SHARED
* credential (visible to leaders or all members), so it must never confer admin: the owner's
* authorities are capped below {@code ROLE_ADMIN}, falling back to {@code ROLE_USER}. This
* blocks the escalation where an admin-owned team key would hand admin rights to everyone who
* can see it.
* Authorities the resolved key authenticates with. A FULL key is the owner using their own
* credential, so it carries the owner's authorities unchanged. A PROCESSING key is restricted
* to the file/PDF endpoints (and is the only kind that can be shared as a team key); as defence
* in depth behind {@code ApiKeyProcessingScopeInterceptor} it can never carry admin, so the
* owner's authorities are capped below {@code ROLE_ADMIN}, falling back to {@code ROLE_USER}.
*/
private static Collection<? extends GrantedAuthority> authoritiesFor(User owner, ApiKey key) {
if (key.getScope() == ApiKeyScope.PERSONAL) {
if (key.getAccess() != ApiKeyAccess.PROCESSING) {
return owner.getAuthorities();
}
String adminRole = Role.ADMIN.getRoleId();
@@ -135,11 +134,12 @@ public class ApiKeyAuthenticationService {
/**
* A resolved key: the user, an optional processor-feed label, the authorities to run as, and
* whether it is a shared team key (which must not confer team-leader powers).
* how much power the key carries (a PROCESSING key is confined to file/PDF endpoints and never
* confers team-leader powers).
*/
public record ApiKeyAuthentication(
User user,
String auditLabel,
Collection<? extends GrantedAuthority> authorities,
boolean teamScoped) {}
ApiKeyAccess access) {}
}
@@ -26,6 +26,7 @@ import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKey;
import stirling.software.proprietary.security.model.ApiKeyAccess;
import stirling.software.proprietary.security.model.ApiKeyScope;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository;
@@ -154,6 +155,7 @@ public class ApiKeyManagementService {
+ " active API keys; revoke one before creating another");
}
ApiKeyScope scope = parseScope(request.scope());
ApiKeyAccess access = parseAccess(request.access(), scope);
Long teamId = null;
String teamName = null;
@@ -181,6 +183,7 @@ public class ApiKeyManagementService {
.ownerUserId(caller.getId())
.teamId(teamId)
.scope(scope)
.access(access)
.enabled(true)
.createdAt(Instant.now())
.build());
@@ -247,6 +250,7 @@ public class ApiKeyManagementService {
.ownerUserId(user.getId())
.teamId(null)
.scope(ApiKeyScope.PERSONAL)
.access(ApiKeyAccess.FULL)
.enabled(true)
.createdAt(Instant.now())
.build());
@@ -285,6 +289,7 @@ public class ApiKeyManagementService {
.name(key.getName())
.prefix(key.getPrefix())
.scope(scopeLabel(key.getScope()))
.access(accessLabel(key.getAccess()))
.teamName(key.getScope().isTeamScoped() ? teamName : null)
.created(
key.getCreatedAt() == null ? "" : CREATED_FORMAT.format(key.getCreatedAt()))
@@ -308,6 +313,39 @@ public class ApiKeyManagementService {
};
}
private static String accessLabel(ApiKeyAccess access) {
return access == ApiKeyAccess.PROCESSING ? "processing" : "full";
}
/**
* Resolve the requested access level, enforcing that a shared (team) key can only ever be
* processing-only - full access acts as the owner and must not be handed to a team. A personal
* key defaults to full (matching the legacy single key) unless the creator limits it.
*/
private static ApiKeyAccess parseAccess(String raw, ApiKeyScope scope) {
ApiKeyAccess requested = null;
if (raw != null && !raw.isBlank()) {
requested =
switch (raw.trim().toLowerCase(Locale.ROOT)) {
case "full" -> ApiKeyAccess.FULL;
case "processing", "processing-only", "processingonly" ->
ApiKeyAccess.PROCESSING;
default ->
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Unknown access: " + raw);
};
}
if (scope.isTeamScoped()) {
if (requested == ApiKeyAccess.FULL) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"A shared team key must be processing-only; full access can't be shared");
}
return ApiKeyAccess.PROCESSING;
}
return requested == null ? ApiKeyAccess.FULL : requested;
}
private static ApiKeyScope parseScope(String raw) {
if (raw == null || raw.isBlank()) {
return ApiKeyScope.PERSONAL;
@@ -3,56 +3,41 @@ package stirling.software.proprietary.security.service;
import java.time.Instant;
import java.time.ZoneOffset;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.ApiKeyDailyUsage;
import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository;
import stirling.software.proprietary.security.repository.ApiKeyRepository;
/**
* Records per-key usage off the request thread. Kept a separate bean so the {@code @Async} proxy is
* honoured (a self-invocation from the resolver would run inline). Best-effort: never fails a
* request.
* request. The actual writes go through {@link ApiKeyUsageWriter} so each step commits in its own
* transaction and a first-write race can't drop a count.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ApiKeyUsageRecorder {
private final ApiKeyRepository apiKeyRepository;
private final ApiKeyDailyUsageRepository usageRepository;
private final ApiKeyUsageWriter writer;
/** Bump today's tally for the key and stamp last-used. */
@Async("auditExecutor")
@Transactional
public void record(Long apiKeyId) {
if (apiKeyId == null) {
return;
}
try {
long epochDay = Instant.now().atZone(ZoneOffset.UTC).toLocalDate().toEpochDay();
if (usageRepository.incrementIfPresent(apiKeyId, epochDay) == 0) {
try {
usageRepository.save(new ApiKeyDailyUsage(apiKeyId, epochDay, 1));
} catch (DataIntegrityViolationException raced) {
// A concurrent first-write already inserted today's row; increment it instead
// of dropping this request's count.
usageRepository.incrementIfPresent(apiKeyId, epochDay);
}
// First writer of the day inserts the row; everyone else (and the loser of an insert
// race) increments. Separate transactions mean a unique-key clash never rolls back an
// already-counted request.
if (writer.increment(apiKeyId, epochDay) == 0
&& !writer.tryInsertFirstUse(apiKeyId, epochDay)) {
writer.increment(apiKeyId, epochDay);
}
apiKeyRepository
.findById(apiKeyId)
.ifPresent(
key -> {
key.setLastUsedAt(Instant.now());
apiKeyRepository.save(key);
});
writer.stampLastUsed(apiKeyId);
} catch (Exception e) {
log.debug("Failed to record API key usage for id={}", apiKeyId, e);
}
@@ -0,0 +1,59 @@
package stirling.software.proprietary.security.service;
import java.time.Instant;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.model.ApiKeyDailyUsage;
import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository;
import stirling.software.proprietary.security.repository.ApiKeyRepository;
/**
* Per-step transactional writes for {@link ApiKeyUsageRecorder}. Each method runs in its own
* ({@code REQUIRES_NEW}) transaction so a unique-key clash when two requests race to insert the
* day's first row rolls back only that failed insert - never an already-counted request or the
* last-used stamp.
*/
@Component
@RequiredArgsConstructor
class ApiKeyUsageWriter {
private final ApiKeyRepository apiKeyRepository;
private final ApiKeyDailyUsageRepository usageRepository;
/** Bump today's tally if the row already exists; returns rows updated (0 if none yet). */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public int increment(Long apiKeyId, long epochDay) {
return usageRepository.incrementIfPresent(apiKeyId, epochDay);
}
/**
* Insert today's row with a count of 1. Flushes so a concurrent first-write's unique-key clash
* surfaces here (returning false) instead of at commit; the caller then increments instead.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean tryInsertFirstUse(Long apiKeyId, long epochDay) {
try {
usageRepository.saveAndFlush(new ApiKeyDailyUsage(apiKeyId, epochDay, 1));
return true;
} catch (DataIntegrityViolationException raced) {
return false;
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void stampLastUsed(Long apiKeyId) {
apiKeyRepository
.findById(apiKeyId)
.ifPresent(
key -> {
key.setLastUsedAt(Instant.now());
apiKeyRepository.save(key);
});
}
}
@@ -29,6 +29,7 @@ import stirling.software.proprietary.security.database.repository.PersistentLogi
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
@@ -160,7 +161,9 @@ class SecurityConfigurationTest {
@Test
@DisplayName("jwtAuthenticationFilter is created")
void jwtAuthenticationFilter() {
JwtAuthenticationFilter filter = newConfig(true).jwtAuthenticationFilter();
JwtAuthenticationFilter filter =
newConfig(true)
.jwtAuthenticationFilter(mock(ApiKeyAuthenticationService.class));
assertThat(filter).isNotNull();
}
@@ -0,0 +1,115 @@
package stirling.software.proprietary.security.filter;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import stirling.software.proprietary.security.model.ApiKeyAccess;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
/**
* The additive boundary the shared-key model rests on: a processing-only API key may reach the
* file/PDF tool namespaces and nothing else.
*/
class ApiKeyProcessingScopeInterceptorTest {
private final ApiKeyProcessingScopeInterceptor interceptor =
new ApiKeyProcessingScopeInterceptor();
@AfterEach
void clear() {
SecurityContextHolder.clearContext();
}
private void authenticateProcessingKey() {
SecurityContextHolder.getContext()
.setAuthentication(
new ApiKeyAuthenticationToken(
"user", "sk", List.of(), ApiKeyAccess.PROCESSING));
}
private boolean preHandle(String uri) throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("POST", uri);
request.setRequestURI(uri);
MockHttpServletResponse response = new MockHttpServletResponse();
return interceptor.preHandle(request, response, new Object());
}
@Test
void processingKeyAllowedOnToolEndpoints() throws Exception {
authenticateProcessingKey();
assertThat(preHandle("/api/v1/general/merge-pdfs")).isTrue();
assertThat(preHandle("/api/v1/convert/pdf/img")).isTrue();
assertThat(preHandle("/api/v1/security/add-password")).isTrue();
assertThat(preHandle("/api/v1/misc/repair")).isTrue();
}
@Test
void processingKeyBlockedOnManagementEndpoints() throws Exception {
authenticateProcessingKey();
MockHttpServletRequest request =
new MockHttpServletRequest(
"POST", "/api/v1/proprietary/ui-data/infrastructure/api-keys");
request.setRequestURI("/api/v1/proprietary/ui-data/infrastructure/api-keys");
MockHttpServletResponse response = new MockHttpServletResponse();
assertThat(interceptor.preHandle(request, response, new Object())).isFalse();
assertThat(response.getStatus()).isEqualTo(403);
}
@Test
void processingKeyBlockedOnTeamAndAdminEndpoints() throws Exception {
authenticateProcessingKey();
assertThat(preHandle("/api/v1/team/invite")).isFalse();
assertThat(preHandle("/api/v1/admin/settings")).isFalse();
assertThat(preHandle("/api/v1/user/change-role")).isFalse();
}
@Test
void prefixLookalikeIsNotTreatedAsATool() throws Exception {
// "/api/v1/general-admin" must not slip through on a naive startsWith("/api/v1/general").
authenticateProcessingKey();
assertThat(preHandle("/api/v1/general-admin/backup")).isFalse();
}
@Test
void contextPathIsStrippedBeforeMatching() throws Exception {
authenticateProcessingKey();
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/stirling");
request.setContextPath("/stirling");
request.setRequestURI("/stirling/api/v1/general/merge-pdfs");
MockHttpServletResponse response = new MockHttpServletResponse();
assertThat(interceptor.preHandle(request, response, new Object())).isTrue();
}
@Test
void fullAccessKeyIsUnrestricted() throws Exception {
SecurityContextHolder.getContext()
.setAuthentication(
new ApiKeyAuthenticationToken("user", "sk", List.of(), ApiKeyAccess.FULL));
assertThat(preHandle("/api/v1/proprietary/ui-data/infrastructure/api-keys")).isTrue();
assertThat(preHandle("/api/v1/admin/settings")).isTrue();
}
@Test
void nonApiKeyAuthIsIgnored() throws Exception {
SecurityContextHolder.getContext()
.setAuthentication(
new UsernamePasswordAuthenticationToken(
"user", null, List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))));
assertThat(preHandle("/api/v1/admin/settings")).isTrue();
}
@Test
void unauthenticatedIsIgnored() throws Exception {
assertThat(preHandle("/api/v1/admin/settings")).isTrue();
}
}
@@ -27,6 +27,7 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.session.SessionInformation;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.model.ApiKeyAccess;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
@@ -113,7 +114,7 @@ class UserAuthenticationFilterTest {
user,
"Prod (sk_demo0000)",
user.getAuthorities(),
false)));
ApiKeyAccess.FULL)));
when(userService.usernameExistsIgnoreCase("api-user")).thenReturn(true);
when(userService.isUserDisabled("api-user")).thenReturn(false);
when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean()))
@@ -20,6 +20,7 @@ import org.springframework.security.core.GrantedAuthority;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKey;
import stirling.software.proprietary.security.model.ApiKeyAccess;
import stirling.software.proprietary.security.model.ApiKeyScope;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
@@ -131,6 +132,7 @@ class ApiKeyAuthenticationServiceTest {
.ownerUserId(7L)
.teamId(3L)
.scope(ApiKeyScope.TEAM_MEMBERS)
.access(ApiKeyAccess.PROCESSING)
.enabled(true)
.createdAt(Instant.now())
.build();
@@ -144,9 +146,9 @@ class ApiKeyAuthenticationServiceTest {
List<String> auths =
result.get().authorities().stream().map(GrantedAuthority::getAuthority).toList();
assertThat(auths).doesNotContain(Role.ADMIN.getRoleId()).contains(Role.USER.getRoleId());
// Marked team-scoped so SaaS team-leader checks (a membership lookup, not an authority)
// won't treat this shared key as a leader.
assertThat(result.get().teamScoped()).isTrue();
// Marked processing-only so SaaS team-leader checks (a membership lookup, not an authority)
// won't treat this shared key as a leader, and the endpoint boundary confines it to tools.
assertThat(result.get().access()).isEqualTo(ApiKeyAccess.PROCESSING);
}
@Test
@@ -164,8 +166,38 @@ class ApiKeyAuthenticationServiceTest {
List<String> auths =
result.get().authorities().stream().map(GrantedAuthority::getAuthority).toList();
assertThat(auths).contains(Role.ADMIN.getRoleId());
// A personal key is not shared, so it is never team-scoped (keeps the owner's full role).
assertThat(result.get().teamScoped()).isFalse();
// A default personal key is full-access, so it keeps the owner's full role.
assertThat(result.get().access()).isEqualTo(ApiKeyAccess.FULL);
}
@Test
@DisplayName("a personal processing-only key drops admin (cap keys on access, not scope)")
void personalProcessingKeyCapsAdmin() {
String raw = "raw-proc";
User owner = user(9, true);
owner.addAuthority(new Authority(Role.ADMIN.getRoleId(), owner));
ApiKey procKey =
ApiKey.builder()
.id(9L)
.name("Limited")
.keyHash(ApiKeyHasher.hash(raw))
.prefix("sk_demo0000")
.ownerUserId(9L)
.scope(ApiKeyScope.PERSONAL)
.access(ApiKeyAccess.PROCESSING)
.enabled(true)
.createdAt(Instant.now())
.build();
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
.thenReturn(Optional.of(procKey));
when(userRepository.findById(9L)).thenReturn(Optional.of(owner));
var result = service.authenticate(raw);
List<String> auths =
result.get().authorities().stream().map(GrantedAuthority::getAuthority).toList();
assertThat(auths).doesNotContain(Role.ADMIN.getRoleId()).contains(Role.USER.getRoleId());
assertThat(result.get().access()).isEqualTo(ApiKeyAccess.PROCESSING);
}
@Test
@@ -30,6 +30,7 @@ import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKey;
import stirling.software.proprietary.security.model.ApiKeyAccess;
import stirling.software.proprietary.security.model.ApiKeyScope;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository;
@@ -170,7 +171,8 @@ class ApiKeyManagementServiceTest {
@Test
@DisplayName("any user can create a personal key and gets a one-time secret")
void createPersonalKey() {
CreatedApiKeyDto created = service.createKey(new CreateApiKeyRequest("My key", "personal"));
CreatedApiKeyDto created =
service.createKey(new CreateApiKeyRequest("My key", "personal", null));
assertThat(created.secret()).startsWith("sk_");
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
@@ -184,7 +186,10 @@ class ApiKeyManagementServiceTest {
@DisplayName("rejects an over-long key name")
void rejectsLongName() {
String longName = "a".repeat(101);
assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest(longName, "personal")))
assertThatThrownBy(
() ->
service.createKey(
new CreateApiKeyRequest(longName, "personal", null)))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("characters or fewer");
verify(apiKeyRepository, never()).save(any());
@@ -199,7 +204,7 @@ class ApiKeyManagementServiceTest {
assertThatThrownBy(
() ->
service.createKey(
new CreateApiKeyRequest("One too many", "personal")))
new CreateApiKeyRequest("One too many", "personal", null)))
.isInstanceOf(ResponseStatusException.class);
verify(apiKeyRepository, never()).save(any());
}
@@ -212,7 +217,7 @@ class ApiKeyManagementServiceTest {
assertThatThrownBy(
() ->
service.createKey(
new CreateApiKeyRequest("Team key", "team-members")))
new CreateApiKeyRequest("Team key", "team-members", null)))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("team leader");
verify(apiKeyRepository, never()).save(any());
@@ -227,12 +232,49 @@ class ApiKeyManagementServiceTest {
team.setName("Acme");
when(teamRepository.findById(5L)).thenReturn(Optional.of(team));
service.createKey(new CreateApiKeyRequest("Team key", "team-members"));
service.createKey(new CreateApiKeyRequest("Team key", "team-members", null));
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
verify(apiKeyRepository).save(saved.capture());
assertThat(saved.getValue().getScope()).isEqualTo(ApiKeyScope.TEAM_MEMBERS);
assertThat(saved.getValue().getTeamId()).isEqualTo(5L);
// A shared key is always processing-only, so it can never carry account powers.
assertThat(saved.getValue().getAccess()).isEqualTo(ApiKeyAccess.PROCESSING);
}
@Test
@DisplayName("a team key can't request full access (full access can't be shared)")
void teamKeyCannotRequestFullAccess() {
// Rejected at access-parse time, before any team-manager/team-id lookup.
assertThatThrownBy(
() ->
service.createKey(
new CreateApiKeyRequest(
"Team full", "team-members", "full")))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("processing-only");
verify(apiKeyRepository, never()).save(any());
}
@Test
@DisplayName("a personal key can be limited to processing-only")
void personalKeyCanBeProcessingOnly() {
service.createKey(new CreateApiKeyRequest("Limited", "personal", "processing"));
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
verify(apiKeyRepository).save(saved.capture());
assertThat(saved.getValue().getScope()).isEqualTo(ApiKeyScope.PERSONAL);
assertThat(saved.getValue().getAccess()).isEqualTo(ApiKeyAccess.PROCESSING);
}
@Test
@DisplayName("a personal key defaults to full access (acts as the owner)")
void personalKeyDefaultsToFullAccess() {
service.createKey(new CreateApiKeyRequest("Default", "personal", null));
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
verify(apiKeyRepository).save(saved.capture());
assertThat(saved.getValue().getAccess()).isEqualTo(ApiKeyAccess.FULL);
}
@Test
@@ -245,7 +287,7 @@ class ApiKeyManagementServiceTest {
team.setName("Acme");
when(teamRepository.findById(5L)).thenReturn(Optional.of(team));
service.createKey(new CreateApiKeyRequest("Admin team key", "team-members"));
service.createKey(new CreateApiKeyRequest("Admin team key", "team-members", null));
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
verify(apiKeyRepository).save(saved.capture());
@@ -427,7 +427,7 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
ApiKeyAuthenticationToken authToken =
new ApiKeyAuthenticationToken(
user, apiKey, resolved.get().authorities(), resolved.get().teamScoped());
user, apiKey, resolved.get().authorities(), resolved.get().access());
SecurityContextHolder.getContext().setAuthentication(authToken);
if (resolved.get().auditLabel() != null) {
MDC.put(ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY, resolved.get().auditLabel());
@@ -35,7 +35,7 @@ public class TeamSecurityExpressions {
/** Whether the current authenticated user is a {@code LEADER} of the given team. */
public boolean isTeamLeader(Long teamId) {
if (isSharedTeamApiKey()) {
if (isProcessingApiKey()) {
return false;
}
User currentUser = getCurrentUser();
@@ -50,7 +50,7 @@ public class TeamSecurityExpressions {
/** Whether the current authenticated user is a {@code LEADER} of their own team. */
public boolean isCurrentUserTeamLeader() {
if (isSharedTeamApiKey()) {
if (isProcessingApiKey()) {
return false;
}
User currentUser = getCurrentUser();
@@ -64,14 +64,16 @@ public class TeamSecurityExpressions {
}
/**
* A shared team key must never authenticate with team-leader powers: it acts at the level of the
* least-privileged member who can use it. Team-leadership isn't a {@code GrantedAuthority} on
* SaaS (it's a membership-role lookup on the acting owner), so the owner-strips in {@code
* ApiKeyAuthenticationService} can't cap it - the token's team-scope flag does.
* A processing-only API key (which every shared team key is) must never authenticate with
* team-leader powers. Team-leadership isn't a {@code GrantedAuthority} on SaaS (it's a
* membership-role lookup on the acting owner), so the owner-strips in {@code
* ApiKeyAuthenticationService} can't cap it - the token's access flag does. Defence in depth
* behind {@code ApiKeyProcessingScopeInterceptor}, which already blocks such a key from the
* team endpoints entirely.
*/
private boolean isSharedTeamApiKey() {
private boolean isProcessingApiKey() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth instanceof ApiKeyAuthenticationToken token && token.isTeamScoped();
return auth instanceof ApiKeyAuthenticationToken token && token.isProcessingOnly();
}
/** The current authenticated user's team id, or {@code null} if unauthenticated / teamless. */
@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS api_keys (
owner_user_id BIGINT NOT NULL,
team_id BIGINT,
scope VARCHAR(32) NOT NULL,
access VARCHAR(20) NOT NULL DEFAULT 'FULL',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL,
last_used_at TIMESTAMPTZ,
@@ -248,7 +248,11 @@ class SupabaseAuthenticationFilterMoreTest {
Optional.of(
new stirling.software.proprietary.security.service
.ApiKeyAuthenticationService.ApiKeyAuthentication(
user, null, user.getAuthorities(), false)));
user,
null,
user.getAuthorities(),
stirling.software.proprietary.security.model
.ApiKeyAccess.FULL)));
request.setRequestURI("/api/v1/something");
request.setMethod("POST");
@@ -30,6 +30,7 @@ import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import stirling.software.proprietary.security.model.ApiKeyAccess;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
@@ -99,7 +100,7 @@ class SupabaseAuthenticationFilterTest {
Optional.of(
new stirling.software.proprietary.security.service
.ApiKeyAuthenticationService.ApiKeyAuthentication(
user, null, user.getAuthorities(), false)));
user, null, user.getAuthorities(), ApiKeyAccess.FULL)));
request.setRequestURI("/api/v1/something");
request.setMethod("POST");
@@ -21,6 +21,7 @@ import org.springframework.security.core.context.SecurityContextHolder;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.model.TeamMembership;
import stirling.software.proprietary.security.model.ApiKeyAccess;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
@@ -119,10 +120,12 @@ class TeamSecurityExpressionsTest {
@Test
void sharedTeamApiKeyNeverLeadsEvenIfOwnerIsLeader() {
// Owner genuinely leads the team, but the request came in on a team-scoped (shared) key.
// Owner genuinely leads the team, but the request came in on a processing-only (shared)
// key.
SecurityContextHolder.getContext()
.setAuthentication(
new ApiKeyAuthenticationToken(leaderUser(), "sk_shared", List.of(), true));
new ApiKeyAuthenticationToken(
leaderUser(), "sk_shared", List.of(), ApiKeyAccess.PROCESSING));
// Denied without consulting membership - a shared key must not confer team-leader powers.
assertFalse(expressions().isCurrentUserTeamLeader());
@@ -131,10 +134,11 @@ class TeamSecurityExpressionsTest {
@Test
void personalApiKeyOfALeaderStillLeads() {
// A personal (non-shared) key acts as the owner; if they lead, the key leads.
// A full-access (non-shared) key acts as the owner; if they lead, the key leads.
SecurityContextHolder.getContext()
.setAuthentication(
new ApiKeyAuthenticationToken(leaderUser(), "sk_personal", List.of(), false));
new ApiKeyAuthenticationToken(
leaderUser(), "sk_personal", List.of(), ApiKeyAccess.FULL));
when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID))
.thenReturn(Optional.of(membershipWithRole(TeamRole.LEADER)));
@@ -6898,6 +6898,7 @@ heading = "API keys"
subheading = "Personal and team-scoped credentials, each with its own usage tracking."
[portal.infrastructure.apiKeys.card]
access = "Access"
created = "Created"
lastUsed = "Last used"
revoke = "Revoke key"
@@ -6990,6 +6991,12 @@ inProgress = "In progress"
notStarted = "Not started"
[portal.infrastructure.createKey]
accessFull = "Full access"
accessFullHelp = "Acts as you, including account and admin endpoints. Personal keys only — full access can't be shared."
accessLabel = "Access"
accessProcessing = "Processing only"
accessProcessingHelp = "Limited to the file/PDF processing endpoints. Can't touch account, team, or admin settings."
accessTeamNote = "Shared keys are always processing-only, so they can't carry account powers."
cancel = "Cancel"
createKey = "Create key"
done = "Done"
@@ -7052,6 +7059,10 @@ subheading = "Live health for every deployed Stirling region — latency, load,
description = "Deployed regions appear here once your workspace is provisioned."
title = "No regions deployed"
[portal.infrastructure.keyAccessLabel]
full = "Full access"
processing = "Processing only"
[portal.infrastructure.keyLabel]
active = "Active"
revoked = "Revoked"
@@ -7976,6 +7987,7 @@ confirm = "Delete"
title = "Delete source?"
[portal.sources.detail]
apiKeyAccess = "Access"
apiKeyLastUsed = "Last used"
apiKeyPrefix = "Key prefix"
apiKeyReadOnly = "Managed in Infrastructure → API keys. Shown here for visibility and usage only."
@@ -52,12 +52,20 @@ export type ApiKeyStatus = "active" | "revoked";
*/
export type ApiKeyScope = "personal" | "team-lead" | "team-members";
/**
* How much power a key carries. {@code full} acts as its owner (account + admin);
* {@code processing} is confined to the file/PDF endpoints. Shared (team) keys are
* always {@code processing}. Mirrors the backend {@code ApiKeyAccess}.
*/
export type ApiKeyAccess = "full" | "processing";
export interface ApiKey {
id: string;
name: string;
/** Non-secret leading fragment, e.g. "sk_a3f81b2c". */
prefix: string;
scope: ApiKeyScope;
access: ApiKeyAccess;
/** Team name for a team-scoped key, else null. */
teamName: string | null;
created: string;
@@ -317,6 +325,7 @@ export async function fetchApiKeys(): Promise<ApiKeysResponse> {
export async function createApiKey(body: {
name: string;
scope: ApiKeyScope;
access: ApiKeyAccess;
}): Promise<CreatedApiKey> {
const opts = { method: "POST" as const, body };
return apiClient.saas.isConfigured()
@@ -8,6 +8,7 @@ const BASE: ApiKey = {
name: "Production · ingest",
prefix: "sk_a3f81b2c",
scope: "personal",
access: "full",
teamName: null,
created: "2026-03-02",
lastUsed: "2026-07-10 09:14",
@@ -42,6 +43,7 @@ export const TeamMembers: Story = {
...BASE,
name: "Team · shared ingest",
scope: "team-members",
access: "processing",
teamName: "Acme Corp",
},
},
@@ -53,6 +55,7 @@ export const TeamLeadOnly: Story = {
...BASE,
name: "Ops · leaders only",
scope: "team-lead",
access: "processing",
teamName: "Acme Corp",
usageToday: 0,
},
@@ -3,6 +3,7 @@ import { Button, Card, Chip, StatusBadge } from "@app/ui";
import { useTranslation } from "react-i18next";
import type { ApiKey } from "@portal/api/infrastructure";
import {
KEY_ACCESS_LABEL,
KEY_LABEL,
KEY_SCOPE_LABEL,
KEY_SCOPE_TONE,
@@ -57,6 +58,10 @@ export function ApiKeyCard({
{open && (
<div className="portal-infra__key-body">
<dl className="portal-infra__kv">
<div>
<dt>{t("portal.infrastructure.apiKeys.card.access")}</dt>
<dd>{t(KEY_ACCESS_LABEL[apiKey.access])}</dd>
</div>
<div>
<dt>{t("portal.infrastructure.apiKeys.card.created")}</dt>
<dd>{apiKey.created}</dd>
@@ -65,10 +65,12 @@ describe("CreateKeyModal", () => {
screen.getByText("sk_live_demo_key_rotate_in_prod"),
).toBeInTheDocument();
await waitFor(() => expect(onCreated).toHaveBeenCalled());
// Personal is the default scope when the leader doesn't pick a team scope.
// Personal is the default scope when the leader doesn't pick a team scope,
// and a personal key defaults to full access.
expect(createApiKey).toHaveBeenCalledWith({
name: "Production ingest",
scope: "personal",
access: "full",
});
});
@@ -12,6 +12,7 @@ import {
} from "@app/ui";
import {
createApiKey,
type ApiKeyAccess,
type ApiKeyScope,
type CreatedApiKey,
} from "@portal/api/infrastructure";
@@ -36,13 +37,26 @@ export function CreateKeyModal({
const { t } = useTranslation();
const [name, setName] = useState("");
const [scope, setScope] = useState<ApiKeyScope>("personal");
const [access, setAccess] = useState<ApiKeyAccess>("full");
const [created, setCreated] = useState<CreatedApiKey | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// A shared (team) key can only ever be processing-only; full access is never sharable.
const isTeamScope = scope !== "personal";
const effectiveAccess: ApiKeyAccess = isTeamScope ? "processing" : access;
function changeScope(next: ApiKeyScope) {
setScope(next);
if (next !== "personal") {
setAccess("processing");
}
}
function reset() {
setName("");
setScope("personal");
setAccess("full");
setCreated(null);
setSubmitting(false);
setError(null);
@@ -58,7 +72,11 @@ export function CreateKeyModal({
setSubmitting(true);
setError(null);
try {
const result = await createApiKey({ name: name.trim(), scope });
const result = await createApiKey({
name: name.trim(),
scope,
access: effectiveAccess,
});
setCreated(result);
onCreated();
} catch (e) {
@@ -69,6 +87,19 @@ export function CreateKeyModal({
}
const teamLabel = teamName ?? t("portal.infrastructure.createKey.yourTeam");
const accessOptions: RadioOption<ApiKeyAccess>[] = [
{
value: "full",
label: t("portal.infrastructure.createKey.accessFull"),
description: t("portal.infrastructure.createKey.accessFullHelp"),
disabled: isTeamScope,
},
{
value: "processing",
label: t("portal.infrastructure.createKey.accessProcessing"),
description: t("portal.infrastructure.createKey.accessProcessingHelp"),
},
];
const scopeOptions: RadioOption<ApiKeyScope>[] = [
{
value: "personal",
@@ -163,7 +194,7 @@ export function CreateKeyModal({
<RadioGroup<ApiKeyScope>
name="apiKeyScope"
value={scope}
onChange={setScope}
onChange={changeScope}
options={scopeOptions}
/>
{!canCreateTeamKeys && (
@@ -173,6 +204,22 @@ export function CreateKeyModal({
)}
</div>
</FormField>
<FormField label={t("portal.infrastructure.createKey.accessLabel")}>
<div className="portal-infra__stack">
<RadioGroup<ApiKeyAccess>
name="apiKeyAccess"
value={effectiveAccess}
onChange={setAccess}
options={accessOptions}
/>
{isTeamScope && (
<p className="portal-infra__muted">
{t("portal.infrastructure.createKey.accessTeamNote")}
</p>
)}
</div>
</FormField>
</div>
)}
</Modal>
@@ -1,6 +1,7 @@
import type { TFunction } from "i18next";
import type { ChipAccent, StatusTone } from "@app/ui";
import type {
ApiKeyAccess,
ApiKeyScope,
ApiKeyStatus,
AttestationStatus,
@@ -76,6 +77,12 @@ export const KEY_SCOPE_TONE: Record<ApiKeyScope, ChipAccent> = {
"team-members": "brand",
};
// Access labels (i18n keys), resolved via t(KEY_ACCESS_LABEL[value]).
export const KEY_ACCESS_LABEL: Record<ApiKeyAccess, string> = {
full: "portal.infrastructure.keyAccessLabel.full",
processing: "portal.infrastructure.keyAccessLabel.processing",
};
export const CERT_TONE: Record<CertStatus, StatusTone> = {
certified: "success",
"in-progress": "warning",
@@ -169,6 +169,7 @@ const API_KEYS_ALL: ApiKey[] = [
name: "Production · ingest",
prefix: "sk_a3f81b2c",
scope: "personal",
access: "full",
teamName: null,
created: "2026-03-02",
lastUsed: "2026-07-10 09:14",
@@ -183,6 +184,7 @@ const API_KEYS_ALL: ApiKey[] = [
name: "Team · shared ingest",
prefix: "sk_77be0f42",
scope: "team-members",
access: "processing",
teamName: "Acme Corp",
created: "2026-01-18",
lastUsed: "2026-07-10 08:33",
@@ -197,6 +199,7 @@ const API_KEYS_ALL: ApiKey[] = [
name: "Ops · leaders only",
prefix: "sk_d9013ab7",
scope: "team-lead",
access: "processing",
teamName: "Acme Corp",
created: "2025-08-09",
lastUsed: "2026-07-04 17:02",
@@ -211,6 +214,7 @@ const API_KEYS_ALL: ApiKey[] = [
name: "Sandbox · webhook tester",
prefix: "sk_2c4a91de",
scope: "personal",
access: "full",
teamName: null,
created: "2026-05-30",
lastUsed: "Never",
@@ -207,6 +207,7 @@ describe("Sources view", () => {
name: "Production ingest",
prefix: "sk_a3f81b2c",
scope: "personal",
access: "full",
teamName: null,
created: "2026-03-02",
lastUsed: "2026-07-10 09:14",
+8 -1
View File
@@ -21,7 +21,10 @@ import {
type ApiKey,
type ApiKeysResponse,
} from "@portal/api/infrastructure";
import { KEY_SCOPE_LABEL } from "@portal/components/infrastructure/infraFormat";
import {
KEY_ACCESS_LABEL,
KEY_SCOPE_LABEL,
} from "@portal/components/infrastructure/infraFormat";
import { AgentBuilderAction } from "@portal/components/sources/AgentBuilderAction";
import { KpiStrip } from "@portal/components/sources/KpiStrip";
import { SourcesTable } from "@portal/components/sources/SourcesTable";
@@ -48,6 +51,10 @@ function apiKeyToSourceRow(k: ApiKey, t: TFunction): SourceView {
referencingPolicies: [],
config: [
{ label: t("portal.sources.detail.apiKeyScope"), value: scope },
{
label: t("portal.sources.detail.apiKeyAccess"),
value: t(KEY_ACCESS_LABEL[k.access]),
},
{ label: t("portal.sources.detail.apiKeyPrefix"), value: k.prefix },
{ label: t("portal.sources.detail.apiKeyLastUsed"), value: k.lastUsed },
],