Skip to content

Commit 28b7333

Browse files
Feature/security (#15)
This pull request introduces a new modular security layer for the project by adding a `core-security` module. The changes implement a flexible policy-based authorization system integrated with Spring Security and Azure AD JWT authentication. The security configuration is now externalized, supporting public endpoints and dynamic policy evaluation for API requests. The main areas of change are the addition of new security components, policy evaluation infrastructure, and configuration updates. **Security configuration and integration:** - Added a new `core-security` module with its own `pom.xml`, including dependencies for Spring Security, OAuth2 resource server, and project contracts. - Introduced `SecurityConfig` and `SecurityProperties` to configure Spring Security, define public endpoints, and integrate JWT authentication with Azure AD. [[1]](diffhunk://#diff-89b518b29fa9ec1944351e69efbcdba71e422c3a60243639e380ba2b7fdf9969R1-R53) [[2]](diffhunk://#diff-a407cfaaee4141697d8cd945a003a242b17f4caf458d659f0f701b97cfdbb499R1-R20) - Updated `application.yaml` to configure OAuth2 resource server with Azure AD issuer and define public endpoints for health checks. **Policy-based authorization system:** - Implemented `PolicyAuthorizationManager`, `PolicyEngine`, and `PolicyService` to support dynamic, rule-based authorization decisions for API requests. These components resolve API definitions, retrieve relevant policies, and evaluate them to allow or deny access. [[1]](diffhunk://#diff-1330c45b35848910097a5c9083c330efbc5c978fdacbe80e768d6657573809beR1-R79) [[2]](diffhunk://#diff-81f467c5c53592ee3767530c780ef2142885ae1fcf0ddae78b68cebdc45b831bR1-R49) [[3]](diffhunk://#diff-c81749eb986f34aa144a64ef9d2e529ddb29c36d75a9c2f84d2815e038aa645dR1-R32) - Added `PolicyContextFactory` to build policy evaluation contexts from HTTP requests and JWT claims. **Policy evaluators:** - Added `AllowedClientsEvaluator` and `FlavorRestrictionEvaluator` as concrete policy evaluators, supporting client-based and request-body-based authorization rules, respectively. [[1]](diffhunk://#diff-abc0ee53c28626c12e7bebda566921b0cdf787cef038bed9d4fde7cfd0c1b923R1-R47) [[2]](diffhunk://#diff-512b30434a48bdd75e08d7bf935623cb31a354bb934066ad730cefd72735cd60R1-R54) **Authentication flow enforcement:** - Added `AuthTypeEnforcementFilter`, `AuthFlowResolver`, and `AuthFlowValidator` to ensure that only allowed authentication flows (e.g., OBO, client credentials) are accepted for each API, with flow-specific validation. [[1]](diffhunk://#diff-3cd26dad5f2149a5873f0b9e0a3dc21468cf1f84f949c18e353d6248185972e3R1-R75) [[2]](diffhunk://#diff-0b949f9f721cec94f4e91188177fa3b549be489ef1404e3530bd417aa638cbc0R1-R22) [[3]](diffhunk://#diff-34a386426c9c407d82e7e04c5dccbaaeec679c47d049077bedcae5d7d4e24fceR1-R11) --------- Co-authored-by: Angel Martinez <angelmp.mail@gmail.com>
1 parent a0a4d2f commit 28b7333

59 files changed

Lines changed: 2891 additions & 2838 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package org.opendevstack.apiservice.project.facade;
2+
3+
import lombok.extern.slf4j.Slf4j;
4+
import org.opendevstack.apiservice.externalservice.api.ExternalService;
5+
import org.opendevstack.apiservice.project.model.Component;
6+
import org.opendevstack.apiservice.project.model.CreateComponentRequest;
7+
import org.springframework.stereotype.Service;
8+
9+
@Service
10+
@Slf4j
11+
class MarketplaceExternalServicePlaceholder implements ExternalService {
12+
13+
@Override
14+
public boolean isHealthy() {
15+
return false;
16+
}
17+
18+
public Component getProjectComponent(String projectId, String componentId) {
19+
log.info("Get component with id '" + componentId + "' for project '" + projectId + "'");
20+
return null;
21+
}
22+
23+
public Component createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) {
24+
log.info("Creating component for project '" + projectId + "'" + " with request: " + createComponentRequest);
25+
return null;
26+
}
27+
}

api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import org.opendevstack.apiservice.project.facade.ProjectsFacade;
88
import org.opendevstack.apiservice.project.model.CreateProjectRequest;
99
import org.opendevstack.apiservice.project.model.CreateProjectResponse;
10+
import org.opendevstack.apiservice.project.util.SecurityUtils;
1011
import org.opendevstack.apiservice.project.validation.ProjectRequestValidator;
1112
import org.springframework.http.HttpStatus;
1213
import org.springframework.http.ResponseEntity;
@@ -37,15 +38,16 @@ public class ProjectController implements ProjectsApi {
3738
@Override
3839
public ResponseEntity<CreateProjectResponse> createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) {
3940
projectRequestValidator.validate(createProjectRequest);
40-
UUID clientId = UUID.fromString("00000000-0000-0000-0000-000000000001");
41+
UUID clientId = SecurityUtils.getClientId();
42+
4143
CreateProjectResponse projectResponse = projectsFacade.createProject(createProjectRequest, clientId);
4244
projectResponse.setLocation(API_BASE_PATH + "/" + projectResponse.getProjectKey());
4345
return ResponseEntity
4446
.status(HttpStatus.OK)
4547
.header(HTTP_HEADER_LOCATION, API_BASE_PATH)
4648
.body(projectResponse);
4749
}
48-
50+
4951
@GetMapping("/{projectKey}")
5052
@Override
5153
public ResponseEntity<CreateProjectResponse> getProject(@PathVariable String projectKey) {

api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import org.opendevstack.apiservice.project.model.CreateProjectResponse;
1111
import org.springframework.http.HttpStatus;
1212
import org.springframework.http.ResponseEntity;
13+
import org.springframework.http.converter.HttpMessageNotReadableException;
1314
import org.springframework.validation.FieldError;
1415
import org.springframework.web.bind.MethodArgumentNotValidException;
1516
import org.springframework.web.bind.annotation.ExceptionHandler;
@@ -108,6 +109,17 @@ public ResponseEntity<CreateProjectResponse> handleAutomationPlatformException(
108109
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
109110
}
110111

112+
@ExceptionHandler(HttpMessageNotReadableException.class)
113+
public ResponseEntity<CreateProjectResponse> handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) {
114+
log.error("Unexpected error: {}", ex.getMessage(), ex);
115+
CreateProjectResponse response = new CreateProjectResponse();
116+
response.setLocation(ProjectController.API_BASE_PATH);
117+
response.setError(ErrorKey.BAD_REQUEST_BODY.getMessage());
118+
response.setErrorKey(ErrorKey.BAD_REQUEST_BODY.getKey());
119+
response.setMessage("An error occurred while processing the request.");
120+
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
121+
}
122+
111123
@ExceptionHandler(Exception.class)
112124
public ResponseEntity<CreateProjectResponse> handleGenericException(Exception ex) {
113125
log.error("Unexpected error: {}", ex.getMessage(), ex);
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package org.opendevstack.apiservice.project.util;
2+
3+
import org.springframework.security.core.context.SecurityContextHolder;
4+
import org.springframework.security.oauth2.jwt.Jwt;
5+
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
6+
7+
import java.util.UUID;
8+
9+
public class SecurityUtils {
10+
11+
private SecurityUtils() {
12+
// to avoid instantiation
13+
}
14+
15+
public static UUID getClientId() {
16+
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
17+
18+
if (principal instanceof Jwt jwt) {
19+
String clientId = jwt.getClaimAsString("azp");
20+
if (clientId == null || clientId.isBlank()) {
21+
clientId = jwt.getClaimAsString("appid");
22+
}
23+
24+
if (clientId == null || clientId.isBlank()) {
25+
throw new InvalidBearerTokenException("Client ID not found in token claims");
26+
}
27+
28+
return UUID.fromString(clientId);
29+
} else {
30+
throw new InvalidBearerTokenException("Invalid authentication token");
31+
}
32+
}
33+
}

api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,17 @@
1111
import org.opendevstack.apiservice.project.validation.ProjectRequestValidator;
1212
import org.springframework.http.HttpStatus;
1313
import org.springframework.http.ResponseEntity;
14+
import org.springframework.security.core.context.SecurityContextHolder;
15+
import org.springframework.security.oauth2.jwt.Jwt;
16+
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
17+
18+
import java.util.UUID;
1419

1520
import static org.assertj.core.api.Assertions.assertThat;
1621
import static org.mockito.ArgumentMatchers.any;
1722
import static org.mockito.Mockito.verify;
1823
import static org.mockito.Mockito.when;
1924

20-
import java.util.UUID;
21-
2225
class ProjectControllerTest {
2326

2427
@Mock
@@ -34,6 +37,14 @@ class ProjectControllerTest {
3437
void setup() {
3538
mocks = MockitoAnnotations.openMocks(this);
3639
sut = new ProjectController(projectsFacade, projectRequestValidator);
40+
41+
Jwt jwtToken = Jwt.withTokenValue("dummy-token")
42+
.claim("appid", UUID.randomUUID().toString())
43+
.claim("sub", "test-user")
44+
.header("alg", "none")
45+
.build();
46+
JwtAuthenticationToken authentication = new JwtAuthenticationToken(jwtToken);
47+
SecurityContextHolder.getContext().setAuthentication(authentication);
3748
}
3849

3950
@AfterEach

application.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,19 @@ spring:
4040
open-in-view: false
4141
show-sql: false
4242

43+
spring:
44+
security:
45+
oauth2:
46+
resourceserver:
47+
jwt:
48+
issuer-uri: https://sts.windows.net/${AZURE_TENANT_ID}/
49+
50+
app:
51+
security:
52+
public-endpoints:
53+
- /actuator/health
54+
- /actuator/health/**
55+
4356
management:
4457
endpoints:
4558
web:

core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyTypes.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ public final class PolicyTypes {
99

1010
private PolicyTypes() {}
1111

12+
public static final String FLAVOR_RESTRICTION = "FLAVOR_RESTRICTION";
1213
public static final String ALLOWED_CLIENTS = "ALLOWED_CLIENTS";
1314
public static final String SCOPE_REQUIRED = "SCOPE_REQUIRED";
1415
}

core-security/pom.xml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
5+
<modelVersion>4.0.0</modelVersion>
6+
7+
<parent>
8+
<groupId>org.opendevstack.apiservice</groupId>
9+
<artifactId>devstack-api-service</artifactId>
10+
<version>0.0.3</version>
11+
</parent>
12+
13+
<artifactId>core-security</artifactId>
14+
<name>core-security</name>
15+
16+
<dependencies>
17+
<dependency>
18+
<groupId>org.springframework.boot</groupId>
19+
<artifactId>spring-boot-starter-web</artifactId>
20+
</dependency>
21+
22+
<dependency>
23+
<groupId>org.springframework.boot</groupId>
24+
<artifactId>spring-boot-starter-security</artifactId>
25+
</dependency>
26+
27+
<dependency>
28+
<groupId>org.springframework.boot</groupId>
29+
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
30+
</dependency>
31+
32+
33+
<dependency>
34+
<groupId>org.opendevstack.apiservice</groupId>
35+
<artifactId>core-contracts</artifactId>
36+
<version>${project.version}</version>
37+
</dependency>
38+
39+
<dependency>
40+
<groupId>org.projectlombok</groupId>
41+
<artifactId>lombok</artifactId>
42+
<scope>provided</scope>
43+
</dependency>
44+
45+
<dependency>
46+
<groupId>org.springframework.boot</groupId>
47+
<artifactId>spring-boot-starter-test</artifactId>
48+
<scope>test</scope>
49+
</dependency>
50+
51+
<dependency>
52+
<groupId>org.springframework.security</groupId>
53+
<artifactId>spring-security-test</artifactId>
54+
<scope>test</scope>
55+
</dependency>
56+
</dependencies>
57+
</project>
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package org.opendevstack.apiservice.core.security.authorization;
2+
3+
4+
import com.fasterxml.jackson.core.type.TypeReference;
5+
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import jakarta.servlet.http.HttpServletRequest;
7+
import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision;
8+
import org.opendevstack.apiservice.core.contracts.policy.PolicyContext;
9+
import org.opendevstack.apiservice.core.contracts.policy.PolicyRule;
10+
import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition;
11+
import org.opendevstack.apiservice.core.security.filter.AuthTypeEnforcementFilter;
12+
import org.opendevstack.apiservice.core.security.registry.ApiDefinitionResolver;
13+
import org.springframework.security.authorization.AuthorizationManager;
14+
import org.springframework.security.core.Authentication;
15+
import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
16+
import org.springframework.stereotype.Component;
17+
18+
import java.io.IOException;
19+
import java.util.List;
20+
import java.util.Map;
21+
import java.util.Optional;
22+
import java.util.function.Supplier;
23+
24+
/**
25+
* Spring Security AuthorizationManager that delegates authorization decisions to the policy engine.
26+
*/
27+
@Component
28+
public class PolicyAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {
29+
30+
private final PolicyEngine policyEngine;
31+
private final PolicyService policyService;
32+
private final PolicyContextFactory contextFactory;
33+
private final ApiDefinitionResolver resolver;
34+
35+
private final ObjectMapper objectMapper;
36+
37+
public PolicyAuthorizationManager(PolicyEngine policyEngine,
38+
PolicyService policyService,
39+
PolicyContextFactory contextFactory,
40+
ApiDefinitionResolver resolver,
41+
ObjectMapper objectMapper) {
42+
this.policyEngine = policyEngine;
43+
this.policyService = policyService;
44+
this.contextFactory = contextFactory;
45+
this.resolver = resolver;
46+
this.objectMapper = objectMapper;
47+
}
48+
49+
@Override
50+
public org.springframework.security.authorization.AuthorizationDecision check(
51+
Supplier<Authentication> authentication,
52+
RequestAuthorizationContext context) {
53+
54+
HttpServletRequest request = context.getRequest();
55+
56+
Optional<ApiDefinition> apiDef = this.resolver.resolve(request);
57+
58+
if (apiDef.isPresent()) {
59+
request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef.get());
60+
}
61+
62+
// Unknown routes are denied (fail-closed). Public API definitions are allowed.
63+
if (apiDef.isEmpty()) {
64+
return new org.springframework.security.authorization.AuthorizationDecision(false);
65+
}
66+
67+
if (apiDef.get().isPublic()) {
68+
return new org.springframework.security.authorization.AuthorizationDecision(true);
69+
}
70+
71+
PolicyContext policyContext = contextFactory.create(apiDef.get(), request);
72+
policyContext = policyContext.withRequestBody(extractRequestBody(request));
73+
74+
List<PolicyRule> rules = policyService.findPolicies(apiDef.get().getId(), policyContext.getClientId());
75+
76+
AuthorizationDecision decision = policyEngine.evaluate(policyContext, rules);
77+
78+
return new org.springframework.security.authorization.AuthorizationDecision(
79+
decision != AuthorizationDecision.DENY
80+
);
81+
}
82+
83+
private Map<String, Object> extractRequestBody(HttpServletRequest request) {
84+
if (!isJsonWriteRequest(request)) {
85+
return Map.of();
86+
}
87+
88+
try {
89+
byte[] bytes = request.getInputStream().readAllBytes();
90+
if (bytes.length == 0) {
91+
return Map.of();
92+
}
93+
return objectMapper.readValue(bytes, new TypeReference<>() {
94+
});
95+
} catch (IOException ex) {
96+
return Map.of();
97+
}
98+
}
99+
100+
private boolean isJsonWriteRequest(HttpServletRequest request) {
101+
String method = request.getMethod();
102+
if (!"POST".equalsIgnoreCase(method)
103+
&& !"PUT".equalsIgnoreCase(method)
104+
&& !"PATCH".equalsIgnoreCase(method)) {
105+
return false;
106+
}
107+
108+
String contentType = request.getContentType();
109+
return contentType != null && contentType.toLowerCase().startsWith("application/json");
110+
}
111+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package org.opendevstack.apiservice.core.security.authorization;
2+
3+
4+
import jakarta.servlet.http.HttpServletRequest;
5+
import org.opendevstack.apiservice.core.contracts.policy.PolicyContext;
6+
import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition;
7+
import org.opendevstack.apiservice.core.security.jwt.AzureJwtAuthenticationConverter;
8+
import org.springframework.security.oauth2.jwt.Jwt;
9+
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
10+
import org.springframework.security.core.context.SecurityContextHolder;
11+
import org.springframework.stereotype.Component;
12+
13+
import java.util.Map;
14+
15+
@Component
16+
public class PolicyContextFactory {
17+
18+
public PolicyContext create(ApiDefinition apiDefinition, HttpServletRequest request) {
19+
var authentication = SecurityContextHolder.getContext().getAuthentication();
20+
21+
String clientId = null;
22+
String subject = null;
23+
Map<String, Object> claims = Map.of();
24+
25+
if (authentication instanceof JwtAuthenticationToken jwtAuth) {
26+
Jwt jwt = jwtAuth.getToken();
27+
clientId = AzureJwtAuthenticationConverter.extractClientId(jwt);
28+
subject = jwt.getClaimAsString("sub");
29+
claims = jwt.getClaims();
30+
}
31+
32+
return new PolicyContext(clientId, subject, claims, apiDefinition, request);
33+
}
34+
}

0 commit comments

Comments
 (0)