Reduce API keys to personal-only; drop team sharing
This commit is contained in:
+9
-21
@@ -20,19 +20,12 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.model.ApiKeyAccess;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* API-key auth for the MCP endpoint: validates a Stirling API key and binds the request to that
|
||||
* user with the MCP scopes.
|
||||
*
|
||||
* <p>Only a full-access key may drive MCP. A processing-only key (every shared team key, plus any
|
||||
* personal key the owner deliberately limited) is confined to the {@code /api} file-processing
|
||||
* allowlist by {@code ApiKeyProcessingScopeInterceptor}; because {@code /mcp} sits outside that
|
||||
* interceptor's path, this filter is where that boundary has to be re-asserted, so it rejects any
|
||||
* key resolved as {@link ApiKeyAccess#PROCESSING}.
|
||||
*/
|
||||
@Slf4j
|
||||
public class McpApiKeyAuthFilter extends OncePerRequestFilter {
|
||||
@@ -42,10 +35,10 @@ public class McpApiKeyAuthFilter extends OncePerRequestFilter {
|
||||
new SimpleGrantedAuthority("SCOPE_mcp.tools.read"),
|
||||
new SimpleGrantedAuthority("SCOPE_mcp.tools.write"));
|
||||
|
||||
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
private final UserService userService;
|
||||
|
||||
public McpApiKeyAuthFilter(ApiKeyAuthenticationService apiKeyAuthenticationService) {
|
||||
this.apiKeyAuthenticationService = apiKeyAuthenticationService;
|
||||
public McpApiKeyAuthFilter(UserService userService) {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -61,22 +54,17 @@ public class McpApiKeyAuthFilter extends OncePerRequestFilter {
|
||||
if (unauthenticated) {
|
||||
String apiKey = extractKey(request);
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
Optional<ApiKeyAuthentication> resolved =
|
||||
apiKeyAuthenticationService.authenticate(apiKey);
|
||||
// Reject a processing-only key: it must not reach MCP tools as the owner.
|
||||
// authenticate()
|
||||
// already rejects inactive keys and disabled owners.
|
||||
if (resolved.isPresent() && resolved.get().access() != ApiKeyAccess.PROCESSING) {
|
||||
Optional<User> user = userService.getUserByApiKey(apiKey);
|
||||
if (user.isPresent() && user.get().isEnabled()) {
|
||||
UsernamePasswordAuthenticationToken auth =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
resolved.get().user().getUsername(), null, MCP_SCOPES);
|
||||
user.get().getUsername(), null, MCP_SCOPES);
|
||||
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||
context.setAuthentication(auth);
|
||||
SecurityContextHolder.setContext(context);
|
||||
} else {
|
||||
log.warn(
|
||||
"MCP access denied: presented API key did not match an active"
|
||||
+ " full-access account");
|
||||
"MCP access denied: presented API key did not match an active account");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-6
@@ -38,7 +38,6 @@ import jakarta.annotation.PostConstruct;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
@@ -53,7 +52,6 @@ public class McpSecurityConfig {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final UserService userService;
|
||||
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
|
||||
// Reuse the app's CORS config; ObjectProvider so the chain still wires when no CORS bean
|
||||
// exists.
|
||||
@@ -64,11 +62,9 @@ public class McpSecurityConfig {
|
||||
public McpSecurityConfig(
|
||||
ApplicationProperties applicationProperties,
|
||||
@Lazy UserService userService,
|
||||
ApiKeyAuthenticationService apiKeyAuthenticationService,
|
||||
ObjectProvider<CorsConfigurationSource> corsConfigurationSource) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.userService = userService;
|
||||
this.apiKeyAuthenticationService = apiKeyAuthenticationService;
|
||||
this.corsConfigurationSource = corsConfigurationSource;
|
||||
}
|
||||
|
||||
@@ -142,8 +138,7 @@ public class McpSecurityConfig {
|
||||
AuthorizationFilter.class)
|
||||
// Authenticate before the anonymous filter sets an anonymous token.
|
||||
.addFilterBefore(
|
||||
new McpApiKeyAuthFilter(apiKeyAuthenticationService),
|
||||
AnonymousAuthenticationFilter.class);
|
||||
new McpApiKeyAuthFilter(userService), AnonymousAuthenticationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
|
||||
+2
-6
@@ -1,8 +1,4 @@
|
||||
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. {@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, String access) {}
|
||||
/** Create-key request body from the portal: just a display name for the new personal key. */
|
||||
public record CreateApiKeyRequest(String name) {}
|
||||
|
||||
+1
-9
@@ -11,12 +11,6 @@ public record PortalApiKeyDto(
|
||||
String id,
|
||||
String name,
|
||||
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,
|
||||
String lastUsed,
|
||||
/** "active" | "revoked". */
|
||||
@@ -24,6 +18,4 @@ public record PortalApiKeyDto(
|
||||
long usageToday,
|
||||
long usageMonth,
|
||||
/** Lifetime request count for the key. */
|
||||
long usageTotal,
|
||||
/** Whether the current caller may revoke this key. */
|
||||
boolean canManage) {}
|
||||
long usageTotal) {}
|
||||
|
||||
+2
-6
@@ -4,10 +4,6 @@ import java.util.List;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* Payload for the API Keys tab: the keys the caller may see, plus whether they may create
|
||||
* team-scoped keys (a team leader / admin) and the team those keys would belong to.
|
||||
*/
|
||||
/** Payload for the API Keys tab: the personal keys the caller owns. */
|
||||
@Builder
|
||||
public record PortalApiKeysResponse(
|
||||
List<PortalApiKeyDto> keys, boolean canCreateTeamKeys, String teamName) {}
|
||||
public record PortalApiKeysResponse(List<PortalApiKeyDto> keys) {}
|
||||
|
||||
-4
@@ -6,7 +6,6 @@ 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
|
||||
@@ -14,13 +13,10 @@ 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/**");
|
||||
}
|
||||
}
|
||||
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+1
-4
@@ -150,10 +150,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
authentication =
|
||||
new ApiKeyAuthenticationToken(
|
||||
resolved.get().user(),
|
||||
apiKey,
|
||||
resolved.get().authorities(),
|
||||
resolved.get().access());
|
||||
resolved.get().user(), apiKey, resolved.get().authorities());
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
return true;
|
||||
} catch (AuthenticationException e) {
|
||||
|
||||
+1
-4
@@ -113,10 +113,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
User user = resolved.get().user();
|
||||
authentication =
|
||||
new ApiKeyAuthenticationToken(
|
||||
user,
|
||||
apiKey,
|
||||
resolved.get().authorities(),
|
||||
resolved.get().access());
|
||||
user, apiKey, resolved.get().authorities());
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
if (resolved.get().auditLabel() != null) {
|
||||
MDC.put(API_KEY_LABEL_MDC, resolved.get().auditLabel());
|
||||
|
||||
+6
-24
@@ -12,18 +12,17 @@ import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* A named API key belonging to a user (and optionally scoped to a team). The raw secret is shown
|
||||
* once at creation and never stored; only its SHA-256 hash is persisted, so a leaked database row
|
||||
* cannot be replayed. Distinct from the legacy single {@code users.apiKey} column, which stays a
|
||||
* per-user personal key for backward compatibility.
|
||||
* A named, personal API key belonging to a user. The raw secret is shown once at creation and never
|
||||
* stored; only its SHA-256 hash is persisted, so a leaked database row cannot be replayed. Distinct
|
||||
* from the legacy single {@code users.apiKey} column, which stays a per-user key for backward
|
||||
* compatibility and is lazily represented here.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "api_keys",
|
||||
indexes = {
|
||||
@Index(name = "idx_api_key_hash", columnList = "key_hash", unique = true),
|
||||
@Index(name = "idx_api_key_owner", columnList = "owner_user_id"),
|
||||
@Index(name = "idx_api_key_team", columnList = "team_id")
|
||||
@Index(name = "idx_api_key_owner", columnList = "owner_user_id")
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@@ -50,27 +49,10 @@ public class ApiKey implements Serializable {
|
||||
@Column(name = "prefix", nullable = false, length = 32)
|
||||
private String prefix;
|
||||
|
||||
/** The user who created and owns the key; a team key still authenticates as this user. */
|
||||
/** The user who created and owns the key; the key authenticates as this user. */
|
||||
@Column(name = "owner_user_id", nullable = false)
|
||||
private Long ownerUserId;
|
||||
|
||||
/** Team the key is scoped to, or null for a personal key. */
|
||||
@Column(name = "team_id")
|
||||
private Long teamId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@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
@@ -1,22 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+1
-31
@@ -5,57 +5,27 @@ import java.util.Collection;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
|
||||
/** Authentication produced from an {@code X-API-KEY} header; runs as the key's owner. */
|
||||
public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
|
||||
|
||||
private final Object principal;
|
||||
private Object credentials;
|
||||
// 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.access = ApiKeyAccess.FULL;
|
||||
setAuthenticated(false);
|
||||
}
|
||||
|
||||
public ApiKeyAuthenticationToken(
|
||||
Object principal, String apiKey, Collection<? extends GrantedAuthority> authorities) {
|
||||
this(principal, apiKey, authorities, ApiKeyAccess.FULL);
|
||||
}
|
||||
|
||||
public ApiKeyAuthenticationToken(
|
||||
Object principal,
|
||||
String apiKey,
|
||||
Collection<? extends GrantedAuthority> authorities,
|
||||
ApiKeyAccess access) {
|
||||
super(authorities);
|
||||
this.principal = principal; // principal can be a UserDetails object
|
||||
this.credentials = apiKey;
|
||||
this.access = access == null ? ApiKeyAccess.FULL : access;
|
||||
super.setAuthenticated(true); // this authentication is trusted
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
public Object getCredentials() {
|
||||
return credentials;
|
||||
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
package stirling.software.proprietary.security.model;
|
||||
|
||||
/**
|
||||
* Who a {@link ApiKey} belongs to and who may see/manage it in the portal. The key itself is always
|
||||
* a bearer credential that authenticates as its owner; the scope governs portal visibility and
|
||||
* management, never a change of the acting principal.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code PERSONAL} - owned by one user, only that user sees or manages it; {@code teamId} is
|
||||
* null. Every key that predates this feature is treated as PERSONAL so it can never leak to a
|
||||
* team.
|
||||
* <li>{@code TEAM_LEAD} - a team key visible to and managed by the team's leaders only.
|
||||
* <li>{@code TEAM_MEMBERS} - a team key visible to every member of the team; still created and
|
||||
* revoked by a leader.
|
||||
* </ul>
|
||||
*/
|
||||
public enum ApiKeyScope {
|
||||
PERSONAL,
|
||||
TEAM_LEAD,
|
||||
TEAM_MEMBERS;
|
||||
|
||||
public boolean isTeamScoped() {
|
||||
return this == TEAM_LEAD || this == TEAM_MEMBERS;
|
||||
}
|
||||
}
|
||||
-2
@@ -16,6 +16,4 @@ public interface ApiKeyRepository extends JpaRepository<ApiKey, Long> {
|
||||
boolean existsByKeyHash(String keyHash);
|
||||
|
||||
List<ApiKey> findByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId);
|
||||
|
||||
List<ApiKey> findByTeamIdOrderByCreatedAtDesc(Long teamId);
|
||||
}
|
||||
|
||||
+8
-46
@@ -2,21 +2,16 @@ package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
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.ApiKeyAccess;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyRepository;
|
||||
|
||||
@@ -26,8 +21,8 @@ import stirling.software.proprietary.security.repository.ApiKeyRepository;
|
||||
* bean cycle.
|
||||
*
|
||||
* <p>Resolution order: the multi-key {@code api_keys} table first (by hash), then the legacy
|
||||
* per-user {@code users.apiKey} column. Legacy keys therefore keep working unchanged and are always
|
||||
* treated as personal to their user.
|
||||
* per-user {@code users.apiKey} column. Legacy keys therefore keep working unchanged. Every key is
|
||||
* personal and authenticates as its owner with the owner's authorities.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -51,9 +46,7 @@ public class ApiKeyAuthenticationService {
|
||||
|
||||
/**
|
||||
* Resolve a raw key, recording usage as a side effect. Returns the owning user, a display label
|
||||
* for the resolved key ({@code null} for the legacy per-user key), and the authorities the
|
||||
* request should run with - which for a team key are capped below admin (see {@link
|
||||
* #authoritiesFor}).
|
||||
* for the resolved key ({@code null} for the legacy per-user key), and the owner's authorities.
|
||||
*/
|
||||
public Optional<ApiKeyAuthentication> authenticate(String rawKey) {
|
||||
if (rawKey == null || rawKey.isBlank()) {
|
||||
@@ -71,40 +64,14 @@ public class ApiKeyAuthenticationService {
|
||||
}
|
||||
usageRecorder.record(key.getId());
|
||||
return Optional.of(
|
||||
new ApiKeyAuthentication(
|
||||
owner, auditLabel(key), authoritiesFor(owner, key), key.getAccess()));
|
||||
new ApiKeyAuthentication(owner, auditLabel(key), owner.getAuthorities()));
|
||||
}
|
||||
|
||||
// Legacy single per-user key: keep working, always a full-access personal key for its user.
|
||||
// Legacy single per-user key: keep working, always a personal key for its user.
|
||||
return userRepository
|
||||
.findByApiKey(rawKey)
|
||||
.filter(User::isEnabled)
|
||||
.map(
|
||||
user ->
|
||||
new ApiKeyAuthentication(
|
||||
user, null, user.getAuthorities(), ApiKeyAccess.FULL));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.getAccess() != ApiKeyAccess.PROCESSING) {
|
||||
return owner.getAuthorities();
|
||||
}
|
||||
String adminRole = Role.ADMIN.getRoleId();
|
||||
List<GrantedAuthority> capped =
|
||||
owner.getAuthorities().stream()
|
||||
.filter(a -> !adminRole.equals(a.getAuthority()))
|
||||
.collect(Collectors.toList());
|
||||
if (capped.isEmpty()) {
|
||||
capped.add(new SimpleGrantedAuthority(Role.USER.getRoleId()));
|
||||
}
|
||||
return capped;
|
||||
.map(user -> new ApiKeyAuthentication(user, null, user.getAuthorities()));
|
||||
}
|
||||
|
||||
/** "Production ingest (sk_a1b2c3d4)" - shown against API-sourced docs in the processor feed. */
|
||||
@@ -133,13 +100,8 @@ public class ApiKeyAuthenticationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* A resolved key: the user, an optional processor-feed label, the authorities to run as, and
|
||||
* how much power the key carries (a PROCESSING key is confined to file/PDF endpoints and never
|
||||
* confers team-leader powers).
|
||||
* A resolved key: the user, an optional processor-feed label, and the authorities to run as.
|
||||
*/
|
||||
public record ApiKeyAuthentication(
|
||||
User user,
|
||||
String auditLabel,
|
||||
Collection<? extends GrantedAuthority> authorities,
|
||||
ApiKeyAccess access) {}
|
||||
User user, String auditLabel, Collection<? extends GrantedAuthority> authorities) {}
|
||||
}
|
||||
|
||||
+16
-161
@@ -3,10 +3,8 @@ package stirling.software.proprietary.security.service;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
@@ -18,29 +16,22 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest;
|
||||
import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto;
|
||||
import stirling.software.proprietary.model.api.apikey.PortalApiKeyDto;
|
||||
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;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyRepository;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
|
||||
/**
|
||||
* Portal-facing CRUD for named API keys: lists what the caller may see, creates personal or
|
||||
* team-scoped keys, and revokes them. Team scoping reuses {@link PolicyManagementAuthority} (a team
|
||||
* leader on SaaS, a global admin self-hosted) so it can't drift from the policy/source rules.
|
||||
* Portal-facing CRUD for named, personal API keys: lists, creates, and revokes the caller's own
|
||||
* keys. Every key belongs to exactly one user and authenticates as that user; there is no sharing.
|
||||
*
|
||||
* <p>Personal keys are strictly owner-only. Every pre-existing single {@code users.apiKey} is
|
||||
* lazily represented as a PERSONAL key owned by that user, so historic keys can never surface to a
|
||||
* team.
|
||||
* <p>Every pre-existing single {@code users.apiKey} is lazily represented as a key owned by that
|
||||
* user, so historic keys list uniformly.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -62,35 +53,19 @@ public class ApiKeyManagementService {
|
||||
private final ApiKeyRepository apiKeyRepository;
|
||||
private final ApiKeyDailyUsageRepository usageRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final TeamRepository teamRepository;
|
||||
private final UserService userService;
|
||||
private final PolicyManagementAuthority policyAuthority;
|
||||
private final ApiKeyLegacyMigrator legacyMigrator;
|
||||
|
||||
/** All keys the caller may see, plus whether they may mint team keys. */
|
||||
/** All keys the caller owns. */
|
||||
@Transactional
|
||||
public PortalApiKeysResponse listVisibleKeys() {
|
||||
User caller = requireCaller();
|
||||
migrateLegacyKey(caller);
|
||||
|
||||
boolean isManager = isTeamKeyManager();
|
||||
Long teamId = policyAuthority.currentUserTeamId();
|
||||
String teamName = teamId == null ? null : teamNameFor(teamId);
|
||||
List<ApiKey> visible =
|
||||
apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(caller.getId());
|
||||
|
||||
List<ApiKey> visible = new ArrayList<>();
|
||||
// Personal keys: only the caller's own.
|
||||
apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(caller.getId()).stream()
|
||||
.filter(k -> k.getScope() == ApiKeyScope.PERSONAL)
|
||||
.forEach(visible::add);
|
||||
// Team keys: members see TEAM_MEMBERS; only managers additionally see TEAM_LEAD.
|
||||
if (teamId != null) {
|
||||
apiKeyRepository.findByTeamIdOrderByCreatedAtDesc(teamId).stream()
|
||||
.filter(k -> k.getScope().isTeamScoped())
|
||||
.filter(k -> k.getScope() == ApiKeyScope.TEAM_MEMBERS || isManager)
|
||||
.forEach(visible::add);
|
||||
}
|
||||
|
||||
// Batch usage for all visible keys into two queries rather than two-per-key (avoids N+1).
|
||||
// Batch usage for all keys into three queries rather than two-per-key (avoids N+1).
|
||||
long today = Instant.now().atZone(ZoneOffset.UTC).toLocalDate().toEpochDay();
|
||||
List<Long> ids = visible.stream().map(ApiKey::getId).toList();
|
||||
Map<Long, Long> todayById = new HashMap<>();
|
||||
@@ -113,25 +88,19 @@ public class ApiKeyManagementService {
|
||||
.map(
|
||||
k ->
|
||||
toDto(
|
||||
caller,
|
||||
k,
|
||||
teamName,
|
||||
zeroIfNull(todayById.get(k.getId())),
|
||||
zeroIfNull(monthById.get(k.getId())),
|
||||
zeroIfNull(totalById.get(k.getId()))))
|
||||
.toList();
|
||||
return PortalApiKeysResponse.builder()
|
||||
.keys(keys)
|
||||
.canCreateTeamKeys(isManager && teamId != null)
|
||||
.teamName(teamName)
|
||||
.build();
|
||||
return PortalApiKeysResponse.builder().keys(keys).build();
|
||||
}
|
||||
|
||||
private static long zeroIfNull(Long value) {
|
||||
return value == null ? 0L : value;
|
||||
}
|
||||
|
||||
/** Create a key and return its one-time secret. */
|
||||
/** Create a personal key and return its one-time secret. */
|
||||
@Transactional
|
||||
public CreatedApiKeyDto createKey(CreateApiKeyRequest request) {
|
||||
User caller = requireCaller();
|
||||
@@ -155,24 +124,6 @@ public class ApiKeyManagementService {
|
||||
+ MAX_ACTIVE_KEYS_PER_USER
|
||||
+ " 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;
|
||||
if (scope.isTeamScoped()) {
|
||||
if (!isTeamKeyManager()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Team API keys can only be created by a team leader or an admin");
|
||||
}
|
||||
teamId = policyAuthority.currentUserTeamId();
|
||||
if (teamId == null) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "No team is available to scope this key to");
|
||||
}
|
||||
teamName = teamNameFor(teamId);
|
||||
}
|
||||
|
||||
String rawKey = ApiKeyHasher.generateRawKey();
|
||||
ApiKey saved =
|
||||
@@ -182,20 +133,14 @@ public class ApiKeyManagementService {
|
||||
.keyHash(ApiKeyHasher.hash(rawKey))
|
||||
.prefix(ApiKeyHasher.displayPrefix(rawKey))
|
||||
.ownerUserId(caller.getId())
|
||||
.teamId(teamId)
|
||||
.scope(scope)
|
||||
.access(access)
|
||||
.enabled(true)
|
||||
.createdAt(Instant.now())
|
||||
.build());
|
||||
|
||||
return CreatedApiKeyDto.builder()
|
||||
.key(toDto(caller, saved, teamName, 0L, 0L, 0L))
|
||||
.secret(rawKey)
|
||||
.build();
|
||||
return CreatedApiKeyDto.builder().key(toDto(saved, 0L, 0L, 0L)).secret(rawKey).build();
|
||||
}
|
||||
|
||||
/** Soft-revoke a key the caller manages; also clears the legacy column if it is that key. */
|
||||
/** Soft-revoke a key the caller owns; also clears the legacy column if it is that key. */
|
||||
@Transactional
|
||||
public void revokeKey(Long id) {
|
||||
User caller = requireCaller();
|
||||
@@ -204,8 +149,8 @@ public class ApiKeyManagementService {
|
||||
.findById(id)
|
||||
.orElseThrow(
|
||||
() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "No key"));
|
||||
if (!canManage(caller, key)) {
|
||||
// Not-found rather than forbidden so a caller can't probe other teams' key ids.
|
||||
if (!key.getOwnerUserId().equals(caller.getId())) {
|
||||
// Not-found rather than forbidden so a caller can't probe other users' key ids.
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No key");
|
||||
}
|
||||
key.setEnabled(false);
|
||||
@@ -214,25 +159,7 @@ public class ApiKeyManagementService {
|
||||
clearLegacyColumnIfMatches(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Team keys are managed by a team leader (the SaaS rule) or an admin (either flavor) - an admin
|
||||
* is effectively a leader for this purpose, which the SaaS team-leader-only check misses.
|
||||
*/
|
||||
private boolean isTeamKeyManager() {
|
||||
return policyAuthority.canEditPolicies() || userService.isCurrentUserAdmin();
|
||||
}
|
||||
|
||||
/** Whether the caller may revoke a given key. */
|
||||
private boolean canManage(User caller, ApiKey key) {
|
||||
if (key.getScope() == ApiKeyScope.PERSONAL) {
|
||||
return key.getOwnerUserId().equals(caller.getId());
|
||||
}
|
||||
return isTeamKeyManager()
|
||||
&& key.getTeamId() != null
|
||||
&& key.getTeamId().equals(policyAuthority.currentUserTeamId());
|
||||
}
|
||||
|
||||
/** Represent a user's pre-existing single key as a PERSONAL row so it lists uniformly. */
|
||||
/** Represent a user's pre-existing single key as a row so it lists uniformly. */
|
||||
private void migrateLegacyKey(User user) {
|
||||
String legacy = user.getApiKey();
|
||||
if (legacy == null || legacy.isBlank()) {
|
||||
@@ -251,9 +178,6 @@ public class ApiKeyManagementService {
|
||||
.keyHash(hash)
|
||||
.prefix(ApiKeyHasher.displayPrefix(legacy))
|
||||
.ownerUserId(user.getId())
|
||||
.teamId(null)
|
||||
.scope(ApiKeyScope.PERSONAL)
|
||||
.access(ApiKeyAccess.FULL)
|
||||
.enabled(true)
|
||||
.createdAt(Instant.now())
|
||||
.build());
|
||||
@@ -280,20 +204,11 @@ public class ApiKeyManagementService {
|
||||
});
|
||||
}
|
||||
|
||||
private PortalApiKeyDto toDto(
|
||||
User caller,
|
||||
ApiKey key,
|
||||
String teamName,
|
||||
long usageToday,
|
||||
long usageMonth,
|
||||
long usageTotal) {
|
||||
private PortalApiKeyDto toDto(ApiKey key, long usageToday, long usageMonth, long usageTotal) {
|
||||
return PortalApiKeyDto.builder()
|
||||
.id(String.valueOf(key.getId()))
|
||||
.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()))
|
||||
.lastUsed(
|
||||
@@ -304,69 +219,9 @@ public class ApiKeyManagementService {
|
||||
.usageToday(usageToday)
|
||||
.usageMonth(usageMonth)
|
||||
.usageTotal(usageTotal)
|
||||
.canManage(canManage(caller, key))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String scopeLabel(ApiKeyScope scope) {
|
||||
return switch (scope) {
|
||||
case PERSONAL -> "personal";
|
||||
case TEAM_LEAD -> "team-lead";
|
||||
case TEAM_MEMBERS -> "team-members";
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return switch (raw.trim().toLowerCase(Locale.ROOT)) {
|
||||
case "personal" -> ApiKeyScope.PERSONAL;
|
||||
case "team-lead", "team_lead", "teamlead" -> ApiKeyScope.TEAM_LEAD;
|
||||
case "team-members", "team_members", "teammembers", "team" -> ApiKeyScope.TEAM_MEMBERS;
|
||||
default ->
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Unknown scope: " + raw);
|
||||
};
|
||||
}
|
||||
|
||||
private String teamNameFor(Long teamId) {
|
||||
return teamRepository.findById(teamId).map(Team::getName).orElse(null);
|
||||
}
|
||||
|
||||
private User requireCaller() {
|
||||
String username = userService.getCurrentUsername();
|
||||
if (username == null || username.isBlank() || "anonymousUser".equalsIgnoreCase(username)) {
|
||||
|
||||
+4
-40
@@ -26,10 +26,7 @@ import stirling.software.proprietary.mcp.tools.StirlingConvertTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingMiscTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingPagesTool;
|
||||
import stirling.software.proprietary.mcp.tools.StirlingSecurityTool;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAccess;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
@@ -41,7 +38,6 @@ import stirling.software.proprietary.security.service.UserService;
|
||||
class McpApiKeyIntegrationTest {
|
||||
|
||||
private static final String VALID_KEY = "stirling-test-key-abc123";
|
||||
private static final String PROCESSING_KEY = "stirling-test-processing-xyz";
|
||||
|
||||
@LocalServerPort private int port;
|
||||
private final HttpClient http = HttpClient.newHttpClient();
|
||||
@@ -87,16 +83,6 @@ class McpApiKeyIntegrationTest {
|
||||
assertThat(response.statusCode()).isEqualTo(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void processingOnlyKey_isRejectedWith401() throws Exception {
|
||||
// A shared/team (processing-only) key must not reach MCP tools as the owner.
|
||||
HttpResponse<String> response =
|
||||
postMcp(
|
||||
b -> b.header("X-API-KEY", PROCESSING_KEY),
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}");
|
||||
assertThat(response.statusCode()).isEqualTo(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noOAuthMetadataInApiKeyMode() throws Exception {
|
||||
HttpRequest req =
|
||||
@@ -146,38 +132,16 @@ class McpApiKeyIntegrationTest {
|
||||
return props;
|
||||
}
|
||||
|
||||
// Still a constructor dependency of McpSecurityConfig (used by the OAuth chain); the
|
||||
// api-key chain now authenticates through ApiKeyAuthenticationService instead.
|
||||
@Bean
|
||||
UserService userService() {
|
||||
return org.mockito.Mockito.mock(UserService.class);
|
||||
}
|
||||
|
||||
@Bean
|
||||
ApiKeyAuthenticationService apiKeyAuthenticationService() {
|
||||
ApiKeyAuthenticationService mock =
|
||||
org.mockito.Mockito.mock(ApiKeyAuthenticationService.class);
|
||||
UserService mock = org.mockito.Mockito.mock(UserService.class);
|
||||
User account = org.mockito.Mockito.mock(User.class);
|
||||
org.mockito.Mockito.when(account.isEnabled()).thenReturn(true);
|
||||
org.mockito.Mockito.when(account.getUsername()).thenReturn("alice");
|
||||
org.mockito.Mockito.when(mock.authenticate(org.mockito.ArgumentMatchers.anyString()))
|
||||
org.mockito.Mockito.when(mock.getUserByApiKey(org.mockito.ArgumentMatchers.anyString()))
|
||||
.thenReturn(Optional.empty());
|
||||
org.mockito.Mockito.when(mock.authenticate(VALID_KEY))
|
||||
.thenReturn(
|
||||
Optional.of(
|
||||
new ApiKeyAuthentication(
|
||||
account,
|
||||
null,
|
||||
java.util.List.of(),
|
||||
ApiKeyAccess.FULL)));
|
||||
org.mockito.Mockito.when(mock.authenticate(PROCESSING_KEY))
|
||||
.thenReturn(
|
||||
Optional.of(
|
||||
new ApiKeyAuthentication(
|
||||
account,
|
||||
"Team key (sk_proc)",
|
||||
java.util.List.of(),
|
||||
ApiKeyAccess.PROCESSING)));
|
||||
org.mockito.Mockito.when(mock.getUserByApiKey(VALID_KEY))
|
||||
.thenReturn(Optional.of(account));
|
||||
return mock;
|
||||
}
|
||||
}
|
||||
|
||||
-9
@@ -352,14 +352,5 @@ class McpOAuthIntegrationTest {
|
||||
.thenReturn(java.util.Optional.of(account));
|
||||
return mock;
|
||||
}
|
||||
|
||||
// Constructor dependency of McpSecurityConfig; unused on the OAuth chain.
|
||||
@Bean
|
||||
stirling.software.proprietary.security.service.ApiKeyAuthenticationService
|
||||
apiKeyAuthenticationService() {
|
||||
return org.mockito.Mockito.mock(
|
||||
stirling.software.proprietary.security.service.ApiKeyAuthenticationService
|
||||
.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-9
@@ -101,14 +101,5 @@ class McpScopeMetadataDisabledTest {
|
||||
UserService userService() {
|
||||
return org.mockito.Mockito.mock(UserService.class);
|
||||
}
|
||||
|
||||
// Constructor dependency of McpSecurityConfig; unused on the OAuth chain.
|
||||
@Bean
|
||||
stirling.software.proprietary.security.service.ApiKeyAuthenticationService
|
||||
apiKeyAuthenticationService() {
|
||||
return org.mockito.Mockito.mock(
|
||||
stirling.software.proprietary.security.service.ApiKeyAuthenticationService
|
||||
.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
+1
-5
@@ -27,7 +27,6 @@ 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;
|
||||
@@ -111,10 +110,7 @@ class UserAuthenticationFilterTest {
|
||||
.thenReturn(
|
||||
Optional.of(
|
||||
new ApiKeyAuthentication(
|
||||
user,
|
||||
"Prod (sk_demo0000)",
|
||||
user.getAuthorities(),
|
||||
ApiKeyAccess.FULL)));
|
||||
user, "Prod (sk_demo0000)", user.getAuthorities())));
|
||||
when(userService.usernameExistsIgnoreCase("api-user")).thenReturn(true);
|
||||
when(userService.isUserDisabled("api-user")).thenReturn(false);
|
||||
when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean()))
|
||||
|
||||
+2
-71
@@ -20,8 +20,6 @@ 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;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyRepository;
|
||||
@@ -50,7 +48,6 @@ class ApiKeyAuthenticationServiceTest {
|
||||
.keyHash(ApiKeyHasher.hash("raw-" + id))
|
||||
.prefix("sk_demo0000")
|
||||
.ownerUserId(ownerId)
|
||||
.scope(ApiKeyScope.PERSONAL)
|
||||
.enabled(enabled)
|
||||
.revokedAt(revoked)
|
||||
.createdAt(Instant.now())
|
||||
@@ -118,42 +115,8 @@ class ApiKeyAuthenticationServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a team key drops admin from the acting authorities (shared credential)")
|
||||
void teamKeyCapsAdminAuthority() {
|
||||
String raw = "raw-team";
|
||||
User owner = user(7, true);
|
||||
owner.addAuthority(new Authority(Role.ADMIN.getRoleId(), owner));
|
||||
ApiKey teamKey =
|
||||
ApiKey.builder()
|
||||
.id(5L)
|
||||
.name("Team key")
|
||||
.keyHash(ApiKeyHasher.hash(raw))
|
||||
.prefix("sk_demo0000")
|
||||
.ownerUserId(7L)
|
||||
.teamId(3L)
|
||||
.scope(ApiKeyScope.TEAM_MEMBERS)
|
||||
.access(ApiKeyAccess.PROCESSING)
|
||||
.enabled(true)
|
||||
.createdAt(Instant.now())
|
||||
.build();
|
||||
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
|
||||
.thenReturn(Optional.of(teamKey));
|
||||
when(userRepository.findById(7L)).thenReturn(Optional.of(owner));
|
||||
|
||||
var result = service.authenticate(raw);
|
||||
|
||||
assertThat(result).isPresent();
|
||||
List<String> auths =
|
||||
result.get().authorities().stream().map(GrantedAuthority::getAuthority).toList();
|
||||
assertThat(auths).doesNotContain(Role.ADMIN.getRoleId()).contains(Role.USER.getRoleId());
|
||||
// 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
|
||||
@DisplayName("a personal key keeps the owner's authorities (owner acts as self)")
|
||||
void personalKeyKeepsOwnerAuthorities() {
|
||||
@DisplayName("a key authenticates with its owner's authorities (owner acts as self)")
|
||||
void keyKeepsOwnerAuthorities() {
|
||||
String raw = "raw-6";
|
||||
User owner = user(8, true);
|
||||
owner.addAuthority(new Authority(Role.ADMIN.getRoleId(), owner));
|
||||
@@ -166,38 +129,6 @@ class ApiKeyAuthenticationServiceTest {
|
||||
List<String> auths =
|
||||
result.get().authorities().stream().map(GrantedAuthority::getAuthority).toList();
|
||||
assertThat(auths).contains(Role.ADMIN.getRoleId());
|
||||
// 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
|
||||
|
||||
+25
-186
@@ -21,22 +21,16 @@ import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest;
|
||||
import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto;
|
||||
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;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyRepository;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("ApiKeyManagementService")
|
||||
@@ -45,9 +39,7 @@ class ApiKeyManagementServiceTest {
|
||||
@Mock private ApiKeyRepository apiKeyRepository;
|
||||
@Mock private ApiKeyDailyUsageRepository usageRepository;
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private TeamRepository teamRepository;
|
||||
@Mock private UserService userService;
|
||||
@Mock private PolicyManagementAuthority policyAuthority;
|
||||
@Mock private ApiKeyLegacyMigrator legacyMigrator;
|
||||
@InjectMocks private ApiKeyManagementService service;
|
||||
|
||||
@@ -63,8 +55,8 @@ class ApiKeyManagementServiceTest {
|
||||
.when(userService.findByUsernameIgnoreCase("alice"))
|
||||
.thenReturn(Optional.of(caller));
|
||||
lenient().when(apiKeyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
lenient().when(usageRepository.countForDay(any(), anyLong())).thenReturn(0L);
|
||||
lenient().when(usageRepository.sumSince(any(), anyLong())).thenReturn(0L);
|
||||
lenient().when(usageRepository.countForDayByIds(any(), anyLong())).thenReturn(List.of());
|
||||
lenient().when(usageRepository.sumSinceByIds(any(), anyLong())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
private ApiKey personalKey(long id, long ownerId) {
|
||||
@@ -74,21 +66,6 @@ class ApiKeyManagementServiceTest {
|
||||
.keyHash("hash" + id)
|
||||
.prefix("sk_demo0000")
|
||||
.ownerUserId(ownerId)
|
||||
.scope(ApiKeyScope.PERSONAL)
|
||||
.enabled(true)
|
||||
.createdAt(Instant.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
private ApiKey teamKey(long id, long teamId, ApiKeyScope scope) {
|
||||
return ApiKey.builder()
|
||||
.id(id)
|
||||
.name("Team key " + id)
|
||||
.keyHash("hash" + id)
|
||||
.prefix("sk_demo0000")
|
||||
.ownerUserId(99L)
|
||||
.teamId(teamId)
|
||||
.scope(scope)
|
||||
.enabled(true)
|
||||
.createdAt(Instant.now())
|
||||
.build();
|
||||
@@ -97,12 +74,11 @@ class ApiKeyManagementServiceTest {
|
||||
// ---- migration safety ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("an existing legacy key migrates to a PERSONAL, owner-only row - never a team")
|
||||
@DisplayName("an existing legacy key migrates to an owner-only row")
|
||||
void legacyKeyMigratesAsPersonal() {
|
||||
caller.setApiKey("legacy-raw-key");
|
||||
when(apiKeyRepository.existsByKeyHash(ApiKeyHasher.hash("legacy-raw-key")))
|
||||
.thenReturn(false);
|
||||
when(policyAuthority.currentUserTeamId()).thenReturn(null);
|
||||
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)).thenReturn(List.of());
|
||||
|
||||
service.listVisibleKeys();
|
||||
@@ -111,9 +87,7 @@ class ApiKeyManagementServiceTest {
|
||||
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
|
||||
verify(legacyMigrator).insertMigratedKey(saved.capture());
|
||||
ApiKey migrated = saved.getValue();
|
||||
assertThat(migrated.getScope()).isEqualTo(ApiKeyScope.PERSONAL);
|
||||
assertThat(migrated.getOwnerUserId()).isEqualTo(1L);
|
||||
assertThat(migrated.getTeamId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -122,7 +96,6 @@ class ApiKeyManagementServiceTest {
|
||||
caller.setApiKey("legacy-raw-key");
|
||||
when(apiKeyRepository.existsByKeyHash(ApiKeyHasher.hash("legacy-raw-key")))
|
||||
.thenReturn(true);
|
||||
when(policyAuthority.currentUserTeamId()).thenReturn(null);
|
||||
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)).thenReturn(List.of());
|
||||
|
||||
service.listVisibleKeys();
|
||||
@@ -133,175 +106,66 @@ class ApiKeyManagementServiceTest {
|
||||
// ---- personal isolation -------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("listing scopes personal keys to the caller by owner id")
|
||||
@DisplayName("listing scopes keys to the caller by owner id")
|
||||
void personalKeysScopedToOwner() {
|
||||
when(policyAuthority.currentUserTeamId()).thenReturn(null);
|
||||
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L))
|
||||
.thenReturn(List.of(personalKey(10, 1L)));
|
||||
|
||||
PortalApiKeysResponse res = service.listVisibleKeys();
|
||||
|
||||
assertThat(res.keys())
|
||||
.singleElement()
|
||||
.satisfies(k -> assertThat(k.scope()).isEqualTo("personal"));
|
||||
assertThat(res.keys()).singleElement().satisfies(k -> assertThat(k.id()).isEqualTo("10"));
|
||||
// Isolation: the query is keyed by the caller's id, never a broad scan.
|
||||
verify(apiKeyRepository).findByOwnerUserIdOrderByCreatedAtDesc(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a member sees TEAM_MEMBERS keys but not TEAM_LEAD keys")
|
||||
void memberSeesTeamMembersNotTeamLead() {
|
||||
when(policyAuthority.canEditPolicies()).thenReturn(false);
|
||||
when(policyAuthority.currentUserTeamId()).thenReturn(5L);
|
||||
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)).thenReturn(List.of());
|
||||
when(apiKeyRepository.findByTeamIdOrderByCreatedAtDesc(5L))
|
||||
.thenReturn(
|
||||
List.of(
|
||||
teamKey(20, 5L, ApiKeyScope.TEAM_LEAD),
|
||||
teamKey(21, 5L, ApiKeyScope.TEAM_MEMBERS)));
|
||||
Team team = new Team();
|
||||
team.setName("Acme");
|
||||
lenient().when(teamRepository.findById(5L)).thenReturn(Optional.of(team));
|
||||
|
||||
PortalApiKeysResponse res = service.listVisibleKeys();
|
||||
|
||||
assertThat(res.keys()).singleElement().satisfies(k -> assertThat(k.id()).isEqualTo("21"));
|
||||
assertThat(res.canCreateTeamKeys()).isFalse();
|
||||
}
|
||||
|
||||
// ---- creation -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("any user can create a personal key and gets a one-time secret")
|
||||
@DisplayName("a user creates a personal key and gets a one-time secret")
|
||||
void createPersonalKey() {
|
||||
CreatedApiKeyDto created =
|
||||
service.createKey(new CreateApiKeyRequest("My key", "personal", null));
|
||||
CreatedApiKeyDto created = service.createKey(new CreateApiKeyRequest("My key"));
|
||||
|
||||
assertThat(created.secret()).startsWith("sk_");
|
||||
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
|
||||
verify(apiKeyRepository).save(saved.capture());
|
||||
assertThat(saved.getValue().getScope()).isEqualTo(ApiKeyScope.PERSONAL);
|
||||
assertThat(saved.getValue().getTeamId()).isNull();
|
||||
assertThat(saved.getValue().getOwnerUserId()).isEqualTo(1L);
|
||||
assertThat(saved.getValue().getName()).isEqualTo("My key");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects an over-long key name")
|
||||
void rejectsLongName() {
|
||||
String longName = "a".repeat(101);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
service.createKey(
|
||||
new CreateApiKeyRequest(longName, "personal", null)))
|
||||
assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest(longName)))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("characters or fewer");
|
||||
verify(apiKeyRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a blank key name")
|
||||
void rejectsBlankName() {
|
||||
assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest(" ")))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("required");
|
||||
verify(apiKeyRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects creating a key past the per-user active-key cap")
|
||||
void rejectsPastActiveKeyCap() {
|
||||
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L))
|
||||
.thenReturn(java.util.Collections.nCopies(50, personalKey(100, 1L)));
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
service.createKey(
|
||||
new CreateApiKeyRequest("One too many", "personal", null)))
|
||||
assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest("One too many")))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verify(apiKeyRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("creating a team key without leader rights is forbidden")
|
||||
void createTeamKeyRequiresLeader() {
|
||||
when(policyAuthority.canEditPolicies()).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
service.createKey(
|
||||
new CreateApiKeyRequest("Team key", "team-members", null)))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("team leader");
|
||||
verify(apiKeyRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a leader creates a team key scoped to their own team")
|
||||
void createTeamKeyAsLeader() {
|
||||
when(policyAuthority.canEditPolicies()).thenReturn(true);
|
||||
when(policyAuthority.currentUserTeamId()).thenReturn(5L);
|
||||
Team team = new Team();
|
||||
team.setName("Acme");
|
||||
when(teamRepository.findById(5L)).thenReturn(Optional.of(team));
|
||||
|
||||
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
|
||||
@DisplayName("an admin who is not a team leader can still create a team key")
|
||||
void adminCanCreateTeamKey() {
|
||||
when(policyAuthority.canEditPolicies()).thenReturn(false);
|
||||
when(userService.isCurrentUserAdmin()).thenReturn(true);
|
||||
when(policyAuthority.currentUserTeamId()).thenReturn(5L);
|
||||
Team team = new Team();
|
||||
team.setName("Acme");
|
||||
when(teamRepository.findById(5L)).thenReturn(Optional.of(team));
|
||||
|
||||
service.createKey(new CreateApiKeyRequest("Admin 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);
|
||||
}
|
||||
|
||||
// ---- revocation ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("owner revokes their personal key and the legacy column is cleared")
|
||||
@DisplayName("owner revokes their key and the legacy column is cleared")
|
||||
void revokePersonalClearsLegacy() {
|
||||
caller.setApiKey("legacy-raw-key");
|
||||
ApiKey legacyRow = personalKey(30, 1L);
|
||||
@@ -318,42 +182,17 @@ class ApiKeyManagementServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a non-owner cannot revoke someone else's personal key")
|
||||
void revokeForeignPersonalKeyForbidden() {
|
||||
@DisplayName(
|
||||
"a non-owner cannot revoke someone else's key (404, not 403, so ids can't be probed)")
|
||||
void revokeForeignKeyForbidden() {
|
||||
when(apiKeyRepository.findById(31L)).thenReturn(Optional.of(personalKey(31, 999L)));
|
||||
|
||||
assertThatThrownBy(() -> service.revokeKey(31L))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verify(apiKeyRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a non-leader cannot revoke a team key")
|
||||
void revokeTeamKeyRequiresLeader() {
|
||||
when(apiKeyRepository.findById(32L))
|
||||
.thenReturn(Optional.of(teamKey(32, 5L, ApiKeyScope.TEAM_MEMBERS)));
|
||||
when(policyAuthority.canEditPolicies()).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> service.revokeKey(32L))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a leader of another team cannot revoke this team's key, and gets 404 not 403")
|
||||
void revokeCrossTeamKeyNotFound() {
|
||||
// Caller is a leader (canEditPolicies) but of a DIFFERENT team than the target key.
|
||||
when(apiKeyRepository.findById(40L))
|
||||
.thenReturn(Optional.of(teamKey(40, 5L, ApiKeyScope.TEAM_MEMBERS)));
|
||||
when(policyAuthority.canEditPolicies()).thenReturn(true);
|
||||
when(policyAuthority.currentUserTeamId()).thenReturn(6L);
|
||||
|
||||
assertThatThrownBy(() -> service.revokeKey(40L))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
// 404 (not 403) so a caller can't probe other teams' key ids.
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.NOT_FOUND));
|
||||
assertThat(((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(404));
|
||||
verify(apiKeyRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
|
||||
+1
-5
@@ -37,7 +37,6 @@ import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
|
||||
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAccess;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
@@ -103,10 +102,7 @@ class UserServiceMoreTest {
|
||||
User u = user("api");
|
||||
u.addAuthority(new Authority("ROLE_USER", u));
|
||||
when(apiKeyAuthenticationService.authenticate("k"))
|
||||
.thenReturn(
|
||||
Optional.of(
|
||||
new ApiKeyAuthentication(
|
||||
u, null, u.getAuthorities(), ApiKeyAccess.FULL)));
|
||||
.thenReturn(Optional.of(new ApiKeyAuthentication(u, null, u.getAuthorities())));
|
||||
|
||||
assertThat(userService.getAuthentication("k")).isNotNull();
|
||||
}
|
||||
|
||||
+1
-2
@@ -426,8 +426,7 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
|
||||
userService.trackApiKeyFirstUse(user);
|
||||
|
||||
ApiKeyAuthenticationToken authToken =
|
||||
new ApiKeyAuthenticationToken(
|
||||
user, apiKey, resolved.get().authorities(), resolved.get().access());
|
||||
new ApiKeyAuthenticationToken(user, apiKey, resolved.get().authorities());
|
||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
if (resolved.get().auditLabel() != null) {
|
||||
MDC.put(ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY, resolved.get().auditLabel());
|
||||
|
||||
@@ -11,7 +11,6 @@ import org.springframework.stereotype.Component;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
@@ -35,9 +34,6 @@ public class TeamSecurityExpressions {
|
||||
|
||||
/** Whether the current authenticated user is a {@code LEADER} of the given team. */
|
||||
public boolean isTeamLeader(Long teamId) {
|
||||
if (isProcessingApiKey()) {
|
||||
return false;
|
||||
}
|
||||
User currentUser = getCurrentUser();
|
||||
if (currentUser == null) {
|
||||
return false;
|
||||
@@ -50,9 +46,6 @@ public class TeamSecurityExpressions {
|
||||
|
||||
/** Whether the current authenticated user is a {@code LEADER} of their own team. */
|
||||
public boolean isCurrentUserTeamLeader() {
|
||||
if (isProcessingApiKey()) {
|
||||
return false;
|
||||
}
|
||||
User currentUser = getCurrentUser();
|
||||
if (currentUser == null || currentUser.getTeam() == null) {
|
||||
return false;
|
||||
@@ -63,19 +56,6 @@ public class TeamSecurityExpressions {
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 isProcessingApiKey() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
return auth instanceof ApiKeyAuthenticationToken token && token.isProcessingOnly();
|
||||
}
|
||||
|
||||
/** The current authenticated user's team id, or {@code null} if unauthenticated / teamless. */
|
||||
public Long currentUserTeamId() {
|
||||
User currentUser = getCurrentUser();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- Named, multi-key API keys with optional team scoping, plus per-key daily usage.
|
||||
-- Named, multi-key personal API keys, plus per-key daily usage.
|
||||
-- Idempotent: Hibernate ddl-auto=update may already have created these on some deployments.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
@@ -7,9 +7,6 @@ CREATE TABLE IF NOT EXISTS api_keys (
|
||||
key_hash VARCHAR(64) NOT NULL,
|
||||
prefix VARCHAR(32) NOT NULL,
|
||||
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,
|
||||
@@ -18,7 +15,6 @@ CREATE TABLE IF NOT EXISTS api_keys (
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_key_hash ON api_keys (key_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_key_owner ON api_keys (owner_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_key_team ON api_keys (team_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_key_daily_usage (
|
||||
api_key_id BIGINT NOT NULL,
|
||||
|
||||
+1
-5
@@ -248,11 +248,7 @@ class SupabaseAuthenticationFilterMoreTest {
|
||||
Optional.of(
|
||||
new stirling.software.proprietary.security.service
|
||||
.ApiKeyAuthenticationService.ApiKeyAuthentication(
|
||||
user,
|
||||
null,
|
||||
user.getAuthorities(),
|
||||
stirling.software.proprietary.security.model
|
||||
.ApiKeyAccess.FULL)));
|
||||
user, null, user.getAuthorities())));
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
|
||||
+1
-2
@@ -30,7 +30,6 @@ 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;
|
||||
@@ -100,7 +99,7 @@ class SupabaseAuthenticationFilterTest {
|
||||
Optional.of(
|
||||
new stirling.software.proprietary.security.service
|
||||
.ApiKeyAuthenticationService.ApiKeyAuthentication(
|
||||
user, null, user.getAuthorities(), ApiKeyAccess.FULL)));
|
||||
user, null, user.getAuthorities())));
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
|
||||
+3
-19
@@ -21,7 +21,6 @@ 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,26 +118,11 @@ class TeamSecurityExpressionsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedTeamApiKeyNeverLeadsEvenIfOwnerIsLeader() {
|
||||
// Owner genuinely leads the team, but the request came in on a processing-only (shared)
|
||||
// key.
|
||||
void apiKeyOfALeaderStillLeads() {
|
||||
// A key acts as the owner; if they lead the team, the key leads.
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(
|
||||
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());
|
||||
assertFalse(expressions().isTeamLeader(TEAM_ID));
|
||||
}
|
||||
|
||||
@Test
|
||||
void personalApiKeyOfALeaderStillLeads() {
|
||||
// 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(), ApiKeyAccess.FULL));
|
||||
new ApiKeyAuthenticationToken(leaderUser(), "sk_personal", List.of()));
|
||||
when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID))
|
||||
.thenReturn(Optional.of(membershipWithRole(TeamRole.LEADER)));
|
||||
|
||||
|
||||
@@ -6898,7 +6898,6 @@ 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"
|
||||
@@ -6906,7 +6905,7 @@ usageMonth = "Usage this month"
|
||||
usageToday = "Usage today"
|
||||
|
||||
[portal.infrastructure.apiKeys.empty]
|
||||
description = "Create a scoped key to start calling the Stirling API."
|
||||
description = "Create a key to start calling the Stirling API."
|
||||
title = "No API keys yet"
|
||||
|
||||
[portal.infrastructure.apiKeys.error]
|
||||
@@ -7000,32 +6999,17 @@ 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"
|
||||
keyNameLabel = "Key name"
|
||||
keyNamePlaceholder = "e.g. Production ingest"
|
||||
scopeLabel = "Scope"
|
||||
scopePersonal = "Personal"
|
||||
scopePersonalHelp = "Only you can see and use this key."
|
||||
scopeTeamLead = "Team leaders"
|
||||
scopeTeamLeadHelp = "Visible to leaders of {{team}} only."
|
||||
scopeTeamMembers = "Team members"
|
||||
scopeTeamMembersHelp = "Visible to everyone in {{team}}."
|
||||
secretKeyCaption = "Secret key"
|
||||
secretWarning = "Store this in a secrets manager. Stirling only ever stores a hash, so there is no way to recover it later."
|
||||
subtitle = "Scope the key to the minimum it needs. You can revoke it at any time."
|
||||
subtitle = "Give the key a name so you can recognise it later. You can revoke it at any time."
|
||||
subtitleCreated = "Copy this secret now — it won't be shown again."
|
||||
teamScopeLeaderOnly = "Team-scoped keys need team-leader or admin rights and a team to scope them to."
|
||||
title = "Create API key"
|
||||
titleCreated = "Key created"
|
||||
yourTeam = "your team"
|
||||
|
||||
[portal.infrastructure.deployLabel]
|
||||
live = "Live"
|
||||
@@ -7068,19 +7052,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"
|
||||
|
||||
[portal.infrastructure.keyScopeLabel]
|
||||
personal = "Personal"
|
||||
teamLead = "Team leaders"
|
||||
teamMembers = "Team members"
|
||||
|
||||
[portal.infrastructure.modelLabel]
|
||||
active = "Active"
|
||||
degraded = "Degraded"
|
||||
@@ -7996,11 +7971,9 @@ 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."
|
||||
apiKeyScope = "Scope"
|
||||
closeAriaLabel = "Close detail"
|
||||
delete = "Delete source"
|
||||
docs24h = "Last 24h"
|
||||
|
||||
@@ -45,29 +45,11 @@ export interface RecentDeployment {
|
||||
|
||||
export type ApiKeyStatus = "active" | "revoked";
|
||||
|
||||
/**
|
||||
* Who a key belongs to and who can see it. Personal keys are owner-only; team
|
||||
* keys are visible to a team's leaders ({@code team-lead}) or all its members
|
||||
* ({@code team-members}). Mirrors the backend {@code ApiKeyScope}.
|
||||
*/
|
||||
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;
|
||||
/** Formatted last-use time, or "Never". */
|
||||
lastUsed: string;
|
||||
@@ -78,16 +60,10 @@ export interface ApiKey {
|
||||
usageMonth: number;
|
||||
/** Lifetime request count. */
|
||||
usageTotal: number;
|
||||
/** Whether the current user may revoke this key. */
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
export interface ApiKeysResponse {
|
||||
keys: ApiKey[];
|
||||
/** True when the caller (a team leader / admin) may mint team-scoped keys. */
|
||||
canCreateTeamKeys: boolean;
|
||||
/** The team team-scoped keys would belong to, or null. */
|
||||
teamName: string | null;
|
||||
}
|
||||
|
||||
/** Returned once on creation: the listed row plus the plaintext secret, shown once. */
|
||||
@@ -314,7 +290,7 @@ export async function fetchDeployments(
|
||||
|
||||
const API_KEYS_PATH = "/api/v1/proprietary/ui-data/infrastructure/api-keys";
|
||||
|
||||
/** GET the caller's visible API keys; SaaS or local, scoped server-side per user/team. */
|
||||
/** GET the caller's personal API keys; SaaS or local, scoped server-side per user. */
|
||||
export async function fetchApiKeys(): Promise<ApiKeysResponse> {
|
||||
return apiClient.saas.isConfigured()
|
||||
? apiClient.saas.json<ApiKeysResponse>(API_KEYS_PATH)
|
||||
@@ -324,8 +300,6 @@ export async function fetchApiKeys(): Promise<ApiKeysResponse> {
|
||||
/** POST a new key; the response carries the one-time secret. */
|
||||
export async function createApiKey(body: {
|
||||
name: string;
|
||||
scope: ApiKeyScope;
|
||||
access: ApiKeyAccess;
|
||||
}): Promise<CreatedApiKey> {
|
||||
const opts = { method: "POST" as const, body };
|
||||
return apiClient.saas.isConfigured()
|
||||
@@ -333,7 +307,7 @@ export async function createApiKey(body: {
|
||||
: apiClient.local.json<CreatedApiKey>(API_KEYS_PATH, opts);
|
||||
}
|
||||
|
||||
/** DELETE (revoke) a key the caller manages. */
|
||||
/** DELETE (revoke) a key the caller owns. */
|
||||
export async function revokeApiKey(id: string): Promise<void> {
|
||||
const path = `${API_KEYS_PATH}/${encodeURIComponent(id)}`;
|
||||
const opts = { method: "DELETE" as const };
|
||||
|
||||
@@ -7,16 +7,12 @@ const BASE: ApiKey = {
|
||||
id: "key-1",
|
||||
name: "Production · ingest",
|
||||
prefix: "sk_a3f81b2c",
|
||||
scope: "personal",
|
||||
access: "full",
|
||||
teamName: null,
|
||||
created: "2026-03-02",
|
||||
lastUsed: "2026-07-10 09:14",
|
||||
status: "active",
|
||||
usageToday: 84210,
|
||||
usageMonth: 2410933,
|
||||
usageTotal: 9820145,
|
||||
canManage: true,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof ApiKeyCard> = {
|
||||
@@ -37,31 +33,6 @@ type Story = StoryObj<typeof ApiKeyCard>;
|
||||
|
||||
export const Personal: Story = { args: { apiKey: BASE } };
|
||||
|
||||
export const TeamMembers: Story = {
|
||||
args: {
|
||||
apiKey: {
|
||||
...BASE,
|
||||
name: "Team · shared ingest",
|
||||
scope: "team-members",
|
||||
access: "processing",
|
||||
teamName: "Acme Corp",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TeamLeadOnly: Story = {
|
||||
args: {
|
||||
apiKey: {
|
||||
...BASE,
|
||||
name: "Ops · leaders only",
|
||||
scope: "team-lead",
|
||||
access: "processing",
|
||||
teamName: "Acme Corp",
|
||||
usageToday: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Revoked: Story = {
|
||||
args: {
|
||||
apiKey: {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Card, Chip, StatusBadge } from "@app/ui";
|
||||
import { Button, Card, 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,
|
||||
KEY_TONE,
|
||||
} from "@portal/components/infrastructure/infraFormat";
|
||||
|
||||
@@ -21,7 +18,7 @@ export function ApiKeyCard({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const revocable = apiKey.canManage && apiKey.status === "active";
|
||||
const revocable = apiKey.status === "active";
|
||||
return (
|
||||
<Card padding="default" className="portal-infra__key">
|
||||
<Button
|
||||
@@ -33,11 +30,6 @@ export function ApiKeyCard({
|
||||
aria-expanded={open}
|
||||
rightSection={
|
||||
<span className="portal-infra__key-head-right">
|
||||
<Chip size="sm" accent={KEY_SCOPE_TONE[apiKey.scope]}>
|
||||
{apiKey.scope === "personal" || !apiKey.teamName
|
||||
? t(KEY_SCOPE_LABEL[apiKey.scope])
|
||||
: `${t(KEY_SCOPE_LABEL[apiKey.scope])} · ${apiKey.teamName}`}
|
||||
</Chip>
|
||||
<StatusBadge tone={KEY_TONE[apiKey.status]} size="sm">
|
||||
{t(KEY_LABEL[apiKey.status])}
|
||||
</StatusBadge>
|
||||
@@ -59,10 +51,6 @@ 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>
|
||||
|
||||
@@ -20,7 +20,7 @@ type Story = StoryObj<typeof ApiKeysTab>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
const EMPTY = { keys: [], canCreateTeamKeys: false, teamName: null };
|
||||
const EMPTY = { keys: [] };
|
||||
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
|
||||
@@ -95,8 +95,6 @@ export function ApiKeysTab() {
|
||||
<CreateKeyModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
canCreateTeamKeys={data?.canCreateTeamKeys ?? false}
|
||||
teamName={data?.teamName ?? null}
|
||||
onCreated={reload}
|
||||
/>
|
||||
|
||||
|
||||
@@ -10,8 +10,6 @@ const meta: Meta<typeof CreateKeyModal> = {
|
||||
open: true,
|
||||
onClose: () => console.log("close"),
|
||||
onCreated: () => console.log("created"),
|
||||
canCreateTeamKeys: true,
|
||||
teamName: "Acme Corp",
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
@@ -19,8 +17,4 @@ type Story = StoryObj<typeof CreateKeyModal>;
|
||||
|
||||
export const Form: Story = {};
|
||||
|
||||
export const PersonalOnly: Story = {
|
||||
args: { canCreateTeamKeys: false, teamName: null },
|
||||
};
|
||||
|
||||
export const Closed: Story = { args: { open: false } };
|
||||
|
||||
@@ -23,14 +23,7 @@ const K = "portal.infrastructure.createKey";
|
||||
function renderModal(props: Partial<ComponentProps<typeof CreateKeyModal>>) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<CreateKeyModal
|
||||
open
|
||||
onClose={() => {}}
|
||||
onCreated={() => {}}
|
||||
canCreateTeamKeys
|
||||
teamName="Acme Corp"
|
||||
{...props}
|
||||
/>
|
||||
<CreateKeyModal open onClose={() => {}} onCreated={() => {}} {...props} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
@@ -65,17 +58,6 @@ 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,
|
||||
// and a personal key defaults to full access.
|
||||
expect(createApiKey).toHaveBeenCalledWith({
|
||||
name: "Production ingest",
|
||||
scope: "personal",
|
||||
access: "full",
|
||||
});
|
||||
});
|
||||
|
||||
it("disables team scopes when the caller cannot create team keys", () => {
|
||||
renderModal({ canCreateTeamKeys: false, teamName: null });
|
||||
expect(screen.getByText(`${K}.teamScopeLeaderOnly`)).toBeInTheDocument();
|
||||
expect(createApiKey).toHaveBeenCalledWith({ name: "Production ingest" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,62 +1,27 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
CodeBlock,
|
||||
FormField,
|
||||
Input,
|
||||
Modal,
|
||||
RadioGroup,
|
||||
type RadioOption,
|
||||
} from "@app/ui";
|
||||
import {
|
||||
createApiKey,
|
||||
type ApiKeyAccess,
|
||||
type ApiKeyScope,
|
||||
type CreatedApiKey,
|
||||
} from "@portal/api/infrastructure";
|
||||
import { Banner, Button, CodeBlock, FormField, Input, Modal } from "@app/ui";
|
||||
import { createApiKey, type CreatedApiKey } from "@portal/api/infrastructure";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
|
||||
export function CreateKeyModal({
|
||||
open,
|
||||
onClose,
|
||||
canCreateTeamKeys,
|
||||
teamName,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Whether the caller (a team leader / admin) may mint team-scoped keys. */
|
||||
canCreateTeamKeys: boolean;
|
||||
/** Team those keys would belong to, for the option descriptions. */
|
||||
teamName: string | null;
|
||||
/** Called after a successful create so the tab can refresh its list. */
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
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);
|
||||
@@ -72,11 +37,7 @@ export function CreateKeyModal({
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await createApiKey({
|
||||
name: name.trim(),
|
||||
scope,
|
||||
access: effectiveAccess,
|
||||
});
|
||||
const result = await createApiKey({ name: name.trim() });
|
||||
setCreated(result);
|
||||
onCreated();
|
||||
} catch (e) {
|
||||
@@ -86,44 +47,6 @@ 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",
|
||||
label: t("portal.infrastructure.createKey.scopePersonal"),
|
||||
description: t("portal.infrastructure.createKey.scopePersonalHelp"),
|
||||
},
|
||||
{
|
||||
value: "team-members",
|
||||
label: t("portal.infrastructure.createKey.scopeTeamMembers"),
|
||||
description: t("portal.infrastructure.createKey.scopeTeamMembersHelp", {
|
||||
team: teamLabel,
|
||||
}),
|
||||
disabled: !canCreateTeamKeys,
|
||||
},
|
||||
{
|
||||
value: "team-lead",
|
||||
label: t("portal.infrastructure.createKey.scopeTeamLead"),
|
||||
description: t("portal.infrastructure.createKey.scopeTeamLeadHelp", {
|
||||
team: teamLabel,
|
||||
}),
|
||||
disabled: !canCreateTeamKeys,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -188,38 +111,6 @@ export function CreateKeyModal({
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("portal.infrastructure.createKey.scopeLabel")}>
|
||||
<div className="portal-infra__stack">
|
||||
<RadioGroup<ApiKeyScope>
|
||||
name="apiKeyScope"
|
||||
value={scope}
|
||||
onChange={changeScope}
|
||||
options={scopeOptions}
|
||||
/>
|
||||
{!canCreateTeamKeys && (
|
||||
<p className="portal-infra__muted">
|
||||
{t("portal.infrastructure.createKey.teamScopeLeaderOnly")}
|
||||
</p>
|
||||
)}
|
||||
</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,8 +1,6 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { ChipAccent, StatusTone } from "@app/ui";
|
||||
import type {
|
||||
ApiKeyAccess,
|
||||
ApiKeyScope,
|
||||
ApiKeyStatus,
|
||||
AttestationStatus,
|
||||
AuditCategory,
|
||||
@@ -64,25 +62,6 @@ export const KEY_LABEL: Record<ApiKeyStatus, string> = {
|
||||
revoked: "portal.infrastructure.keyLabel.revoked",
|
||||
};
|
||||
|
||||
// Scope chip labels (i18n keys), resolved via t(KEY_SCOPE_LABEL[value]).
|
||||
export const KEY_SCOPE_LABEL: Record<ApiKeyScope, string> = {
|
||||
personal: "portal.infrastructure.keyScopeLabel.personal",
|
||||
"team-lead": "portal.infrastructure.keyScopeLabel.teamLead",
|
||||
"team-members": "portal.infrastructure.keyScopeLabel.teamMembers",
|
||||
};
|
||||
|
||||
export const KEY_SCOPE_TONE: Record<ApiKeyScope, ChipAccent> = {
|
||||
personal: "default",
|
||||
"team-lead": "premium",
|
||||
"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",
|
||||
|
||||
@@ -42,25 +42,18 @@ export const infrastructureHandlers = [
|
||||
await delay(120);
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
name?: string;
|
||||
scope?: string;
|
||||
};
|
||||
const scope = (body.scope ?? "personal") as
|
||||
| "personal"
|
||||
| "team-lead"
|
||||
| "team-members";
|
||||
return HttpResponse.json({
|
||||
key: {
|
||||
id: `key-${Date.now()}`,
|
||||
name: body.name ?? "New key",
|
||||
prefix: "sk_demo0000",
|
||||
scope,
|
||||
teamName: scope === "personal" ? null : "Acme Corp",
|
||||
created: "2026-07-10",
|
||||
lastUsed: "Never",
|
||||
status: "active",
|
||||
usageToday: 0,
|
||||
usageMonth: 0,
|
||||
canManage: true,
|
||||
usageTotal: 0,
|
||||
},
|
||||
secret: "sk_live_demo_key_rotate_in_prod",
|
||||
});
|
||||
|
||||
@@ -168,61 +168,45 @@ const API_KEYS_ALL: ApiKey[] = [
|
||||
id: "key-1",
|
||||
name: "Production · ingest",
|
||||
prefix: "sk_a3f81b2c",
|
||||
scope: "personal",
|
||||
access: "full",
|
||||
teamName: null,
|
||||
created: "2026-03-02",
|
||||
lastUsed: "2026-07-10 09:14",
|
||||
status: "active",
|
||||
usageToday: 84210,
|
||||
usageMonth: 2410933,
|
||||
usageTotal: 2410933,
|
||||
canManage: true,
|
||||
},
|
||||
{
|
||||
id: "key-2",
|
||||
name: "Team · shared ingest",
|
||||
name: "Nightly batch",
|
||||
prefix: "sk_77be0f42",
|
||||
scope: "team-members",
|
||||
access: "processing",
|
||||
teamName: "Acme Corp",
|
||||
created: "2026-01-18",
|
||||
lastUsed: "2026-07-10 08:33",
|
||||
status: "active",
|
||||
usageToday: 6120,
|
||||
usageMonth: 188400,
|
||||
usageTotal: 188400,
|
||||
canManage: true,
|
||||
},
|
||||
{
|
||||
id: "key-3",
|
||||
name: "Ops · leaders only",
|
||||
name: "CI pipeline",
|
||||
prefix: "sk_d9013ab7",
|
||||
scope: "team-lead",
|
||||
access: "processing",
|
||||
teamName: "Acme Corp",
|
||||
created: "2025-08-09",
|
||||
lastUsed: "2026-07-04 17:02",
|
||||
status: "active",
|
||||
usageToday: 0,
|
||||
usageMonth: 14200,
|
||||
usageTotal: 14200,
|
||||
canManage: true,
|
||||
},
|
||||
{
|
||||
id: "key-4",
|
||||
name: "Sandbox · webhook tester",
|
||||
prefix: "sk_2c4a91de",
|
||||
scope: "personal",
|
||||
access: "full",
|
||||
teamName: null,
|
||||
created: "2026-05-30",
|
||||
lastUsed: "Never",
|
||||
status: "revoked",
|
||||
usageToday: 0,
|
||||
usageMonth: 0,
|
||||
usageTotal: 0,
|
||||
canManage: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -233,7 +217,7 @@ export function apiKeysFor(tier: Tier): ApiKeysResponse {
|
||||
: tier === "pro"
|
||||
? API_KEYS_ALL.slice(0, 3)
|
||||
: API_KEYS_ALL;
|
||||
return { keys, canCreateTeamKeys: tier !== "free", teamName: "Acme Corp" };
|
||||
return { keys };
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -97,11 +97,7 @@ describe("Sources view", () => {
|
||||
createSource.mockReset();
|
||||
deleteSource.mockReset();
|
||||
fetchApiKeys.mockReset();
|
||||
fetchApiKeys.mockResolvedValue({
|
||||
keys: [],
|
||||
canCreateTeamKeys: false,
|
||||
teamName: null,
|
||||
});
|
||||
fetchApiKeys.mockResolvedValue({ keys: [] });
|
||||
});
|
||||
|
||||
it("surfaces the inline 409 message when deleting a referenced source", async () => {
|
||||
@@ -206,20 +202,14 @@ describe("Sources view", () => {
|
||||
id: "7",
|
||||
name: "Production ingest",
|
||||
prefix: "sk_a3f81b2c",
|
||||
scope: "personal",
|
||||
access: "full",
|
||||
teamName: null,
|
||||
created: "2026-03-02",
|
||||
lastUsed: "2026-07-10 09:14",
|
||||
status: "active",
|
||||
usageToday: 12,
|
||||
usageMonth: 340,
|
||||
usageTotal: 5000,
|
||||
canManage: true,
|
||||
},
|
||||
],
|
||||
canCreateTeamKeys: false,
|
||||
teamName: null,
|
||||
});
|
||||
|
||||
renderView();
|
||||
|
||||
@@ -21,10 +21,6 @@ import {
|
||||
type ApiKey,
|
||||
type ApiKeysResponse,
|
||||
} from "@portal/api/infrastructure";
|
||||
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";
|
||||
@@ -38,10 +34,6 @@ import "@portal/views/Sources.css";
|
||||
* usage stats but is never a policy input (type "apikey" has no edit/create path).
|
||||
*/
|
||||
function apiKeyToSourceRow(k: ApiKey, t: TFunction): SourceView {
|
||||
const scope =
|
||||
k.scope === "personal" || !k.teamName
|
||||
? t(KEY_SCOPE_LABEL[k.scope])
|
||||
: `${t(KEY_SCOPE_LABEL[k.scope])} · ${k.teamName}`;
|
||||
return {
|
||||
id: `apikey:${k.id}`,
|
||||
name: k.name,
|
||||
@@ -50,11 +42,6 @@ function apiKeyToSourceRow(k: ApiKey, t: TFunction): SourceView {
|
||||
referenceCount: 0,
|
||||
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 },
|
||||
],
|
||||
@@ -277,8 +264,6 @@ export function Sources() {
|
||||
<CreateKeyModal
|
||||
open={apiKeyModalOpen}
|
||||
onClose={() => setApiKeyModalOpen(false)}
|
||||
canCreateTeamKeys={apiKeyState.data?.canCreateTeamKeys ?? false}
|
||||
teamName={apiKeyState.data?.teamName ?? null}
|
||||
onCreated={refetch}
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Feature: API Keys management API
|
||||
|
||||
Tests for the portal API-keys REST API, which lets a user mint, list, and
|
||||
revoke named API keys - personal or team-scoped.
|
||||
revoke named personal API keys.
|
||||
|
||||
Endpoints (all @EnterpriseEndpoint, JWT/ROLE required):
|
||||
- GET /api/v1/proprietary/ui-data/infrastructure/api-keys (list)
|
||||
@@ -14,7 +14,7 @@ Feature: API Keys management API
|
||||
requests must always be rejected with 401.
|
||||
|
||||
The legacy single per-user key (the global API key) must keep working so no
|
||||
key created before multi-key support is ever lost or exposed to a team.
|
||||
key created before multi-key support is ever lost.
|
||||
|
||||
Admin credentials: username=admin, password=stirling
|
||||
Global API key: 123456789
|
||||
@@ -41,13 +41,13 @@ Feature: API Keys management API
|
||||
@positive
|
||||
Scenario: Admin can create a personal API key
|
||||
Given I am logged in as admin
|
||||
When I send a JSON POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication and body '{"name": "bdd_personal_key", "scope": "personal"}'
|
||||
When I send a JSON POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication and body '{"name": "bdd_personal_key"}'
|
||||
Then the response status code should be one of "200, 403"
|
||||
|
||||
@negative
|
||||
Scenario: Creating a key without a name is rejected
|
||||
Given I am logged in as admin
|
||||
When I send a JSON POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication and body '{"name": "", "scope": "personal"}'
|
||||
When I send a JSON POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication and body '{"name": ""}'
|
||||
Then the response status code should be one of "400, 403"
|
||||
|
||||
@negative
|
||||
|
||||
Reference in New Issue
Block a user