Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
14265b7
Remove obsolete test classes and update pom.xml to include core-secur…
jorge-romero Mar 26, 2026
f0b76f0
Add OAuth2 resource server configuration and public endpoints to appl…
jorge-romero Mar 26, 2026
d5ea2c7
Add unit tests for security components and remove obsolete tests
jorge-romero Mar 27, 2026
fa47e7a
Add security test matrix for HTTP client interactions
jorge-romero Mar 27, 2026
ad955dc
Add AllowedClientsEvaluator to enforce client authorization policies
jorge-romero Mar 27, 2026
c38be02
Add comprehensive test scenarios for core-security module
jorge-romero Mar 27, 2026
69a5bc5
Add FlavorRestrictionEvaluator to enforce flavor restriction policies
jorge-romero Mar 27, 2026
d1d98d4
Remove commented-out code for API definition retrieval in PolicyAutho…
jorge-romero Mar 27, 2026
386326d
Add unit tests for SecurityConfig to validate security filter chain b…
jorge-romero Mar 27, 2026
8c065a5
added missed project exception handler
angelmp01 Mar 31, 2026
be35da5
Merge branch 'develop' into feature/security
angelmp01 Mar 31, 2026
5bf946e
Refactor MarketplaceExternalServicePlaceholder to separate file and u…
angelmp01 Mar 31, 2026
f037972
Add SecurityUtils to extract client ID from JWT and update project cr…
angelmp01 Mar 31, 2026
e12af7e
Merge branch 'develop' into feature/security
angelmp01 Mar 31, 2026
bb398b1
Implement client credential and on-behalf-of flow validators; update …
angelmp01 Apr 1, 2026
5e950fa
Enhance PolicyAuthorizationManager to extract request body for JSON w…
angelmp01 Apr 1, 2026
8aa52a7
Add CachedBodyRequestFilter and CachedBodyHttpServletRequest to suppo…
angelmp01 Apr 1, 2026
9cf0718
Refactor PolicyAuthorizationManager to use AuthTypeEnforcementFilter …
angelmp01 Apr 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -37,15 +38,16 @@ public class ProjectController implements ProjectsApi {
@Override
public ResponseEntity<CreateProjectResponse> 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
.status(HttpStatus.OK)
.header(HTTP_HEADER_LOCATION, API_BASE_PATH)
.body(projectResponse);
}

@GetMapping("/{projectKey}")
@Override
public ResponseEntity<CreateProjectResponse> getProject(@PathVariable String projectKey) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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;
Expand Down Expand Up @@ -108,6 +109,17 @@ public ResponseEntity<CreateProjectResponse> handleAutomationPlatformException(
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}

@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<CreateProjectResponse> 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<CreateProjectResponse> handleGenericException(Exception ex) {
log.error("Unexpected error: {}", ex.getMessage(), ex);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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() {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

if (principal instanceof Jwt jwt) {
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");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
57 changes: 57 additions & 0 deletions core-security/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.opendevstack.apiservice</groupId>
<artifactId>devstack-api-service</artifactId>
<version>0.0.3</version>
</parent>

<artifactId>core-security</artifactId>
<name>core-security</name>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>


<dependency>
<groupId>org.opendevstack.apiservice</groupId>
<artifactId>core-contracts</artifactId>
<version>${project.version}</version>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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;
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;
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;

/**
* Spring Security AuthorizationManager that delegates authorization decisions to the policy engine.
*/
@Component
public class PolicyAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {

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> authentication,
RequestAuthorizationContext context) {

HttpServletRequest request = context.getRequest();

Optional<ApiDefinition> apiDef = this.resolver.resolve(request);

if (apiDef.isPresent()) {
request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef.get());
}

// 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);
policyContext = policyContext.withRequestBody(extractRequestBody(request));

List<PolicyRule> rules = policyService.findPolicies(apiDef.get().getId(), policyContext.getClientId());

AuthorizationDecision decision = policyEngine.evaluate(policyContext, rules);

return new org.springframework.security.authorization.AuthorizationDecision(
decision != AuthorizationDecision.DENY
);
}

private Map<String, Object> 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");
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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);
}
}
Loading
Loading