Skip to content

Commit 73b0f1b

Browse files
feature/notification-and-signature-org-validation (#186)
* feat(notification): validate user organization (check that it is admin or has the same org id than the procedure that is being modified) * update version * feat(retry-signature): implement orgId validation * add xivatos * refactor: add BackofficePdp to validate sign, notification and revocation flows * upgrade coverage * upgrade coverage * upgrade coverage * fix backofficePdpImpl package name * refactor BackofficePdpImpl * restore comments, remove and fix logs * remove unused imports * enhance Changelog entry
1 parent ad2086f commit 73b0f1b

File tree

17 files changed

+1132
-846
lines changed

17 files changed

+1132
-846
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [v2.2.2](https://github.com/in2workspace/in2-issuer-api/releases/tag/v2.2.2)
8+
### Changed
9+
- Add org ID validation for notification and async signature flows.
10+
711
## [v2.2.1](https://github.com/in2workspace/in2-issuer-api/releases/tag/v2.2.1)
812
### Added
913
- Add environment variable `sys-admin`, use it instead of constant DEFAULT_ORGANIZATION_NAME, which was used in email templates.

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ plugins {
1212

1313
group = 'es.in2'
1414

15-
version = '2.2.1'
15+
version = '2.2.2'
1616

1717
java {
1818
sourceCompatibility = '17'

src/main/java/es/in2/issuer/backend/backoffice/application/workflow/impl/CredentialStatusWorkflowImpl.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.fasterxml.jackson.databind.JsonNode;
66
import com.fasterxml.jackson.databind.ObjectMapper;
77
import es.in2.issuer.backend.backoffice.application.workflow.CredentialStatusWorkflow;
8+
import es.in2.issuer.backend.backoffice.application.workflow.policies.BackofficePdp;
89
import es.in2.issuer.backend.backoffice.domain.service.CredentialStatusAuthorizationService;
910
import es.in2.issuer.backend.backoffice.domain.service.CredentialStatusService;
1011
import es.in2.issuer.backend.backoffice.domain.exception.InvalidStatusException;
@@ -30,6 +31,7 @@ public class CredentialStatusWorkflowImpl implements CredentialStatusWorkflow {
3031

3132
private final CredentialStatusService credentialStatusService;
3233
private final AccessTokenService accessTokenService;
34+
private final BackofficePdp backofficePdp;
3335
private final CredentialStatusAuthorizationService credentialStatusAuthorizationService;
3436
private final CredentialProcedureService credentialProcedureService;
3537
private final ObjectMapper objectMapper;
@@ -47,7 +49,7 @@ public Flux<String> getCredentialsByListId(String processId, int listId) {
4749
@Override
4850
public Mono<Void> revokeCredential(String processId, String bearerToken, String credentialProcedureId, int listId) {
4951
return accessTokenService.getCleanBearerToken(bearerToken)
50-
.flatMap(token -> credentialStatusAuthorizationService.authorize(processId, token, credentialProcedureId)
52+
.flatMap(token -> backofficePdp.validateRevokeCredential(processId, token, credentialProcedureId)
5153
.then(credentialProcedureService.getCredentialProcedureById(credentialProcedureId))
5254
)
5355
.flatMap(credential -> validateStatus(credential.getCredentialStatus())
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package es.in2.issuer.backend.backoffice.application.workflow.policies;
2+
3+
import reactor.core.publisher.Mono;
4+
5+
public interface BackofficePdp {
6+
7+
Mono<Void> validateSignCredential(String processId, String token, String credentialProcedureId);
8+
9+
Mono<Void> validateRevokeCredential(String processId, String token, String credentialProcedureId);
10+
11+
Mono<Void> validateSendReminder(String processId, String token, String credentialProcedureId);
12+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package es.in2.issuer.backend.backoffice.application.workflow.policies.impl;
2+
3+
import com.nimbusds.jose.Payload;
4+
import com.nimbusds.jwt.SignedJWT;
5+
import es.in2.issuer.backend.backoffice.application.workflow.policies.BackofficePdp;
6+
import es.in2.issuer.backend.shared.domain.exception.JWTParsingException;
7+
import es.in2.issuer.backend.shared.domain.exception.UnauthorizedRoleException;
8+
import es.in2.issuer.backend.shared.domain.service.JWTService;
9+
import es.in2.issuer.backend.shared.domain.util.factory.LEARCredentialEmployeeFactory;
10+
import es.in2.issuer.backend.shared.infrastructure.config.AppConfig;
11+
import es.in2.issuer.backend.shared.infrastructure.repository.CredentialProcedureRepository;
12+
import lombok.RequiredArgsConstructor;
13+
import lombok.extern.slf4j.Slf4j;
14+
import org.springframework.stereotype.Service;
15+
import reactor.core.publisher.Mono;
16+
17+
import java.text.ParseException;
18+
import java.util.UUID;
19+
20+
import static es.in2.issuer.backend.backoffice.domain.util.Constants.LEAR;
21+
import static es.in2.issuer.backend.backoffice.domain.util.Constants.ROLE;
22+
import static es.in2.issuer.backend.backoffice.domain.util.Constants.VC;
23+
24+
@Service
25+
@Slf4j
26+
@RequiredArgsConstructor
27+
public class BackofficePdpImpl implements BackofficePdp {
28+
29+
private final AppConfig appConfig;
30+
private final JWTService jwtService;
31+
private final LEARCredentialEmployeeFactory learCredentialEmployeeFactory;
32+
private final CredentialProcedureRepository credentialProcedureRepository;
33+
34+
@Override
35+
public Mono<Void> validateSignCredential(String processId, String token, String credentialProcedureId) {
36+
log.info("Validating 'sign' action for processId={} and credentialProcedureId={}", processId, credentialProcedureId);
37+
return validateCommon(token, credentialProcedureId);
38+
}
39+
40+
@Override
41+
public Mono<Void> validateRevokeCredential(String processId, String token, String credentialProcedureId) {
42+
log.info("Validating 'revoke' action for processId={} and credentialProcedureId={}", processId, credentialProcedureId);
43+
return validateCommon(token, credentialProcedureId);
44+
}
45+
46+
@Override
47+
public Mono<Void> validateSendReminder(String processId, String token, String credentialProcedureId) {
48+
log.info("Validating 'send reminder' action for processId={} and credentialProcedureId={}", processId, credentialProcedureId);
49+
return validateCommon(token, credentialProcedureId);
50+
}
51+
52+
private Mono<Void> validateCommon(String token, String credentialProcedureId) {
53+
return parseTokenAndValidateRole(token)
54+
.flatMap(signedJWT ->
55+
extractUserOrganizationIdentifier(signedJWT)
56+
.flatMap(userOrg -> {
57+
58+
if (isSysAdmin(userOrg)) {
59+
log.info("User belongs to admin organization. Skipping DB lookup.");
60+
return Mono.empty();
61+
}
62+
63+
return credentialProcedureRepository.findById(UUID.fromString(credentialProcedureId))
64+
.flatMap(credentialProcedure ->
65+
matchUserAndCredentialOrganization(
66+
userOrg,
67+
credentialProcedure.getOrganizationIdentifier()
68+
)
69+
);
70+
})
71+
);
72+
}
73+
74+
private Mono<SignedJWT> parseTokenAndValidateRole(String token) {
75+
return parseToken(token)
76+
.flatMap(signedJWT ->
77+
extractRole(signedJWT)
78+
.flatMap(this::ensureRoleIsLear)
79+
.thenReturn(signedJWT)
80+
);
81+
}
82+
83+
private Mono<SignedJWT> parseToken(String token) {
84+
return Mono.fromCallable(() -> jwtService.parseJWT(token));
85+
}
86+
87+
private Mono<String> extractRole(SignedJWT signedJWT) {
88+
try {
89+
String role = (String) signedJWT.getJWTClaimsSet().getClaim(ROLE);
90+
log.info("Extracted role: {}", role);
91+
return Mono.just(role);
92+
} catch (ParseException e) {
93+
return Mono.error(new JWTParsingException(e.getMessage()));
94+
}
95+
}
96+
97+
private Mono<String> extractUserOrganizationIdentifier(SignedJWT signedJWT) {
98+
Payload payload = signedJWT.getPayload();
99+
String vcClaim = jwtService.getClaimFromPayload(payload, VC);
100+
log.debug("VC claim: {}", vcClaim);
101+
102+
// TODO: Adapt to all credential types if needed
103+
String userOrganizationIdentifier =
104+
learCredentialEmployeeFactory
105+
.mapStringToLEARCredentialEmployee(vcClaim)
106+
.credentialSubject()
107+
.mandate()
108+
.mandator()
109+
.organizationIdentifier();
110+
111+
log.info("User organization identifier: {}", userOrganizationIdentifier);
112+
return Mono.just(userOrganizationIdentifier);
113+
}
114+
115+
private boolean isSysAdmin(String userOrganizationIdentifier) {
116+
return userOrganizationIdentifier.equals(appConfig.getAdminOrganizationId());
117+
}
118+
119+
private Mono<Void> matchUserAndCredentialOrganization(String userOrganizationIdentifier,
120+
String credentialOrganizationIdentifier) {
121+
122+
if (userOrganizationIdentifier.equals(credentialOrganizationIdentifier)) {
123+
return Mono.empty();
124+
}
125+
126+
return Mono.error(
127+
new UnauthorizedRoleException("Access denied: Unauthorized organization identifier")
128+
);
129+
}
130+
131+
private Mono<Void> ensureRoleIsLear(String role) {
132+
if (!LEAR.equals(role)) {
133+
return Mono.error(new UnauthorizedRoleException(
134+
"Access denied: Unauthorized role to perform this credential action"));
135+
}
136+
return Mono.empty();
137+
}
138+
}

src/main/java/es/in2/issuer/backend/backoffice/domain/service/NotificationService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
import reactor.core.publisher.Mono;
44

55
public interface NotificationService {
6-
Mono<Void> sendNotification(String processId,String procedureId);
6+
Mono<Void> sendNotification(String processId,String procedureId, String token);
77
}
Lines changed: 47 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,72 @@
11
package es.in2.issuer.backend.backoffice.domain.service.impl;
22

3-
3+
import es.in2.issuer.backend.backoffice.application.workflow.policies.BackofficePdp;
44
import es.in2.issuer.backend.backoffice.domain.service.NotificationService;
55
import es.in2.issuer.backend.shared.domain.exception.EmailCommunicationException;
6-
import es.in2.issuer.backend.shared.domain.service.CredentialProcedureService;
7-
import es.in2.issuer.backend.shared.domain.service.DeferredCredentialMetadataService;
8-
import es.in2.issuer.backend.shared.domain.service.EmailService;
9-
import es.in2.issuer.backend.shared.domain.service.TranslationService;
6+
import es.in2.issuer.backend.shared.domain.service.*;
107
import es.in2.issuer.backend.shared.infrastructure.config.AppConfig;
118
import lombok.RequiredArgsConstructor;
129
import lombok.extern.slf4j.Slf4j;
10+
import org.springframework.http.HttpStatus;
11+
import org.springframework.security.access.AccessDeniedException;
1312
import org.springframework.stereotype.Service;
13+
import org.springframework.web.server.ResponseStatusException;
1414
import reactor.core.publisher.Mono;
1515

1616
import static es.in2.issuer.backend.backoffice.domain.util.Constants.*;
17-
import static es.in2.issuer.backend.shared.domain.model.enums.CredentialStatusEnum.*;
1817

1918
@Slf4j
2019
@Service
2120
@RequiredArgsConstructor
2221
public class NotificationServiceImpl implements NotificationService {
2322

2423
private final AppConfig appConfig;
24+
private final AccessTokenService accessTokenService;
25+
private final BackofficePdp backofficePdp;
2526
private final EmailService emailService;
2627
private final CredentialProcedureService credentialProcedureService;
2728
private final DeferredCredentialMetadataService deferredCredentialMetadataService;
28-
private final TranslationService translationService;
2929

3030
@Override
31-
public Mono<Void> sendNotification(String processId, String procedureId) {
31+
public Mono<Void> sendNotification(String processId, String procedureId, String bearerToken) {
3232
// TODO this flow doesn't udpate the credential procedure, but we should consider updating the "udpated_by" field for auditing and maybe have the last person to send a reminder to receive the failed signature email
33-
return credentialProcedureService.getCredentialProcedureById(procedureId)
34-
.flatMap(credentialProcedure -> credentialProcedureService.getCredentialOfferEmailInfoByProcedureId(procedureId)
35-
.flatMap(emailCredentialOfferInfo -> {
36-
// TODO we need to remove the withdraw status from the condition since the v1.2.0 version is deprecated but in order to support retro compatibility issues we will keep it for now.
37-
if (credentialProcedure.getCredentialStatus().toString().equals(DRAFT.toString()) || credentialProcedure.getCredentialStatus().toString().equals(WITHDRAWN.toString())) {
38-
return deferredCredentialMetadataService.updateTransactionCodeInDeferredCredentialMetadata(procedureId)
39-
.flatMap(newTransactionCode -> emailService.sendCredentialActivationEmail(
40-
emailCredentialOfferInfo.email(),
41-
CREDENTIAL_ACTIVATION_EMAIL_SUBJECT,
42-
appConfig.getIssuerFrontendUrl() + "/credential-offer?transaction_code=" + newTransactionCode,
43-
appConfig.getKnowledgebaseWalletUrl(),
44-
emailCredentialOfferInfo.organization()
45-
))
46-
.onErrorMap(exception ->
47-
new EmailCommunicationException(MAIL_ERROR_COMMUNICATION_EXCEPTION_MESSAGE));
48-
} else if (credentialProcedure.getCredentialStatus().toString().equals(PEND_DOWNLOAD.toString())) {
49-
50-
return emailService.sendCredentialSignedNotification(credentialProcedure.getEmail(), CREDENTIAL_READY, "email.you-can-use-wallet");
51-
} else {
52-
return Mono.empty();
53-
}
54-
}
55-
)
56-
);
57-
}
33+
log.info("sendNotification processId={} organizationId={} token={}", processId, bearerToken, procedureId);
34+
35+
return accessTokenService.getCleanBearerToken(bearerToken)
36+
.flatMap(token -> backofficePdp.validateSendReminder(processId, token, procedureId)
37+
.then(credentialProcedureService.getCredentialProcedureById(procedureId))
38+
)
39+
.zipWhen(credentialProcedure -> credentialProcedureService.getCredentialOfferEmailInfoByProcedureId(procedureId))
40+
.flatMap(tuple -> {
41+
final var credentialProcedure = tuple.getT1();
42+
final var emailInfo = tuple.getT2();
5843

59-
}
44+
return switch (credentialProcedure.getCredentialStatus()) {
45+
// TODO we need to remove the withdraw status from the condition since the v1.2.0 version is deprecated but in order to support retro compatibility issues we will keep it for now.
46+
case DRAFT, WITHDRAWN ->
47+
deferredCredentialMetadataService
48+
.updateTransactionCodeInDeferredCredentialMetadata(procedureId)
49+
.flatMap(newTransactionCode ->
50+
emailService.sendCredentialActivationEmail(
51+
emailInfo.email(),
52+
CREDENTIAL_ACTIVATION_EMAIL_SUBJECT,
53+
appConfig.getIssuerFrontendUrl() + "/credential-offer?transaction_code=" + newTransactionCode,
54+
appConfig.getKnowledgebaseWalletUrl(),
55+
emailInfo.organization()
56+
)
57+
)
58+
.onErrorMap(ex -> new EmailCommunicationException(MAIL_ERROR_COMMUNICATION_EXCEPTION_MESSAGE));
59+
60+
case PEND_DOWNLOAD ->
61+
emailService.sendCredentialSignedNotification(
62+
credentialProcedure.getEmail(),
63+
CREDENTIAL_READY,
64+
"email.you-can-use-wallet"
65+
);
66+
67+
default -> Mono.empty();
68+
};
69+
})
70+
.then();
71+
}
72+
}

src/main/java/es/in2/issuer/backend/backoffice/infrastructure/controller/NotificationController.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import org.springframework.http.MediaType;
88
import org.springframework.web.bind.annotation.*;
99
import reactor.core.publisher.Mono;
10+
import org.springframework.http.HttpHeaders;
1011

1112
import java.util.UUID;
1213

@@ -20,8 +21,8 @@ public class NotificationController {
2021

2122
@PostMapping(value = "/{procedure_id}", produces = MediaType.APPLICATION_JSON_VALUE)
2223
@ResponseStatus(HttpStatus.NO_CONTENT)
23-
public Mono<Void> sendEmailNotification(@PathVariable("procedure_id") String procedureId) {
24+
public Mono<Void> sendEmailNotification(@RequestHeader(HttpHeaders.AUTHORIZATION) String bearerToken, @PathVariable("procedure_id") String procedureId) {
2425
String processId = UUID.randomUUID().toString();
25-
return notificationService.sendNotification(processId, procedureId);
26+
return notificationService.sendNotification(processId, procedureId, bearerToken);
2627
}
2728
}
Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package es.in2.issuer.backend.backoffice.infrastructure.controller;
22

33
import es.in2.issuer.backend.shared.application.workflow.CredentialSignerWorkflow;
4-
import es.in2.issuer.backend.shared.domain.service.AccessTokenService;
54
import lombok.RequiredArgsConstructor;
65
import lombok.extern.slf4j.Slf4j;
76
import org.springframework.http.HttpHeaders;
@@ -10,28 +9,28 @@
109
import org.springframework.web.bind.annotation.*;
1110
import reactor.core.publisher.Mono;
1211

12+
import java.util.UUID;
13+
1314
@Slf4j
1415
@RestController
1516
@RequestMapping("/backoffice/v1/retry-sign-credential")
1617
@RequiredArgsConstructor
1718
public class SignUnsignedCredentialController {
1819

1920
private final CredentialSignerWorkflow credentialSignerWorkflow;
20-
private final AccessTokenService accessTokenService;
2121

2222
@PostMapping(value = "/{procedure_id}", produces = MediaType.APPLICATION_JSON_VALUE)
2323
@ResponseStatus(HttpStatus.CREATED)
2424
public Mono<Void> signUnsignedCredential(
2525
@RequestHeader(HttpHeaders.AUTHORIZATION) String authorizationHeader,
2626
@PathVariable("procedure_id") String procedureId) {
2727

28-
return accessTokenService
29-
.getCleanBearerToken(authorizationHeader)
30-
.flatMap(token ->
31-
accessTokenService.getMandateeEmail(token)
32-
.flatMap(email ->
33-
credentialSignerWorkflow.retrySignUnsignedCredential(token, procedureId, email)
34-
)
35-
);
28+
String processId = UUID.randomUUID().toString();
29+
30+
return credentialSignerWorkflow.retrySignUnsignedCredential(
31+
processId,
32+
authorizationHeader,
33+
procedureId
34+
);
3635
}
3736
}

src/main/java/es/in2/issuer/backend/shared/application/workflow/CredentialSignerWorkflow.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@
55
public interface CredentialSignerWorkflow {
66
Mono<String> signAndUpdateCredentialByProcedureId(String authorizationHeader, String procedureId, String format);
77

8-
Mono<Void> retrySignUnsignedCredential(String authorizationHeader, String procedureId, String email);
8+
Mono<Void> retrySignUnsignedCredential(String processId, String authorizationHeader, String procedureId);
99
}

0 commit comments

Comments
 (0)