Add API key access levels and processing-only key boundary
This commit is contained in:
+3
-2
@@ -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) {}
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+4
@@ -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/**");
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+85
@@ -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;
|
||||
}
|
||||
}
|
||||
+14
-4
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
+22
@@ -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;
|
||||
}
|
||||
}
|
||||
+23
-10
@@ -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
-16
@@ -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) {}
|
||||
}
|
||||
|
||||
+38
@@ -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;
|
||||
|
||||
+10
-25
@@ -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);
|
||||
}
|
||||
|
||||
+59
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+115
@@ -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();
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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()))
|
||||
|
||||
+37
-5
@@ -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
|
||||
|
||||
+48
-6
@@ -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());
|
||||
|
||||
+1
-1
@@ -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());
|
||||
|
||||
+10
-8
@@ -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,
|
||||
|
||||
+5
-1
@@ -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");
|
||||
|
||||
+2
-1
@@ -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");
|
||||
|
||||
+8
-4
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user