From 14265b7eb581b5bbecb6f0a8e9745562b5814e64 Mon Sep 17 00:00:00 2001 From: Jorge Romero Date: Thu, 26 Mar 2026 18:56:29 +0100 Subject: [PATCH 01/16] Remove obsolete test classes and update pom.xml to include core-security module - Deleted FlowPropertiesTest, FlowEnforcementAspectTest, FlowValidatorTest, SecurityExpressionRootTest classes as they are no longer needed. - Updated pom.xml to add core-security module to the project structure. --- core-security/pom.xml | 44 +++ .../security}/config/CustomRoleConverter.java | 26 +- .../core/security/config/SecurityConfig.java | 54 +++ .../security/config/SecurityProperties.java | 20 + .../config/CustomRoleConverterTest.java | 2 +- .../security}/config/SecurityConfigTest.java | 2 +- core/pom.xml | 12 + .../apiservice/core/config/AspectConfig.java | 10 - .../core/config/FlowProperties.java | 47 --- .../core/config/OnBehalfOfConfig.java | 10 - .../core/config/OnBehalfOfProperties.java | 111 ------ .../apiservice/core/config/OpenApiConfig.java | 99 ----- .../core/config/SecurityConfig.java | 177 --------- .../core/config/SecurityProperties.java | 38 -- ...CustomMethodSecurityExpressionHandler.java | 21 - .../CustomSecurityExpressionRoot.java | 69 ---- .../core/security/FlowEnforcementAspect.java | 165 -------- .../core/security/FlowValidator.java | 228 ----------- .../apiservice/core/security/RequireFlow.java | 41 -- .../core/config/FlowPropertiesTest.java | 366 ----------------- .../security/FlowEnforcementAspectTest.java | 370 ------------------ .../core/security/FlowValidatorTest.java | 345 ---------------- .../security/SecurityExpressionRootTest.java | 327 ---------------- pom.xml | 1 + 24 files changed, 148 insertions(+), 2437 deletions(-) create mode 100644 core-security/pom.xml rename {core/src/main/java/org/opendevstack/apiservice/core => core-security/src/main/java/org/opendevstack/apiservice/core/security}/config/CustomRoleConverter.java (76%) create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityProperties.java rename {core/src/test/java/org/opendevstack/apiservice/core => core-security/src/tests/java/org/opendevstack/apiservice/core/security}/config/CustomRoleConverterTest.java (99%) rename {core/src/test/java/org/opendevstack/apiservice/core => core-security/src/tests/java/org/opendevstack/apiservice/core/security}/config/SecurityConfigTest.java (97%) delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/AspectConfig.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/FlowProperties.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfConfig.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfProperties.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/OpenApiConfig.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/SecurityConfig.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/SecurityProperties.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/security/CustomMethodSecurityExpressionHandler.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/security/CustomSecurityExpressionRoot.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspect.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/security/FlowValidator.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/security/RequireFlow.java delete mode 100644 core/src/test/java/org/opendevstack/apiservice/core/config/FlowPropertiesTest.java delete mode 100644 core/src/test/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspectTest.java delete mode 100644 core/src/test/java/org/opendevstack/apiservice/core/security/FlowValidatorTest.java delete mode 100644 core/src/test/java/org/opendevstack/apiservice/core/security/SecurityExpressionRootTest.java diff --git a/core-security/pom.xml b/core-security/pom.xml new file mode 100644 index 0000000..3027435 --- /dev/null +++ b/core-security/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + core-security + core-security + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + org.projectlombok + lombok + provided + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + \ No newline at end of file diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/CustomRoleConverter.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/CustomRoleConverter.java similarity index 76% rename from core/src/main/java/org/opendevstack/apiservice/core/config/CustomRoleConverter.java rename to core-security/src/main/java/org/opendevstack/apiservice/core/security/config/CustomRoleConverter.java index a705ffb..0d2cf7f 100644 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/CustomRoleConverter.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/CustomRoleConverter.java @@ -1,4 +1,4 @@ -package org.opendevstack.apiservice.core.config; +package org.opendevstack.apiservice.core.security.config; import org.springframework.core.convert.converter.Converter; import org.springframework.security.core.GrantedAuthority; @@ -19,14 +19,19 @@ public Collection convert(Jwt jwt) { if (jwt == null) { return List.of(); } - // Extract realm roles (Keycloak/Auth0 standard) + // Extract Azure Entra ID top-level roles claim + List entraRoles = getEntraRoles(jwt); + + // Extract realm roles (Keycloak standard) List realmRoles = getRealmRoles(jwt); - // Extract resource roles (client-specific roles) + // Extract resource roles (Keycloak client-specific roles) List resourceRoles = getResourceRoles(jwt); // Combine all roles - List allRoles = combineRoles(realmRoles, resourceRoles); + List allRoles = java.util.stream.Stream.of(entraRoles, realmRoles, resourceRoles) + .flatMap(Collection::stream) + .toList(); // Convert to GrantedAuthority with ROLE_ prefix return allRoles.stream() @@ -35,6 +40,12 @@ public Collection convert(Jwt jwt) { .toList(); } + private List getEntraRoles(Jwt jwt) { + // Azure Entra ID puts app roles in a top-level "roles" claim + List roles = jwt.getClaimAsStringList(ROLES_CLAIM); + return roles != null ? roles : List.of(); + } + private List getRealmRoles(Jwt jwt) { Map realmAccess = jwt.getClaimAsMap("realm_access"); if (realmAccess != null && realmAccess.containsKey(ROLES_CLAIM)) { @@ -68,11 +79,4 @@ private List extractRolesFromResource(Object resource) { } return List.of(); } - - private List combineRoles(List realmRoles, List resourceRoles) { - return java.util.stream.Stream.concat( - realmRoles.stream(), - resourceRoles.stream() - ).toList(); - } } diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java new file mode 100644 index 0000000..9c1c0fb --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java @@ -0,0 +1,54 @@ +package org.opendevstack.apiservice.core.security.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.web.SecurityFilterChain; +import lombok.extern.slf4j.Slf4j; +import java.util.Arrays; + +@Slf4j +@Configuration +@EnableWebSecurity +@EnableMethodSecurity(prePostEnabled = true) +public class SecurityConfig { + + private final SecurityProperties securityProperties; + + public SecurityConfig(SecurityProperties securityProperties) { + this.securityProperties = securityProperties; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(authz -> { + // Allow public endpoints from security properties + if (securityProperties.getPublicEndpoints() != null) { + Arrays.stream(securityProperties.getPublicEndpoints()) + .forEach(endpoint -> { + log.info("Public endpoint configured: {}", endpoint); + authz.requestMatchers(endpoint).permitAll(); + }); + } + authz.anyRequest().authenticated(); + }) + .oauth2ResourceServer((oauth2) -> + oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())) + ) + .headers(headers -> headers.frameOptions(frame -> frame.disable())) + .csrf(csrf -> csrf.disable()); + + return http.build(); + } + + @Bean + public JwtAuthenticationConverter jwtAuthenticationConverter() { + JwtAuthenticationConverter authenticationConverter = new JwtAuthenticationConverter(); + authenticationConverter.setJwtGrantedAuthoritiesConverter(new CustomRoleConverter()); + return authenticationConverter; + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityProperties.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityProperties.java new file mode 100644 index 0000000..e995724 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityProperties.java @@ -0,0 +1,20 @@ +package org.opendevstack.apiservice.core.security.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "app.security") +public class SecurityProperties { + + private boolean enabled = true; + + /** + * Map of endpoints that don't require authentication + */ + private String[] publicEndpoints; +} diff --git a/core/src/test/java/org/opendevstack/apiservice/core/config/CustomRoleConverterTest.java b/core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/CustomRoleConverterTest.java similarity index 99% rename from core/src/test/java/org/opendevstack/apiservice/core/config/CustomRoleConverterTest.java rename to core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/CustomRoleConverterTest.java index dccedaf..a74aceb 100644 --- a/core/src/test/java/org/opendevstack/apiservice/core/config/CustomRoleConverterTest.java +++ b/core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/CustomRoleConverterTest.java @@ -1,4 +1,4 @@ -package org.opendevstack.apiservice.core.config; +package org.opendevstack.apiservice.core.security.config; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/core/src/test/java/org/opendevstack/apiservice/core/config/SecurityConfigTest.java b/core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java similarity index 97% rename from core/src/test/java/org/opendevstack/apiservice/core/config/SecurityConfigTest.java rename to core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java index 88634c2..7ac046a 100644 --- a/core/src/test/java/org/opendevstack/apiservice/core/config/SecurityConfigTest.java +++ b/core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java @@ -1,4 +1,4 @@ -package org.opendevstack.apiservice.core.config; +package org.opendevstack.apiservice.core.security.config; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/core/pom.xml b/core/pom.xml index c3aa14b..80a0503 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -30,6 +30,13 @@ spring-boot-starter-oauth2-resource-server + + + org.opendevstack.apiservice + core-security + ${project.version} + + org.springframework.boot spring-boot-starter-aop @@ -127,6 +134,11 @@ ${project.version} + + org.opendevstack.apiservice + api-project-component-v0 + ${project.version} + org.opendevstack.apiservice diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/AspectConfig.java b/core/src/main/java/org/opendevstack/apiservice/core/config/AspectConfig.java deleted file mode 100644 index 90d5d5d..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/AspectConfig.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.EnableAspectJAutoProxy; - -@Configuration -@EnableAspectJAutoProxy -public class AspectConfig { - // Enables AspectJ auto-proxy for flow enforcement -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/FlowProperties.java b/core/src/main/java/org/opendevstack/apiservice/core/config/FlowProperties.java deleted file mode 100644 index e367e65..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/FlowProperties.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; - -@Getter -@Setter -@Component -@ConfigurationProperties(prefix = "app.security.flows") -public class FlowProperties { - - private Global global = new Global(); - - private Map apis; - - @Getter - @Setter - public static class Global { - private List enabledFlows; - private String defaultFlow = "client-credentials"; - } - - @Getter - @Setter - public static class ApiFlows { - private String defaultFlow; - private List endpoints; - } - - @Getter - @Setter - public static class EndpointFlow { - private String pattern; // Now as a property instead of map key - private List flows; - private List roles; - private boolean permitAll = false; - private boolean requireActor = false; - private boolean requireAuthentication = true; - private int requireDelegationDepth = 0; - private List requiredScopes; - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfConfig.java b/core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfConfig.java deleted file mode 100644 index 0c72cba..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfConfig.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.opendevstack.apiservice.core.config; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Configuration; - -@Configuration -@EnableConfigurationProperties(OnBehalfOfProperties.class) -public class OnBehalfOfConfig { - // This class enables binding of OnBehalfOfProperties to configuration - // No additional code needed - just enables the configuration properties -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfProperties.java b/core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfProperties.java deleted file mode 100644 index ea43459..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfProperties.java +++ /dev/null @@ -1,111 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; - -@Getter -@Setter -@Component -@ConfigurationProperties(prefix = "app.on-behalf-of") -public class OnBehalfOfProperties { - - private boolean enabled = true; - - private TokenExchange tokenExchange = new TokenExchange(); - - private Flow flow = new Flow(); - - private Delegation delegation = new Delegation(); - - private Security security = new Security(); - - private Provider provider = new Provider(); - - @Getter - @Setter - public static class TokenExchange { - private String grantType = "urn:ietf:params:oauth:grant-type:token-exchange"; - private List supportedSubjectTokenTypes; - private String requestedTokenType = "urn:ietf:params:oauth:token-type:access_token"; - private String defaultAudience; - } - - @Getter - @Setter - public static class Flow { - private boolean validateActor = false; - private List allowedActorClients; - private List requiredScopes; - private int delegatedTokenLifetime = 3600; - } - - @Getter - @Setter - public static class Delegation { - private Map rules; - - @Getter - @Setter - public static class ServiceDelegation { - private List canDelegateTo; - private List allowedScopes; - private int maxDelegationDepth = 1; - } - } - - @Getter - @Setter - public static class Security { - private boolean requireActorClaim = false; - private String introspectionEndpoint; - private AuditLogging auditLogging = new AuditLogging(); - - @Getter - @Setter - public static class AuditLogging { - private boolean enabled = true; - private boolean logSuccessfulExchanges = true; - private boolean logFailedExchanges = true; - private boolean includeTokenScope = false; - } - } - - @Getter - @Setter - public static class Provider { - private String type = "keycloak"; - private Settings settings = new Settings(); - - @Getter - @Setter - public static class Settings { - private Keycloak keycloak = new Keycloak(); - private Auth0 auth0 = new Auth0(); - private Generic generic = new Generic(); - - @Getter - @Setter - public static class Keycloak { - private String tokenExchangePolicy = "on-behalf-of"; - } - - @Getter - @Setter - public static class Auth0 { - private String organization; - } - - @Getter - @Setter - public static class Generic { - private String tokenExchangeUrl; - private String clientAuthMethod = "client_secret_basic"; - } - } - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/OpenApiConfig.java b/core/src/main/java/org/opendevstack/apiservice/core/config/OpenApiConfig.java deleted file mode 100644 index 4069616..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/OpenApiConfig.java +++ /dev/null @@ -1,99 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.info.Contact; -import io.swagger.v3.oas.models.info.Info; -import io.swagger.v3.oas.models.servers.Server; -import io.swagger.v3.oas.models.security.SecurityScheme; -import io.swagger.v3.oas.models.security.SecurityRequirement; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.env.Environment; -import org.springframework.boot.context.properties.bind.Binder; -import org.springframework.boot.context.properties.bind.Bindable; - -import java.util.List; - -@Configuration -public class OpenApiConfig { - - @Value("${openapi.info.title:'API Documentation'}") - private String title; - - @Value("${openapi.info.description:'API description not provided'}") - private String description; - - @Value("${openapi.info.version:'1.0.0'}") - private String version; - - @Value("${openapi.info.contact.name:'API Support'}") - private String contactName; - - @Value("${openapi.info.contact.email:'support@example.com'}") - private String contactEmail; - - private final Environment environment; - - public OpenApiConfig(Environment environment) { - this.environment = environment; - } - - @Bean - public OpenAPI customOpenAPI() { - // bind the YAML list under openapi.servers to the nested ServerProperties class - List configured = Binder.get(environment) - .bind("openapi.servers", Bindable.listOf(ServerProperties.class)) - .orElse(List.of()); - - List servers = configured.stream() - .map(s -> new Server().url(s.getUrl()).description(s.getDescription())) - .toList(); - - // fallback to a sensible default if no servers configured - if (servers.isEmpty()) { - servers = List.of(new Server().url("http://localhost:8080").description("Development server")); - } - - final String securitySchemeName = "bearerAuth"; - return new OpenAPI() - .info(new Info() - .title(title) - .description(description) - .version(version) - .contact(new Contact().name(contactName).email(contactEmail))) - .servers(servers) - .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) - .components( - new Components() - .addSecuritySchemes(securitySchemeName, - new SecurityScheme() - .name(securitySchemeName) - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT"))); - - } - - public static class ServerProperties { - private String url; - private String description; - - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/SecurityConfig.java b/core/src/main/java/org/opendevstack/apiservice/core/config/SecurityConfig.java deleted file mode 100644 index cd8b73e..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/SecurityConfig.java +++ /dev/null @@ -1,177 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; -import org.springframework.security.web.SecurityFilterChain; -import lombok.extern.slf4j.Slf4j; -import java.util.Arrays; - -@Slf4j -@Configuration -@EnableWebSecurity -@EnableMethodSecurity(prePostEnabled = true) -public class SecurityConfig { - - private final SecurityProperties securityProperties; - private final FlowProperties flowProperties; - - public SecurityConfig(SecurityProperties securityProperties, FlowProperties flowProperties) { - this.securityProperties = securityProperties; - this.flowProperties = flowProperties; - } - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .authorizeHttpRequests(authz -> { - // Allow public endpoints from security properties - if (securityProperties.getPublicEndpoints() != null) { - Arrays.stream(securityProperties.getPublicEndpoints()) - .forEach(endpoint -> authz.requestMatchers(endpoint).permitAll()); - } - - // Apply flow-based security rules - applyFlowBasedSecurity(authz); - - // All other requests require authentication - authz.anyRequest().authenticated(); - }) - .oauth2ResourceServer(oauth2 -> - oauth2.jwt(jwt -> jwt - .jwtAuthenticationConverter(jwtAuthenticationConverter()) - ) - ) - .headers(headers -> headers.frameOptions(frame -> frame.disable())) - .csrf(csrf -> csrf.disable()); - - return http.build(); - } - - private void applyFlowBasedSecurity(org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry authz) { - if (flowProperties.getApis() == null) { - return; - } - - // Apply security rules based on flow configuration - flowProperties.getApis().forEach((apiName, apiFlows) -> { - if (apiFlows.getEndpoints() != null) { - apiFlows.getEndpoints().forEach(endpointFlow -> { - String pattern = endpointFlow.getPattern(); - - // Debug logging to see what patterns we're receiving - log.info("Processing security pattern: '{}' for API: {}", pattern, apiName); - - // Permit all if specified - if (endpointFlow.isPermitAll()) { - authz.requestMatchers(pattern).permitAll(); - } - // Apply role-based security - else if (endpointFlow.getRoles() != null && !endpointFlow.getRoles().isEmpty()) { - String[] rolesArray = endpointFlow.getRoles().toArray(new String[0]); - authz.requestMatchers(pattern).hasAnyRole(rolesArray); - } - // Otherwise require authentication - else if (endpointFlow.isRequireAuthentication()) { - authz.requestMatchers(pattern).authenticated(); - } - }); - } - }); - } - - @Bean - public JwtAuthenticationConverter jwtAuthenticationConverter() { - JwtAuthenticationConverter authenticationConverter = new JwtAuthenticationConverter(); - authenticationConverter.setJwtGrantedAuthoritiesConverter(new CustomRoleConverter()); - return authenticationConverter; - } - - @Bean - public CustomRoleConverter customRoleConverter() { - return new CustomRoleConverter(); - } - - @Bean - public JwtDecoder jwtDecoder() { - // For development: use a lenient decoder when JWT validation is disabled - if (!securityProperties.isJwtValidationEnabled()) { - return createLenientJwtDecoder(); - } - - // For production: use proper JWT validation with JWK Set URI - return createValidatingJwtDecoder(); - } - - private JwtDecoder createLenientJwtDecoder() { - return token -> { - // Parse JWT without validation - String[] parts = token.split("\\."); - if (parts.length != 3) { - throw new org.springframework.security.oauth2.jwt.BadJwtException("Invalid JWT token"); - } - - // Decode payload - java.util.Base64.Decoder decoder = java.util.Base64.getUrlDecoder(); - String payload = new String(decoder.decode(parts[1])); - - return parseJwtPayload(token, payload); - }; - } - - private org.springframework.security.oauth2.jwt.Jwt parseJwtPayload(String token, String payload) { - org.springframework.security.oauth2.jwt.Jwt.Builder builder = - org.springframework.security.oauth2.jwt.Jwt.withTokenValue(token); - - try { - com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - @SuppressWarnings("unchecked") - java.util.Map claims = mapper.readValue(payload, java.util.Map.class); - - builder.claims(c -> c.putAll(claims)); - - // Set standard claims if present - if (claims.containsKey("iss")) { - builder.issuer(claims.get("iss").toString()); - } - if (claims.containsKey("sub")) { - builder.subject(claims.get("sub").toString()); - } - if (claims.containsKey("exp")) { - Number exp = (Number) claims.get("exp"); - builder.expiresAt(java.time.Instant.ofEpochSecond(exp.longValue())); - } else { - // Set a far future expiration if not present - builder.expiresAt(java.time.Instant.now().plusSeconds(3600)); - } - if (claims.containsKey("iat")) { - Number iat = (Number) claims.get("iat"); - builder.issuedAt(java.time.Instant.ofEpochSecond(iat.longValue())); - } else { - builder.issuedAt(java.time.Instant.now()); - } - - builder.header("alg", "none"); - - return builder.build(); - } catch (Exception e) { - throw new org.springframework.security.oauth2.jwt.BadJwtException("Failed to parse JWT", e); - } - } - - private JwtDecoder createValidatingJwtDecoder() { - if (securityProperties.getJwkSetUri() != null && !securityProperties.getJwkSetUri().isEmpty()) { - return NimbusJwtDecoder.withJwkSetUri(securityProperties.getJwkSetUri()).build(); - } - - throw new IllegalStateException( - "JWT validation is enabled but no JWK Set URI is configured. " + - "Please set app.security.jwk-set-uri in your configuration." - ); - } -} \ No newline at end of file diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/SecurityProperties.java b/core/src/main/java/org/opendevstack/apiservice/core/config/SecurityProperties.java deleted file mode 100644 index 5d21689..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/SecurityProperties.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -import java.util.HashMap; -import java.util.Map; - -@Getter -@Setter -@Component -@ConfigurationProperties(prefix = "app.security") -public class SecurityProperties { - - private boolean enabled = true; - private boolean jwtValidationEnabled = false; - private String issuer; - private String audience; - private String jwkSetUri; - - /** - * Map of endpoint patterns to required roles - * Format: pattern -> list of roles - */ - private Map endpointRoles = new HashMap<>(); - - /** - * Map of endpoints that don't require authentication - */ - private String[] publicEndpoints = { - "/api/public/**", - "/actuator/health", - "/actuator/info", - "/h2-console/**" - }; -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/security/CustomMethodSecurityExpressionHandler.java b/core/src/main/java/org/opendevstack/apiservice/core/security/CustomMethodSecurityExpressionHandler.java deleted file mode 100644 index 29a459b..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/security/CustomMethodSecurityExpressionHandler.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; - -/** - * Custom method security expression handler for custom security expressions. - * - * In Spring Security 6.x, custom methods in SecurityExpressionRoot are automatically - * recognized without needing to override createSecurityExpressionRoot(). - * - * This class extends DefaultMethodSecurityExpressionHandler to ensure our - * custom SecurityExpressionRoot is used for method security expressions. - * - * Note: In Spring Security 6.x, the createSecurityExpressionRoot() method signature - * changed and MethodInvocation was removed. Custom expression methods defined in - * SecurityExpressionRoot are automatically available in SpEL expressions. - */ -public class CustomMethodSecurityExpressionHandler extends DefaultMethodSecurityExpressionHandler { - // No overrides needed in Spring Security 6.x - custom methods in - // SecurityExpressionRoot are automatically recognized -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/security/CustomSecurityExpressionRoot.java b/core/src/main/java/org/opendevstack/apiservice/core/security/CustomSecurityExpressionRoot.java deleted file mode 100644 index bc8679d..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/security/CustomSecurityExpressionRoot.java +++ /dev/null @@ -1,69 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.util.Optional; - -/** - * Custom security expression root with additional methods - * Provides custom security expressions like isAdmin(), isUser(), etc. - * In Spring Security 6.x, we extend Spring's SecurityExpressionRoot and add custom methods - */ -public class CustomSecurityExpressionRoot extends org.springframework.security.access.expression.SecurityExpressionRoot { - - private final Authentication authentication; - - public CustomSecurityExpressionRoot(Authentication authentication) { - super(authentication); - this.authentication = authentication; - } - - /** - * Check if user has all of the specified roles - * Uses the inherited hasRole() method from Spring Security 6.x - * Note: hasAnyRole is final in Spring Security 6.x and cannot be overridden - */ - public boolean hasAllRoles(String... roles) { - for (String role : roles) { - if (!hasRole(role)) { - return false; - } - } - return true; - } - - public boolean isOwner() { - // Simplified for example: owner if authenticated principal is a Jwt - return authentication != null && authentication.getPrincipal() instanceof Jwt; - } - - public Optional getCurrentUserEmail() { - if (authentication != null && authentication.getPrincipal() instanceof Jwt jwt) { - return Optional.ofNullable(jwt.getClaimAsString("email")); - } - return Optional.empty(); - } - - public Optional getCurrentUserId() { - if (authentication != null && authentication.getPrincipal() instanceof Jwt jwt) { - return Optional.ofNullable(jwt.getClaimAsString("sub")); - } - return Optional.empty(); - } - - public Optional getCurrentUserName() { - if (authentication != null && authentication.getPrincipal() instanceof Jwt jwt) { - return Optional.ofNullable(jwt.getClaimAsString("preferred_username")); - } - return Optional.empty(); - } - - public boolean isAdmin() { - return hasRole("admin") || hasRole("super-admin"); - } - - public boolean isUser() { - return hasRole("user") || isAdmin(); - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspect.java b/core/src/main/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspect.java deleted file mode 100644 index a1f48b8..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspect.java +++ /dev/null @@ -1,165 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.springframework.core.annotation.Order; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; - -/** - * Aspect to enforce OAuth2 flow requirements - * Intercepts methods annotated with @RequireFlow - */ -@Aspect -@Component -@Order(100) // Run after Spring Security's authorization -public class FlowEnforcementAspect { - - @Around("@annotation(requireFlow)") - public Object enforceFlowRequirement(ProceedingJoinPoint joinPoint, RequireFlow requireFlow) throws Throwable { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - - if (authentication == null) { - throw new InsufficientFlowException("Authentication required"); - } - - if (!(authentication.getPrincipal() instanceof Jwt)) { - throw new InsufficientFlowException("JWT token required"); - } - - Jwt jwt = (Jwt) authentication.getPrincipal(); - FlowValidator.ValidationResult result = validateFlowRequirements(requireFlow, jwt); - - if (!result.isValid()) { - return ResponseEntity.status(403).body( - new org.opendevstack.apiservice.core.dto.ApiResponse<>( - false, null, "Flow validation failed: " + result.getErrorMessage() - ) - ); - } - - return joinPoint.proceed(); - } - - private FlowValidator.ValidationResult validateFlowRequirements(RequireFlow requireFlow, Jwt jwt) { - FlowValidator.ValidationResult result = new FlowValidator.ValidationResult(); - - validateRequiredFlows(requireFlow, jwt, result); - validateActorRequirement(requireFlow, jwt, result); - validateDelegationDepth(requireFlow, jwt, result); - validateRequiredScopes(requireFlow, jwt, result); - - return result; - } - - private void validateRequiredFlows(RequireFlow requireFlow, Jwt jwt, FlowValidator.ValidationResult result) { - if (requireFlow.value().length > 0) { - boolean hasRequiredFlow = validateFlows(requireFlow.value(), jwt); - if (!hasRequiredFlow) { - result.addError("Token does not match required flows: " + - String.join(", ", requireFlow.value())); - } - } - } - - private void validateActorRequirement(RequireFlow requireFlow, Jwt jwt, FlowValidator.ValidationResult result) { - if (requireFlow.requireActor()) { - String actor = jwt.getClaimAsString("actor"); - if (actor == null || actor.isEmpty()) { - result.addError("Actor claim required (On-Behalf-Of flow)"); - } - } - } - - private void validateDelegationDepth(RequireFlow requireFlow, Jwt jwt, FlowValidator.ValidationResult result) { - if (requireFlow.requireDelegationDepth() > 0) { - Number depth = jwt.getClaim("delegation_depth"); - int currentDepth = depth != null ? depth.intValue() : 0; - if (currentDepth < requireFlow.requireDelegationDepth()) { - result.addError("Insufficient delegation depth. Required: " + - requireFlow.requireDelegationDepth() + ", Actual: " + currentDepth); - } - } - } - - private void validateRequiredScopes(RequireFlow requireFlow, Jwt jwt, FlowValidator.ValidationResult result) { - if (requireFlow.scopes().length > 0) { - String scope = jwt.getClaimAsString("scope"); - if (scope == null || scope.isEmpty()) { - result.addError("Token has no scopes"); - } else { - validateEachScope(requireFlow.scopes(), scope.split(" "), result); - } - } - } - - private void validateEachScope(String[] requiredScopes, String[] tokenScopes, FlowValidator.ValidationResult result) { - for (String requiredScope : requiredScopes) { - if (!isScopePresent(requiredScope, tokenScopes)) { - result.addError("Missing required scope: " + requiredScope); - } - } - } - - private boolean isScopePresent(String requiredScope, String[] tokenScopes) { - for (String tokenScope : tokenScopes) { - if (requiredScope.equals(tokenScope)) { - return true; - } - } - return false; - } - - private boolean validateFlows(String[] requiredFlows, Jwt jwt) { - for (String requiredFlow : requiredFlows) { - if (isFlowValid(requiredFlow, jwt)) { - return true; - } - } - return false; - } - - private boolean isFlowValid(String requiredFlow, Jwt jwt) { - switch (requiredFlow) { - case "authorization-code": - return isAuthorizationCodeFlow(jwt); - case "client-credentials": - return isClientCredentialsFlow(jwt); - case "on-behalf-of": - return isOnBehalfOfFlow(jwt); - default: - return false; - } - } - - private boolean isAuthorizationCodeFlow(Jwt jwt) { - return "Bearer".equals(jwt.getClaimAsString("token_type")); - } - - private boolean isClientCredentialsFlow(Jwt jwt) { - String grantType = jwt.getClaimAsString("grant_type"); - return "client_credentials".equals(grantType); - } - - private boolean isOnBehalfOfFlow(Jwt jwt) { - String actor = jwt.getClaimAsString("actor"); - if (actor != null && !actor.isEmpty()) { - return true; - } - String scope = jwt.getClaimAsString("scope"); - return scope != null && (scope.contains("on-behalf-of") || scope.contains("delegated_access")); - } - - /** - * Exception thrown when flow requirements are not met - */ - public static class InsufficientFlowException extends RuntimeException { - public InsufficientFlowException(String message) { - super(message); - } - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/security/FlowValidator.java b/core/src/main/java/org/opendevstack/apiservice/core/security/FlowValidator.java deleted file mode 100644 index f4c24aa..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/security/FlowValidator.java +++ /dev/null @@ -1,228 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.opendevstack.apiservice.core.config.FlowProperties; -import org.opendevstack.apiservice.core.config.FlowProperties.EndpointFlow; -import lombok.Getter; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; - -/** - * Validates OAuth2 flows for endpoints - * Checks if authenticated token meets the required flow criteria - */ -@Component -public class FlowValidator { - - private final FlowProperties flowProperties; - - public FlowValidator(FlowProperties flowProperties) { - this.flowProperties = flowProperties; - } - - /** - * Validate if current token meets flow requirements for an endpoint - */ - public ValidationResult validateEndpointFlow(String endpointPattern) { - EndpointFlow endpointFlow = findEndpointFlow(endpointPattern); - if (endpointFlow == null) { - // No specific flow configuration, use global defaults - return ValidationResult.success(); - } - - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null) { - return ValidationResult.failure("No authentication"); - } - - ValidationResult result = new ValidationResult(); - - // Check if authentication is required - if (!endpointFlow.isRequireAuthentication()) { - return ValidationResult.success(); - } - - // Check if token is present - if (authentication.getPrincipal() instanceof Jwt jwt) { - // Validate required flows - if (endpointFlow.getFlows() != null && !endpointFlow.getFlows().isEmpty()) { - boolean hasRequiredFlow = validateFlows(endpointFlow.getFlows(), jwt); - if (!hasRequiredFlow) { - result.addError("Token does not match required flows: " + endpointFlow.getFlows()); - } - } - - // Validate actor requirement (for On-Behalf-Of) - if (endpointFlow.isRequireActor()) { - boolean hasActor = validateActorClaim(jwt); - if (!hasActor) { - result.addError("Token must have actor claim (On-Behalf-Of flow required)"); - } - } - - // Validate delegation depth - if (endpointFlow.getRequireDelegationDepth() > 0) { - int currentDepth = getDelegationDepth(jwt); - if (currentDepth < endpointFlow.getRequireDelegationDepth()) { - result.addError("Insufficient delegation depth. Required: " + - endpointFlow.getRequireDelegationDepth() + ", Actual: " + currentDepth); - } - } - - // Validate required scopes - if (endpointFlow.getRequiredScopes() != null && !endpointFlow.getRequiredScopes().isEmpty()) { - boolean hasRequiredScopes = validateScopes(endpointFlow.getRequiredScopes(), jwt); - if (!hasRequiredScopes) { - result.addError("Token missing required scopes: " + endpointFlow.getRequiredScopes()); - } - } - } - - return result; - } - - /** - * Find flow configuration for an endpoint - */ - private EndpointFlow findEndpointFlow(String endpointPattern) { - if (flowProperties.getApis() == null) { - return null; - } - - // Check each API's endpoints - for (Map.Entry apiEntry : flowProperties.getApis().entrySet()) { - FlowProperties.ApiFlows apiFlows = apiEntry.getValue(); - if (apiFlows.getEndpoints() != null) { - // Check if pattern matches - for (EndpointFlow endpointFlow : apiFlows.getEndpoints()) { - String pattern = endpointFlow.getPattern(); - if (pattern != null && matchesPattern(endpointPattern, pattern)) { - return endpointFlow; - } - } - } - } - - return null; - } - - /** - * Simple pattern matching (supports wildcards like /**) - */ - private boolean matchesPattern(String endpoint, String pattern) { - if (pattern.endsWith("/**")) { - String basePattern = pattern.substring(0, pattern.length() - 3); - return endpoint.startsWith(basePattern); - } - return endpoint.equals(pattern); - } - - /** - * Validate if token has one of the required flows - */ - private boolean validateFlows(List requiredFlows, Jwt jwt) { - // Get flow from token claims or scopes - String scope = jwt.getClaimAsString("scope"); - String grantType = jwt.getClaimAsString("grant_type"); - - for (String requiredFlow : requiredFlows) { - // Check if flow matches - if ("authorization-code".equals(requiredFlow)) { - if ("Bearer".equals(jwt.getClaimAsString("token_type"))) { - return true; // Authorization code tokens appear as Bearer tokens - } - } else if ("client-credentials".equals(requiredFlow)) { - if ("client_credentials".equals(grantType)) { - return true; - } - } else if ("on-behalf-of".equals(requiredFlow)) { - // Check for actor claim (indicates OBO flow) - String actor = jwt.getClaimAsString("actor"); - if (actor != null && !actor.isEmpty()) { - return true; - } - // Or check scope - if (scope != null && (scope.contains("on-behalf-of") || scope.contains("delegated_access"))) { - return true; - } - } - } - - return false; - } - - /** - * Validate actor claim for On-Behalf-Of flow - */ - private boolean validateActorClaim(Jwt jwt) { - String actor = jwt.getClaimAsString("actor"); - return actor != null && !actor.isEmpty(); - } - - /** - * Get delegation depth from token - */ - private int getDelegationDepth(Jwt jwt) { - Number depth = jwt.getClaim("delegation_depth"); - return depth != null ? depth.intValue() : 0; - } - - /** - * Validate required scopes - */ - private boolean validateScopes(List requiredScopes, Jwt jwt) { - String scope = jwt.getClaimAsString("scope"); - if (scope == null || scope.isEmpty()) { - return false; - } - - String[] tokenScopes = scope.split(" "); - for (String requiredScope : requiredScopes) { - boolean found = false; - for (String tokenScope : tokenScopes) { - if (requiredScope.equals(tokenScope)) { - found = true; - break; - } - } - if (!found) { - return false; - } - } - - return true; - } - - /** - * Validation result for flow checks - */ - @Getter - public static class ValidationResult { - private boolean valid = true; - private java.util.List errors = new java.util.ArrayList<>(); - - public static ValidationResult success() { - return new ValidationResult(); - } - - public static ValidationResult failure(String error) { - ValidationResult result = new ValidationResult(); - result.valid = false; - result.errors.add(error); - return result; - } - - public void addError(String error) { - this.valid = false; - this.errors.add(error); - } - - public String getErrorMessage() { - return String.join("; ", errors); - } - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/security/RequireFlow.java b/core/src/main/java/org/opendevstack/apiservice/core/security/RequireFlow.java deleted file mode 100644 index f113e79..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/security/RequireFlow.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Annotation to specify required OAuth2 flow for an endpoint - * Can be used on classes or methods - */ -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -public @interface RequireFlow { - - /** - * Required OAuth2 flow(s) - * Values: authorization-code, client-credentials, on-behalf-of - */ - String[] value() default {}; - - /** - * Whether actor claim is required (for On-Behalf-Of flow) - */ - boolean requireActor() default false; - - /** - * Required delegation depth - */ - int requireDelegationDepth() default 0; - - /** - * Required scopes - */ - String[] scopes() default {}; - - /** - * Error message if validation fails - */ - String message() default "Insufficient OAuth2 flow permissions"; -} diff --git a/core/src/test/java/org/opendevstack/apiservice/core/config/FlowPropertiesTest.java b/core/src/test/java/org/opendevstack/apiservice/core/config/FlowPropertiesTest.java deleted file mode 100644 index 70b3b0e..0000000 --- a/core/src/test/java/org/opendevstack/apiservice/core/config/FlowPropertiesTest.java +++ /dev/null @@ -1,366 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; - -class FlowPropertiesTest { - - private FlowProperties flowProperties; - - @BeforeEach - void setUp() { - flowProperties = new FlowProperties(); - } - - @Test - void testDefaultGlobalProperties() { - // When - FlowProperties.Global global = flowProperties.getGlobal(); - - // Then - assertNotNull(global); - assertEquals("client-credentials", global.getDefaultFlow()); - } - - @Test - void testSetAndGetGlobal() { - // Given - FlowProperties.Global global = new FlowProperties.Global(); - global.setDefaultFlow("authorization-code"); - global.setEnabledFlows(Arrays.asList("authorization-code", "client-credentials")); - - // When - flowProperties.setGlobal(global); - - // Then - assertEquals("authorization-code", flowProperties.getGlobal().getDefaultFlow()); - assertEquals(2, flowProperties.getGlobal().getEnabledFlows().size()); - assertTrue(flowProperties.getGlobal().getEnabledFlows().contains("authorization-code")); - } - - @Test - void testSetAndGetApis() { - // Given - Map apis = new HashMap<>(); - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setDefaultFlow("on-behalf-of"); - apis.put("test-api", apiFlows); - - // When - flowProperties.setApis(apis); - - // Then - assertNotNull(flowProperties.getApis()); - assertEquals(1, flowProperties.getApis().size()); - assertTrue(flowProperties.getApis().containsKey("test-api")); - assertEquals("on-behalf-of", flowProperties.getApis().get("test-api").getDefaultFlow()); - } - - @Test - void testGlobalEnabledFlows() { - // Given - FlowProperties.Global global = new FlowProperties.Global(); - List flows = Arrays.asList("authorization-code", "client-credentials", "on-behalf-of"); - - // When - global.setEnabledFlows(flows); - - // Then - assertEquals(3, global.getEnabledFlows().size()); - assertTrue(global.getEnabledFlows().contains("authorization-code")); - assertTrue(global.getEnabledFlows().contains("client-credentials")); - assertTrue(global.getEnabledFlows().contains("on-behalf-of")); - } - - @Test - void testGlobalDefaultFlow() { - // Given - FlowProperties.Global global = new FlowProperties.Global(); - - // When - global.setDefaultFlow("authorization-code"); - - // Then - assertEquals("authorization-code", global.getDefaultFlow()); - } - - @Test - void testApiFlowsDefaultFlow() { - // Given - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - - // When - apiFlows.setDefaultFlow("client-credentials"); - - // Then - assertEquals("client-credentials", apiFlows.getDefaultFlow()); - } - - @Test - void testApiFlowsEndpoints() { - // Given - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - FlowProperties.EndpointFlow endpoint1 = new FlowProperties.EndpointFlow(); - endpoint1.setPattern("/api/test"); - - FlowProperties.EndpointFlow endpoint2 = new FlowProperties.EndpointFlow(); - endpoint2.setPattern("/api/admin/**"); - - List endpoints = Arrays.asList(endpoint1, endpoint2); - - // When - apiFlows.setEndpoints(endpoints); - - // Then - assertNotNull(apiFlows.getEndpoints()); - assertEquals(2, apiFlows.getEndpoints().size()); - } - - @Test - void testEndpointFlowPattern() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // When - endpoint.setPattern("/api/users/**"); - - // Then - assertEquals("/api/users/**", endpoint.getPattern()); - } - - @Test - void testEndpointFlowFlows() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - List flows = Arrays.asList("authorization-code", "on-behalf-of"); - - // When - endpoint.setFlows(flows); - - // Then - assertEquals(2, endpoint.getFlows().size()); - assertTrue(endpoint.getFlows().contains("authorization-code")); - assertTrue(endpoint.getFlows().contains("on-behalf-of")); - } - - @Test - void testEndpointFlowRoles() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - List roles = Arrays.asList("admin", "user"); - - // When - endpoint.setRoles(roles); - - // Then - assertEquals(2, endpoint.getRoles().size()); - assertTrue(endpoint.getRoles().contains("admin")); - assertTrue(endpoint.getRoles().contains("user")); - } - - @Test - void testEndpointFlowPermitAll() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // When - endpoint.setPermitAll(true); - - // Then - assertTrue(endpoint.isPermitAll()); - } - - @Test - void testEndpointFlowPermitAllDefaultFalse() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // Then - assertFalse(endpoint.isPermitAll()); - } - - @Test - void testEndpointFlowRequireActor() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // When - endpoint.setRequireActor(true); - - // Then - assertTrue(endpoint.isRequireActor()); - } - - @Test - void testEndpointFlowRequireActorDefaultFalse() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // Then - assertFalse(endpoint.isRequireActor()); - } - - @Test - void testEndpointFlowRequireAuthentication() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // When - endpoint.setRequireAuthentication(false); - - // Then - assertFalse(endpoint.isRequireAuthentication()); - } - - @Test - void testEndpointFlowRequireAuthenticationDefaultTrue() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // Then - assertTrue(endpoint.isRequireAuthentication()); - } - - @Test - void testEndpointFlowRequireDelegationDepth() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // When - endpoint.setRequireDelegationDepth(3); - - // Then - assertEquals(3, endpoint.getRequireDelegationDepth()); - } - - @Test - void testEndpointFlowRequireDelegationDepthDefaultZero() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // Then - assertEquals(0, endpoint.getRequireDelegationDepth()); - } - - @Test - void testEndpointFlowRequiredScopes() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - List scopes = Arrays.asList("read:data", "write:data"); - - // When - endpoint.setRequiredScopes(scopes); - - // Then - assertEquals(2, endpoint.getRequiredScopes().size()); - assertTrue(endpoint.getRequiredScopes().contains("read:data")); - assertTrue(endpoint.getRequiredScopes().contains("write:data")); - } - - @Test - void testCompleteConfigurationStructure() { - // Given - FlowProperties config = new FlowProperties(); - - // Global configuration - FlowProperties.Global global = new FlowProperties.Global(); - global.setDefaultFlow("client-credentials"); - global.setEnabledFlows(Arrays.asList("authorization-code", "client-credentials")); - config.setGlobal(global); - - // API configuration - FlowProperties.ApiFlows userApi = new FlowProperties.ApiFlows(); - userApi.setDefaultFlow("authorization-code"); - - FlowProperties.EndpointFlow userEndpoint = new FlowProperties.EndpointFlow(); - userEndpoint.setPattern("/api/users/**"); - userEndpoint.setFlows(Arrays.asList("authorization-code")); - userEndpoint.setRoles(Arrays.asList("user", "admin")); - userEndpoint.setRequireAuthentication(true); - - userApi.setEndpoints(Arrays.asList(userEndpoint)); - - Map apis = new HashMap<>(); - apis.put("user-api", userApi); - config.setApis(apis); - - // Then - assertNotNull(config.getGlobal()); - assertEquals("client-credentials", config.getGlobal().getDefaultFlow()); - assertEquals(2, config.getGlobal().getEnabledFlows().size()); - - assertNotNull(config.getApis()); - assertEquals(1, config.getApis().size()); - - FlowProperties.ApiFlows retrievedApi = config.getApis().get("user-api"); - assertNotNull(retrievedApi); - assertEquals("authorization-code", retrievedApi.getDefaultFlow()); - assertEquals(1, retrievedApi.getEndpoints().size()); - - FlowProperties.EndpointFlow retrievedEndpoint = retrievedApi.getEndpoints().get(0); - assertEquals("/api/users/**", retrievedEndpoint.getPattern()); - assertEquals(1, retrievedEndpoint.getFlows().size()); - assertEquals(2, retrievedEndpoint.getRoles().size()); - assertTrue(retrievedEndpoint.isRequireAuthentication()); - } - - @Test - void testMultipleApisConfiguration() { - // Given - FlowProperties config = new FlowProperties(); - - FlowProperties.ApiFlows api1 = new FlowProperties.ApiFlows(); - api1.setDefaultFlow("authorization-code"); - - FlowProperties.ApiFlows api2 = new FlowProperties.ApiFlows(); - api2.setDefaultFlow("client-credentials"); - - Map apis = new HashMap<>(); - apis.put("api1", api1); - apis.put("api2", api2); - - // When - config.setApis(apis); - - // Then - assertEquals(2, config.getApis().size()); - assertEquals("authorization-code", config.getApis().get("api1").getDefaultFlow()); - assertEquals("client-credentials", config.getApis().get("api2").getDefaultFlow()); - } - - @Test - void testEndpointFlowWithAllProperties() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // When - endpoint.setPattern("/api/secure/**"); - endpoint.setFlows(Arrays.asList("on-behalf-of")); - endpoint.setRoles(Arrays.asList("admin")); - endpoint.setPermitAll(false); - endpoint.setRequireActor(true); - endpoint.setRequireAuthentication(true); - endpoint.setRequireDelegationDepth(2); - endpoint.setRequiredScopes(Arrays.asList("admin:all")); - - // Then - assertEquals("/api/secure/**", endpoint.getPattern()); - assertEquals(1, endpoint.getFlows().size()); - assertEquals("on-behalf-of", endpoint.getFlows().get(0)); - assertEquals(1, endpoint.getRoles().size()); - assertEquals("admin", endpoint.getRoles().get(0)); - assertFalse(endpoint.isPermitAll()); - assertTrue(endpoint.isRequireActor()); - assertTrue(endpoint.isRequireAuthentication()); - assertEquals(2, endpoint.getRequireDelegationDepth()); - assertEquals(1, endpoint.getRequiredScopes().size()); - assertEquals("admin:all", endpoint.getRequiredScopes().get(0)); - } -} diff --git a/core/src/test/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspectTest.java b/core/src/test/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspectTest.java deleted file mode 100644 index a46475e..0000000 --- a/core/src/test/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspectTest.java +++ /dev/null @@ -1,370 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.opendevstack.apiservice.core.dto.ApiResponse; -import org.aspectj.lang.ProceedingJoinPoint; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.time.Instant; -import java.util.Map; -import java.util.stream.Stream; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class FlowEnforcementAspectTest { - - @Mock - private FlowValidator flowValidator; - - @Mock - private ProceedingJoinPoint joinPoint; - - @Mock - private SecurityContext securityContext; - - @Mock - private Authentication authentication; - - private FlowEnforcementAspect aspect; - - @BeforeEach - void setUp() { - aspect = new FlowEnforcementAspect(); - SecurityContextHolder.setContext(securityContext); - } - - // Parameterized test for successful flow validations - static Stream successfulFlowScenarios() { - return Stream.of( - Arguments.of("client-credentials", Map.of("grant_type", "client_credentials"), "client-credentials flow"), - Arguments.of("authorization-code", Map.of("token_type", "Bearer"), "authorization-code flow"), - Arguments.of("on-behalf-of", Map.of("actor", "service-account"), "on-behalf-of with actor"), - Arguments.of("on-behalf-of", Map.of("scope", "on-behalf-of delegated_access"), "on-behalf-of with scope") - ); - } - - @ParameterizedTest(name = "{2}") - @MethodSource("successfulFlowScenarios") - void testSuccessfulFlowValidation(String flowType, Map claims, String description) throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{flowType}, false, 0, new String[]{}); - Jwt jwt = createJwt(claims); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - when(joinPoint.proceed()).thenReturn("success"); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertEquals("success", result); - verify(joinPoint).proceed(); - } - - @Test - void testEnforceFlowRequirementWithNoAuthentication() throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{"client-credentials"}, false, 0, new String[]{}); - when(securityContext.getAuthentication()).thenReturn(null); - - // When & Then - assertThrows(FlowEnforcementAspect.InsufficientFlowException.class, - () -> aspect.enforceFlowRequirement(joinPoint, requireFlow)); - - verify(joinPoint, never()).proceed(); - } - - @Test - void testEnforceFlowRequirementWithNonJwtAuthentication() throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{"client-credentials"}, false, 0, new String[]{}); - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn("not-a-jwt"); - - // When & Then - assertThrows(FlowEnforcementAspect.InsufficientFlowException.class, - () -> aspect.enforceFlowRequirement(joinPoint, requireFlow)); - - verify(joinPoint, never()).proceed(); - } - - @Test - void testEnforceFlowRequirementFailureReturns403() throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{"client-credentials"}, false, 0, new String[]{}); - Jwt jwt = createJwt(Map.of("grant_type", "authorization_code")); // Wrong flow - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertNotNull(result); - assertTrue(result instanceof ResponseEntity); - ResponseEntity response = (ResponseEntity) result; - assertEquals(403, response.getStatusCode().value()); - - ApiResponse apiResponse = (ApiResponse) response.getBody(); - assertNotNull(apiResponse); - assertFalse(apiResponse.isSuccess()); - assertTrue(apiResponse.getMessage().contains("Flow validation failed")); - - verify(joinPoint, never()).proceed(); - } - - // Parameterized test for successful actor validations - static Stream successfulActorScenarios() { - return Stream.of( - Arguments.of(Map.of("actor", "service-principal"), true, 0, "actor present"), - Arguments.of(Map.of("delegation_depth", 3), false, 2, "delegation depth sufficient") - ); - } - - @ParameterizedTest(name = "{3}") - @MethodSource("successfulActorScenarios") - void testSuccessfulActorValidation(Map claims, boolean requireActor, int delegationDepth, String description) throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{}, requireActor, delegationDepth, new String[]{}); - Jwt jwt = createJwt(claims); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - when(joinPoint.proceed()).thenReturn("success"); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertEquals("success", result); - verify(joinPoint).proceed(); - } - - // Parameterized test for 403 failure scenarios - static Stream failureScenarios() { - return Stream.of( - Arguments.of(new String[]{}, true, 0, new String[]{}, Map.of(), "Actor claim required"), - Arguments.of(new String[]{}, false, 2, new String[]{}, Map.of("delegation_depth", 1), "Insufficient delegation depth"), - Arguments.of(new String[]{}, false, 0, new String[]{"read:data", "write:data"}, Map.of("scope", "read:data"), "Missing required scope"), - Arguments.of(new String[]{}, false, 0, new String[]{"read:data"}, Map.of(), "Token has no scopes") - ); - } - - @ParameterizedTest(name = "{5}") - @MethodSource("failureScenarios") - void testFlowValidationFailures(String[] flows, boolean requireActor, int delegationDepth, - String[] scopes, Map claims, String expectedError) throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(flows, requireActor, delegationDepth, scopes); - Jwt jwt = createJwt(claims); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertTrue(result instanceof ResponseEntity); - ResponseEntity response = (ResponseEntity) result; - assertEquals(403, response.getStatusCode().value()); - - ApiResponse apiResponse = (ApiResponse) response.getBody(); - assertTrue(apiResponse.getMessage().contains(expectedError)); - - verify(joinPoint, never()).proceed(); - } - - @Test - void testRequiredScopesSuccess() throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{}, false, 0, new String[]{"read:data", "write:data"}); - Jwt jwt = createJwt(Map.of("scope", "read:data write:data admin:all")); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - when(joinPoint.proceed()).thenReturn("success"); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertEquals("success", result); - verify(joinPoint).proceed(); - } - - @Test - void testMultipleFlowsOneMatches() throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow( - new String[]{"authorization-code", "client-credentials"}, false, 0, new String[]{}); - Jwt jwt = createJwt(Map.of("grant_type", "client_credentials")); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - when(joinPoint.proceed()).thenReturn("success"); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertEquals("success", result); - verify(joinPoint).proceed(); - } - - @Test - void testEmptyFlowRequirementAllowsAnyFlow() throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{}, false, 0, new String[]{}); - Jwt jwt = createJwt(Map.of()); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - when(joinPoint.proceed()).thenReturn("success"); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertEquals("success", result); - verify(joinPoint).proceed(); - } - - @Test - void testInsufficientFlowExceptionMessage() { - // When - FlowEnforcementAspect.InsufficientFlowException exception = - new FlowEnforcementAspect.InsufficientFlowException("Custom error message"); - - // Then - assertEquals("Custom error message", exception.getMessage()); - } - - // Helper methods - - private Jwt createJwt(Map claims) { - return Jwt.withTokenValue("token") - .header("alg", "none") - .subject("test-user") - .issuedAt(Instant.now()) - .expiresAt(Instant.now().plusSeconds(3600)) - .claims(c -> c.putAll(claims)) - .build(); - } - - private RequireFlow createRequireFlow(String[] flows, boolean requireActor, - int requireDelegationDepth, String[] scopes) { - return new RequireFlow() { - @Override - public Class annotationType() { - return RequireFlow.class; - } - - @Override - public String[] value() { - return flows; - } - - @Override - public boolean requireActor() { - return requireActor; - } - - @Override - public int requireDelegationDepth() { - return requireDelegationDepth; - } - - @Override - public String[] scopes() { - return scopes; - } - - @Override - public String message() { - return "Insufficient OAuth2 flow permissions"; - } - }; - } - @Test - void testValidateFlowsWithSingleValidFlow() { - FlowEnforcementAspect testAspect = new FlowEnforcementAspect(); - Jwt jwt = createJwt(Map.of("grant_type", "client_credentials")); - String[] requiredFlows = {"client-credentials"}; - boolean result = invokeValidateFlows(testAspect, requiredFlows, jwt); - assertTrue(result); - } - - @Test - void testValidateFlowsWithMultipleFlowsOneValid() { - FlowEnforcementAspect testAspect = new FlowEnforcementAspect(); - Jwt jwt = createJwt(Map.of("token_type", "Bearer")); - String[] requiredFlows = {"client-credentials", "authorization-code"}; - boolean result = invokeValidateFlows(testAspect, requiredFlows, jwt); - assertTrue(result); - } - - @Test - void testValidateFlowsWithNoValidFlow() { - FlowEnforcementAspect testAspect = new FlowEnforcementAspect(); - Jwt jwt = createJwt(Map.of("grant_type", "other")); - String[] requiredFlows = {"client-credentials"}; - boolean result = invokeValidateFlows(testAspect, requiredFlows, jwt); - assertFalse(result); - } - - static Stream isFlowValidScenarios() { - return Stream.of( - Arguments.of("authorization-code", Map.of("token_type", "Bearer"), true, "authorization-code with Bearer token"), - Arguments.of("client-credentials", Map.of("grant_type", "client_credentials"), true, "client-credentials flow"), - Arguments.of("on-behalf-of", Map.of("actor", "service-account"), true, "on-behalf-of with actor"), - Arguments.of("on-behalf-of", Map.of("scope", "on-behalf-of delegated_access"), true, "on-behalf-of with scope"), - Arguments.of("unknown-flow", Map.of(), false, "unknown flow type") - ); - } - - @ParameterizedTest(name = "{3}") - @MethodSource("isFlowValidScenarios") - void testIsFlowValid(String flow, Map claims, boolean expected, String description) { - FlowEnforcementAspect testAspect = new FlowEnforcementAspect(); - Jwt jwt = createJwt(claims); - boolean result = invokeIsFlowValid(testAspect, flow, jwt); - assertEquals(expected, result); - } - - // Reflection helpers to access private methods - private boolean invokeValidateFlows(FlowEnforcementAspect aspect, String[] flows, Jwt jwt) { - try { - java.lang.reflect.Method m = FlowEnforcementAspect.class.getDeclaredMethod("validateFlows", String[].class, Jwt.class); - m.setAccessible(true); - return (boolean) m.invoke(aspect, flows, jwt); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - private boolean invokeIsFlowValid(FlowEnforcementAspect aspect, String flow, Jwt jwt) { - try { - java.lang.reflect.Method m = FlowEnforcementAspect.class.getDeclaredMethod("isFlowValid", String.class, Jwt.class); - m.setAccessible(true); - return (boolean) m.invoke(aspect, flow, jwt); - } catch (Exception e) { - throw new RuntimeException(e); - } - } -} diff --git a/core/src/test/java/org/opendevstack/apiservice/core/security/FlowValidatorTest.java b/core/src/test/java/org/opendevstack/apiservice/core/security/FlowValidatorTest.java deleted file mode 100644 index f07e3ba..0000000 --- a/core/src/test/java/org/opendevstack/apiservice/core/security/FlowValidatorTest.java +++ /dev/null @@ -1,345 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.opendevstack.apiservice.core.config.FlowProperties; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.time.Instant; -import java.util.*; -import java.util.stream.Stream; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class FlowValidatorTest { - - private FlowValidator flowValidator; - private FlowProperties flowProperties; - - @BeforeEach - void setUp() { - flowProperties = new FlowProperties(); - flowValidator = new FlowValidator(flowProperties); - - // Clear security context before each test - SecurityContextHolder.clearContext(); - } - - @Test - void testValidateEndpointFlowWithNoConfiguration() { - // Given - no flow configuration - setupAuthentication(createJwt(Map.of())); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/test"); - - // Then - assertTrue(result.isValid()); - assertTrue(result.getErrors().isEmpty()); - } - - @Test - void testValidateEndpointFlowWithNoAuthenticationAndConfiguration() { - // Given - no authentication but with endpoint configuration requiring it - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/test"); - endpointFlow.setRequireAuthentication(true); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - SecurityContextHolder.clearContext(); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/test"); - - // Then - assertFalse(result.isValid()); - assertEquals("No authentication", result.getErrorMessage()); - } - - @Test - void testValidateEndpointFlowWithAuthenticationNotRequired() { - // Given - endpoint that doesn't require authentication - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/public/**"); - endpointFlow.setRequireAuthentication(false); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - setupAuthentication(createJwt(Map.of())); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/public/test"); - - // Then - assertTrue(result.isValid()); - } - - // Parameterized test for successful flow validations - static Stream successfulFlowScenarios() { - return Stream.of( - Arguments.of("authorization-code", "/api/user/**", "/api/user/profile", Map.of("token_type", "Bearer")), - Arguments.of("client-credentials", "/api/service/**", "/api/service/data", Map.of("grant_type", "client_credentials")), - Arguments.of("on-behalf-of", "/api/delegated/**", "/api/delegated/action", Map.of("actor", "service-account")) - ); - } - - @ParameterizedTest(name = "{0} flow validation") - @MethodSource("successfulFlowScenarios") - void testSuccessfulFlowValidation(String flowType, String pattern, String endpoint, Map claims) { - // Given - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern(pattern); - endpointFlow.setFlows(Arrays.asList(flowType)); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(claims); - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow(endpoint); - - // Then - assertTrue(result.isValid()); - } - - @Test - void testValidateFlowFailureWhenFlowDoesNotMatch() { - // Given - endpoint requiring client-credentials but token is authorization-code - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/service/**"); - endpointFlow.setFlows(Arrays.asList("client-credentials")); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(Map.of("token_type", "Bearer")); // Not client-credentials - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/service/data"); - - // Then - assertFalse(result.isValid()); - assertTrue(result.getErrorMessage().contains("does not match required flows")); - } - - // Parameterized test for actor and delegation depth validations - static Stream actorAndDepthScenarios() { - return Stream.of( - Arguments.of(true, 0, Map.of("actor", "service-principal"), "/api/obo/**", "/api/obo/action", true, "actor present"), - Arguments.of(true, 0, Map.of(), "/api/obo/**", "/api/obo/action", false, "actor missing"), - Arguments.of(false, 2, Map.of("delegation_depth", 3), "/api/deep/**", "/api/deep/action", true, "delegation depth sufficient"), - Arguments.of(false, 2, Map.of("delegation_depth", 1), "/api/deep/**", "/api/deep/action", false, "delegation depth insufficient") - ); - } - - @ParameterizedTest(name = "{6}") - @MethodSource("actorAndDepthScenarios") - void testActorAndDelegationDepthValidation(boolean requireActor, int delegationDepth, Map claims, - String pattern, String endpoint, boolean shouldBeValid, String description) { - // Given - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern(pattern); - endpointFlow.setRequireActor(requireActor); - endpointFlow.setRequireDelegationDepth(delegationDepth); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(claims); - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow(endpoint); - - // Then - assertEquals(shouldBeValid, result.isValid()); - if (!shouldBeValid) { - assertFalse(result.getErrorMessage().isEmpty()); - } - } - - // Parameterized test for scope validations - static Stream scopeValidationScenarios() { - return Stream.of( - Arguments.of(Arrays.asList("read:data", "write:data"), "read:data write:data admin:all", true, "all scopes present"), - Arguments.of(Arrays.asList("read:data", "write:data"), "read:data", false, "missing write:data scope") - ); - } - - @ParameterizedTest(name = "{3}") - @MethodSource("scopeValidationScenarios") - void testScopeValidation(List requiredScopes, String tokenScope, boolean shouldBeValid, String description) { - // Given - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/scoped/**"); - endpointFlow.setRequiredScopes(requiredScopes); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(Map.of("scope", tokenScope)); - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/scoped/action"); - - // Then - assertEquals(shouldBeValid, result.isValid()); - if (!shouldBeValid) { - assertTrue(result.getErrorMessage().contains("missing required scopes")); - } - } - - @Test - void testPatternMatchingExact() { - // Given - exact pattern match - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/exact"); - endpointFlow.setRequireActor(true); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(Map.of("actor", "service")); - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/exact"); - - // Then - assertTrue(result.isValid()); - } - - @Test - void testPatternMatchingWildcard() { - // Given - wildcard pattern match - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/wildcard/**"); - endpointFlow.setRequireActor(true); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(Map.of("actor", "service")); - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/wildcard/sub/path"); - - // Then - assertTrue(result.isValid()); - } - - @Test - void testValidationResultSuccess() { - // When - FlowValidator.ValidationResult result = FlowValidator.ValidationResult.success(); - - // Then - assertTrue(result.isValid()); - assertTrue(result.getErrors().isEmpty()); - } - - @Test - void testValidationResultFailure() { - // When - FlowValidator.ValidationResult result = FlowValidator.ValidationResult.failure("Error occurred"); - - // Then - assertFalse(result.isValid()); - assertEquals(1, result.getErrors().size()); - assertEquals("Error occurred", result.getErrorMessage()); - } - - @Test - void testValidationResultAddError() { - // Given - FlowValidator.ValidationResult result = new FlowValidator.ValidationResult(); - - // When - result.addError("First error"); - result.addError("Second error"); - - // Then - assertFalse(result.isValid()); - assertEquals(2, result.getErrors().size()); - assertTrue(result.getErrorMessage().contains("First error")); - assertTrue(result.getErrorMessage().contains("Second error")); - } - - @Test - void testMultipleValidationErrors() { - // Given - endpoint with multiple requirements - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/strict/**"); - endpointFlow.setFlows(Arrays.asList("on-behalf-of")); - endpointFlow.setRequireActor(true); - endpointFlow.setRequiredScopes(Arrays.asList("admin:all")); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(Map.of("scope", "read:data")); // Missing actor and wrong flow - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/strict/action"); - - // Then - assertFalse(result.isValid()); - assertTrue(result.getErrors().size() >= 2); - } - - // Helper methods - - private Jwt createJwt(Map claims) { - return Jwt.withTokenValue("token") - .header("alg", "none") - .subject("test-user") - .issuedAt(Instant.now()) - .expiresAt(Instant.now().plusSeconds(3600)) - .claims(c -> c.putAll(claims)) - .build(); - } - - private void setupAuthentication(Jwt jwt) { - SecurityContext securityContext = mock(SecurityContext.class); - Authentication authentication = mock(Authentication.class); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - - SecurityContextHolder.setContext(securityContext); - } -} diff --git a/core/src/test/java/org/opendevstack/apiservice/core/security/SecurityExpressionRootTest.java b/core/src/test/java/org/opendevstack/apiservice/core/security/SecurityExpressionRootTest.java deleted file mode 100644 index 8760709..0000000 --- a/core/src/test/java/org/opendevstack/apiservice/core/security/SecurityExpressionRootTest.java +++ /dev/null @@ -1,327 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.security.authentication.TestingAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.time.Instant; -import java.util.Arrays; -import java.util.Map; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; - -class SecurityExpressionRootTest { - - private CustomSecurityExpressionRoot expressionRoot; - private Authentication authentication; - - @BeforeEach - void setUp() { - // Default setup with basic authentication - authentication = new TestingAuthenticationToken("user", "password", - Arrays.asList( - new SimpleGrantedAuthority("ROLE_user"), - new SimpleGrantedAuthority("ROLE_admin") - )); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - } - - @Test - void testHasAllRolesWithAllRolesPresent() { - // Given - authentication has both ROLE_user and ROLE_admin - - // When - boolean result = expressionRoot.hasAllRoles("user", "admin"); - - // Then - assertTrue(result); - } - - @Test - void testHasAllRolesWithSomeRolesMissing() { - // Given - authentication has ROLE_user and ROLE_admin but not ROLE_super-admin - - // When - boolean result = expressionRoot.hasAllRoles("user", "super-admin"); - - // Then - assertFalse(result); - } - - @Test - void testHasAllRolesWithSingleRole() { - // Given - authentication has ROLE_user - - // When - boolean result = expressionRoot.hasAllRoles("user"); - - // Then - assertTrue(result); - } - - @Test - void testHasAllRolesWithNoRoles() { - // Given - authentication with no roles - authentication = new TestingAuthenticationToken("user", "password"); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.hasAllRoles("user"); - - // Then - assertFalse(result); - } - - @Test - void testIsAdminWithAdminRole() { - // Given - authentication has ROLE_admin - - // When - boolean result = expressionRoot.isAdmin(); - - // Then - assertTrue(result); - } - - @Test - void testIsAdminWithSuperAdminRole() { - // Given - authentication has ROLE_super-admin - authentication = new TestingAuthenticationToken("user", "password", - Arrays.asList(new SimpleGrantedAuthority("ROLE_super-admin"))); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isAdmin(); - - // Then - assertTrue(result); - } - - @Test - void testIsAdminWithoutAdminRole() { - // Given - authentication without admin role - authentication = new TestingAuthenticationToken("user", "password", - Arrays.asList(new SimpleGrantedAuthority("ROLE_user"))); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isAdmin(); - - // Then - assertFalse(result); - } - - @Test - void testIsUserWithUserRole() { - // Given - authentication has ROLE_user - authentication = new TestingAuthenticationToken("user", "password", - Arrays.asList(new SimpleGrantedAuthority("ROLE_user"))); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isUser(); - - // Then - assertTrue(result); - } - - @Test - void testIsUserWithAdminRole() { - // Given - authentication has ROLE_admin (admin is also a user) - authentication = new TestingAuthenticationToken("admin", "password", - Arrays.asList(new SimpleGrantedAuthority("ROLE_admin"))); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isUser(); - - // Then - assertTrue(result); - } - - @Test - void testIsUserWithoutUserOrAdminRole() { - // Given - authentication without user or admin role - authentication = new TestingAuthenticationToken("guest", "password", - Arrays.asList(new SimpleGrantedAuthority("ROLE_guest"))); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isUser(); - - // Then - assertFalse(result); - } - - @Test - void testGetCurrentUserEmailFromJwt() { - // Given - JWT with email claim - Jwt jwt = createJwtWithClaims(Map.of("email", "test@example.com")); - authentication = new TestingAuthenticationToken(jwt, null); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional email = expressionRoot.getCurrentUserEmail(); - - // Then - assertTrue(email.isPresent()); - assertEquals("test@example.com", email.get()); - } - - @Test - void testGetCurrentUserEmailWithoutJwt() { - // Given - non-JWT authentication - authentication = new TestingAuthenticationToken("user", "password"); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional email = expressionRoot.getCurrentUserEmail(); - - // Then - assertFalse(email.isPresent()); - } - - @Test - void testGetCurrentUserEmailWhenNullAuthentication() { - // Given - null authentication - expressionRoot = new CustomSecurityExpressionRoot(null); - - // When - Optional email = expressionRoot.getCurrentUserEmail(); - - // Then - assertFalse(email.isPresent()); - } - - @Test - void testGetCurrentUserIdFromJwt() { - // Given - JWT with subject - Jwt jwt = createJwtWithClaims(Map.of("sub", "user-123")); - authentication = new TestingAuthenticationToken(jwt, null); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional userId = expressionRoot.getCurrentUserId(); - - // Then - assertTrue(userId.isPresent()); - assertEquals("user-123", userId.get()); - } - - @Test - void testGetCurrentUserIdWithoutJwt() { - // Given - non-JWT authentication - authentication = new TestingAuthenticationToken("user", "password"); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional userId = expressionRoot.getCurrentUserId(); - - // Then - assertFalse(userId.isPresent()); - } - - @Test - void testGetCurrentUserNameFromJwt() { - // Given - JWT with preferred_username claim - Jwt jwt = createJwtWithClaims(Map.of("preferred_username", "john.doe")); - authentication = new TestingAuthenticationToken(jwt, null); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional username = expressionRoot.getCurrentUserName(); - - // Then - assertTrue(username.isPresent()); - assertEquals("john.doe", username.get()); - } - - @Test - void testGetCurrentUserNameWithoutJwt() { - // Given - non-JWT authentication - authentication = new TestingAuthenticationToken("user", "password"); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional username = expressionRoot.getCurrentUserName(); - - // Then - assertFalse(username.isPresent()); - } - - @Test - void testIsOwnerReturnsTrue() { - // Given - JWT authentication - Jwt jwt = createJwtWithClaims(Map.of("sub", "user-123")); - authentication = new TestingAuthenticationToken(jwt, null); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isOwner(); - - // Then - // Note: Current implementation is simplified and returns true - assertTrue(result); - } - - @Test - void testIsOwnerWithNullAuthentication() { - // Given - null authentication - expressionRoot = new CustomSecurityExpressionRoot(null); - - // When - boolean result = expressionRoot.isOwner(); - - // Then - assertFalse(result); - } - - @Test - void testIsOwnerWithNonJwtAuthentication() { - // Given - non-JWT authentication - authentication = new TestingAuthenticationToken("user", "password"); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isOwner(); - - // Then - assertFalse(result); - } - - @Test - void testGetCurrentUserEmailWithMissingEmailClaim() { - // Given - JWT without email claim - Jwt jwt = createJwtWithClaims(Map.of("sub", "user-123")); - authentication = new TestingAuthenticationToken(jwt, null); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional email = expressionRoot.getCurrentUserEmail(); - - // Then - assertFalse(email.isPresent()); - } - - // Helper methods - - private Jwt createJwtWithClaims(Map claims) { - Jwt.Builder builder = Jwt.withTokenValue("token") - .header("alg", "none") - .issuedAt(Instant.now()) - .expiresAt(Instant.now().plusSeconds(3600)); - - // Add subject if present in claims - if (claims.containsKey("sub")) { - builder.subject((String) claims.get("sub")); - } else { - builder.subject("test-user"); - } - - return builder.claims(c -> c.putAll(claims)).build(); - } -} diff --git a/pom.xml b/pom.xml index 4e30a28..fb0e033 100644 --- a/pom.xml +++ b/pom.xml @@ -48,6 +48,7 @@ persistence core core-contracts + core-security external-service-aap external-service-projects-info-service external-service-uipath From f0b76f0b52023cedf4e3942bfafcd5bcfc89749e Mon Sep 17 00:00:00 2001 From: Jorge Romero Date: Thu, 26 Mar 2026 18:56:56 +0100 Subject: [PATCH 02/16] Add OAuth2 resource server configuration and public endpoints to application.yaml --- application.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/application.yaml b/application.yaml index dc47155..4696ac4 100644 --- a/application.yaml +++ b/application.yaml @@ -40,6 +40,19 @@ spring: open-in-view: false show-sql: false +spring: + security: + oauth2: + resourceserver: + jwt: + issuer-uri: https://sts.windows.net/${AZURE_TENANT_ID}/ + +app: + security: + public-endpoints: + - /actuator/health + - /actuator/health/** + management: endpoints: web: From d5ea2c74d7adbf322d057b2642e09b2b7530dfaf Mon Sep 17 00:00:00 2001 From: Jorge Romero Date: Fri, 27 Mar 2026 09:57:30 +0100 Subject: [PATCH 03/16] Add unit tests for security components and remove obsolete tests - Introduced PolicyEngineTest to validate policy evaluation logic. - Added PolicyServiceTest to ensure correct policy retrieval based on client ID. - Created SecurityConfigTest to verify security configuration and filter chain setup. - Implemented AuthTypeEnforcementFilterTest to test authentication flow enforcement. - Added AuthFlowResolverTest to validate authentication flow resolution based on JWT claims. - Created AzureJwtAuthenticationConverterTest for testing JWT authority extraction. - Added ApiDefinitionResolverTest and CoreApiRegistryTest to ensure API definition resolution works as expected. - Removed outdated CustomRoleConverterTest and SecurityConfigTest from the tests directory. --- core-security/pom.xml | 13 + .../PolicyAuthorizationManager.java | 82 ++++++ .../authorization/PolicyContextFactory.java | 34 +++ .../security/authorization/PolicyEngine.java | 49 ++++ .../security/authorization/PolicyService.java | 32 +++ .../security/config/CustomRoleConverter.java | 82 ------ .../core/security/config/SecurityConfig.java | 21 +- .../filter/AuthTypeEnforcementFilter.java | 75 +++++ .../core/security/flow/AuthFlowResolver.java | 22 ++ .../core/security/flow/AuthFlowValidator.java | 11 + .../jwt/AzureJwtAuthenticationConverter.java | 62 ++++ .../registry/ApiDefinitionResolver.java | 32 +++ .../security/registry/CoreApiRegistry.java | 79 ++++++ .../PolicyAuthorizationManagerTest.java | 119 ++++++++ .../PolicyContextFactoryTest.java | 87 ++++++ .../authorization/PolicyEngineTest.java | 114 ++++++++ .../authorization/PolicyServiceTest.java | 62 ++++ .../security/config/SecurityConfigTest.java | 54 ++++ .../filter/AuthTypeEnforcementFilterTest.java | 171 ++++++++++++ .../security/flow/AuthFlowResolverTest.java | 54 ++++ .../AzureJwtAuthenticationConverterTest.java | 145 ++++++++++ .../registry/ApiDefinitionResolverTest.java | 97 +++++++ .../registry/CoreApiRegistryTest.java | 142 ++++++++++ .../config/CustomRoleConverterTest.java | 264 ------------------ .../security/config/SecurityConfigTest.java | 67 ----- 25 files changed, 1546 insertions(+), 424 deletions(-) create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactory.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngine.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyService.java delete mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/config/CustomRoleConverter.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilter.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolver.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowValidator.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverter.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolver.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistry.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactoryTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngineTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyServiceTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilterTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolverTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverterTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolverTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistryTest.java delete mode 100644 core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/CustomRoleConverterTest.java delete mode 100644 core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java diff --git a/core-security/pom.xml b/core-security/pom.xml index 3027435..04bc8fb 100644 --- a/core-security/pom.xml +++ b/core-security/pom.xml @@ -14,6 +14,11 @@ core-security + + org.springframework.boot + spring-boot-starter-web + + org.springframework.boot spring-boot-starter-security @@ -24,6 +29,13 @@ spring-boot-starter-oauth2-resource-server + + + org.opendevstack.apiservice + core-contracts + ${project.version} + + org.projectlombok lombok @@ -35,6 +47,7 @@ spring-boot-starter-test test + org.springframework.security spring-security-test diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java new file mode 100644 index 0000000..b5930f2 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java @@ -0,0 +1,82 @@ +package org.opendevstack.apiservice.core.security.authorization; + + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; + +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.opendevstack.apiservice.core.security.filter.AuthTypeEnforcementFilter; +import org.opendevstack.apiservice.core.security.registry.ApiDefinitionResolver; +//** */ import org.opendevstack.apiservice.core.engine.filter.CachedBodyHttpServletRequest; +//** */import org.opendevstack.apiservice.core.security.filter.AuthTypeEnforcementFilter; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +/** + * Spring Security AuthorizationManager that delegates authorization decisions to the policy engine. + */ +@Component +public class PolicyAuthorizationManager implements AuthorizationManager { + + private final PolicyEngine policyEngine; + private final PolicyService policyService; + private final PolicyContextFactory contextFactory; + private final ApiDefinitionResolver resolver; + + private final ObjectMapper objectMapper; + + public PolicyAuthorizationManager(PolicyEngine policyEngine, + PolicyService policyService, + PolicyContextFactory contextFactory, + ApiDefinitionResolver resolver, + ObjectMapper objectMapper) { + this.policyEngine = policyEngine; + this.policyService = policyService; + this.contextFactory = contextFactory; + this.resolver = resolver; + this.objectMapper = objectMapper; + } + + @Override + public org.springframework.security.authorization.AuthorizationDecision check( + Supplier authentication, + RequestAuthorizationContext context) { + + HttpServletRequest request = context.getRequest(); + + Optional apiDef = this.resolver.resolve(request); + + + //ApiDefinition apiDef = (ApiDefinition) request.getAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR); + + // Unknown routes are denied (fail-closed). Public API definitions are allowed. + if (apiDef.isEmpty()) { + return new org.springframework.security.authorization.AuthorizationDecision(false); + } + + if (apiDef.get().isPublic()) { + return new org.springframework.security.authorization.AuthorizationDecision(true); + } + + PolicyContext policyContext = contextFactory.create(apiDef.get(), request); + + List rules = policyService.findPolicies(apiDef.get().getId(), policyContext.getClientId()); + + AuthorizationDecision decision = policyEngine.evaluate(policyContext, rules); + + return new org.springframework.security.authorization.AuthorizationDecision( + decision != AuthorizationDecision.DENY + ); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactory.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactory.java new file mode 100644 index 0000000..120c1cd --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactory.java @@ -0,0 +1,34 @@ +package org.opendevstack.apiservice.core.security.authorization; + + +import jakarta.servlet.http.HttpServletRequest; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.opendevstack.apiservice.core.security.jwt.AzureJwtAuthenticationConverter; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class PolicyContextFactory { + + public PolicyContext create(ApiDefinition apiDefinition, HttpServletRequest request) { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + + String clientId = null; + String subject = null; + Map claims = Map.of(); + + if (authentication instanceof JwtAuthenticationToken jwtAuth) { + Jwt jwt = jwtAuth.getToken(); + clientId = AzureJwtAuthenticationConverter.extractClientId(jwt); + subject = jwt.getClaimAsString("sub"); + claims = jwt.getClaims(); + } + + return new PolicyContext(clientId, subject, claims, apiDefinition, request); + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngine.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngine.java new file mode 100644 index 0000000..e1cec33 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngine.java @@ -0,0 +1,49 @@ +package org.opendevstack.apiservice.core.security.authorization; + +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.policy.PolicyEvaluator; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class PolicyEngine { + + private final List evaluators; + + public PolicyEngine(List evaluators) { + this.evaluators = evaluators; + } + + public AuthorizationDecision evaluate(PolicyContext context, List rules) { + if (rules == null || rules.isEmpty()) { + return AuthorizationDecision.DENY; + } + + AuthorizationDecision finalDecision = AuthorizationDecision.ABSTAIN; + + for (PolicyRule rule : rules) { + PolicyEvaluator evaluator = evaluators.stream() + .filter(e -> e.supports(rule.getPolicyType())) + .findFirst() + .orElse(null); + + if (evaluator == null) { + continue; + } + + AuthorizationDecision decision = evaluator.evaluate(context.withRule(rule)); + finalDecision = AuthorizationDecision.combine(finalDecision, decision); + + if (finalDecision == AuthorizationDecision.DENY) { + return AuthorizationDecision.DENY; + } + } + + return finalDecision == AuthorizationDecision.ABSTAIN + ? AuthorizationDecision.PERMIT + : finalDecision; + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyService.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyService.java new file mode 100644 index 0000000..f1c28de --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyService.java @@ -0,0 +1,32 @@ +package org.opendevstack.apiservice.core.security.authorization; + +//import org.opendevstack.apiservice.core.config.CacheConfig; +import org.opendevstack.apiservice.core.contracts.persistence.PolicyDao; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +//import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Service +public class PolicyService { + + private final PolicyDao policyDao; + + public PolicyService(PolicyDao policyDao) { + this.policyDao = policyDao; + } + +// @Cacheable(cacheNames = CacheConfig.POLICIES_CACHE, +// key = "#apiDefinitionId + '::' + (#clientId != null ? #clientId : '*')") + public List findPolicies(String apiDefinitionId, String clientId) { + if (clientId != null) { + List rules = new ArrayList<>(); + rules.addAll(policyDao.findGlobalByApiDefinitionId(apiDefinitionId)); + rules.addAll(policyDao.findByApiDefinitionIdAndClientId(apiDefinitionId, clientId)); + return rules; + } + return policyDao.findByApiDefinitionId(apiDefinitionId); + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/CustomRoleConverter.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/CustomRoleConverter.java deleted file mode 100644 index 0d2cf7f..0000000 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/CustomRoleConverter.java +++ /dev/null @@ -1,82 +0,0 @@ -package org.opendevstack.apiservice.core.security.config; - -import org.springframework.core.convert.converter.Converter; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.util.Collection; -import java.util.List; -import java.util.Map; - -public class CustomRoleConverter implements Converter> { - - private static final String ROLES_CLAIM = "roles"; - - @Override - @SuppressWarnings("nullness") - public Collection convert(Jwt jwt) { - if (jwt == null) { - return List.of(); - } - // Extract Azure Entra ID top-level roles claim - List entraRoles = getEntraRoles(jwt); - - // Extract realm roles (Keycloak standard) - List realmRoles = getRealmRoles(jwt); - - // Extract resource roles (Keycloak client-specific roles) - List resourceRoles = getResourceRoles(jwt); - - // Combine all roles - List allRoles = java.util.stream.Stream.of(entraRoles, realmRoles, resourceRoles) - .flatMap(Collection::stream) - .toList(); - - // Convert to GrantedAuthority with ROLE_ prefix - return allRoles.stream() - .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) - .map(GrantedAuthority.class::cast) - .toList(); - } - - private List getEntraRoles(Jwt jwt) { - // Azure Entra ID puts app roles in a top-level "roles" claim - List roles = jwt.getClaimAsStringList(ROLES_CLAIM); - return roles != null ? roles : List.of(); - } - - private List getRealmRoles(Jwt jwt) { - Map realmAccess = jwt.getClaimAsMap("realm_access"); - if (realmAccess != null && realmAccess.containsKey(ROLES_CLAIM)) { - @SuppressWarnings("unchecked") - List roles = (List) realmAccess.get(ROLES_CLAIM); - return roles != null ? roles : List.of(); - } - return List.of(); - } - - private List getResourceRoles(Jwt jwt) { - Map resourceAccess = jwt.getClaimAsMap("resource_access"); - if (resourceAccess == null) { - return List.of(); - } - - return resourceAccess.values().stream() - .map(this::extractRolesFromResource) - .flatMap(Collection::stream) - .toList(); - } - - @SuppressWarnings("unchecked") - private List extractRolesFromResource(Object resource) { - if (resource instanceof Map) { - Map resourceMap = (Map) resource; - Object roles = resourceMap.get(ROLES_CLAIM); - if (roles instanceof List) { - return (List) roles; - } - } - return List.of(); - } -} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java index 9c1c0fb..0267fc5 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java @@ -1,11 +1,12 @@ package org.opendevstack.apiservice.core.security.config; +import org.opendevstack.apiservice.core.security.authorization.PolicyAuthorizationManager; +import org.opendevstack.apiservice.core.security.jwt.AzureJwtAuthenticationConverter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.web.SecurityFilterChain; import lombok.extern.slf4j.Slf4j; import java.util.Arrays; @@ -17,9 +18,14 @@ public class SecurityConfig { private final SecurityProperties securityProperties; + private final PolicyAuthorizationManager policyAuthorizationManager; + private final AzureJwtAuthenticationConverter azureJwtAuthenticationConverter; - public SecurityConfig(SecurityProperties securityProperties) { + + public SecurityConfig(SecurityProperties securityProperties, PolicyAuthorizationManager policyAuthorizationManager, AzureJwtAuthenticationConverter azureJwtAuthenticationConverter) { this.securityProperties = securityProperties; + this.policyAuthorizationManager = policyAuthorizationManager; + this.azureJwtAuthenticationConverter = azureJwtAuthenticationConverter; } @Bean @@ -34,21 +40,14 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti authz.requestMatchers(endpoint).permitAll(); }); } - authz.anyRequest().authenticated(); + authz.anyRequest().access(policyAuthorizationManager); }) .oauth2ResourceServer((oauth2) -> - oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())) + oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(azureJwtAuthenticationConverter)) ) .headers(headers -> headers.frameOptions(frame -> frame.disable())) .csrf(csrf -> csrf.disable()); return http.build(); } - - @Bean - public JwtAuthenticationConverter jwtAuthenticationConverter() { - JwtAuthenticationConverter authenticationConverter = new JwtAuthenticationConverter(); - authenticationConverter.setJwtGrantedAuthoritiesConverter(new CustomRoleConverter()); - return authenticationConverter; - } } diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilter.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilter.java new file mode 100644 index 0000000..0ac2ddc --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilter.java @@ -0,0 +1,75 @@ +package org.opendevstack.apiservice.core.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.opendevstack.apiservice.core.security.flow.AuthFlowResolver; +import org.opendevstack.apiservice.core.security.flow.AuthFlowValidator; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +public class AuthTypeEnforcementFilter extends OncePerRequestFilter { + + public static final String API_DEFINITION_ATTR = "oas.apiDefinition"; + + private final AuthFlowResolver flowResolver; + private final Map validators; + + public AuthTypeEnforcementFilter(AuthFlowResolver flowResolver, + List validatorList) { + this.flowResolver = flowResolver; + this.validators = validatorList.stream() + .collect(Collectors.toMap(AuthFlowValidator::getSupportedFlow, Function.identity())); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + ApiDefinition apiDef = (ApiDefinition) request.getAttribute(API_DEFINITION_ATTR); + + // If no API definition resolved or does not require auth, continue + if (apiDef == null || !apiDef.requiresAuth()) { + filterChain.doFilter(request, response); + return; + } + + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) { + throw new AuthenticationCredentialsNotFoundException("JWT required"); + } + + Jwt jwt = jwtAuth.getToken(); + AuthType detectedFlow = flowResolver.resolve(jwt); + + // Verify detected flow is in the set of allowed flows for this API + if (detectedFlow == null || !apiDef.getAuthTypes().contains(detectedFlow)) { + throw new AccessDeniedException( + "Flow '" + detectedFlow + "' is not allowed for this API. Allowed: " + apiDef.getAuthTypes()); + } + + // Validate the flow specifics + AuthFlowValidator validator = validators.get(detectedFlow); + if (validator != null && !validator.validate(jwt)) { + throw new AccessDeniedException("Token validation failed for flow: " + detectedFlow); + } + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolver.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolver.java new file mode 100644 index 0000000..0d5d53d --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolver.java @@ -0,0 +1,22 @@ +package org.opendevstack.apiservice.core.security.flow; + +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; + +@Component +public class AuthFlowResolver { + + public AuthType resolve(Jwt jwt) { + if (jwt == null) { + return AuthType.NONE; + } + // In Azure AD, OBO tokens have a "scp" claim (delegated permissions). + // Client credentials tokens have a "roles" claim but no "scp". + String scp = jwt.getClaimAsString("scp"); + if (scp != null && !scp.isBlank()) { + return AuthType.OBO; + } + return AuthType.CLIENT_CREDENTIALS; + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowValidator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowValidator.java new file mode 100644 index 0000000..8036b1c --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowValidator.java @@ -0,0 +1,11 @@ +package org.opendevstack.apiservice.core.security.flow; + +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.springframework.security.oauth2.jwt.Jwt; + +public interface AuthFlowValidator { + + AuthType getSupportedFlow(); + + boolean validate(Jwt jwt); +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverter.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverter.java new file mode 100644 index 0000000..7bf20c8 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverter.java @@ -0,0 +1,62 @@ +package org.opendevstack.apiservice.core.security.jwt; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +@Component +public class AzureJwtAuthenticationConverter implements Converter { + + @Override + public AbstractAuthenticationToken convert(Jwt jwt) { + Collection authorities = extractAuthorities(jwt); + return new JwtAuthenticationToken(jwt, authorities, jwt.getClaimAsString("sub")); + } + + private Collection extractAuthorities(Jwt jwt) { + List authorities = new ArrayList<>(); + + // App Roles from "roles" claim (client-credentials flow) + List roles = jwt.getClaimAsStringList("roles"); + if (roles != null) { + for (String role : roles) { + if (role != null && !role.isBlank()) { + authorities.add(new SimpleGrantedAuthority("ROLE_" + role)); + } + } + } + + // Delegated scopes from "scp" claim (authorization-code / OBO flows) + String scp = jwt.getClaimAsString("scp"); + if (scp != null && !scp.isBlank()) { + for (String scope : scp.split(" ")) { + if (!scope.isBlank()) { + authorities.add(new SimpleGrantedAuthority("SCOPE_" + scope)); + } + } + } + + return Collections.unmodifiableList(authorities); + } + + /** + * Extracts the client application identifier from the JWT. + * Entra ID uses {@code azp} (v2 tokens) or {@code appid} (v1 tokens). + */ + public static String extractClientId(Jwt jwt) { + String clientId = jwt.getClaimAsString("azp"); + if (clientId == null || clientId.isBlank()) { + clientId = jwt.getClaimAsString("appid"); + } + return clientId; + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolver.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolver.java new file mode 100644 index 0000000..5feb497 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolver.java @@ -0,0 +1,32 @@ +package org.opendevstack.apiservice.core.security.registry; + +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +public class ApiDefinitionResolver { + + private static final Pattern VERSION_PATH_PATTERN = Pattern.compile("^/(?:api/(?:pub/)?)?(v\\d+)/(.+)$"); + + private final CoreApiRegistry registry; + + public ApiDefinitionResolver(CoreApiRegistry registry) { + this.registry = registry; + } + + public Optional resolve(HttpServletRequest request) { + String uri = request.getRequestURI(); + Matcher matcher = VERSION_PATH_PATTERN.matcher(uri); + if (!matcher.matches()) { + return Optional.empty(); + } + String version = matcher.group(1); // e.g. "v0", "v1" + String pathAfterVersion = matcher.group(2); + return registry.resolveBestMatch(version, pathAfterVersion); + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistry.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistry.java new file mode 100644 index 0000000..7e22f3c --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistry.java @@ -0,0 +1,79 @@ +package org.opendevstack.apiservice.core.security.registry; + +import org.opendevstack.apiservice.core.contracts.persistence.ApiDefinitionDao; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.springframework.stereotype.Service; +import org.springframework.util.AntPathMatcher; + +import jakarta.annotation.PostConstruct; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class CoreApiRegistry { + + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + private final ApiDefinitionDao apiDefinitionDao; + + private final Map routeIndex = new ConcurrentHashMap<>(); + + public CoreApiRegistry(ApiDefinitionDao apiDefinitionDao) { + this.apiDefinitionDao = apiDefinitionDao; + } + + @PostConstruct + public void init() { + refreshIndex(); + } + + public void refreshIndex() { + routeIndex.clear(); + List definitions = apiDefinitionDao.findAllEnabled(); + for (ApiDefinition def : definitions) { + String key = buildKey(def.getVersion(), def.getBasePath()); + routeIndex.put(key, def); + } + } + + public Optional resolve(String version, String basePath) { + String key = buildKey(version, basePath); + return Optional.ofNullable(routeIndex.get(key)); + } + + public Optional resolveBestMatch(String version, String requestPathAfterVersion) { + String normalizedRequestPath = normalizePath(requestPathAfterVersion); + if (normalizedRequestPath.isBlank()) { + return Optional.empty(); + } + + return routeIndex.values().stream() + .filter(definition -> version.equals(definition.getVersion())) + .filter(definition -> matchesPattern(definition.getBasePath(), normalizedRequestPath)) + .max((left, right) -> Integer.compare(patternSpecificity(left.getBasePath()), patternSpecificity(right.getBasePath()))); + } + + private String buildKey(String version, String basePath) { + String normalizedPath = basePath.startsWith("/") ? basePath.substring(1) : basePath; + return version + "/" + normalizedPath; + } + + private boolean matchesPattern(String basePathPattern, String requestPath) { + String normalizedPattern = normalizePath(basePathPattern); + return pathMatcher.match(normalizedPattern, requestPath) + || pathMatcher.match(normalizedPattern + "/**", requestPath); + } + + private int patternSpecificity(String basePathPattern) { + return normalizePath(basePathPattern).length(); + } + + private String normalizePath(String rawPath) { + if (rawPath == null || rawPath.isBlank()) { + return ""; + } + return rawPath.startsWith("/") ? rawPath.substring(1) : rawPath; + } +} \ No newline at end of file diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java new file mode 100644 index 0000000..26faac1 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java @@ -0,0 +1,119 @@ +package org.opendevstack.apiservice.core.security.authorization; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.opendevstack.apiservice.core.security.registry.ApiDefinitionResolver; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; + +import java.util.*; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PolicyAuthorizationManagerTest { + + private PolicyEngine policyEngine; + private PolicyService policyService; + private PolicyContextFactory contextFactory; + private ApiDefinitionResolver resolver; + private PolicyAuthorizationManager manager; + + @BeforeEach + void setUp() { + policyEngine = mock(PolicyEngine.class); + policyService = mock(PolicyService.class); + contextFactory = mock(PolicyContextFactory.class); + resolver = mock(ApiDefinitionResolver.class); + manager = new PolicyAuthorizationManager(policyEngine, policyService, contextFactory, resolver, new ObjectMapper()); + } + + @Test + void check_unknownRoute_denied() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/unknown"); + when(resolver.resolve(request)).thenReturn(Optional.empty()); + + var decision = manager.check(() -> null, new RequestAuthorizationContext(request)); + + assertFalse(decision.isGranted()); + } + + @Test + void check_publicApi_permitted() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/health"); + ApiDefinition apiDef = new ApiDefinition("api-1", "Health", "/health", "v0", + Set.of(AuthType.NONE), true, null, true); + when(resolver.resolve(request)).thenReturn(Optional.of(apiDef)); + + var decision = manager.check(() -> null, new RequestAuthorizationContext(request)); + + assertTrue(decision.isGranted()); + verifyNoInteractions(policyEngine); + } + + @Test + void check_securedApi_policyPermits() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/projects"); + ApiDefinition apiDef = new ApiDefinition("api-1", "Projects", "/projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(resolver.resolve(request)).thenReturn(Optional.of(apiDef)); + + PolicyContext ctx = mock(PolicyContext.class); + when(ctx.getClientId()).thenReturn("client-a"); + when(contextFactory.create(apiDef, request)).thenReturn(ctx); + + List rules = List.of(new PolicyRule(UUID.randomUUID(), "api-1", "client-a", "ALLOWED_CLIENTS", Map.of())); + when(policyService.findPolicies("api-1", "client-a")).thenReturn(rules); + when(policyEngine.evaluate(ctx, rules)).thenReturn(AuthorizationDecision.PERMIT); + + var decision = manager.check(() -> null, new RequestAuthorizationContext(request)); + + assertTrue(decision.isGranted()); + } + + @Test + void check_securedApi_policyDenies() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/projects"); + ApiDefinition apiDef = new ApiDefinition("api-1", "Projects", "/projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(resolver.resolve(request)).thenReturn(Optional.of(apiDef)); + + PolicyContext ctx = mock(PolicyContext.class); + when(ctx.getClientId()).thenReturn("client-b"); + when(contextFactory.create(apiDef, request)).thenReturn(ctx); + + List rules = List.of(new PolicyRule(UUID.randomUUID(), "api-1", "client-b", "ALLOWED_CLIENTS", Map.of())); + when(policyService.findPolicies("api-1", "client-b")).thenReturn(rules); + when(policyEngine.evaluate(ctx, rules)).thenReturn(AuthorizationDecision.DENY); + + var decision = manager.check(() -> null, new RequestAuthorizationContext(request)); + + assertFalse(decision.isGranted()); + } + + @Test + void check_securedApi_abstainPermits() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/projects"); + ApiDefinition apiDef = new ApiDefinition("api-1", "Projects", "/projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(resolver.resolve(request)).thenReturn(Optional.of(apiDef)); + + PolicyContext ctx = mock(PolicyContext.class); + when(ctx.getClientId()).thenReturn("client-a"); + when(contextFactory.create(apiDef, request)).thenReturn(ctx); + + when(policyService.findPolicies(anyString(), anyString())).thenReturn(List.of()); + when(policyEngine.evaluate(any(), any())).thenReturn(AuthorizationDecision.ABSTAIN); + + var decision = manager.check(() -> null, new RequestAuthorizationContext(request)); + + assertTrue(decision.isGranted()); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactoryTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactoryTest.java new file mode 100644 index 0000000..1b4fe8c --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactoryTest.java @@ -0,0 +1,87 @@ +package org.opendevstack.apiservice.core.security.authorization; + +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.time.Instant; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class PolicyContextFactoryTest { + + private final PolicyContextFactory factory = new PolicyContextFactory(); + + @Test + void create_withJwtAuthentication_extractsClientIdSubjectAndClaims() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", "user-1") + .claim("azp", "client-app") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .build(); + JwtAuthenticationToken authToken = new JwtAuthenticationToken(jwt); + SecurityContextHolder.getContext().setAuthentication(authToken); + + ApiDefinition apiDef = new ApiDefinition("api-1", "Test API", "/test", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/test"); + + PolicyContext ctx = factory.create(apiDef, request); + + assertEquals("client-app", ctx.getClientId()); + assertEquals("user-1", ctx.getSubject()); + assertSame(apiDef, ctx.getApiDefinition()); + assertSame(request, ctx.getRequest()); + assertFalse(ctx.getClaims().isEmpty()); + + SecurityContextHolder.clearContext(); + } + + @Test + void create_withNoAuthentication_returnsNullClientIdAndSubject() { + SecurityContextHolder.clearContext(); + + ApiDefinition apiDef = new ApiDefinition("api-1", "Test API", "/test", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/test"); + + PolicyContext ctx = factory.create(apiDef, request); + + assertNull(ctx.getClientId()); + assertNull(ctx.getSubject()); + assertTrue(ctx.getClaims().isEmpty()); + } + + @Test + void create_withAzpBlank_fallsBackToAppid() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", "user-1") + .claim("azp", "") + .claim("appid", "v1-client") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .build(); + JwtAuthenticationToken authToken = new JwtAuthenticationToken(jwt); + SecurityContextHolder.getContext().setAuthentication(authToken); + + ApiDefinition apiDef = new ApiDefinition("api-1", "Test API", "/test", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/test"); + + PolicyContext ctx = factory.create(apiDef, request); + + assertEquals("v1-client", ctx.getClientId()); + + SecurityContextHolder.clearContext(); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngineTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngineTest.java new file mode 100644 index 0000000..514b362 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngineTest.java @@ -0,0 +1,114 @@ +package org.opendevstack.apiservice.core.security.authorization; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.policy.PolicyEvaluator; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PolicyEngineTest { + + private PolicyEvaluator evaluatorA; + private PolicyEvaluator evaluatorB; + private PolicyEngine engine; + private PolicyContext context; + + @BeforeEach + void setUp() { + evaluatorA = mock(PolicyEvaluator.class); + evaluatorB = mock(PolicyEvaluator.class); + when(evaluatorA.supports("TYPE_A")).thenReturn(true); + when(evaluatorB.supports("TYPE_B")).thenReturn(true); + engine = new PolicyEngine(List.of(evaluatorA, evaluatorB)); + context = mock(PolicyContext.class); + when(context.withRule(any())).thenReturn(context); + } + + @Test + void evaluate_emptyRules_returnsDeny() { + assertEquals(AuthorizationDecision.DENY, engine.evaluate(context, Collections.emptyList())); + } + + @Test + void evaluate_nullRules_returnsDeny() { + assertEquals(AuthorizationDecision.DENY, engine.evaluate(context, null)); + } + + @Test + void evaluate_singleRulePermit_returnsPermit() { + PolicyRule rule = rule("TYPE_A"); + when(evaluatorA.evaluate(context)).thenReturn(AuthorizationDecision.PERMIT); + + assertEquals(AuthorizationDecision.PERMIT, engine.evaluate(context, List.of(rule))); + } + + @Test + void evaluate_singleRuleDeny_returnsDeny() { + PolicyRule rule = rule("TYPE_A"); + when(evaluatorA.evaluate(context)).thenReturn(AuthorizationDecision.DENY); + + assertEquals(AuthorizationDecision.DENY, engine.evaluate(context, List.of(rule))); + } + + @Test + void evaluate_twoRules_permitThenDeny_returnsDeny() { + PolicyRule ruleA = rule("TYPE_A"); + PolicyRule ruleB = rule("TYPE_B"); + when(evaluatorA.evaluate(context)).thenReturn(AuthorizationDecision.PERMIT); + when(evaluatorB.evaluate(context)).thenReturn(AuthorizationDecision.DENY); + + assertEquals(AuthorizationDecision.DENY, engine.evaluate(context, List.of(ruleA, ruleB))); + } + + @Test + void evaluate_twoRules_permitThenAbstain_returnsPermit() { + PolicyRule ruleA = rule("TYPE_A"); + PolicyRule ruleB = rule("TYPE_B"); + when(evaluatorA.evaluate(context)).thenReturn(AuthorizationDecision.PERMIT); + when(evaluatorB.evaluate(context)).thenReturn(AuthorizationDecision.ABSTAIN); + + assertEquals(AuthorizationDecision.PERMIT, engine.evaluate(context, List.of(ruleA, ruleB))); + } + + @Test + void evaluate_allAbstain_returnsPermit() { + PolicyRule ruleA = rule("TYPE_A"); + PolicyRule ruleB = rule("TYPE_B"); + when(evaluatorA.evaluate(context)).thenReturn(AuthorizationDecision.ABSTAIN); + when(evaluatorB.evaluate(context)).thenReturn(AuthorizationDecision.ABSTAIN); + + assertEquals(AuthorizationDecision.PERMIT, engine.evaluate(context, List.of(ruleA, ruleB))); + } + + @Test + void evaluate_ruleWithNoMatchingEvaluator_isSkipped() { + PolicyRule unknownRule = rule("UNKNOWN_TYPE"); + + // No evaluator supports UNKNOWN_TYPE → skipped → finalDecision stays ABSTAIN → PERMIT + assertEquals(AuthorizationDecision.PERMIT, engine.evaluate(context, List.of(unknownRule))); + } + + @Test + void evaluate_denyShortCircuits_secondEvaluatorNotCalled() { + PolicyRule ruleA = rule("TYPE_A"); + PolicyRule ruleB = rule("TYPE_B"); + when(evaluatorA.evaluate(context)).thenReturn(AuthorizationDecision.DENY); + + engine.evaluate(context, List.of(ruleA, ruleB)); + + verify(evaluatorB, never()).evaluate(any()); + } + + private PolicyRule rule(String type) { + return new PolicyRule(UUID.randomUUID(), "api-1", "client-1", type, Map.of()); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyServiceTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyServiceTest.java new file mode 100644 index 0000000..3688564 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyServiceTest.java @@ -0,0 +1,62 @@ +package org.opendevstack.apiservice.core.security.authorization; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.persistence.PolicyDao; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PolicyServiceTest { + + private PolicyDao policyDao; + private PolicyService policyService; + + @BeforeEach + void setUp() { + policyDao = mock(PolicyDao.class); + policyService = new PolicyService(policyDao); + } + + @Test + void findPolicies_withClientId_combinesGlobalAndClientSpecificRules() { + PolicyRule globalRule = new PolicyRule(UUID.randomUUID(), "api-1", null, "TYPE_A", Map.of()); + PolicyRule clientRule = new PolicyRule(UUID.randomUUID(), "api-1", "client-a", "TYPE_B", Map.of()); + + when(policyDao.findGlobalByApiDefinitionId("api-1")).thenReturn(List.of(globalRule)); + when(policyDao.findByApiDefinitionIdAndClientId("api-1", "client-a")).thenReturn(List.of(clientRule)); + + List result = policyService.findPolicies("api-1", "client-a"); + + assertEquals(2, result.size()); + assertTrue(result.contains(globalRule)); + assertTrue(result.contains(clientRule)); + } + + @Test + void findPolicies_withNullClientId_usesApiDefinitionOnlyLookup() { + PolicyRule rule = new PolicyRule(UUID.randomUUID(), "api-1", null, "TYPE_A", Map.of()); + when(policyDao.findByApiDefinitionId("api-1")).thenReturn(List.of(rule)); + + List result = policyService.findPolicies("api-1", null); + + assertEquals(1, result.size()); + verify(policyDao, never()).findGlobalByApiDefinitionId(any()); + verify(policyDao, never()).findByApiDefinitionIdAndClientId(any(), any()); + } + + @Test + void findPolicies_withClientId_noRulesFound_returnsEmpty() { + when(policyDao.findGlobalByApiDefinitionId("api-1")).thenReturn(List.of()); + when(policyDao.findByApiDefinitionIdAndClientId("api-1", "client-x")).thenReturn(List.of()); + + List result = policyService.findPolicies("api-1", "client-x"); + + assertTrue(result.isEmpty()); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java new file mode 100644 index 0000000..58a2147 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java @@ -0,0 +1,54 @@ +package org.opendevstack.apiservice.core.security.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.security.authorization.PolicyAuthorizationManager; +import org.opendevstack.apiservice.core.security.jwt.AzureJwtAuthenticationConverter; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SecurityConfigTest { + + private SecurityProperties securityProperties; + private PolicyAuthorizationManager policyAuthorizationManager; + private AzureJwtAuthenticationConverter azureJwtAuthenticationConverter; + private SecurityConfig securityConfig; + + @BeforeEach + void setUp() { + securityProperties = mock(SecurityProperties.class); + policyAuthorizationManager = mock(PolicyAuthorizationManager.class); + azureJwtAuthenticationConverter = new AzureJwtAuthenticationConverter(); + securityConfig = new SecurityConfig(securityProperties, policyAuthorizationManager, azureJwtAuthenticationConverter); + } + + @Test + void securityFilterChain_withPublicEndpoints() throws Exception { + when(securityProperties.getPublicEndpoints()).thenReturn(new String[]{"/health", "/info"}); + HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); + + SecurityFilterChain chain = securityConfig.securityFilterChain(http); + assertNotNull(chain); + } + + @Test + void securityFilterChain_withNullPublicEndpoints() throws Exception { + when(securityProperties.getPublicEndpoints()).thenReturn(null); + HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); + + SecurityFilterChain chain = securityConfig.securityFilterChain(http); + assertNotNull(chain); + } + + @Test + void securityFilterChain_withEmptyPublicEndpoints() throws Exception { + when(securityProperties.getPublicEndpoints()).thenReturn(new String[]{}); + HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); + + SecurityFilterChain chain = securityConfig.securityFilterChain(http); + assertNotNull(chain); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilterTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilterTest.java new file mode 100644 index 0000000..7c593c3 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilterTest.java @@ -0,0 +1,171 @@ +package org.opendevstack.apiservice.core.security.filter; + +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.opendevstack.apiservice.core.security.flow.AuthFlowResolver; +import org.opendevstack.apiservice.core.security.flow.AuthFlowValidator; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AuthTypeEnforcementFilterTest { + + private AuthFlowResolver flowResolver; + private AuthFlowValidator ccValidator; + private AuthFlowValidator oboValidator; + private AuthTypeEnforcementFilter filter; + private FilterChain filterChain; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + flowResolver = new AuthFlowResolver(); + ccValidator = mock(AuthFlowValidator.class); + when(ccValidator.getSupportedFlow()).thenReturn(AuthType.CLIENT_CREDENTIALS); + when(ccValidator.validate(any())).thenReturn(true); + + oboValidator = mock(AuthFlowValidator.class); + when(oboValidator.getSupportedFlow()).thenReturn(AuthType.OBO); + when(oboValidator.validate(any())).thenReturn(true); + + filter = new AuthTypeEnforcementFilter(flowResolver, List.of(ccValidator, oboValidator)); + filterChain = mock(FilterChain.class); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void noApiDefinition_continuesChain() throws Exception { + // No API_DEFINITION_ATTR set + filter.doFilterInternal(request, response, filterChain); + verify(filterChain).doFilter(request, response); + } + + @Test + void publicApi_continuesChain() throws Exception { + ApiDefinition apiDef = new ApiDefinition("api-1", "Public", "/pub", "v0", + Set.of(AuthType.NONE), true, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + filter.doFilterInternal(request, response, filterChain); + verify(filterChain).doFilter(request, response); + } + + @Test + void securedApi_noJwtAuthentication_throwsCredentialsNotFound() { + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + assertThrows(AuthenticationCredentialsNotFoundException.class, + () -> filter.doFilterInternal(request, response, filterChain)); + } + + @Test + void securedApi_clientCredentials_allowedFlow_continuesChain() throws Exception { + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + setJwtAuth(Map.of("roles", List.of("ADMIN"))); // no scp → CLIENT_CREDENTIALS + + filter.doFilterInternal(request, response, filterChain); + verify(filterChain).doFilter(request, response); + } + + @Test + void securedApi_obo_allowedFlow_continuesChain() throws Exception { + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.OBO), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + setJwtAuth(Map.of("scp", "api.read")); // scp present → OBO + + filter.doFilterInternal(request, response, filterChain); + verify(filterChain).doFilter(request, response); + } + + @Test + void securedApi_wrongFlow_throwsAccessDenied() { + // API only allows OBO, but token is CLIENT_CREDENTIALS + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.OBO), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + setJwtAuth(Map.of("roles", List.of("ADMIN"))); // no scp → CLIENT_CREDENTIALS + + assertThrows(AccessDeniedException.class, + () -> filter.doFilterInternal(request, response, filterChain)); + } + + @Test + void securedApi_flowValidatorFails_throwsAccessDenied() { + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + setJwtAuth(Map.of("roles", List.of("ADMIN"))); + when(ccValidator.validate(any())).thenReturn(false); + + assertThrows(AccessDeniedException.class, + () -> filter.doFilterInternal(request, response, filterChain)); + } + + @Test + void securedApi_bothFlowsAllowed_clientCredentialsToken_passes() throws Exception { + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS, AuthType.OBO), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + setJwtAuth(Map.of("roles", List.of("ADMIN"))); + + filter.doFilterInternal(request, response, filterChain); + verify(filterChain).doFilter(request, response); + } + + @Test + void securedApi_bothFlowsAllowed_oboToken_passes() throws Exception { + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS, AuthType.OBO), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + setJwtAuth(Map.of("scp", "api.read")); + + filter.doFilterInternal(request, response, filterChain); + verify(filterChain).doFilter(request, response); + } + + private void setJwtAuth(Map claims) { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", "test-subject") + .claims(c -> c.putAll(claims)) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .build(); + JwtAuthenticationToken authToken = new JwtAuthenticationToken(jwt); + SecurityContextHolder.getContext().setAuthentication(authToken); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolverTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolverTest.java new file mode 100644 index 0000000..d2cc296 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolverTest.java @@ -0,0 +1,54 @@ +package org.opendevstack.apiservice.core.security.flow; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class AuthFlowResolverTest { + + private AuthFlowResolver resolver; + + @BeforeEach + void setUp() { + resolver = new AuthFlowResolver(); + } + + @Test + void resolve_withScpClaim_returnsObo() { + Jwt jwt = buildJwt(Map.of("scp", "api.read api.write")); + assertEquals(AuthType.OBO, resolver.resolve(jwt)); + } + + @Test + void resolve_withBlankScpClaim_returnsClientCredentials() { + Jwt jwt = buildJwt(Map.of("scp", " ")); + assertEquals(AuthType.CLIENT_CREDENTIALS, resolver.resolve(jwt)); + } + + @Test + void resolve_withoutScpClaim_returnsClientCredentials() { + Jwt jwt = buildJwt(Map.of("sub", "user-id")); + assertEquals(AuthType.CLIENT_CREDENTIALS, resolver.resolve(jwt)); + } + + @Test + void resolve_withNullJwt_returnsNone() { + assertEquals(AuthType.NONE, resolver.resolve(null)); + } + + private Jwt buildJwt(Map claims) { + return Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", "test-subject") + .claims(c -> c.putAll(claims)) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .build(); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverterTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverterTest.java new file mode 100644 index 0000000..05a9947 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverterTest.java @@ -0,0 +1,145 @@ +package org.opendevstack.apiservice.core.security.jwt; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +class AzureJwtAuthenticationConverterTest { + + private AzureJwtAuthenticationConverter converter; + + @BeforeEach + void setUp() { + converter = new AzureJwtAuthenticationConverter(); + } + + // ── Authority extraction ── + + @Test + void convert_withRolesOnly_mapsToRolePrefixedAuthorities() { + Jwt jwt = buildJwt(Map.of("roles", List.of("ADMIN", "READER"))); + + AbstractAuthenticationToken token = converter.convert(jwt); + + Collection authorities = authorityStrings(token); + assertTrue(authorities.contains("ROLE_ADMIN")); + assertTrue(authorities.contains("ROLE_READER")); + assertEquals(2, authorities.size()); + } + + @Test + void convert_withScpOnly_mapsToScopePrefixedAuthorities() { + Jwt jwt = buildJwt(Map.of("scp", "api.read api.write")); + + AbstractAuthenticationToken token = converter.convert(jwt); + + Collection authorities = authorityStrings(token); + assertTrue(authorities.contains("SCOPE_api.read")); + assertTrue(authorities.contains("SCOPE_api.write")); + assertEquals(2, authorities.size()); + } + + @Test + void convert_withBothRolesAndScp_mapsBoth() { + Jwt jwt = buildJwt(Map.of("roles", List.of("ADMIN"), "scp", "api.read")); + + AbstractAuthenticationToken token = converter.convert(jwt); + + Collection authorities = authorityStrings(token); + assertTrue(authorities.contains("ROLE_ADMIN")); + assertTrue(authorities.contains("SCOPE_api.read")); + assertEquals(2, authorities.size()); + } + + @Test + void convert_withNeitherClaim_returnsEmptyAuthorities() { + Jwt jwt = buildJwt(Map.of()); + + AbstractAuthenticationToken token = converter.convert(jwt); + + assertTrue(token.getAuthorities().isEmpty()); + } + + @Test + void convert_withBlankRole_isIgnored() { + Jwt jwt = buildJwt(Map.of("roles", List.of("ADMIN", " ", ""))); + + AbstractAuthenticationToken token = converter.convert(jwt); + + Collection authorities = authorityStrings(token); + assertEquals(1, authorities.size()); + assertTrue(authorities.contains("ROLE_ADMIN")); + } + + @Test + void convert_withBlankScp_returnsNoScopeAuthorities() { + Jwt jwt = buildJwt(Map.of("scp", " ")); + + AbstractAuthenticationToken token = converter.convert(jwt); + + assertTrue(token.getAuthorities().isEmpty()); + } + + @Test + void convert_setsSubjectAsName() { + Jwt jwt = buildJwt(Map.of("sub", "user-123")); + + AbstractAuthenticationToken token = converter.convert(jwt); + + assertEquals("user-123", token.getName()); + } + + // ── extractClientId ── + + @Test + void extractClientId_withAzp_returnsAzp() { + Jwt jwt = buildJwt(Map.of("azp", "client-a")); + assertEquals("client-a", AzureJwtAuthenticationConverter.extractClientId(jwt)); + } + + @Test + void extractClientId_withAzpBlank_fallsBackToAppid() { + Jwt jwt = buildJwt(Map.of("azp", "", "appid", "client-b")); + assertEquals("client-b", AzureJwtAuthenticationConverter.extractClientId(jwt)); + } + + @Test + void extractClientId_withAzpNull_fallsBackToAppid() { + Jwt jwt = buildJwt(Map.of("appid", "client-b")); + assertEquals("client-b", AzureJwtAuthenticationConverter.extractClientId(jwt)); + } + + @Test + void extractClientId_withNeitherClaim_returnsNull() { + Jwt jwt = buildJwt(Map.of()); + assertNull(AzureJwtAuthenticationConverter.extractClientId(jwt)); + } + + // ── helpers ── + + private Collection authorityStrings(AbstractAuthenticationToken token) { + return token.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + } + + private Jwt buildJwt(Map extraClaims) { + return Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", "test-subject") + .claims(c -> c.putAll(extraClaims)) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .build(); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolverTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolverTest.java new file mode 100644 index 0000000..ec90ee3 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolverTest.java @@ -0,0 +1,97 @@ +package org.opendevstack.apiservice.core.security.registry; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ApiDefinitionResolverTest { + + private CoreApiRegistry registry; + private ApiDefinitionResolver resolver; + + @BeforeEach + void setUp() { + registry = mock(CoreApiRegistry.class); + resolver = new ApiDefinitionResolver(registry); + } + + @Test + void resolve_standardVersionedPath_delegatesToRegistry() { + ApiDefinition expected = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(registry.resolveBestMatch("v0", "projects")).thenReturn(Optional.of(expected)); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/projects"); + + Optional result = resolver.resolve(request); + + assertTrue(result.isPresent()); + assertEquals(expected, result.get()); + } + + @Test + void resolve_publicApiPath_delegatesToRegistry() { + ApiDefinition expected = new ApiDefinition("api-pub", "Health", "health", "v0", + Set.of(AuthType.NONE), true, null, true); + when(registry.resolveBestMatch("v0", "health")).thenReturn(Optional.of(expected)); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/pub/v0/health"); + + Optional result = resolver.resolve(request); + + assertTrue(result.isPresent()); + assertEquals(expected, result.get()); + } + + @Test + void resolve_directVersionPath_delegatesToRegistry() { + ApiDefinition expected = new ApiDefinition("api-1", "Projects", "projects", "v1", + Set.of(AuthType.OBO), false, null, true); + when(registry.resolveBestMatch("v1", "projects")).thenReturn(Optional.of(expected)); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/projects"); + + Optional result = resolver.resolve(request); + + assertTrue(result.isPresent()); + assertEquals(expected, result.get()); + } + + @Test + void resolve_noVersionInPath_returnsEmpty() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/health"); + + Optional result = resolver.resolve(request); + + assertTrue(result.isEmpty()); + verifyNoInteractions(registry); + } + + @Test + void resolve_rootPath_returnsEmpty() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/"); + + Optional result = resolver.resolve(request); + + assertTrue(result.isEmpty()); + } + + @Test + void resolve_registryReturnsEmpty_returnsEmpty() { + when(registry.resolveBestMatch("v0", "unknown")).thenReturn(Optional.empty()); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/unknown"); + + Optional result = resolver.resolve(request); + + assertTrue(result.isEmpty()); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistryTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistryTest.java new file mode 100644 index 0000000..931bea7 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistryTest.java @@ -0,0 +1,142 @@ +package org.opendevstack.apiservice.core.security.registry; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.persistence.ApiDefinitionDao; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class CoreApiRegistryTest { + + private ApiDefinitionDao apiDefinitionDao; + private CoreApiRegistry registry; + + @BeforeEach + void setUp() { + apiDefinitionDao = mock(ApiDefinitionDao.class); + registry = new CoreApiRegistry(apiDefinitionDao); + } + + @Test + void init_loadsDefinitionsFromDao() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + + registry.init(); + + Optional result = registry.resolve("v0", "projects"); + assertTrue(result.isPresent()); + assertEquals("api-1", result.get().getId()); + } + + @Test + void resolve_exactMatch_returnsDefinition() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + registry.init(); + + assertTrue(registry.resolve("v0", "projects").isPresent()); + } + + @Test + void resolve_noMatch_returnsEmpty() { + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of()); + registry.init(); + + assertTrue(registry.resolve("v0", "nonexistent").isEmpty()); + } + + @Test + void resolveBestMatch_exactPath_returnsDefinition() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + registry.init(); + + Optional result = registry.resolveBestMatch("v0", "projects"); + assertTrue(result.isPresent()); + assertEquals("api-1", result.get().getId()); + } + + @Test + void resolveBestMatch_subPath_matchesViaAntPattern() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + registry.init(); + + Optional result = registry.resolveBestMatch("v0", "projects/123/tasks"); + assertTrue(result.isPresent()); + assertEquals("api-1", result.get().getId()); + } + + @Test + void resolveBestMatch_wrongVersion_returnsEmpty() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + registry.init(); + + assertTrue(registry.resolveBestMatch("v1", "projects").isEmpty()); + } + + @Test + void resolveBestMatch_picksMostSpecific() { + ApiDefinition broad = new ApiDefinition("api-broad", "Broad", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + ApiDefinition specific = new ApiDefinition("api-specific", "Specific", "projects/tasks", "v0", + Set.of(AuthType.OBO), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(broad, specific)); + registry.init(); + + Optional result = registry.resolveBestMatch("v0", "projects/tasks/42"); + assertTrue(result.isPresent()); + assertEquals("api-specific", result.get().getId()); + } + + @Test + void resolveBestMatch_blankPath_returnsEmpty() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + registry.init(); + + assertTrue(registry.resolveBestMatch("v0", "").isEmpty()); + } + + @Test + void refreshIndex_clearsOldEntriesAndReloads() { + ApiDefinition old = new ApiDefinition("api-old", "Old", "old-path", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(old)); + registry.init(); + assertTrue(registry.resolve("v0", "old-path").isPresent()); + + ApiDefinition updated = new ApiDefinition("api-new", "New", "new-path", "v0", + Set.of(AuthType.OBO), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(updated)); + registry.refreshIndex(); + + assertTrue(registry.resolve("v0", "old-path").isEmpty()); + assertTrue(registry.resolve("v0", "new-path").isPresent()); + } + + @Test + void resolve_basePathWithLeadingSlash_matches() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "/projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + registry.init(); + + assertTrue(registry.resolve("v0", "projects").isPresent()); + } +} diff --git a/core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/CustomRoleConverterTest.java b/core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/CustomRoleConverterTest.java deleted file mode 100644 index a74aceb..0000000 --- a/core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/CustomRoleConverterTest.java +++ /dev/null @@ -1,264 +0,0 @@ -package org.opendevstack.apiservice.core.security.config; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.time.Instant; -import java.util.*; - -import static org.junit.jupiter.api.Assertions.*; - -class CustomRoleConverterTest { - - private CustomRoleConverter converter; - - @BeforeEach - void setUp() { - converter = new CustomRoleConverter(); - } - - @Test - void testConvertWithRealmRoles() { - // Given - Map realmAccess = new HashMap<>(); - realmAccess.put("roles", Arrays.asList("admin", "user")); - - Jwt jwt = createJwtWithClaims(Map.of("realm_access", realmAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertEquals(2, authorities.size()); - assertTrue(containsAuthority(authorities, "ROLE_admin")); - assertTrue(containsAuthority(authorities, "ROLE_user")); - } - - @Test - void testConvertWithResourceRoles() { - // Given - Map clientRoles = new HashMap<>(); - clientRoles.put("roles", Arrays.asList("manager", "viewer")); - - Map resourceAccess = new HashMap<>(); - resourceAccess.put("my-client", clientRoles); - - Jwt jwt = createJwtWithClaims(Map.of("resource_access", resourceAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertEquals(2, authorities.size()); - assertTrue(containsAuthority(authorities, "ROLE_manager")); - assertTrue(containsAuthority(authorities, "ROLE_viewer")); - } - - @Test - void testConvertWithBothRealmAndResourceRoles() { - // Given - Map realmAccess = new HashMap<>(); - realmAccess.put("roles", Arrays.asList("admin")); - - Map clientRoles = new HashMap<>(); - clientRoles.put("roles", Arrays.asList("manager")); - - Map resourceAccess = new HashMap<>(); - resourceAccess.put("my-client", clientRoles); - - Map claims = new HashMap<>(); - claims.put("realm_access", realmAccess); - claims.put("resource_access", resourceAccess); - - Jwt jwt = createJwtWithClaims(claims); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertEquals(2, authorities.size()); - assertTrue(containsAuthority(authorities, "ROLE_admin")); - assertTrue(containsAuthority(authorities, "ROLE_manager")); - } - - @Test - void testConvertWithMultipleResourceClients() { - // Given - Map client1Roles = new HashMap<>(); - client1Roles.put("roles", Arrays.asList("role1", "role2")); - - Map client2Roles = new HashMap<>(); - client2Roles.put("roles", Arrays.asList("role3")); - - Map resourceAccess = new HashMap<>(); - resourceAccess.put("client1", client1Roles); - resourceAccess.put("client2", client2Roles); - - Jwt jwt = createJwtWithClaims(Map.of("resource_access", resourceAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertEquals(3, authorities.size()); - assertTrue(containsAuthority(authorities, "ROLE_role1")); - assertTrue(containsAuthority(authorities, "ROLE_role2")); - assertTrue(containsAuthority(authorities, "ROLE_role3")); - } - - @Test - void testConvertWithNullRealmAccess() { - // Given - Jwt jwt = createJwtWithClaims(Map.of()); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertTrue(authorities.isEmpty()); - } - - @Test - void testConvertWithEmptyRealmRoles() { - // Given - Map realmAccess = new HashMap<>(); - realmAccess.put("roles", Collections.emptyList()); - - Jwt jwt = createJwtWithClaims(Map.of("realm_access", realmAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertTrue(authorities.isEmpty()); - } - - @Test - void testConvertWithNullResourceAccess() { - // Given - Map realmAccess = new HashMap<>(); - realmAccess.put("roles", Arrays.asList("admin")); - - Jwt jwt = createJwtWithClaims(Map.of("realm_access", realmAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertEquals(1, authorities.size()); - assertTrue(containsAuthority(authorities, "ROLE_admin")); - } - - @Test - void testConvertWithRealmAccessWithoutRoles() { - // Given - Map realmAccess = new HashMap<>(); - // No "roles" key - - Jwt jwt = createJwtWithClaims(Map.of("realm_access", realmAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertTrue(authorities.isEmpty()); - } - - @Test - void testConvertWithMalformedResourceAccess() { - // Given - resource_access without proper structure - Map resourceAccess = new HashMap<>(); - resourceAccess.put("client1", "not-a-map"); // Should be a Map - - Jwt jwt = createJwtWithClaims(Map.of("resource_access", resourceAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertTrue(authorities.isEmpty()); - } - - @Test - void testConvertWithResourceAccessClientWithoutRoles() { - // Given - Map clientConfig = new HashMap<>(); - clientConfig.put("other-field", "value"); - // No "roles" key - - Map resourceAccess = new HashMap<>(); - resourceAccess.put("my-client", clientConfig); - - Jwt jwt = createJwtWithClaims(Map.of("resource_access", resourceAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertTrue(authorities.isEmpty()); - } - - @Test - void testConvertWithNullRolesInRealmAccess() { - // Given - Map realmAccess = new HashMap<>(); - realmAccess.put("roles", null); - - Jwt jwt = createJwtWithClaims(Map.of("realm_access", realmAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertTrue(authorities.isEmpty()); - } - - @Test - void testRolePrefixIsAdded() { - // Given - Map realmAccess = new HashMap<>(); - realmAccess.put("roles", Arrays.asList("test-role")); - - Jwt jwt = createJwtWithClaims(Map.of("realm_access", realmAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertEquals(1, authorities.size()); - GrantedAuthority authority = authorities.iterator().next(); - assertTrue(authority.getAuthority().startsWith("ROLE_")); - assertEquals("ROLE_test-role", authority.getAuthority()); - } - - // Helper methods - - private Jwt createJwtWithClaims(Map claims) { - return Jwt.withTokenValue("token") - .header("alg", "none") - .subject("test-user") - .issuedAt(Instant.now()) - .expiresAt(Instant.now().plusSeconds(3600)) - .claims(c -> c.putAll(claims)) - .build(); - } - - private boolean containsAuthority(Collection authorities, String authority) { - return authorities.stream() - .anyMatch(a -> a.getAuthority().equals(authority)); - } -} diff --git a/core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java b/core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java deleted file mode 100644 index 7ac046a..0000000 --- a/core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java +++ /dev/null @@ -1,67 +0,0 @@ -package org.opendevstack.apiservice.core.security.config; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; -import org.springframework.security.web.SecurityFilterChain; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class SecurityConfigTest { - private SecurityProperties securityProperties; - private FlowProperties flowProperties; - private SecurityConfig securityConfig; - - @BeforeEach - void setUp() { - securityProperties = mock(SecurityProperties.class); - flowProperties = mock(FlowProperties.class); - securityConfig = new SecurityConfig(securityProperties, flowProperties); - } - - @Test - void testJwtAuthenticationConverterBean() { - JwtAuthenticationConverter converter = securityConfig.jwtAuthenticationConverter(); - assertNotNull(converter); - } - - @Test - void testCustomRoleConverterBean() { - CustomRoleConverter converter = securityConfig.customRoleConverter(); - assertNotNull(converter); - } - - @Test - void testJwtDecoderLenient() { - when(securityProperties.isJwtValidationEnabled()).thenReturn(false); - JwtDecoder decoder = securityConfig.jwtDecoder(); - assertNotNull(decoder); - } - - @Test - void testJwtDecoderValidating() { - when(securityProperties.isJwtValidationEnabled()).thenReturn(true); - when(securityProperties.getJwkSetUri()).thenReturn("http://localhost/jwk"); - JwtDecoder decoder = securityConfig.jwtDecoder(); - assertNotNull(decoder); - } - - @Test - void testJwtDecoderThrowsIfNoJwkSetUri() { - when(securityProperties.isJwtValidationEnabled()).thenReturn(true); - when(securityProperties.getJwkSetUri()).thenReturn(""); - Exception exception = assertThrows(IllegalStateException.class, () -> securityConfig.jwtDecoder()); - assertTrue(exception.getMessage().contains("no JWK Set URI")); - } - - @Test - void testSecurityFilterChainBean() throws Exception { - HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); - // Just verify bean creation does not throw - SecurityFilterChain chain = securityConfig.securityFilterChain(http); - assertNotNull(chain); - } -} From fa47e7a1ae65f8a0027c345eb851a6dbb536fcc0 Mon Sep 17 00:00:00 2001 From: Jorge Romero Date: Fri, 27 Mar 2026 12:43:47 +0100 Subject: [PATCH 04/16] Add security test matrix for HTTP client interactions --- security-tests.http | 547 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 547 insertions(+) create mode 100644 security-tests.http diff --git a/security-tests.http b/security-tests.http new file mode 100644 index 0000000..e8f0a6e --- /dev/null +++ b/security-tests.http @@ -0,0 +1,547 @@ +### =========================================================================== +### core-security — Security Test Matrix (HTTP Client) +### Reference: doc/core-security/tests.md +### =========================================================================== +### +### SETUP — create a .env file at project root: +### +### AZURE_TENANT_ID= +### AZURE_CLIENT_ID= # app in ALLOWED_CLIENTS policy +### AZURE_CLIENT_SECRET= +### AZURE_CLIENT_ID_OTHER= # app NOT in ALLOWED_CLIENTS policy +### AZURE_CLIENT_SECRET_OTHER= +### AZURE_SCOPE= # e.g. 5dcea642-a10e-4bc8-9e7e-f758d28def0a +### +### OBO TOKENS — cannot be obtained automatically (require browser/user login). +### Obtain via MSAL device-code or auth-code flow: +### scope: api:///user_impersonation +### Paste the access_token into @obo_token and @obo_token_wrong_scope below. +### +### FEASIBILITY KEY +### ✅ Fully automated — token is retrieved via client_credentials grant +### ⚠ Partially manual — OBO token must be pasted in @obo_token +### 🔧 Config required — needs specific DB api_definition or policy setup +### =========================================================================== + +@host = http://localhost:8080 + +### ── Azure AD – client credentials (ALLOWED client) ────────────────────────── +@azure_tenant_id = {{$dotenv AZURE_TENANT_ID}} +@azure_client_id = {{$dotenv AZURE_CLIENT_ID}} +@azure_client_secret = {{$dotenv AZURE_CLIENT_SECRET}} +@azure_scope = api://{{$dotenv AZURE_SCOPE}}/.default + +### ── Azure AD – client credentials (DISALLOWED client) ─────────────────────── +@azure_client_id_other = {{$dotenv AZURE_CLIENT_ID_OTHER}} +@azure_client_secret_other = {{$dotenv AZURE_CLIENT_SECRET_OTHER}} + +### ── Test data ──────────────────────────────────────────────────────────────── +@project_key = {{$dotenv PROJECT_KEY}} +@user_id = {{$dotenv USER_ID}} + +### ── Bad tokens (static, for error-case tests) ────────────────────────────── +### S-00-04: malformed — not a valid JWT structure at all +@malformed_token = not.a.valid.jwt.token + +### S-00-05: expired — well-formed JWT but exp=2020-01-01, signature is invalid → +### BearerTokenAuthenticationFilter returns 401 before any policy runs +@expired_token = {{$dotenv EXPIRED_TOKEN}} + +### ── OBO tokens (MANUAL — paste after browser / MSAL device-code login) ────── +### Required for: §4 all OBO tests, P-03, flow-enforcement cross-flow tests +@obo_token = {{$dotenv OBO_TOKEN}} + +### OBO token from a client NOT in ALLOWED_CLIENTS (different azp claim) +### Required for: OBO-P-02, OBO-M-03 + @obo_token_other_client = {{$dotenv OBO_TOKEN_OTHER_CLIENT}} + +### OBO token whose scp does NOT include the required scope +### Required for: OBO-S-02, OBO-M-02 +@obo_token_wrong_scope = {{$dotenv OBO_TOKEN_WRONG_SCOPE}} + + +### =========================================================================== +### TOKEN ACQUISITION — automated via client_credentials grant +### =========================================================================== + +### ── Allowed client ────────────────────────────────────────────────────────── +# @name getToken +POST https://login.microsoftonline.com/{{azure_tenant_id}}/oauth2/v2.0/token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id={{azure_client_id}} +&client_secret={{azure_client_secret}} +&scope={{azure_scope}} + +### + +@cc_token = {{getToken.response.body.access_token}} + +### ── Disallowed client ─────────────────────────────────────────────────────── +# @name getTokenOther +POST https://login.microsoftonline.com/{{azure_tenant_id}}/oauth2/v2.0/token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id={{azure_client_id_other}} +&client_secret={{azure_client_secret_other}} +&scope={{azure_scope}} + +### + +@cc_token_other = {{getTokenOther.response.body.access_token}} + + +### =========================================================================== +### §1 PUBLIC API +### Endpoint: GET /actuator/health (configured via app.security.public-endpoints) +### All cases → 200 — Spring Security permitAll() bypasses JWT validation entirely +### =========================================================================== + +### P-01 ✅ · No token → 200 +GET {{host}}/actuator/health +Accept: application/json + +### + +### P-02 ✅ · Valid CC token → 200 (token accepted but not required) +GET {{host}}/actuator/health +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### P-03 ⚠ · Valid OBO token → 200 (token accepted but not required) +GET {{host}}/actuator/health +Accept: application/json +Authorization: Bearer {{obo_token}} + +### + +### P-04a ✅ · Malformed token → 401 (permitAll short-circuits before JWT validation) +GET {{host}}/actuator/health +Accept: application/json +Authorization: Bearer {{malformed_token}} + +### + +### P-04b ✅ · Expired token → 401 (permitAll short-circuits before JWT validation) +GET {{host}}/actuator/health +Accept: application/json +Authorization: Bearer {{expired_token}} + +### + + +### =========================================================================== +### §2 SECURED API — NO TOKEN +### Endpoint: GET /api/pub/v0/projects/{key} (CLIENT_CREDENTIALS, not public) +### Expected: 401 — BearerTokenAuthenticationFilter rejects before policy runs +### =========================================================================== + +### S-00-01 ✅ · CLIENT_CREDENTIALS endpoint, no token → 401 +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json + +### + +### S-00-02 ✅ · OBO-typed endpoint (project-users-v1), no token → 401 +GET {{host}}/api/v1/projects/{{project_key}}/users/{{user_id}}/status +Accept: application/json + +### + +### S-00-03 ✅ · Multiple-auth-types endpoint, no token → 401 +### (use any secured endpoint; both CC and OBO require a bearer token) +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json + +### + +### S-00-04 ✅ · Malformed JWT → 401 +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{malformed_token}} + +### + +### S-00-05 ✅ · Expired JWT → 401 +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{expired_token}} + +### + + +### =========================================================================== +### §3 CLIENT_CREDENTIALS FLOW +### =========================================================================== + +### ── §3.1 Flow enforcement (AuthTypeEnforcementFilter) ───────────────────── +### +### Note: flow enforcement at the filter level requires an upstream component +### to set the API_DEFINITION_ATTR request attribute. Without that, flow type +### is enforced implicitly via policy rules (ALLOWED_CLIENTS / SCOPE_REQUIRED). +### Tests CC-F-02 and CC-F-04 therefore depend on DB policy configuration. + +### CC-F-01 ✅ · CC endpoint + CC token → passes filter (200 or policy result) +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### CC-F-02 🔧 · OBO-only endpoint + CC token → 403 (wrong flow) +### Requires: api_definition with auth_types = ['OBO'] in DB +### As currently seeded: project-users-v1 uses CLIENT_CREDENTIALS, so a CC +### token here may pass flow but fail ALLOWED_CLIENTS policy. +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{cc_token}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "CC-F-02: CC token on OBO-only endpoint — expected 403" +} + +### + +### CC-F-03 ✅ · Both-flows endpoint + CC token → passes filter +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### CC-F-04 🔧 · No-auth endpoint (AuthType.NONE) + CC token → passes (token ignored) +### Seeded: project-platforms-v1 has auth_types=['NONE'] — requiresAuth()=false +### Note: PolicyAuthorizationManager will still run; result depends on policy rules. +GET {{host}}/api/v1/projects/{{project_key}}/platforms +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### ── §3.2 Policy — ALLOWED_CLIENTS ───────────────────────────────────────── + +### CC-P-01 ✅ · Allowed client (azp in ALLOWED_CLIENTS policy) → 200 / PERMIT +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### CC-P-02 ✅ · Disallowed client (azp NOT in ALLOWED_CLIENTS) → 403 +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{cc_token_other}} + +### + +### CC-P-03 🔧 · No policy rules configured for this API → ABSTAIN → 200 +### Requires: api_definition with zero rows in authorization_policies table +### Adjust path to an endpoint whose api_definition has no policy rows. +GET {{host}}/api/pub/v0/projects/{{project_key}}/components/anything +Authorization: Bearer {{cc_token}} + +### + +### CC-P-04 🔧 · Empty rules list → PolicyEngine.evaluate([]) → DENY → 403 +### Same condition as CC-P-03: covered by unit test PolicyEngineTest.evaluate_emptyRules_returnsDeny +### Integration path: any secured endpoint with zero policy rows in DB. + +### + +### ── §3.3 Policy — SCOPE_REQUIRED ────────────────────────────────────────── + +### CC-S-01 ✅ · CC token (no scp claim) against SCOPE_REQUIRED policy → 403 +### A standard CC token has roles[] but no scp claim. +### If the api_definition has SCOPE_REQUIRED policy, evaluator returns DENY. +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### CC-S-02 🔧 · CC token WITH scp + SCOPE_REQUIRED matching scope → 200 +### Non-standard scenario: requires either a custom CC token with scp, or +### a resource server that issues CC tokens with delegated scopes. +### Best validated via SecurityIntegrationTest.ccToken_scopeRequired_withScp_returns200. + +### + +### ── §3.4 Multiple rules (ALLOWED_CLIENTS + SCOPE_REQUIRED) ───────────────── + +### CC-M-01 ✅ · Allowed client + required scope present → 200 +### Standard CC token from allowed client; if SCOPE_REQUIRED is active, +### add scp manually (see CC-S-02 note). Without SCOPE_REQUIRED policy, passes via ALLOWED_CLIENTS. +POST {{host}}/api/pub/v0/projects +Content-Type: application/json +Authorization: Bearer {{cc_token}} + +{ + "projectName": "Security Test CC-M-01", + "projectKey": "{{project_key}}-m01", + "flavor": "DLSS", + "projectDescription": "CC-M-01: allowed client + scope — expected 200" +} + +### + +### CC-M-02 ✅ · Allowed client + scope missing → 403 +### CC token has no scp; if SCOPE_REQUIRED policy is active → DENY +POST {{host}}/api/pub/v0/projects +Content-Type: application/json +Authorization: Bearer {{cc_token}} + +{ + "projectName": "Security Test CC-M-02", + "projectKey": "{{project_key}}-m02", + "flavor": "DLSS", + "projectDescription": "CC-M-02: scope missing — expected 403 if SCOPE_REQUIRED is active" +} + +### + +### CC-M-03 ✅ · Disallowed client + scope present → 403 +POST {{host}}/api/pub/v0/projects +Content-Type: application/json +Authorization: Bearer {{cc_token_other}} + +{ + "projectName": "Security Test CC-M-03", + "projectKey": "{{project_key}}-m03", + "flavor": "DLSS", + "projectDescription": "CC-M-03: disallowed client — expected 403" +} + +### + +### CC-M-04 ✅ · Disallowed client + scope missing → 403 +POST {{host}}/api/pub/v0/projects +Content-Type: application/json +Authorization: Bearer {{cc_token_other}} + +{ + "projectName": "Security Test CC-M-04", + "projectKey": "{{project_key}}-m04", + "flavor": "DLSS", + "projectDescription": "CC-M-04: disallowed client + no scope — expected 403" +} + +### + + +### =========================================================================== +### §4 OBO FLOW (On-Behalf-Of / delegated) +### ⚠ ALL TESTS BELOW require @obo_token (and variants) to be set manually. +### See SETUP INSTRUCTIONS at the top of this file. +### =========================================================================== + +### ── §4.1 Flow enforcement ─────────────────────────────────────────────────── + +### OBO-F-01 ⚠ · OBO-only endpoint + OBO token → passes (200 or policy result) +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-F-01: correct OBO flow" +} + +### + +### OBO-F-02 ✅ · OBO-configured endpoint + CC token → 403 (wrong flow) +### CC token sent to project-users endpoint; if ALLOWED_CLIENTS policy is active +### and cc_token_other's azp is not in the allowed list → 403. +### To test pure flow enforcement, configure auth_types=['OBO'] for this api_definition. +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{cc_token_other}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-F-02: CC token on OBO endpoint — expected 403" +} + +### + +### OBO-F-03 ⚠ · Both-flows endpoint + OBO token → passes +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{obo_token}} + +### + +### ── §4.2 Policy — ALLOWED_CLIENTS ────────────────────────────────────────── + +### OBO-P-01 ⚠ · Allowed client OBO token (azp in ALLOWED_CLIENTS) → 200 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-P-01: allowed SPA client" +} + +### + +### OBO-P-02 ⚠ · Disallowed client OBO token → 403 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token_other_client}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-P-02: disallowed client — expected 403" +} + +### + +### OBO-P-03 🔧 · No policy rules for OBO api_definition → ABSTAIN → 200 +### Requires: api_definition entry with zero authorization_policies rows in DB + +### + +### ── §4.3 Policy — SCOPE_REQUIRED ─────────────────────────────────────────── + +### OBO-S-01 ⚠ · scp contains required scope → 200 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-S-01: scp has required scope" +} + +### + +### OBO-S-02 ⚠ · scp does NOT contain required scope → 403 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token_wrong_scope}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-S-02: scp missing required scope — expected 403" +} + +### + +### OBO-S-03 · Blank scp claim → AuthFlowResolver treats as CLIENT_CREDENTIALS +### Cannot be crafted easily in .http client (requires custom JWT with scp=" "). +### Covered by: SecurityIntegrationTest › OboTests › blankScp_returns403 + +### + +### ── §4.4 Multiple rules ────────────────────────────────────────────────────── + +### OBO-M-01 ⚠ · Allowed client + required scope → 200 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-M-01: both PERMIT" +} + +### + +### OBO-M-02 ⚠ · Allowed client + scope missing → 403 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token_wrong_scope}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-M-02: PERMIT + DENY — expected 403" +} + +### + +### OBO-M-03 ⚠ · Disallowed client + scope present → 403 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token_other_client}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-M-03: DENY + PERMIT — expected 403" +} + +### + + +### =========================================================================== +### §5 UNKNOWN ROUTE (no matching api_definition) +### PolicyAuthorizationManager.check() → resolver returns empty → fail-closed +### Expected: 403 (or 401 when no token and ExceptionTranslationFilter fires first) +### =========================================================================== + +### U-01 ✅ · No token → 401 (no JWT) or 403 (anonymous → fail-closed) +GET {{host}}/api/v0/nonexistent-endpoint +Accept: application/json + +### + +### U-02 ✅ · Valid CC token, unknown route → 403 +GET {{host}}/api/v0/nonexistent-endpoint +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### U-03 ⚠ · Valid OBO token, unknown route → 403 +GET {{host}}/api/v0/nonexistent-endpoint +Accept: application/json +Authorization: Bearer {{obo_token}} + +### + + +### =========================================================================== +### SUMMARY MATRIX (reference) +### +### │ PUBLIC │ SECURED/CC │ SECURED/OBO │ UNKNOWN ROUTE +### ───────────────────┼────────┼────────────┼─────────────┼────────────── +### No token │ 200 │ 401 │ 401 │ 401/403 +### Valid, allowed │ 200 │ 200 │ 200 │ 403 +### Valid, wrong flow │ 200 │ 403 │ 403 │ 403 +### Valid, denied │ 200 │ 403 │ 403 │ 403 +### Expired/malformed │ 200 │ 401 │ 401 │ 401 +### =========================================================================== From ad955dce39ac8e9951586a896fa3b86086183ccf Mon Sep 17 00:00:00 2001 From: Jorge Romero Date: Fri, 27 Mar 2026 12:44:07 +0100 Subject: [PATCH 05/16] Add AllowedClientsEvaluator to enforce client authorization policies --- .../evaluator/AllowedClientsEvaluator.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/AllowedClientsEvaluator.java diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/AllowedClientsEvaluator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/AllowedClientsEvaluator.java new file mode 100644 index 0000000..cd24768 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/AllowedClientsEvaluator.java @@ -0,0 +1,47 @@ +package org.opendevstack.apiservice.core.security.authorization.evaluator; + + +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.policy.PolicyEvaluator; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.opendevstack.apiservice.core.contracts.policy.PolicyTypes; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Evaluates ALLOWED_CLIENTS policy rules. + * Verifies that the clientId from the JWT is in the list of authorized clients. + */ +@Component +public class AllowedClientsEvaluator implements PolicyEvaluator { + + @Override + public boolean supports(String policyType) { + return PolicyTypes.ALLOWED_CLIENTS.equals(policyType); + } + + @Override + @SuppressWarnings("unchecked") + public AuthorizationDecision evaluate(PolicyContext context) { + PolicyRule rule = context.getActiveRule(); + if (rule == null || rule.getConfig() == null) { + return AuthorizationDecision.ABSTAIN; + } + + String clientId = context.getClientId(); + if (clientId == null) { + return AuthorizationDecision.DENY; + } + + List allowedClients = (List) rule.getConfig().get("allowedClients"); + if (allowedClients == null || allowedClients.isEmpty()) { + return AuthorizationDecision.ABSTAIN; + } + + return allowedClients.contains(clientId) + ? AuthorizationDecision.PERMIT + : AuthorizationDecision.DENY; + } +} \ No newline at end of file From c38be02fe860ded7a8a21615088dc0001330b0d7 Mon Sep 17 00:00:00 2001 From: Jorge Romero Date: Fri, 27 Mar 2026 12:44:28 +0100 Subject: [PATCH 06/16] Add comprehensive test scenarios for core-security module --- doc/core-security/tests.md | 195 +++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 doc/core-security/tests.md diff --git a/doc/core-security/tests.md b/doc/core-security/tests.md new file mode 100644 index 0000000..2117fe7 --- /dev/null +++ b/doc/core-security/tests.md @@ -0,0 +1,195 @@ +# core-security — Test Combinations + +This document enumerates the test scenarios for the `core-security` module. +Each scenario is defined by the combination of: **API visibility** × **Auth flow** × **Client type** × **Policy outcome**. + +--- + +## Dimensions + +| Dimension | Values | +|---|---| +| API visibility | `PUBLIC`, `SECURED` | +| Auth flow | `CLIENT_CREDENTIALS`, `OBO`, `NONE` (no token) | +| Client type | Internal service, External / third-party app, UI SPA (on behalf of a user) | +| Policy | No rules, `ALLOWED_CLIENTS`, `SCOPE_REQUIRED`, multiple rules | +| Token state | Valid, Expired, Malformed, Missing | + +--- + +## 1. Public API + +Public APIs (`ApiDefinition.isPublic = true`) must be accessible regardless of authentication state. + +| # | Flow | Token | Client type | Expected | +|---|---|---|---|---| +| P-01 | NONE | Missing | Any | `200 OK` — no token required | +| P-02 | CLIENT_CREDENTIALS | Valid | Internal service | `200 OK` — token accepted but not required | +| P-03 | OBO | Valid | UI SPA | `200 OK` — token accepted but not required | +| P-04 | NONE | Malformed / expired | Any | `200 OK` — public path bypasses JWT validation | + +> `PolicyAuthorizationManager` returns `PERMIT` immediately when `apiDef.isPublic() == true`. +> `AuthTypeEnforcementFilter` skips the flow check when `!apiDef.requiresAuth()`. + +--- + +## 2. Secured API — No Token + +| # | API auth types | Token | Expected | +|---|---|---|---| +| S-00-01 | `[CLIENT_CREDENTIALS]` | Missing | `401 Unauthorized` | +| S-00-02 | `[OBO]` | Missing | `401 Unauthorized` | +| S-00-03 | `[CLIENT_CREDENTIALS, OBO]` | Missing | `401 Unauthorized` | +| S-00-04 | `[CLIENT_CREDENTIALS]` | Malformed JWT | `401 Unauthorized` | +| S-00-05 | `[CLIENT_CREDENTIALS]` | Expired JWT | `401 Unauthorized` | + +--- + +## 3. Secured API — CLIENT_CREDENTIALS flow + +Token characteristics: has `roles` claim, **no** `scp` claim. +`AuthFlowResolver` resolves to `CLIENT_CREDENTIALS`. + +### 3.1 Flow enforcement (`AuthTypeEnforcementFilter`) + +| # | API auth types | Token flow | Expected | +|---|---|---|---| +| CC-F-01 | `[CLIENT_CREDENTIALS]` | CLIENT_CREDENTIALS | passes filter | +| CC-F-02 | `[OBO]` | CLIENT_CREDENTIALS | `403 Forbidden` — wrong flow | +| CC-F-03 | `[CLIENT_CREDENTIALS, OBO]` | CLIENT_CREDENTIALS | passes filter | +| CC-F-04 | `[NONE]` | CLIENT_CREDENTIALS | passes filter (no auth required) | + +### 3.2 Policy — ALLOWED_CLIENTS + +Config: `{ "clientIds": ["app-a", "app-b"] }` + +| # | Client (azp/appid) | Policy | Expected | +|---|---|---|---| +| CC-P-01 | `app-a` (internal service) | ALLOWED_CLIENTS: [app-a, app-b] | `PERMIT` | +| CC-P-02 | `app-c` (external / unknown app) | ALLOWED_CLIENTS: [app-a, app-b] | `DENY` → `403` | +| CC-P-03 | `app-a` | No rules configured | `PERMIT` (empty rules → `ABSTAIN` → `PERMIT`) | +| CC-P-04 | `app-a` | Rules empty list | `DENY` (rules == null or empty → explicit `DENY`) | + +### 3.3 Policy — SCOPE_REQUIRED + +Config: `{ "scopes": ["api.read"] }` +CLIENT_CREDENTIALS tokens carry app-level roles, not delegated scopes. + +| # | Token scopes (scp) | Policy | Expected | +|---|---|---|---| +| CC-S-01 | none (no scp claim) | SCOPE_REQUIRED: api.read | `DENY` → `403` | +| CC-S-02 | `api.read api.write` | SCOPE_REQUIRED: api.read | `PERMIT` | + +### 3.4 Multiple rules (ALLOWED_CLIENTS + SCOPE_REQUIRED) + +| # | Client | Scopes | Rules | Expected combined decision | +|---|---|---|---|---| +| CC-M-01 | allowed | required scope present | both PERMIT | `PERMIT` | +| CC-M-02 | allowed | scope missing | PERMIT + DENY | `DENY` → `403` | +| CC-M-03 | not allowed | required scope present | DENY + PERMIT | `DENY` → `403` | +| CC-M-04 | not allowed | scope missing | both DENY | `DENY` → `403` | + +--- + +## 4. Secured API — OBO flow (On-Behalf-Of / delegated) + +Token characteristics: has `scp` claim with delegated scopes, identifies an end user via `sub`. +`AuthFlowResolver` resolves to `OBO`. + +### 4.1 Flow enforcement (`AuthTypeEnforcementFilter`) + +| # | API auth types | Token flow | Expected | +|---|---|---|---| +| OBO-F-01 | `[OBO]` | OBO | passes filter | +| OBO-F-02 | `[CLIENT_CREDENTIALS]` | OBO | `403 Forbidden` — wrong flow | +| OBO-F-03 | `[CLIENT_CREDENTIALS, OBO]` | OBO | passes filter | + +### 4.2 Policy — ALLOWED_CLIENTS + +| # | Client (azp) | Policy | Expected | +|---|---|---|---| +| OBO-P-01 | `spa-frontend` (UI SPA) | ALLOWED_CLIENTS: [spa-frontend] | `PERMIT` | +| OBO-P-02 | `mobile-app` (external app) | ALLOWED_CLIENTS: [spa-frontend] | `DENY` → `403` | +| OBO-P-03 | `spa-frontend` | No rules | `PERMIT` | + +### 4.3 Policy — SCOPE_REQUIRED + +| # | Token scp | Policy | Expected | +|---|---|---|---| +| OBO-S-01 | `api.read api.write` | SCOPE_REQUIRED: api.read | `PERMIT` | +| OBO-S-02 | `api.write` | SCOPE_REQUIRED: api.read | `DENY` → `403` | +| OBO-S-03 | empty / blank scp | SCOPE_REQUIRED: api.read | `DENY` → `403` | + +### 4.4 Multiple rules + +| # | Client | Scopes | Rules | Expected | +|---|---|---|---|---| +| OBO-M-01 | allowed | required scope present | both PERMIT | `PERMIT` | +| OBO-M-02 | allowed | scope missing | PERMIT + DENY | `DENY` | +| OBO-M-03 | not allowed | required scope present | DENY + PERMIT | `DENY` | + +--- + +## 5. Route not registered (unknown API definition) + +`ApiDefinitionResolver` returns empty — `PolicyAuthorizationManager` denies immediately (fail-closed). + +| # | Token | Expected | +|---|---|---| +| U-01 | Missing | `403 Forbidden` | +| U-02 | Valid CLIENT_CREDENTIALS | `403 Forbidden` | +| U-03 | Valid OBO | `403 Forbidden` | + +--- + +## 6. JWT claim extraction (`AzureJwtAuthenticationConverter`) + +Unit tests for authority and client-id extraction — no HTTP stack needed. + +| # | Token claims | Expected authorities | Expected clientId | +|---|---|---|---| +| J-01 | `roles: [ADMIN]`, no scp | `ROLE_ADMIN` | `azp` value | +| J-02 | `scp: "api.read api.write"`, no roles | `SCOPE_api.read`, `SCOPE_api.write` | `azp` value | +| J-03 | both `roles` and `scp` | `ROLE_*` + `SCOPE_*` | `azp` value | +| J-04 | neither claim | empty collection | `azp` value | +| J-05 | `azp` absent, `appid` present | — | `appid` value (v1 token fallback) | +| J-06 | both `azp` and `appid` absent | — | `null` | + +--- + +## 7. `AuthFlowResolver` unit tests + +| # | JWT claims | Expected `AuthType` | +|---|---|---| +| R-01 | `scp` present and non-blank | `OBO` | +| R-02 | `scp` blank string | `CLIENT_CREDENTIALS` | +| R-03 | `scp` null / absent | `CLIENT_CREDENTIALS` | +| R-04 | JWT is null | `NONE` | + +--- + +## 8. `PolicyEngine` unit tests + +| # | Rules | Evaluator outcomes | Expected decision | +|---|---|---|---| +| E-01 | empty list | — | `DENY` | +| E-02 | one rule | `PERMIT` | `PERMIT` | +| E-03 | one rule | `DENY` | `DENY` | +| E-04 | two rules | `PERMIT`, `DENY` | `DENY` (short-circuit) | +| E-05 | two rules | `PERMIT`, `ABSTAIN` | `PERMIT` | +| E-06 | two rules | `ABSTAIN`, `ABSTAIN` | `PERMIT` (all-abstain fallback) | +| E-07 | rule with no matching evaluator | — | skipped → `PERMIT` (falls through to abstain default) | + +--- + +## Summary matrix + +``` + │ PUBLIC │ SECURED / CC │ SECURED / OBO │ UNKNOWN ROUTE +─────────────────────┼────────┼──────────────┼───────────────┼────────────── +No token │ 200 │ 401 │ 401 │ 403 +Valid, allowed │ 200 │ 200 │ 200 │ 403 +Valid, wrong flow │ 200 │ 403 │ 403 │ 403 +Valid, not in policy │ 200 │ 403 │ 403 │ 403 +Expired / malformed │ 200 │ 401 │ 401 │ 401/403 +``` From 69a5bc5443eba35572e3304eaec5aed1311774ba Mon Sep 17 00:00:00 2001 From: Jorge Romero Date: Fri, 27 Mar 2026 14:45:00 +0100 Subject: [PATCH 07/16] Add FlavorRestrictionEvaluator to enforce flavor restriction policies --- .../evaluator/FlavorRestrictionEvaluator.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java new file mode 100644 index 0000000..d33e38a --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java @@ -0,0 +1,54 @@ +package org.opendevstack.apiservice.core.security.authorization.evaluator; + +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.policy.PolicyEvaluator; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * Evaluates FLAVOR_RESTRICTION policy rules. + * Verifies that the requested flavor in the request body is in the list + * of allowed flavors for the client. + */ +@Component +public class FlavorRestrictionEvaluator implements PolicyEvaluator { + + public static final String FLAVOR_RESTRICTION = "FLAVOR_RESTRICTION"; + + @Override + public boolean supports(String policyType) { + return FLAVOR_RESTRICTION.equals(policyType); + } + + @Override + @SuppressWarnings("unchecked") + public AuthorizationDecision evaluate(PolicyContext context) { + PolicyRule rule = context.getActiveRule(); + if (rule == null || rule.getConfig() == null) { + return AuthorizationDecision.ABSTAIN; + } + + Map body = context.getRequestBody(); + if (body == null) { + return AuthorizationDecision.ABSTAIN; + } + + String requestedFlavor = (String) body.get("flavor"); + if (requestedFlavor == null) { + return AuthorizationDecision.ABSTAIN; + } + + List allowedFlavors = (List) rule.getConfig().get("allowedFlavors"); + if (allowedFlavors == null || allowedFlavors.isEmpty()) { + return AuthorizationDecision.ABSTAIN; + } + + return allowedFlavors.contains(requestedFlavor) + ? AuthorizationDecision.PERMIT + : AuthorizationDecision.DENY; + } +} \ No newline at end of file From d1d98d4e225a4c362dc70955dc440549c2477fb1 Mon Sep 17 00:00:00 2001 From: Jorge Romero Date: Fri, 27 Mar 2026 14:45:08 +0100 Subject: [PATCH 08/16] Remove commented-out code for API definition retrieval in PolicyAuthorizationManager --- .../security/authorization/PolicyAuthorizationManager.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java index b5930f2..244bef2 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java @@ -57,9 +57,6 @@ public org.springframework.security.authorization.AuthorizationDecision check( Optional apiDef = this.resolver.resolve(request); - - //ApiDefinition apiDef = (ApiDefinition) request.getAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR); - // Unknown routes are denied (fail-closed). Public API definitions are allowed. if (apiDef.isEmpty()) { return new org.springframework.security.authorization.AuthorizationDecision(false); From 386326dbbd1fcabbb0ae62f74989343311ddc31f Mon Sep 17 00:00:00 2001 From: Jorge Romero Date: Fri, 27 Mar 2026 14:45:30 +0100 Subject: [PATCH 09/16] Add unit tests for SecurityConfig to validate security filter chain behavior --- .../security/config/SecurityConfigTest.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java diff --git a/core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java b/core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java new file mode 100644 index 0000000..58a2147 --- /dev/null +++ b/core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java @@ -0,0 +1,54 @@ +package org.opendevstack.apiservice.core.security.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.security.authorization.PolicyAuthorizationManager; +import org.opendevstack.apiservice.core.security.jwt.AzureJwtAuthenticationConverter; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SecurityConfigTest { + + private SecurityProperties securityProperties; + private PolicyAuthorizationManager policyAuthorizationManager; + private AzureJwtAuthenticationConverter azureJwtAuthenticationConverter; + private SecurityConfig securityConfig; + + @BeforeEach + void setUp() { + securityProperties = mock(SecurityProperties.class); + policyAuthorizationManager = mock(PolicyAuthorizationManager.class); + azureJwtAuthenticationConverter = new AzureJwtAuthenticationConverter(); + securityConfig = new SecurityConfig(securityProperties, policyAuthorizationManager, azureJwtAuthenticationConverter); + } + + @Test + void securityFilterChain_withPublicEndpoints() throws Exception { + when(securityProperties.getPublicEndpoints()).thenReturn(new String[]{"/health", "/info"}); + HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); + + SecurityFilterChain chain = securityConfig.securityFilterChain(http); + assertNotNull(chain); + } + + @Test + void securityFilterChain_withNullPublicEndpoints() throws Exception { + when(securityProperties.getPublicEndpoints()).thenReturn(null); + HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); + + SecurityFilterChain chain = securityConfig.securityFilterChain(http); + assertNotNull(chain); + } + + @Test + void securityFilterChain_withEmptyPublicEndpoints() throws Exception { + when(securityProperties.getPublicEndpoints()).thenReturn(new String[]{}); + HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); + + SecurityFilterChain chain = securityConfig.securityFilterChain(http); + assertNotNull(chain); + } +} From 8c065a502a43a38ade64ce742aa52a26514a2299 Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Tue, 31 Mar 2026 14:48:34 +0200 Subject: [PATCH 10/16] added missed project exception handler --- .../advice/ProjectExceptionHandler.java | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java new file mode 100644 index 0000000..6f76d9e --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java @@ -0,0 +1,135 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; +import org.opendevstack.apiservice.project.controller.ProjectController; +import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; + +@RestControllerAdvice(assignableTypes = ProjectController.class) +@Slf4j +public class ProjectExceptionHandler { + + private static final Map FIELD_ERROR_MAP = Map.of( + "projectKey", ErrorKey.PROJECT_KEY_INVALID_FORMAT, + "projectName", ErrorKey.PROJECT_NAME_INVALID_FORMAT, + "projectDescription", ErrorKey.PROJECT_DESCRIPTION_INVALID_FORMAT, + "projectFlavor", ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, + "configurationItem", ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, + "x2OdsAccount", ErrorKey.PROJECT_X2ACCOUNT_INVALID_FORMAT, + "owner", ErrorKey.PROJECT_OWNER_INVALID_FORMAT + ); + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException ex) { + + log.warn("Request body validation error: {}", ex.getMessage()); + + FieldError fieldError = ex.getBindingResult().getFieldErrors().stream() + .findFirst() + .orElse(null); + + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.BAD_REQUEST.getReasonPhrase()); + + if (fieldError != null) { + String field = fieldError.getField(); + + ErrorKey key = FIELD_ERROR_MAP.getOrDefault(field, ErrorKey.BAD_REQUEST_BODY); + + response.setErrorKey(key.getKey()); + response.setMessage(key.getMessage()); + } else { + response.setErrorKey(ErrorKey.BAD_REQUEST_BODY.getKey()); + response.setMessage(ErrorKey.BAD_REQUEST_BODY.getMessage()); + } + + return ResponseEntity.badRequest().body(response); + } + + @ExceptionHandler(ClientAppNotRegisteredException.class) + public ResponseEntity handleClientAppNotRegisteredException( + ClientAppNotRegisteredException ex) { + log.warn("ClientApp registration error: {}", ex.getMessage()); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.FORBIDDEN.getReasonPhrase()); + response.setErrorKey(ErrorKey.CLIENT_APP_NOT_REGISTERED.getKey()); + response.setMessage(ErrorKey.CLIENT_APP_NOT_REGISTERED.getMessage()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); + } + + @ExceptionHandler(ProjectValidationException.class) + public ResponseEntity handleValidationException(ProjectValidationException ex) { + log.warn("Validation error: {}", ex.getMessage()); + ErrorKey errorKey = ex.getErrorKey(); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.BAD_REQUEST.getReasonPhrase()); + response.setErrorKey(errorKey.getKey()); + response.setMessage(errorKey.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(ProjectCreationException.class) + public ResponseEntity handleProjectCreationException( + ProjectCreationException ex) { + log.error("Project creation error: {}", ex.getMessage(), ex); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.INTERNAL_ERROR.getMessage()); + response.setErrorKey(ErrorKey.INTERNAL_ERROR.getKey()); + response.setMessage(ex.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + + @ExceptionHandler(AutomationPlatformException.class) + public ResponseEntity handleAutomationPlatformException( + AutomationPlatformException ex) { + log.error("Failed to execute automated job: {}", ex.getMessage(), ex); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.INTERNAL_ERROR.getMessage()); + response.setErrorKey(ErrorKey.INTERNAL_ERROR.getKey()); + response.setMessage(ex.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) { + log.error("Unexpected error: {}", ex.getMessage(), ex); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.BAD_REQUEST_BODY.getMessage()); + response.setErrorKey(ErrorKey.BAD_REQUEST_BODY.getKey()); + response.setMessage("An error occurred while processing the request."); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception ex) { + log.error("Unexpected error: {}", ex.getMessage(), ex); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.INTERNAL_ERROR.getMessage()); + response.setErrorKey(ErrorKey.INTERNAL_ERROR.getKey()); + response.setMessage("An error occurred while processing the request."); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } +} + + From 5bf946e69266a6f887f0755fa9a1f3ae02d4eb61 Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Tue, 31 Mar 2026 16:50:22 +0200 Subject: [PATCH 11/16] Refactor MarketplaceExternalServicePlaceholder to separate file and update imports in tests - Moved MarketplaceExternalServicePlaceholder to its own file for better modularity - Updated ComponentsFacade and ComponentsServiceTest to reference the new class location - Cleaned up unused imports and improved static import specificity in test classes - Minor code cleanup in PolicyAuthorizationManager and PolicyService --- .../project/facade/ComponentsFacade.java | 19 ------- ...MarketplaceExternalServicePlaceholder.java | 27 ++++++++++ .../project/facade/ComponentsServiceTest.java | 2 +- .../PolicyAuthorizationManager.java | 8 +-- .../security/authorization/PolicyService.java | 6 +-- .../PolicyAuthorizationManagerTest.java | 18 +++++-- .../PolicyContextFactoryTest.java | 7 ++- .../authorization/PolicyEngineTest.java | 8 ++- .../authorization/PolicyServiceTest.java | 9 +++- .../security/config/SecurityConfigTest.java | 6 ++- .../filter/AuthTypeEnforcementFilterTest.java | 7 ++- .../security/flow/AuthFlowResolverTest.java | 2 +- .../AzureJwtAuthenticationConverterTest.java | 4 +- .../registry/ApiDefinitionResolverTest.java | 7 ++- .../registry/CoreApiRegistryTest.java | 6 ++- .../security/config/SecurityConfigTest.java | 54 ------------------- 16 files changed, 83 insertions(+), 107 deletions(-) create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/MarketplaceExternalServicePlaceholder.java delete mode 100644 core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java index 012f21d..929f26d 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java @@ -21,23 +21,4 @@ public Component getProjectComponent(String projectId, String componentId) { public Component createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { return marketplaceExternalService.createProjectComponent(projectId, createComponentRequest); } - - @Service - class MarketplaceExternalServicePlaceholder implements ExternalService { - - @Override - public boolean isHealthy() { - return false; - } - - public Component getProjectComponent(String projectId, String componentId) { - log.info("Get component with id '" + componentId + "' for project '" + projectId + "'"); - return null; - } - - public Component createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { - log.info("Creating component for project '" + projectId + "'" + " with request: " + createComponentRequest); - return null; - } - } } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/MarketplaceExternalServicePlaceholder.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/MarketplaceExternalServicePlaceholder.java new file mode 100644 index 0000000..990374c --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/MarketplaceExternalServicePlaceholder.java @@ -0,0 +1,27 @@ +package org.opendevstack.apiservice.project.facade; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.api.ExternalService; +import org.opendevstack.apiservice.project.model.Component; +import org.opendevstack.apiservice.project.model.CreateComponentRequest; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +class MarketplaceExternalServicePlaceholder implements ExternalService { + + @Override + public boolean isHealthy() { + return false; + } + + public Component getProjectComponent(String projectId, String componentId) { + log.info("Get component with id '" + componentId + "' for project '" + projectId + "'"); + return null; + } + + public Component createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { + log.info("Creating component for project '" + projectId + "'" + " with request: " + createComponentRequest); + return null; + } +} diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsServiceTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsServiceTest.java index 31d2bcb..6cb5dd6 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsServiceTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsServiceTest.java @@ -19,7 +19,7 @@ class ComponentsServiceTest { @Mock - private ComponentsFacade.MarketplaceExternalServicePlaceholder marketPlaceExternalServicePlaceholder; + private MarketplaceExternalServicePlaceholder marketPlaceExternalServicePlaceholder; private ComponentsFacade componentsFacade; diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java index 244bef2..3460af3 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java @@ -1,26 +1,20 @@ package org.opendevstack.apiservice.core.security.authorization; -import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; -import java.util.Optional; - import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; -import org.opendevstack.apiservice.core.security.filter.AuthTypeEnforcementFilter; import org.opendevstack.apiservice.core.security.registry.ApiDefinitionResolver; -//** */ import org.opendevstack.apiservice.core.engine.filter.CachedBodyHttpServletRequest; -//** */import org.opendevstack.apiservice.core.security.filter.AuthTypeEnforcementFilter; import org.springframework.security.authorization.AuthorizationManager; import org.springframework.security.core.Authentication; import org.springframework.security.web.access.intercept.RequestAuthorizationContext; import org.springframework.stereotype.Component; import java.util.List; -import java.util.Map; +import java.util.Optional; import java.util.function.Supplier; /** diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyService.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyService.java index f1c28de..316f2c0 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyService.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyService.java @@ -1,9 +1,7 @@ package org.opendevstack.apiservice.core.security.authorization; -//import org.opendevstack.apiservice.core.config.CacheConfig; import org.opendevstack.apiservice.core.contracts.persistence.PolicyDao; import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; -//import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import java.util.ArrayList; @@ -17,9 +15,7 @@ public class PolicyService { public PolicyService(PolicyDao policyDao) { this.policyDao = policyDao; } - -// @Cacheable(cacheNames = CacheConfig.POLICIES_CACHE, -// key = "#apiDefinitionId + '::' + (#clientId != null ? #clientId : '*')") + public List findPolicies(String apiDefinitionId, String clientId) { if (clientId != null) { List rules = new ArrayList<>(); diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java index 26faac1..ef8f9bf 100644 --- a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java @@ -12,11 +12,19 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.security.web.access.intercept.RequestAuthorizationContext; -import java.util.*; -import java.util.function.Supplier; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; class PolicyAuthorizationManagerTest { diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactoryTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactoryTest.java index 1b4fe8c..4f94057 100644 --- a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactoryTest.java +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactoryTest.java @@ -10,10 +10,13 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import java.time.Instant; -import java.util.Map; import java.util.Set; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; class PolicyContextFactoryTest { diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngineTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngineTest.java index 514b362..81657e1 100644 --- a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngineTest.java +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngineTest.java @@ -12,8 +12,12 @@ import java.util.Map; import java.util.UUID; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class PolicyEngineTest { diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyServiceTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyServiceTest.java index 3688564..635a30e 100644 --- a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyServiceTest.java +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyServiceTest.java @@ -9,8 +9,13 @@ import java.util.Map; import java.util.UUID; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class PolicyServiceTest { diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java index 58a2147..f01206d 100644 --- a/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java @@ -7,8 +7,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.SecurityFilterChain; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class SecurityConfigTest { diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilterTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilterTest.java index 7c593c3..6a3ee8d 100644 --- a/core-security/src/test/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilterTest.java +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilterTest.java @@ -21,8 +21,11 @@ import java.util.Map; import java.util.Set; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class AuthTypeEnforcementFilterTest { diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolverTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolverTest.java index d2cc296..d5821b7 100644 --- a/core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolverTest.java +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolverTest.java @@ -8,7 +8,7 @@ import java.time.Instant; import java.util.Map; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; class AuthFlowResolverTest { diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverterTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverterTest.java index 05a9947..1fd8eca 100644 --- a/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverterTest.java +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverterTest.java @@ -12,7 +12,9 @@ import java.util.Map; import java.util.stream.Collectors; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; class AzureJwtAuthenticationConverterTest { diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolverTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolverTest.java index ec90ee3..a0f6c77 100644 --- a/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolverTest.java +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolverTest.java @@ -9,8 +9,11 @@ import java.util.Optional; import java.util.Set; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; class ApiDefinitionResolverTest { diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistryTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistryTest.java index 931bea7..ed27df9 100644 --- a/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistryTest.java +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistryTest.java @@ -10,8 +10,10 @@ import java.util.Optional; import java.util.Set; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class CoreApiRegistryTest { diff --git a/core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java b/core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java deleted file mode 100644 index 58a2147..0000000 --- a/core-security/src/tests/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java +++ /dev/null @@ -1,54 +0,0 @@ -package org.opendevstack.apiservice.core.security.config; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.opendevstack.apiservice.core.security.authorization.PolicyAuthorizationManager; -import org.opendevstack.apiservice.core.security.jwt.AzureJwtAuthenticationConverter; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.web.SecurityFilterChain; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class SecurityConfigTest { - - private SecurityProperties securityProperties; - private PolicyAuthorizationManager policyAuthorizationManager; - private AzureJwtAuthenticationConverter azureJwtAuthenticationConverter; - private SecurityConfig securityConfig; - - @BeforeEach - void setUp() { - securityProperties = mock(SecurityProperties.class); - policyAuthorizationManager = mock(PolicyAuthorizationManager.class); - azureJwtAuthenticationConverter = new AzureJwtAuthenticationConverter(); - securityConfig = new SecurityConfig(securityProperties, policyAuthorizationManager, azureJwtAuthenticationConverter); - } - - @Test - void securityFilterChain_withPublicEndpoints() throws Exception { - when(securityProperties.getPublicEndpoints()).thenReturn(new String[]{"/health", "/info"}); - HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); - - SecurityFilterChain chain = securityConfig.securityFilterChain(http); - assertNotNull(chain); - } - - @Test - void securityFilterChain_withNullPublicEndpoints() throws Exception { - when(securityProperties.getPublicEndpoints()).thenReturn(null); - HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); - - SecurityFilterChain chain = securityConfig.securityFilterChain(http); - assertNotNull(chain); - } - - @Test - void securityFilterChain_withEmptyPublicEndpoints() throws Exception { - when(securityProperties.getPublicEndpoints()).thenReturn(new String[]{}); - HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); - - SecurityFilterChain chain = securityConfig.securityFilterChain(http); - assertNotNull(chain); - } -} From f037972643c725bbb90905d7727bb4519ae46a66 Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Tue, 31 Mar 2026 18:33:58 +0200 Subject: [PATCH 12/16] Add SecurityUtils to extract client ID from JWT and update project creation to use authenticated client --- .../project/controller/ProjectController.java | 6 +++-- .../project/util/SecurityUtils.java | 27 +++++++++++++++++++ .../controller/ProjectControllerTest.java | 15 +++++++++-- 3 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java index b714133..c9bc530 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java @@ -7,6 +7,7 @@ import org.opendevstack.apiservice.project.facade.ProjectsFacade; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.util.SecurityUtils; import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -37,7 +38,8 @@ public class ProjectController implements ProjectsApi { @Override public ResponseEntity createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) { projectRequestValidator.validate(createProjectRequest); - UUID clientId = UUID.fromString("00000000-0000-0000-0000-000000000001"); + UUID clientId = SecurityUtils.getClientId(); + CreateProjectResponse projectResponse = projectsFacade.createProject(createProjectRequest, clientId); projectResponse.setLocation(API_BASE_PATH + "/" + projectResponse.getProjectKey()); return ResponseEntity @@ -45,7 +47,7 @@ public ResponseEntity createProject(@Valid @RequestBody C .header(HTTP_HEADER_LOCATION, API_BASE_PATH) .body(projectResponse); } - + @GetMapping("/{projectKey}") @Override public ResponseEntity getProject(@PathVariable String projectKey) { diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java b/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java new file mode 100644 index 0000000..1863ec0 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java @@ -0,0 +1,27 @@ +package org.opendevstack.apiservice.project.util; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; + +import java.util.UUID; + +public class SecurityUtils { + + private SecurityUtils() { + // to avoid instantiation + } + + public static UUID getClientId() { + String appId = null; + Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + + if (principal instanceof Jwt) { + appId = ((Jwt) principal).getClaimAsString("appid"); + } else { + throw new InvalidBearerTokenException("Invalid authentication token: " + principal.getClass().getName()); + } + + return UUID.fromString(appId); + } +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java index fbd1123..d184ef9 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java @@ -11,14 +11,17 @@ import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import java.util.UUID; - class ProjectControllerTest { @Mock @@ -34,6 +37,14 @@ class ProjectControllerTest { void setup() { mocks = MockitoAnnotations.openMocks(this); sut = new ProjectController(projectsFacade, projectRequestValidator); + + Jwt jwtToken = Jwt.withTokenValue("dummy-token") + .claim("appid", UUID.randomUUID().toString()) + .claim("sub", "test-user") + .header("alg", "none") + .build(); + JwtAuthenticationToken authentication = new JwtAuthenticationToken(jwtToken); + SecurityContextHolder.getContext().setAuthentication(authentication); } @AfterEach From bb398b18c294700aabbd2107f4612790ef0dc369 Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Wed, 1 Apr 2026 11:58:12 +0200 Subject: [PATCH 13/16] Implement client credential and on-behalf-of flow validators; update flavor handling in authorization --- .../project/util/SecurityUtils.java | 15 ++++-- .../PolicyAuthorizationManager.java | 4 ++ .../evaluator/FlavorRestrictionEvaluator.java | 2 +- .../ClientCredentialFlowValidator.java | 47 +++++++++++++++++++ .../flow/validator/OboFlowValidator.java | 40 ++++++++++++++++ core/pom.xml | 7 +-- 6 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/OboFlowValidator.java diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java b/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java index 1863ec0..72add05 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java @@ -13,15 +13,22 @@ private SecurityUtils() { } public static UUID getClientId() { - String appId = null; Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); if (principal instanceof Jwt) { - appId = ((Jwt) principal).getClaimAsString("appid"); + Jwt jwt = ((Jwt) principal); + String clientId = jwt.getClaimAsString("azp"); + if (clientId == null || clientId.isBlank()) { + clientId = jwt.getClaimAsString("appid"); + } + + if (clientId == null || clientId.isBlank()) { + throw new InvalidBearerTokenException("Client ID not found in token claims"); + } + + return UUID.fromString(clientId); } else { throw new InvalidBearerTokenException("Invalid authentication token: " + principal.getClass().getName()); } - - return UUID.fromString(appId); } } diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java index 3460af3..4cfa5b4 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java @@ -51,6 +51,10 @@ public org.springframework.security.authorization.AuthorizationDecision check( Optional apiDef = this.resolver.resolve(request); + if (apiDef.isPresent()) { + request.setAttribute("oas.apiDefinition", apiDef.get()); + } + // Unknown routes are denied (fail-closed). Public API definitions are allowed. if (apiDef.isEmpty()) { return new org.springframework.security.authorization.AuthorizationDecision(false); diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java index d33e38a..2970722 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java @@ -37,7 +37,7 @@ public AuthorizationDecision evaluate(PolicyContext context) { return AuthorizationDecision.ABSTAIN; } - String requestedFlavor = (String) body.get("flavor"); + String requestedFlavor = (String) body.get("projectFlavor"); if (requestedFlavor == null) { return AuthorizationDecision.ABSTAIN; } diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java new file mode 100644 index 0000000..f3d1fde --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java @@ -0,0 +1,47 @@ +package org.opendevstack.apiservice.core.security.flow.validator; + +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.security.flow.AuthFlowValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; + +@Component +public class ClientCredentialFlowValidator implements AuthFlowValidator { + + @Override + public AuthType getSupportedFlow() { + return AuthType.CLIENT_CREDENTIALS; + } + + @Override + public boolean validate(Jwt jwt) { + String appid = jwt.getClaimAsString("appid"); + if (appid == null || appid.isBlank()) { + return false; + } + + Object aud = jwt.getClaim("aud"); + if (aud == null) { + return false; + } + + String scp = jwt.getClaimAsString("scp"); + if (scp != null && !scp.isBlank()) { + return false; + } + + String upn = jwt.getClaimAsString("upn"); + String preferredUsername = jwt.getClaimAsString("preferred_username"); + if ((upn != null && !upn.isBlank()) || (preferredUsername != null && !preferredUsername.isBlank())) { + return false; + } + + String sub = jwt.getSubject(); + String oid = jwt.getClaimAsString("oid"); + if (sub == null || oid == null || !sub.equals(oid)) { + return false; + } + + return true; + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/OboFlowValidator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/OboFlowValidator.java new file mode 100644 index 0000000..9395f5e --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/OboFlowValidator.java @@ -0,0 +1,40 @@ +package org.opendevstack.apiservice.core.security.flow.validator; + +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.security.flow.AuthFlowValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; + +@Component +public class OboFlowValidator implements AuthFlowValidator { + + @Override + public AuthType getSupportedFlow() { + return AuthType.OBO; + } + + @Override + public boolean validate(Jwt jwt) { + String scp = jwt.getClaimAsString("scp"); + if (scp == null || scp.isBlank()) { + return false; + } + + String roles = jwt.getClaimAsString("roles"); + if (roles != null && !roles.isBlank()) { + return false; + } + + String user = jwt.getClaimAsString("upn"); + if (user == null || user.isBlank()) { + user = jwt.getClaimAsString("preferred_username"); + } + + if (user == null || user.isBlank()) { + user = jwt.getSubject(); + } + + return user != null && !user.isBlank(); + } + +} diff --git a/core/pom.xml b/core/pom.xml index 2395b96..a0ba531 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -145,12 +145,7 @@ api-project-platform ${project.version} - - - org.opendevstack.apiservice - api-project-component-v0 - ${project.version} - + org.opendevstack.apiservice From 5e950facc373d96ed99dc157cbacae0e95458bbe Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Wed, 1 Apr 2026 12:09:46 +0200 Subject: [PATCH 14/16] Enhance PolicyAuthorizationManager to extract request body for JSON write requests and update SecurityConfig to include CachedBodyRequestFilter --- .../PolicyAuthorizationManager.java | 33 +++++++++++++++++++ .../core/security/config/SecurityConfig.java | 11 ++++++- .../PolicyAuthorizationManagerTest.java | 33 +++++++++++++++++++ .../security/config/SecurityConfigTest.java | 10 +++++- 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java index 4cfa5b4..cfca9d6 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java @@ -1,6 +1,7 @@ package org.opendevstack.apiservice.core.security.authorization; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; @@ -13,7 +14,9 @@ import org.springframework.security.web.access.intercept.RequestAuthorizationContext; import org.springframework.stereotype.Component; +import java.io.IOException; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.function.Supplier; @@ -65,6 +68,7 @@ public org.springframework.security.authorization.AuthorizationDecision check( } PolicyContext policyContext = contextFactory.create(apiDef.get(), request); + policyContext = policyContext.withRequestBody(extractRequestBody(request)); List rules = policyService.findPolicies(apiDef.get().getId(), policyContext.getClientId()); @@ -74,4 +78,33 @@ public org.springframework.security.authorization.AuthorizationDecision check( decision != AuthorizationDecision.DENY ); } + + private Map extractRequestBody(HttpServletRequest request) { + if (!isJsonWriteRequest(request)) { + return Map.of(); + } + + try { + byte[] bytes = request.getInputStream().readAllBytes(); + if (bytes.length == 0) { + return Map.of(); + } + return objectMapper.readValue(bytes, new TypeReference<>() { + }); + } catch (IOException ex) { + return Map.of(); + } + } + + private boolean isJsonWriteRequest(HttpServletRequest request) { + String method = request.getMethod(); + if (!"POST".equalsIgnoreCase(method) + && !"PUT".equalsIgnoreCase(method) + && !"PATCH".equalsIgnoreCase(method)) { + return false; + } + + String contentType = request.getContentType(); + return contentType != null && contentType.toLowerCase().startsWith("application/json"); + } } diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java index 0267fc5..149be54 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java @@ -1,12 +1,14 @@ package org.opendevstack.apiservice.core.security.config; import org.opendevstack.apiservice.core.security.authorization.PolicyAuthorizationManager; +import org.opendevstack.apiservice.core.security.filter.CachedBodyRequestFilter; import org.opendevstack.apiservice.core.security.jwt.AzureJwtAuthenticationConverter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.access.intercept.AuthorizationFilter; import org.springframework.security.web.SecurityFilterChain; import lombok.extern.slf4j.Slf4j; import java.util.Arrays; @@ -20,12 +22,17 @@ public class SecurityConfig { private final SecurityProperties securityProperties; private final PolicyAuthorizationManager policyAuthorizationManager; private final AzureJwtAuthenticationConverter azureJwtAuthenticationConverter; + private final CachedBodyRequestFilter cachedBodyRequestFilter; - public SecurityConfig(SecurityProperties securityProperties, PolicyAuthorizationManager policyAuthorizationManager, AzureJwtAuthenticationConverter azureJwtAuthenticationConverter) { + public SecurityConfig(SecurityProperties securityProperties, + PolicyAuthorizationManager policyAuthorizationManager, + AzureJwtAuthenticationConverter azureJwtAuthenticationConverter, + CachedBodyRequestFilter cachedBodyRequestFilter) { this.securityProperties = securityProperties; this.policyAuthorizationManager = policyAuthorizationManager; this.azureJwtAuthenticationConverter = azureJwtAuthenticationConverter; + this.cachedBodyRequestFilter = cachedBodyRequestFilter; } @Bean @@ -48,6 +55,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .headers(headers -> headers.frameOptions(frame -> frame.disable())) .csrf(csrf -> csrf.disable()); + http.addFilterBefore(cachedBodyRequestFilter, AuthorizationFilter.class); + return http.build(); } } diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java index ef8f9bf..21fb51d 100644 --- a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java @@ -12,6 +12,7 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.security.web.access.intercept.RequestAuthorizationContext; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.Optional; @@ -21,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyMap; import static org.mockito.Mockito.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verifyNoInteractions; @@ -75,6 +77,7 @@ void check_securedApi_policyPermits() { PolicyContext ctx = mock(PolicyContext.class); when(ctx.getClientId()).thenReturn("client-a"); + when(ctx.withRequestBody(anyMap())).thenReturn(ctx); when(contextFactory.create(apiDef, request)).thenReturn(ctx); List rules = List.of(new PolicyRule(UUID.randomUUID(), "api-1", "client-a", "ALLOWED_CLIENTS", Map.of())); @@ -95,6 +98,7 @@ void check_securedApi_policyDenies() { PolicyContext ctx = mock(PolicyContext.class); when(ctx.getClientId()).thenReturn("client-b"); + when(ctx.withRequestBody(anyMap())).thenReturn(ctx); when(contextFactory.create(apiDef, request)).thenReturn(ctx); List rules = List.of(new PolicyRule(UUID.randomUUID(), "api-1", "client-b", "ALLOWED_CLIENTS", Map.of())); @@ -115,6 +119,7 @@ void check_securedApi_abstainPermits() { PolicyContext ctx = mock(PolicyContext.class); when(ctx.getClientId()).thenReturn("client-a"); + when(ctx.withRequestBody(anyMap())).thenReturn(ctx); when(contextFactory.create(apiDef, request)).thenReturn(ctx); when(policyService.findPolicies(anyString(), anyString())).thenReturn(List.of()); @@ -124,4 +129,32 @@ void check_securedApi_abstainPermits() { assertTrue(decision.isGranted()); } + + @Test + void check_securedPostRequest_populatesRequestBodyInContext() { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v0/projects"); + request.setContentType("application/json"); + String requestBody = "{" + + "\"projectFlavor\":\"DLSS\"," + + "\"location\":\"UNKNOWN_REGION\"" + + "}"; + request.setContent(requestBody.getBytes(StandardCharsets.UTF_8)); + + ApiDefinition apiDef = new ApiDefinition("api-1", "Projects", "/projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(resolver.resolve(request)).thenReturn(Optional.of(apiDef)); + + PolicyContext ctx = mock(PolicyContext.class); + when(ctx.getClientId()).thenReturn("client-a"); + when(ctx.withRequestBody(anyMap())).thenReturn(ctx); + when(contextFactory.create(apiDef, request)).thenReturn(ctx); + + List rules = List.of(new PolicyRule(UUID.randomUUID(), "api-1", "client-a", "FLAVOR_RESTRICTION", Map.of())); + when(policyService.findPolicies("api-1", "client-a")).thenReturn(rules); + when(policyEngine.evaluate(ctx, rules)).thenReturn(AuthorizationDecision.PERMIT); + + var decision = manager.check(() -> null, new RequestAuthorizationContext(request)); + + assertTrue(decision.isGranted()); + } } diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java index f01206d..5a49c23 100644 --- a/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.opendevstack.apiservice.core.security.authorization.PolicyAuthorizationManager; +import org.opendevstack.apiservice.core.security.filter.CachedBodyRequestFilter; import org.opendevstack.apiservice.core.security.jwt.AzureJwtAuthenticationConverter; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.SecurityFilterChain; @@ -17,6 +18,7 @@ class SecurityConfigTest { private SecurityProperties securityProperties; private PolicyAuthorizationManager policyAuthorizationManager; private AzureJwtAuthenticationConverter azureJwtAuthenticationConverter; + private CachedBodyRequestFilter cachedBodyRequestFilter; private SecurityConfig securityConfig; @BeforeEach @@ -24,7 +26,13 @@ void setUp() { securityProperties = mock(SecurityProperties.class); policyAuthorizationManager = mock(PolicyAuthorizationManager.class); azureJwtAuthenticationConverter = new AzureJwtAuthenticationConverter(); - securityConfig = new SecurityConfig(securityProperties, policyAuthorizationManager, azureJwtAuthenticationConverter); + cachedBodyRequestFilter = mock(CachedBodyRequestFilter.class); + securityConfig = new SecurityConfig( + securityProperties, + policyAuthorizationManager, + azureJwtAuthenticationConverter, + cachedBodyRequestFilter + ); } @Test From 8aa52a7064f1170357f62ebdc1a503661619f334 Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Wed, 1 Apr 2026 15:05:39 +0200 Subject: [PATCH 15/16] Add CachedBodyRequestFilter and CachedBodyHttpServletRequest to support reading request bodies multiple times; refactor ClientCredentialFlowValidator and FlavorRestrictionEvaluator for improved clarity --- .../project/util/SecurityUtils.java | 3 +- .../core/contracts/policy/PolicyTypes.java | 1 + .../evaluator/FlavorRestrictionEvaluator.java | 5 +- .../filter/CachedBodyHttpServletRequest.java | 56 +++++++++++++++++++ .../filter/CachedBodyRequestFilter.java | 38 +++++++++++++ .../ClientCredentialFlowValidator.java | 6 +- 6 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyHttpServletRequest.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyRequestFilter.java diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java b/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java index 72add05..d5986ed 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java @@ -15,8 +15,7 @@ private SecurityUtils() { public static UUID getClientId() { Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - if (principal instanceof Jwt) { - Jwt jwt = ((Jwt) principal); + if (principal instanceof Jwt jwt) { String clientId = jwt.getClaimAsString("azp"); if (clientId == null || clientId.isBlank()) { clientId = jwt.getClaimAsString("appid"); diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyTypes.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyTypes.java index 9657727..43e256b 100644 --- a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyTypes.java +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyTypes.java @@ -9,6 +9,7 @@ public final class PolicyTypes { private PolicyTypes() {} + public static final String FLAVOR_RESTRICTION = "FLAVOR_RESTRICTION"; public static final String ALLOWED_CLIENTS = "ALLOWED_CLIENTS"; public static final String SCOPE_REQUIRED = "SCOPE_REQUIRED"; } diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java index 2970722..9da0b9a 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java @@ -4,6 +4,7 @@ import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; import org.opendevstack.apiservice.core.contracts.policy.PolicyEvaluator; import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.opendevstack.apiservice.core.contracts.policy.PolicyTypes; import org.springframework.stereotype.Component; import java.util.List; @@ -17,11 +18,9 @@ @Component public class FlavorRestrictionEvaluator implements PolicyEvaluator { - public static final String FLAVOR_RESTRICTION = "FLAVOR_RESTRICTION"; - @Override public boolean supports(String policyType) { - return FLAVOR_RESTRICTION.equals(policyType); + return PolicyTypes.FLAVOR_RESTRICTION.equals(policyType); } @Override diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyHttpServletRequest.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyHttpServletRequest.java new file mode 100644 index 0000000..3511ffd --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyHttpServletRequest.java @@ -0,0 +1,56 @@ +package org.opendevstack.apiservice.core.security.filter; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +/** + * Request wrapper that caches the body so it can be read multiple times. + */ +public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { + + private final byte[] cachedBody; + + public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException { + super(request); + this.cachedBody = request.getInputStream().readAllBytes(); + } + + @Override + public ServletInputStream getInputStream() { + ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(cachedBody); + return new ServletInputStream() { + @Override + public int read() { + return byteArrayInputStream.read(); + } + + @Override + public boolean isFinished() { + return byteArrayInputStream.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + // no-op for synchronous request handling + } + }; + } + + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyRequestFilter.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyRequestFilter.java new file mode 100644 index 0000000..43ea1ea --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyRequestFilter.java @@ -0,0 +1,38 @@ +package org.opendevstack.apiservice.core.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class CachedBodyRequestFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + if (shouldWrap(request)) { + filterChain.doFilter(new CachedBodyHttpServletRequest(request), response); + return; + } + + filterChain.doFilter(request, response); + } + + private boolean shouldWrap(HttpServletRequest request) { + String method = request.getMethod(); + if (!"POST".equalsIgnoreCase(method) + && !"PUT".equalsIgnoreCase(method) + && !"PATCH".equalsIgnoreCase(method)) { + return false; + } + + String contentType = request.getContentType(); + return contentType != null && contentType.toLowerCase().startsWith("application/json"); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java index f3d1fde..531577c 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java @@ -38,10 +38,6 @@ public boolean validate(Jwt jwt) { String sub = jwt.getSubject(); String oid = jwt.getClaimAsString("oid"); - if (sub == null || oid == null || !sub.equals(oid)) { - return false; - } - - return true; + return sub != null && oid != null && sub.equals(oid); } } From 9cf0718d1d5ed314fbff4b6ecf5c149678a3c60c Mon Sep 17 00:00:00 2001 From: Angel Martinez Date: Wed, 1 Apr 2026 17:07:26 +0200 Subject: [PATCH 16/16] Refactor PolicyAuthorizationManager to use AuthTypeEnforcementFilter for setting API definition attribute; update SecurityUtils to simplify error message for invalid authentication tokens --- .../opendevstack/apiservice/project/util/SecurityUtils.java | 2 +- .../security/authorization/PolicyAuthorizationManager.java | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java b/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java index d5986ed..bb926a9 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java @@ -27,7 +27,7 @@ public static UUID getClientId() { return UUID.fromString(clientId); } else { - throw new InvalidBearerTokenException("Invalid authentication token: " + principal.getClass().getName()); + throw new InvalidBearerTokenException("Invalid authentication token"); } } } diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java index cfca9d6..0a660af 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java @@ -8,6 +8,7 @@ import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.opendevstack.apiservice.core.security.filter.AuthTypeEnforcementFilter; import org.opendevstack.apiservice.core.security.registry.ApiDefinitionResolver; import org.springframework.security.authorization.AuthorizationManager; import org.springframework.security.core.Authentication; @@ -55,7 +56,7 @@ public org.springframework.security.authorization.AuthorizationDecision check( Optional apiDef = this.resolver.resolve(request); if (apiDef.isPresent()) { - request.setAttribute("oas.apiDefinition", apiDef.get()); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef.get()); } // Unknown routes are denied (fail-closed). Public API definitions are allowed.