diff --git a/.gitignore b/.gitignore index a5d727fb..2a5c464d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ .idea/ *.iml .DS_Store +dbaas/dbaas-aggregator/src/test/resources/mock-oidc-token/ diff --git a/dbaas/dbaas-aggregator/pom.xml b/dbaas/dbaas-aggregator/pom.xml index 3333344b..70725816 100644 --- a/dbaas/dbaas-aggregator/pom.xml +++ b/dbaas/dbaas-aggregator/pom.xml @@ -194,6 +194,20 @@ shedlock-provider-jdbc-template ${shedlock.version} + + io.quarkus + quarkus-smallrye-jwt + + + io.quarkus + quarkus-smallrye-jwt-build + + + com.netcracker.cloud.security.core.utils + k8s-utils + 2.2.2 + + com.fasterxml.jackson.datatype jackson-datatype-jsr310 @@ -239,6 +253,11 @@ 1.21.4 test + + io.quarkus + quarkus-test-oidc-server + test + diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/config/security/BasicAndKubernetesAuthMechanism.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/config/security/BasicAndKubernetesAuthMechanism.java new file mode 100644 index 00000000..94eea06d --- /dev/null +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/config/security/BasicAndKubernetesAuthMechanism.java @@ -0,0 +1,66 @@ +package com.netcracker.cloud.dbaas.config.security; + +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.AuthenticationRequest; +import io.quarkus.smallrye.jwt.runtime.auth.JWTAuthMechanism; +import io.quarkus.vertx.http.runtime.security.BasicAuthenticationMechanism; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.quarkus.vertx.http.runtime.security.HttpCredentialTransport; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import lombok.extern.slf4j.Slf4j; + +import java.util.HashSet; +import java.util.Set; + +@Priority(1) +@ApplicationScoped +@Slf4j +public class BasicAndKubernetesAuthMechanism implements HttpAuthenticationMechanism { + @Inject + BasicAuthenticationMechanism basicAuth; + + @Inject + JWTAuthMechanism jwtAuth; + + @Override + public Uni authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) { + return selectMechanism(context).authenticate(context, identityProviderManager); + } + + @Override + public Uni getChallenge(RoutingContext context) { + return selectMechanism(context).getChallenge(context); + } + + @Override + public Set> getCredentialTypes() { + Set> types = new HashSet<>(); + types.addAll(basicAuth.getCredentialTypes()); + types.addAll(jwtAuth.getCredentialTypes()); + return types; + } + + @Override + public Uni getCredentialTransport(RoutingContext context) { + return selectMechanism(context).getCredentialTransport(context); + } + + private HttpAuthenticationMechanism selectMechanism(RoutingContext context) { + if (isBearerTokenPresent(context)) { + return jwtAuth; + } else { + return basicAuth; + } + } + + private boolean isBearerTokenPresent(RoutingContext context) { + String authHeader = context.request().getHeader("Authorization"); + return authHeader != null && authHeader.startsWith("Bearer "); + } +} diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/config/security/KubernetesJWTCallerPrincipalFactory.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/config/security/KubernetesJWTCallerPrincipalFactory.java new file mode 100644 index 00000000..8e9d28a9 --- /dev/null +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/config/security/KubernetesJWTCallerPrincipalFactory.java @@ -0,0 +1,60 @@ +package com.netcracker.cloud.dbaas.config.security; + +import com.netcracker.cloud.security.core.utils.k8s.KubernetesTokenVerificationException; +import com.netcracker.cloud.security.core.utils.k8s.KubernetesTokenVerifier; +import io.quarkus.runtime.StartupEvent; +import io.smallrye.jwt.auth.principal.*; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.Alternative; +import jakarta.inject.Inject; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +@ApplicationScoped +@Alternative +@Priority(1) +@Slf4j +public class KubernetesJWTCallerPrincipalFactory extends JWTCallerPrincipalFactory { + + private final KubernetesTokenVerifier verifier; + + @Inject + public KubernetesJWTCallerPrincipalFactory( + @ConfigProperty(name = "dbaas.security.k8s.jwt.enabled") boolean jwtEnabled, + @ConfigProperty(name = "dbaas.security.k8s.jwt.audience") String jwtAudience + ) { + if (!jwtEnabled) { + log.info("JWT not enabled, skipping verifier initialization"); + this.verifier = null; + return; + } + + log.info("Initializing KubernetesTokenVerifier"); + try { + this.verifier = new KubernetesTokenVerifier(jwtAudience); + log.info("KubernetesTokenVerifier initialized successfully"); + } catch (RuntimeException e) { + log.error("Failed to initialize KubernetesTokenVerifier", e); + throw e; + } + } + + KubernetesJWTCallerPrincipalFactory(KubernetesTokenVerifier verifier) { + this.verifier = verifier; + } + + @Override + public JWTCallerPrincipal parse(String token, JWTAuthContextInfo authContextInfo) throws ParseException { + try { + return new DefaultJWTCallerPrincipal(verifier.verify(token)); + } catch (KubernetesTokenVerificationException e) { + throw new ParseException("failed to parse kubernetes projected volume token", e); + } + } + + // observe StartupEvent so that bean is created at startup + void onStartUp(@Observes StartupEvent event) { + } +} diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/controller/error/ForbiddenDeleteBackupOperationExceptionMapper.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/controller/error/ForbiddenDeleteBackupOperationExceptionMapper.java deleted file mode 100644 index a76350c8..00000000 --- a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/controller/error/ForbiddenDeleteBackupOperationExceptionMapper.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.netcracker.cloud.dbaas.controller.error; - -import com.netcracker.cloud.dbaas.exceptions.ForbiddenDeleteBackupOperationException; -import jakarta.ws.rs.core.Context; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.UriInfo; -import jakarta.ws.rs.ext.ExceptionMapper; -import jakarta.ws.rs.ext.Provider; - -import static com.netcracker.cloud.dbaas.controller.error.Utils.buildDefaultResponse; - -@Provider -public class ForbiddenDeleteBackupOperationExceptionMapper implements ExceptionMapper { - - @Context - UriInfo uriInfo; - - @Override - public Response toResponse(ForbiddenDeleteBackupOperationException e) { - return buildDefaultResponse(uriInfo, e, Response.Status.FORBIDDEN); - } -} diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/controller/error/ForbiddenDeleteOperationExceptionMapper.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/controller/error/ForbiddenExceptionMapper.java similarity index 63% rename from dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/controller/error/ForbiddenDeleteOperationExceptionMapper.java rename to dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/controller/error/ForbiddenExceptionMapper.java index 89b383f1..cfa7cb3a 100644 --- a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/controller/error/ForbiddenDeleteOperationExceptionMapper.java +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/controller/error/ForbiddenExceptionMapper.java @@ -1,22 +1,21 @@ package com.netcracker.cloud.dbaas.controller.error; -import com.netcracker.cloud.dbaas.exceptions.ForbiddenDeleteOperationException; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriInfo; import jakarta.ws.rs.ext.ExceptionMapper; import jakarta.ws.rs.ext.Provider; +import com.netcracker.cloud.dbaas.exceptions.ForbiddenException; import static com.netcracker.cloud.dbaas.controller.error.Utils.buildDefaultResponse; @Provider -public class ForbiddenDeleteOperationExceptionMapper implements ExceptionMapper { - +public class ForbiddenExceptionMapper implements ExceptionMapper { @Context UriInfo uriInfo; @Override - public Response toResponse(ForbiddenDeleteOperationException e) { + public Response toResponse(ForbiddenException e) { return buildDefaultResponse(uriInfo, e, Response.Status.FORBIDDEN); } } diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/controller/v3/AggregatedDatabaseAdministrationControllerV3.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/controller/v3/AggregatedDatabaseAdministrationControllerV3.java index 6623700a..537887ef 100644 --- a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/controller/v3/AggregatedDatabaseAdministrationControllerV3.java +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/controller/v3/AggregatedDatabaseAdministrationControllerV3.java @@ -1,5 +1,6 @@ package com.netcracker.cloud.dbaas.controller.v3; +import com.netcracker.cloud.context.propagation.core.ContextManager; import com.netcracker.cloud.dbaas.controller.abstact.AbstractDatabaseAdministrationController; import com.netcracker.cloud.dbaas.dto.ClassifierWithRolesRequest; import com.netcracker.cloud.dbaas.dto.Source; @@ -12,13 +13,19 @@ import com.netcracker.cloud.dbaas.exceptions.*; import com.netcracker.cloud.dbaas.exceptions.NotFoundException; import com.netcracker.cloud.dbaas.monitoring.model.DatabasesInfo; +import com.netcracker.cloud.dbaas.security.validators.NamespaceValidator; import com.netcracker.cloud.dbaas.service.*; +import com.netcracker.cloud.dbaas.utils.JwtUtils; +import com.netcracker.cloud.framework.contexts.tenant.BaseTenantProvider; +import com.netcracker.cloud.framework.contexts.tenant.TenantContextObject; +import io.smallrye.jwt.auth.principal.DefaultJWTCallerPrincipal; import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; import lombok.extern.slf4j.Slf4j; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; @@ -64,6 +71,10 @@ public class AggregatedDatabaseAdministrationControllerV3 extends AbstractDataba PasswordEncryption encryption; @Inject BlueGreenService blueGreenService; + @Inject + NamespaceValidator namespaceValidator; + @Inject + SecurityContext securityContext; @Operation(summary = "V3. Creates new database V3", description = "Creates new database and returns it with connection information, " + @@ -85,11 +96,11 @@ public Response createDatabase(@Parameter(description = "The model for creating @PathParam(NAMESPACE_PARAMETER) String namespace, @Parameter(description = "Determines if database should be created asynchronously") @QueryParam(ASYNC_PARAMETER) Boolean async) { - if (!AggregatedDatabaseAdministrationService.AggregatedDatabaseAdministrationUtils.isClassifierCorrect(createRequest.getClassifier())) { - throw new InvalidClassifierException("Classifier doesn't contain all mandatory fields. " + - "Check that classifier has `microserviceName`, `scope`. If `scope` = `tenant`, classifier must contain `tenantId` property", - createRequest.getClassifier(), Source.builder().pointer("/classifier").build()); + if (!AggregatedDatabaseAdministrationService.AggregatedDatabaseAdministrationUtils.isClassifierCorrect(createRequest.getClassifier()) || + !namespaceValidator.checkNamespaceFromClassifier(securityContext, createRequest.getClassifier())) { + throw InvalidClassifierException.withDefaultMsg(createRequest.getClassifier()); } + checkTenantId(createRequest.getClassifier()); checkOriginService(createRequest); namespace = (String) createRequest.getClassifier().get(NAMESPACE); @@ -198,10 +209,10 @@ public Response getDatabaseByClassifier(@Parameter(description = "Classifier on @PathParam(NAMESPACE_PARAMETER) String namespace, @Parameter(description = "The type of base in which the database was created. For example PostgreSQL or MongoDB", required = true) @PathParam("type") String type) { - checkOriginService(classifierRequest); - if (!dBaaService.isValidClassifierV3(classifierRequest.getClassifier())) { + if (!dBaaService.isValidClassifierV3(classifierRequest.getClassifier()) || !namespaceValidator.checkNamespaceFromClassifier(securityContext, classifierRequest.getClassifier())) { throw new InvalidClassifierException("Invalid V3 classifier", classifierRequest.getClassifier(), Source.builder().pointer("").build()); } + checkTenantId(classifierRequest.getClassifier()); checkOriginService(classifierRequest); namespace = (String) classifierRequest.getClassifier().get(NAMESPACE); @@ -285,11 +296,10 @@ public Response addExternalDatabase(@Parameter(description = "Request with conne @Parameter(description = "Namespace with which new database will be connected", required = true) @PathParam(NAMESPACE_PARAMETER) String namespace) { log.info("Get request on adding external database with classifier {} and type {} in namespace {}", externalDatabaseRequest.getClassifier(), externalDatabaseRequest.getType(), namespace); - if (!AggregatedDatabaseAdministrationService.AggregatedDatabaseAdministrationUtils.isClassifierCorrect(externalDatabaseRequest.getClassifier())) { - throw new InvalidClassifierException("Classifier doesn't contain all mandatory fields. " + - "Check that classifier has `microserviceName`, `scope`. If `scope` = `tenant`, classifier must contain `tenantId` property", - externalDatabaseRequest.getClassifier(), Source.builder().pointer("/classifier").build()); + if (!AggregatedDatabaseAdministrationService.AggregatedDatabaseAdministrationUtils.isClassifierCorrect(externalDatabaseRequest.getClassifier()) || !namespaceValidator.checkNamespaceFromClassifier(securityContext, externalDatabaseRequest.getClassifier())) { + throw InvalidClassifierException.withDefaultMsg(externalDatabaseRequest.getClassifier()); } + checkTenantId(externalDatabaseRequest.getClassifier()); DatabaseRegistry databaseRegistry = externalDatabaseRequest.toDatabaseRegistry(); Optional bgDomainOpt = aggregatedDatabaseAdministrationService.getBgDomain(namespace); if (bgDomainOpt.isPresent()) { @@ -403,9 +413,23 @@ public Response deleteDatabaseByClassifier(@Parameter(description = "A unique id } private void checkOriginService(UserRolesServices rolesServices) { + if (securityContext.getUserPrincipal() instanceof DefaultJWTCallerPrincipal principal) { + rolesServices.setOriginService(JwtUtils.getServiceAccountName(principal)); + } if (rolesServices.getOriginService() == null || rolesServices.getOriginService().isEmpty()) { log.error("Request body={} must contain originService", rolesServices); throw new InvalidOriginServiceException(); } } + + private void checkTenantId(Map classifier) { + if (!Objects.equals(classifier.get(SCOPE), SCOPE_VALUE_TENANT)) { + return; + } + String tenantId = ((TenantContextObject) ContextManager.get(BaseTenantProvider.TENANT_CONTEXT_NAME)).getTenant(); + if (tenantId.equals(classifier.get(TENANT_ID))) { + return; + } + throw new ForbiddenTenantIdException(); + } } diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/dto/ClassifierWithRolesRequest.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/dto/ClassifierWithRolesRequest.java index 1a3b8cab..74e87f49 100644 --- a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/dto/ClassifierWithRolesRequest.java +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/dto/ClassifierWithRolesRequest.java @@ -11,7 +11,7 @@ public class ClassifierWithRolesRequest implements UserRolesServices { @Schema(description = "Database composite identify key. See details in https://perch.qubership.org/display/CLOUDCORE/DbaaS+Database+Classifier", required = true) private Map classifier; - @Schema(description = "Origin service which send request", required = true) + @Schema(description = "Origin service which send request") private String originService; @Schema(description = "Indicates connection properties with which user role should be returned to a client") diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/dto/v3/DatabaseCreateRequestV3.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/dto/v3/DatabaseCreateRequestV3.java index d9517b16..5c6730c7 100644 --- a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/dto/v3/DatabaseCreateRequestV3.java +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/dto/v3/DatabaseCreateRequestV3.java @@ -18,7 +18,7 @@ public DatabaseCreateRequestV3(@NonNull Map classifier, @NonNull super(classifier, type); } - @Schema(description = "Origin service which send request", required = true) + @Schema(description = "Origin service which send request") private String originService; @Schema(description = "Indicates connection properties with which user role should be returned to a client") diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/dto/v3/UserRolesServices.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/dto/v3/UserRolesServices.java index 7c9aee52..01b47e64 100644 --- a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/dto/v3/UserRolesServices.java +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/dto/v3/UserRolesServices.java @@ -5,6 +5,8 @@ public interface UserRolesServices { String getOriginService(); + void setOriginService(String originService); + String getUserRole(); Map getClassifier(); diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/ErrorCodes.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/ErrorCodes.java index ef079558..8d95010d 100644 --- a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/ErrorCodes.java +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/ErrorCodes.java @@ -242,6 +242,16 @@ public enum ErrorCodes implements ErrorCode { "Digest mismatch", "Digest header mismatch: %s" ), + CORE_DBAAS_4053( + "CORE-DBAAS-4053", + "Invalid tenantId in classifier", + "tenantId from classifier and tenantId from request don't match"), + CORE_DBAAS_4054( + "CORE-DBAAS-4054", + "Failed namespace isolation check", + "Namespace from path and namespace from jwt token doesn't not match or aren't in the same composite structure"), + + CORE_DBAAS_7002( "CORE-DBAAS-7002", "trackingId not found", diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/FailedNamespaceIsolationCheckException.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/FailedNamespaceIsolationCheckException.java new file mode 100644 index 00000000..df627ca9 --- /dev/null +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/FailedNamespaceIsolationCheckException.java @@ -0,0 +1,7 @@ +package com.netcracker.cloud.dbaas.exceptions; + +public class FailedNamespaceIsolationCheckException extends ForbiddenException { + public FailedNamespaceIsolationCheckException() { + super(ErrorCodes.CORE_DBAAS_4054, ErrorCodes.CORE_DBAAS_4054.getDetail()); + } +} diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/ForbiddenDeleteBackupOperationException.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/ForbiddenDeleteBackupOperationException.java index 85f4f14a..cf4817da 100644 --- a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/ForbiddenDeleteBackupOperationException.java +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/ForbiddenDeleteBackupOperationException.java @@ -4,7 +4,7 @@ import lombok.Getter; @Getter -public class ForbiddenDeleteBackupOperationException extends ErrorCodeException { +public class ForbiddenDeleteBackupOperationException extends ForbiddenException { public ForbiddenDeleteBackupOperationException(String detail) { super(ErrorCodes.CORE_DBAAS_4013, ErrorCodes.CORE_DBAAS_4013.getDetail(detail)); diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/ForbiddenDeleteOperationException.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/ForbiddenDeleteOperationException.java index 8a60578a..cf83c27a 100644 --- a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/ForbiddenDeleteOperationException.java +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/ForbiddenDeleteOperationException.java @@ -4,7 +4,7 @@ import lombok.Getter; @Getter -public class ForbiddenDeleteOperationException extends ErrorCodeException { +public class ForbiddenDeleteOperationException extends ForbiddenException { public ForbiddenDeleteOperationException() { super(ErrorCodes.CORE_DBAAS_4003, ErrorCodes.CORE_DBAAS_4003.getDetail()); diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/ForbiddenException.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/ForbiddenException.java new file mode 100644 index 00000000..58e5542a --- /dev/null +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/ForbiddenException.java @@ -0,0 +1,10 @@ +package com.netcracker.cloud.dbaas.exceptions; + +import com.netcracker.cloud.core.error.runtime.ErrorCode; +import com.netcracker.cloud.core.error.runtime.ErrorCodeException; + +public class ForbiddenException extends ErrorCodeException { + public ForbiddenException(ErrorCode errorCode, String detail) { + super(errorCode, detail); + } +} diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/ForbiddenTenantIdException.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/ForbiddenTenantIdException.java new file mode 100644 index 00000000..1af2f6a9 --- /dev/null +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/ForbiddenTenantIdException.java @@ -0,0 +1,7 @@ +package com.netcracker.cloud.dbaas.exceptions; + +public class ForbiddenTenantIdException extends ForbiddenException { + public ForbiddenTenantIdException() { + super(ErrorCodes.CORE_DBAAS_4053, ErrorCodes.CORE_DBAAS_4053.getDetail()); + } +} diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/InvalidClassifierException.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/InvalidClassifierException.java index eead1edd..1e675589 100644 --- a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/InvalidClassifierException.java +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/exceptions/InvalidClassifierException.java @@ -10,4 +10,11 @@ public class InvalidClassifierException extends ValidationException { public InvalidClassifierException(String detail, Map classifier, Source source) { super(ErrorCodes.CORE_DBAAS_4010, ErrorCodes.CORE_DBAAS_4010.getDetail(detail, classifier), source); } + + public static InvalidClassifierException withDefaultMsg(Map classifier) { + return new InvalidClassifierException("Classifier doesn't contain all mandatory fields. " + + "If authenticating with token, namespace in classifier and in token must be equal or be in the same composite structure. " + + "Check that classifier has `microserviceName`, `scope`. If `scope` = `tenant`, classifier must contain `tenantId` property", + classifier, Source.builder().pointer("/classifier").build()); + } } diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/rest/DbaasAdapterRestClientV2.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/rest/DbaasAdapterRestClientV2.java index eefac421..29f16db5 100644 --- a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/rest/DbaasAdapterRestClientV2.java +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/rest/DbaasAdapterRestClientV2.java @@ -110,7 +110,6 @@ LogicalRestoreAdapterResponse trackRestoreV2( @QueryParam("blobPath") String blobPath ); - @DELETE @Path("/api/v2/dbaas/adapter/{type}/backups/{backupId}") String deleteBackup(@PathParam("type") String type, @PathParam("backupId") String backupId); diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/rest/SecureDbaasAdapterRestClientV2.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/rest/SecureDbaasAdapterRestClientV2.java new file mode 100644 index 00000000..9070c38b --- /dev/null +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/rest/SecureDbaasAdapterRestClientV2.java @@ -0,0 +1,195 @@ +package com.netcracker.cloud.dbaas.rest; + +import com.netcracker.cloud.dbaas.dto.*; +import com.netcracker.cloud.dbaas.dto.v3.CreatedDatabaseV3; +import com.netcracker.cloud.dbaas.dto.v3.GetOrCreateUserAdapterRequest; +import com.netcracker.cloud.dbaas.dto.v3.UserEnsureRequestV3; +import com.netcracker.cloud.dbaas.entity.dto.backupV2.BackupAdapterRequest; +import com.netcracker.cloud.dbaas.entity.dto.backupV2.LogicalBackupAdapterResponse; +import com.netcracker.cloud.dbaas.entity.dto.backupV2.LogicalRestoreAdapterResponse; +import com.netcracker.cloud.dbaas.entity.dto.backupV2.RestoreAdapterRequest; +import com.netcracker.cloud.dbaas.entity.pg.DbResource; +import com.netcracker.cloud.dbaas.entity.pg.backup.TrackedAction; +import com.netcracker.cloud.dbaas.monitoring.AdapterHealthStatus; +import com.netcracker.cloud.dbaas.security.filters.AuthFilterSelector; +import com.netcracker.cloud.dbaas.security.filters.BasicAuthFilter; +import com.netcracker.cloud.dbaas.security.filters.KubernetesTokenAuthFilter; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; + +import java.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +public class SecureDbaasAdapterRestClientV2 implements DbaasAdapterRestClientV2 { + private final boolean isJwtEnabled; + + private final BasicAuthFilter basicAuthFilter; + private final KubernetesTokenAuthFilter kubernetesTokenAuthFilter; + + private final DbaasAdapterRestClientV2 restClient; + private final AuthFilterSelector authFilterSelector; + + private final AtomicReference lastTokenAuthSetTime; + + public SecureDbaasAdapterRestClientV2(DbaasAdapterRestClientV2 restClient, BasicAuthFilter basicAuthFilter, KubernetesTokenAuthFilter kubernetesTokenAuthFilter, AuthFilterSelector authFilterSelector, boolean isJwtEnabled) { + this.restClient = restClient; + this.basicAuthFilter = basicAuthFilter; + this.kubernetesTokenAuthFilter = kubernetesTokenAuthFilter; + this.authFilterSelector = authFilterSelector; + this.lastTokenAuthSetTime = new AtomicReference<>(Instant.now()); + this.isJwtEnabled = isJwtEnabled; + } + + private R executeRequest(final Supplier supplier) { + try { + if (isJwtEnabled && authFilterSelector.getAuthFilter() instanceof BasicAuthFilter && Duration.between(lastTokenAuthSetTime.get(), Instant.now()).toMinutes() >= 60) { + authFilterSelector.selectAuthFilter(kubernetesTokenAuthFilter); + lastTokenAuthSetTime.set(Instant.now()); + } + return supplier.get(); + } catch (WebApplicationException e) { + if (isJwtEnabled && e.getResponse().getStatus() == Response.Status.UNAUTHORIZED.getStatusCode() && authFilterSelector.getAuthFilter() instanceof KubernetesTokenAuthFilter) { + authFilterSelector.selectAuthFilter(basicAuthFilter); + return supplier.get(); + } + throw e; + } + } + + @Override + public AdapterHealthStatus getHealth() { + return executeRequest(restClient::getHealth); + } + + @Override + public Response handshake(String type) { + return executeRequest(() -> restClient.handshake(type)); + } + + @Override + public Map supports(String type) { + return executeRequest(() -> restClient.supports(type)); + } + + @Override + public TrackedAction restoreBackup(String type, String backupId, RestoreRequest restoreRequest) { + return executeRequest(() -> restClient.restoreBackup(type, backupId, restoreRequest)); + } + + @Override + public TrackedAction restoreBackup(String type, String backupId, boolean regenerateNames, List databases) { + return executeRequest(() -> restClient.restoreBackup(type, backupId, regenerateNames, databases)); + } + + @Override + public TrackedAction collectBackup(String type, Boolean allowEviction, String keep, List databases) { + return executeRequest(() -> restClient.collectBackup(type, allowEviction, keep, databases)); + } + + @Override + public TrackedAction trackBackup(String type, String action, String track) { + return executeRequest(() -> restClient.trackBackup(type, action, track)); + } + + @Override + public String deleteBackup(String type, String backupId) { + return executeRequest(() -> restClient.deleteBackup(type, backupId)); + } + + @Override + public Response dropResources(String type, List resources) { + return executeRequest(() -> restClient.dropResources(type, resources)); + } + + @Override + public EnsuredUser ensureUser(String type, String username, UserEnsureRequest request) { + return executeRequest(() -> restClient.ensureUser(type, username, request)); + } + + @Override + public EnsuredUser ensureUser(String type, String username, UserEnsureRequestV3 request) { + return executeRequest(() -> restClient.ensureUser(type, username, request)); + } + + @Override + public EnsuredUser ensureUser(String type, UserEnsureRequestV3 request) { + return executeRequest(() -> restClient.ensureUser(type, request)); + } + + @Override + public EnsuredUser createUser(String type, GetOrCreateUserAdapterRequest request) { + return executeRequest(() -> restClient.createUser(type, request)); + } + + @Override + public Response restorePassword(String type, RestorePasswordsAdapterRequest request) { + return executeRequest(() -> restClient.restorePassword(type, request)); + } + + @Override + public void changeMetaData(String type, String dbName, Map metadata) { + executeRequest(() -> { + restClient.changeMetaData(type, dbName, metadata); + return null; + }); + } + + @Override + public Map describeDatabases(String type, boolean connectionProperties, boolean resources, Collection databases) { + return executeRequest(() -> restClient.describeDatabases(type, connectionProperties, resources, databases)); + } + + @Override + public Set getDatabases(String type) { + return executeRequest(() -> restClient.getDatabases(type)); + } + + @Override + public CreatedDatabaseV3 createDatabase(String type, AdapterDatabaseCreateRequest createRequest) { + return executeRequest(() -> restClient.createDatabase(type, createRequest)); + } + + @Override + public String updateSettings(String type, String dbName, UpdateSettingsAdapterRequest request) { + return executeRequest(() -> restClient.updateSettings(type, dbName, request)); + } + + @Override + public LogicalBackupAdapterResponse backupV2(String dbType, BackupAdapterRequest request) { + return executeRequest(() -> restClient.backupV2(dbType, request)); + } + + @Override + public LogicalBackupAdapterResponse trackBackupV2(String dbType, String logicalBackupName, String storageName, String blobPath) { + return executeRequest(() -> restClient.trackBackupV2(dbType, logicalBackupName, storageName, blobPath)); + } + + @Override + public void deleteBackupV2(String dbType, String logicalBackupName, String blobPath) { + executeRequest(() -> { + restClient.deleteBackupV2(dbType, logicalBackupName, blobPath); + return null; + }); + } + + @Override + public LogicalRestoreAdapterResponse restoreV2(String dbType, String logicalRestoreName, Boolean dryRun, RestoreAdapterRequest request) { + return executeRequest(() -> restClient.restoreV2(dbType, logicalRestoreName, dryRun, request)); + } + + @Override + public LogicalRestoreAdapterResponse trackRestoreV2(String dbType, String logicalBackupName, String storageName, String blobPath) { + return executeRequest(() -> restClient.trackRestoreV2(dbType, logicalBackupName, storageName, blobPath)); + } + + @Override + public void close() throws Exception { + restClient.close(); + } +} diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/ServiceAccountRolesAugmentor.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/ServiceAccountRolesAugmentor.java new file mode 100644 index 00000000..a7861882 --- /dev/null +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/ServiceAccountRolesAugmentor.java @@ -0,0 +1,70 @@ +package com.netcracker.cloud.dbaas.security; + +import com.netcracker.cloud.dbaas.Constants; +import io.quarkus.security.credential.Credential; +import io.quarkus.security.credential.PasswordCredential; +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.SecurityIdentityAugmentor; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.util.Set; + +/** + * Augments a {@link SecurityIdentity} with roles based on a service account name + * derived from the principal. + * + *

Principal name format

+ *

+ * The principal name is expected to contain the service account name as the + * last segment, separated by colons: + *

+ * + *
+ *     <prefix>:<serviceAccountName>
+ * 
+ * + *

+ * The service account name is extracted as the substring after the last {@code ':'} + * character and is used to resolve roles via {@link ServiceAccountRolesManager}. + *

+ */ +@ApplicationScoped +public class ServiceAccountRolesAugmentor implements SecurityIdentityAugmentor { + @Inject + ServiceAccountRolesManager rolesManager; + + public ServiceAccountRolesAugmentor(ServiceAccountRolesManager rolesManager) { + this.rolesManager = rolesManager; + } + + @Override + public Uni augment(SecurityIdentity identity, AuthenticationRequestContext context) { + if (identity.isAnonymous()) { + return Uni.createFrom().item(identity); + } + + // skip if basic auth + for (Credential cred : identity.getCredentials()) { + if (cred instanceof PasswordCredential) { + return Uni.createFrom().item(identity); + } + } + + String principal = identity.getPrincipal().getName(); + String serviceName = principal.substring(principal.lastIndexOf(':') + 1); + Set roles = rolesManager.getRolesByServiceAccountName(serviceName); + + QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(identity); + if (roles != null && !roles.isEmpty()) { + builder.addRoles(roles); + } else { + builder.addRole(Constants.DB_CLIENT); + } + + return Uni.createFrom().item(builder.build()); + } +} diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/ServiceAccountRolesManager.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/ServiceAccountRolesManager.java new file mode 100644 index 00000000..854fcab4 --- /dev/null +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/ServiceAccountRolesManager.java @@ -0,0 +1,42 @@ +package com.netcracker.cloud.dbaas.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +@ApplicationScoped +@NoArgsConstructor +@Slf4j +public class ServiceAccountRolesManager { + private final Map> serviceAccountsWithRoles = new HashMap<>(); + + public Set getRolesByServiceAccountName(String serviceAccountName) { + return serviceAccountsWithRoles.get(serviceAccountName); + } + + void onStart(@Observes StartupEvent ev, @ConfigProperty(name = "dbaas.security.k8s.service.account.roles.path") String rolesConfigPath) { + try { + log.info("Start kubernetes service account roles loading from {}", rolesConfigPath); + String rawYaml = Files.readString(Path.of(rolesConfigPath)); + + ObjectMapper yamlReader = new ObjectMapper(new YAMLFactory()); + Map rawServiceAccountsWithRoles = yamlReader.readValue(rawYaml, Map.class); + for (Map.Entry serviceAccount : rawServiceAccountsWithRoles.entrySet()) { + List roles = (List) serviceAccount.getValue(); + serviceAccountsWithRoles.put(serviceAccount.getKey(), new HashSet<>(roles)); + } + log.info("Roles for kubernetes service accounts loaded"); + } catch (Exception e) { + throw new RuntimeException("Failed to parse user roles secret YAML", e); + } + } +} diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/filters/AuthFilterSelector.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/filters/AuthFilterSelector.java new file mode 100644 index 00000000..4d98ef1a --- /dev/null +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/filters/AuthFilterSelector.java @@ -0,0 +1,9 @@ +package com.netcracker.cloud.dbaas.security.filters; + +import jakarta.ws.rs.client.ClientRequestFilter; + +public interface AuthFilterSelector { + void selectAuthFilter(ClientRequestFilter authFilter); + + ClientRequestFilter getAuthFilter(); +} diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/rest/BasicAuthFilter.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/filters/BasicAuthFilter.java similarity index 89% rename from dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/rest/BasicAuthFilter.java rename to dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/filters/BasicAuthFilter.java index 7957e73d..c3b0a7ad 100644 --- a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/rest/BasicAuthFilter.java +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/filters/BasicAuthFilter.java @@ -1,4 +1,4 @@ -package com.netcracker.cloud.dbaas.rest; +package com.netcracker.cloud.dbaas.security.filters; import jakarta.annotation.Priority; import jakarta.ws.rs.client.ClientRequestContext; @@ -13,7 +13,7 @@ @Priority(AUTHENTICATION) public class BasicAuthFilter implements ClientRequestFilter { - private String header; + private final String header; public BasicAuthFilter(String username, String password) { header = "Basic " + Base64.getEncoder().encodeToString((username + ":" + password).getBytes()); diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/filters/DynamicAuthFilter.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/filters/DynamicAuthFilter.java new file mode 100644 index 00000000..49197251 --- /dev/null +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/filters/DynamicAuthFilter.java @@ -0,0 +1,29 @@ +package com.netcracker.cloud.dbaas.security.filters; + +import jakarta.ws.rs.client.ClientRequestContext; +import jakarta.ws.rs.client.ClientRequestFilter; + +import java.io.IOException; + +public class DynamicAuthFilter implements ClientRequestFilter, AuthFilterSelector { + private volatile ClientRequestFilter authFilter; + + public DynamicAuthFilter(ClientRequestFilter defaultAuthFilter) { + this.authFilter = defaultAuthFilter; + } + + @Override + public void filter(ClientRequestContext clientRequestContext) throws IOException { + authFilter.filter(clientRequestContext); + } + + @Override + public void selectAuthFilter(ClientRequestFilter authFilter) { + this.authFilter = authFilter; + } + + @Override + public ClientRequestFilter getAuthFilter() { + return this.authFilter; + } +} diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/filters/KubernetesTokenAuthFilter.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/filters/KubernetesTokenAuthFilter.java new file mode 100644 index 00000000..a0653047 --- /dev/null +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/filters/KubernetesTokenAuthFilter.java @@ -0,0 +1,23 @@ +package com.netcracker.cloud.dbaas.security.filters; + +import jakarta.ws.rs.client.ClientRequestContext; +import jakarta.ws.rs.client.ClientRequestFilter; +import jakarta.ws.rs.core.HttpHeaders; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.function.Supplier; + +@Slf4j +public class KubernetesTokenAuthFilter implements ClientRequestFilter { + private final Supplier tokenSupplier; + + public KubernetesTokenAuthFilter(Supplier tokenSupplier) { + this.tokenSupplier = tokenSupplier; + } + + @Override + public void filter(ClientRequestContext clientRequestContext) throws IOException { + clientRequestContext.getHeaders().add(HttpHeaders.AUTHORIZATION, "Bearer " + tokenSupplier.get()); + } +} diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/validators/NamespaceValidationRequestFilter.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/validators/NamespaceValidationRequestFilter.java new file mode 100644 index 00000000..b011384d --- /dev/null +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/validators/NamespaceValidationRequestFilter.java @@ -0,0 +1,36 @@ +package com.netcracker.cloud.dbaas.security.validators; + +import com.netcracker.cloud.core.error.runtime.ErrorCodeException; +import com.netcracker.cloud.dbaas.DbaasApiPath; +import com.netcracker.cloud.dbaas.controller.error.Utils; +import com.netcracker.cloud.dbaas.exceptions.ErrorCodes; +import com.netcracker.cloud.dbaas.exceptions.FailedNamespaceIsolationCheckException; +import com.netcracker.cloud.dbaas.utils.JwtUtils; +import io.smallrye.jwt.auth.principal.JWTCallerPrincipal; +import jakarta.inject.Inject; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.Provider; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; + +@Provider +@Slf4j +public class NamespaceValidationRequestFilter implements ContainerRequestFilter { + @Inject + NamespaceValidator namespaceValidator; + + @Override + public void filter(ContainerRequestContext requestContext) throws IOException { + String namespaceFromPath = requestContext.getUriInfo().getPathParameters().getFirst(DbaasApiPath.NAMESPACE_PARAMETER); + // Don't check namespace if not present or not token auth + if (namespaceFromPath == null || !(requestContext.getSecurityContext().getUserPrincipal() instanceof JWTCallerPrincipal)) { + return; + } + if (!namespaceValidator.checkNamespaceIsolation(namespaceFromPath, JwtUtils.getNamespace(requestContext.getSecurityContext()))) { + throw new FailedNamespaceIsolationCheckException(); + } + } +} diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/validators/NamespaceValidator.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/validators/NamespaceValidator.java new file mode 100644 index 00000000..a2f57a23 --- /dev/null +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/security/validators/NamespaceValidator.java @@ -0,0 +1,68 @@ +package com.netcracker.cloud.dbaas.security.validators; + +import com.netcracker.cloud.dbaas.entity.pg.composite.CompositeStructure; +import com.netcracker.cloud.dbaas.service.composite.CompositeNamespaceService; +import com.netcracker.cloud.dbaas.utils.JwtUtils; +import io.smallrye.jwt.auth.principal.JWTCallerPrincipal; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.SecurityContext; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.util.Map; +import java.util.Optional; + +@Slf4j +@ApplicationScoped +public class NamespaceValidator { + @ConfigProperty(name = "dbaas.security.namespace-isolation-enabled") + boolean namespaceIsolationEnabled; + + @Inject + CompositeNamespaceService compositeNamespaceService; + + public boolean checkNamespaceIsolation(String namespaceFromPath, String namespaceFromJwt) { + if (!namespaceIsolationEnabled) { + return true; + } + return checkNamespacesEqual(namespaceFromPath, namespaceFromJwt); + } + + public boolean checkNamespaceFromClassifier(SecurityContext securityContext, Map classifier) { + if (!(securityContext.getUserPrincipal() instanceof JWTCallerPrincipal)) { + return true; + } + String namespaceFromClassifier = (String) classifier.get("namespace"); + if (namespaceFromClassifier == null) { + return false; + } + return checkNamespacesEqual(namespaceFromClassifier, JwtUtils.getNamespace(securityContext)); + } + + private boolean checkNamespacesEqual(String namespace0, String namespace1) { + if (namespace0.equals(namespace1)) { + return true; + } else { + return inSameCompositeStructure(namespace0, namespace1); + } + } + + private boolean inSameCompositeStructure(String namespace0, String namespace1) { + Optional baseLine = compositeNamespaceService.getBaselineByNamespace(namespace0); + + if (baseLine.isEmpty()) { + return false; + } + + if (baseLine.get().equals(namespace1)) { + return true; + } + + Optional compositeStructure = compositeNamespaceService.getCompositeStructure(baseLine.get()); + + return compositeStructure.map(structure -> structure.getNamespaces().contains(namespace1)) + .orElse(false); + + } +} diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/service/DbaasAdapterRESTClientFactory.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/service/DbaasAdapterRESTClientFactory.java index 81d8516b..f32b3e47 100644 --- a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/service/DbaasAdapterRESTClientFactory.java +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/service/DbaasAdapterRESTClientFactory.java @@ -2,13 +2,19 @@ import com.netcracker.cloud.dbaas.dto.v3.ApiVersion; import com.netcracker.cloud.dbaas.monitoring.interceptor.TimeMeasurementManager; -import com.netcracker.cloud.dbaas.rest.BasicAuthFilter; +import com.netcracker.cloud.dbaas.rest.SecureDbaasAdapterRestClientV2; +import com.netcracker.cloud.dbaas.security.filters.BasicAuthFilter; import com.netcracker.cloud.dbaas.rest.DbaasAdapterRestClient; import com.netcracker.cloud.dbaas.rest.DbaasAdapterRestClientLoggingFilter; import com.netcracker.cloud.dbaas.rest.DbaasAdapterRestClientV2; +import com.netcracker.cloud.dbaas.security.filters.DynamicAuthFilter; +import com.netcracker.cloud.dbaas.security.filters.KubernetesTokenAuthFilter; +import com.netcracker.cloud.security.core.utils.k8s.KubernetesServiceAccountToken; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.ws.rs.Priorities; +import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.rest.client.RestClientBuilder; import java.lang.reflect.Proxy; @@ -17,6 +23,10 @@ @ApplicationScoped public class DbaasAdapterRESTClientFactory { + @Inject + @ConfigProperty(name = "dbaas.security.k8s.jwt.enabled") + boolean jwtEnabled; + @Inject TimeMeasurementManager timeMeasurementManager; @@ -34,15 +44,24 @@ public DbaasAdapter createDbaasAdapterClient(String username, String password, S public DbaasAdapter createDbaasAdapterClientV2(String username, String password, String adapterAddress, String type, String identifier, AdapterActionTrackerClient tracker, ApiVersion apiVersions) { - BasicAuthFilter authFilter = new BasicAuthFilter(username, password); + BasicAuthFilter basicAuthFilter = new BasicAuthFilter(username, password); + KubernetesTokenAuthFilter kubernetesTokenAuthFilter = null; + if (jwtEnabled) { + kubernetesTokenAuthFilter = new KubernetesTokenAuthFilter(KubernetesServiceAccountToken::getToken); + } + DynamicAuthFilter dynamicAuthFilter = new DynamicAuthFilter(kubernetesTokenAuthFilter != null ? kubernetesTokenAuthFilter : basicAuthFilter); + DbaasAdapterRestClientV2 restClient = RestClientBuilder.newBuilder().baseUri(URI.create(adapterAddress)) - .register(authFilter) + .register(dynamicAuthFilter, Priorities.AUTHENTICATION) .register(new DbaasAdapterRestClientLoggingFilter()) .connectTimeout(3, TimeUnit.MINUTES) .readTimeout(3, TimeUnit.MINUTES) .build(DbaasAdapterRestClientV2.class); + + SecureDbaasAdapterRestClientV2 secureRestClient = new SecureDbaasAdapterRestClientV2(restClient, basicAuthFilter, kubernetesTokenAuthFilter, dynamicAuthFilter, jwtEnabled); + return (DbaasAdapter) Proxy.newProxyInstance(DbaasAdapter.class.getClassLoader(), new Class[]{DbaasAdapter.class}, - timeMeasurementManager.provideTimeMeasurementInvocationHandler(new DbaasAdapterRESTClientV2(adapterAddress, type, restClient, identifier, tracker, apiVersions))); + timeMeasurementManager.provideTimeMeasurementInvocationHandler(new DbaasAdapterRESTClientV2(adapterAddress, type, secureRestClient, identifier, tracker, apiVersions))); } } diff --git a/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/utils/JwtUtils.java b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/utils/JwtUtils.java new file mode 100644 index 00000000..cfbd6b1c --- /dev/null +++ b/dbaas/dbaas-aggregator/src/main/java/com/netcracker/cloud/dbaas/utils/JwtUtils.java @@ -0,0 +1,27 @@ +package com.netcracker.cloud.dbaas.utils; + +import io.smallrye.jwt.auth.principal.DefaultJWTCallerPrincipal; +import jakarta.json.JsonObject; +import jakarta.json.JsonString; +import jakarta.ws.rs.core.SecurityContext; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import java.util.Map; + +public class JwtUtils { + public static String getServiceAccountName(JsonWebToken token) { + Map kubernetesClaims = token.getClaim("kubernetes.io"); + JsonObject serviceAccount = (JsonObject) kubernetesClaims.get("serviceaccount"); + JsonString serviceAccountName = (JsonString) serviceAccount.get("name"); + return serviceAccountName.getString(); + } + + public static String getNamespace(SecurityContext securityContext) { + if (securityContext.getUserPrincipal() instanceof DefaultJWTCallerPrincipal principal) { + Map kubernetesClaims = principal.getClaim("kubernetes.io"); + JsonString namespaceFromJwt = (JsonString) kubernetesClaims.get("namespace"); + return namespaceFromJwt.getString(); + } + return ""; + } +} diff --git a/dbaas/dbaas-aggregator/src/main/resources/application.properties b/dbaas/dbaas-aggregator/src/main/resources/application.properties index 783ff065..fc33fed1 100644 --- a/dbaas/dbaas-aggregator/src/main/resources/application.properties +++ b/dbaas/dbaas-aggregator/src/main/resources/application.properties @@ -86,6 +86,14 @@ quarkus.http.auth.permission.logging-manager-update.paths=/q/logging-manager quarkus.http.auth.permission.logging-manager-update.methods=POST quarkus.http.auth.permission.logging-manager-update.policy=db-client-access +# K8s projected volume tokens Authentication Configuration +quarkus.smallrye-jwt.enabled=true + +dbaas.security.namespace-isolation-enabled=${DBAAS_SECURITY_NAMESPACE_ISOLATION_ENABLED:true} +dbaas.security.k8s.service.account.roles.path=${DBAAS_SECURITY_CONFIGURATION_LOCATION:/etc/dbaas/security}/service-account-roles.yaml +dbaas.security.k8s.jwt.enabled=${KUBERNETES_JWT_ENABLED:true} +dbaas.security.k8s.jwt.audience=${KUBERNETES_JWT_AUDIENCE:dbaas} + # Open-API and Swagger quarkus.swagger-ui.always-include=true quarkus.swagger-ui.path=/swagger-ui diff --git a/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/config/security/BasicAndKubernetesAuthMechanismTest.java b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/config/security/BasicAndKubernetesAuthMechanismTest.java new file mode 100644 index 00000000..84f35947 --- /dev/null +++ b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/config/security/BasicAndKubernetesAuthMechanismTest.java @@ -0,0 +1,156 @@ +package com.netcracker.cloud.dbaas.config.security; + +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.AuthenticationRequest; +import io.quarkus.smallrye.jwt.runtime.auth.JWTAuthMechanism; +import io.quarkus.vertx.http.runtime.security.BasicAuthenticationMechanism; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.quarkus.vertx.http.runtime.security.HttpCredentialTransport; +import io.smallrye.mutiny.Uni; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.ext.web.RoutingContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class BasicAndKubernetesAuthMechanismTest { + + private BasicAndKubernetesAuthMechanism mechanism; + private BasicAuthenticationMechanism basicAuth; + private JWTAuthMechanism jwtAuth; + private RoutingContext context; + private HttpServerRequest request; + private IdentityProviderManager idManager; + + @BeforeEach + void setUp() { + mechanism = new BasicAndKubernetesAuthMechanism(); + basicAuth = mock(BasicAuthenticationMechanism.class); + jwtAuth = mock(JWTAuthMechanism.class); + mechanism.basicAuth = basicAuth; + mechanism.jwtAuth = jwtAuth; + + context = mock(RoutingContext.class); + request = mock(HttpServerRequest.class); + when(context.request()).thenReturn(request); + idManager = mock(IdentityProviderManager.class); + } + + @Test + void testAuthenticate_withBearerToken() { + when(request.getHeader("Authorization")).thenReturn("Bearer token"); + Uni expected = Uni.createFrom().nullItem(); + when(jwtAuth.authenticate(any(), any())).thenReturn(expected); + + Uni result = mechanism.authenticate(context, idManager); + + verify(jwtAuth, times(1)).authenticate(any(), any()); + verify(basicAuth, never()).authenticate(any(), any()); + assertEquals(expected, result); + } + + @Test + void testAuthenticate_withBasicToken() { + when(request.getHeader("Authorization")).thenReturn("Basic abc"); + Uni expected = Uni.createFrom().nullItem(); + when(basicAuth.authenticate(any(), any())).thenReturn(expected); + + Uni result = mechanism.authenticate(context, idManager); + + verify(basicAuth, times(1)).authenticate(any(), any()); + verify(jwtAuth, never()).authenticate(any(), any()); + assertEquals(expected, result); + } + + @Test + void testGetChallenge_withBearerToken() { + when(request.getHeader("Authorization")).thenReturn("Bearer token"); + Uni expected = Uni.createFrom().nullItem(); + when(jwtAuth.getChallenge(any())).thenReturn(expected); + + Uni result = mechanism.getChallenge(context); + + verify(jwtAuth, times(1)).getChallenge(any()); + assertEquals(expected, result); + } + + @Test + void testGetChallenge_withBasicAuth() { + when(request.getHeader("Authorization")).thenReturn("Basic abc"); + Uni expected = Uni.createFrom().nullItem(); + when(basicAuth.getChallenge(any())).thenReturn(expected); + + Uni result = mechanism.getChallenge(context); + + verify(basicAuth, times(1)).getChallenge(any()); + assertEquals(expected, result); + } + + @Test + void testGetCredentialTypes() { + Set> basicTypes = + new HashSet<>(Collections.singleton(AuthenticationRequest.class)); + Set> jwtTypes = + new HashSet<>(Collections.singleton(AuthenticationRequest.class)); + + when(basicAuth.getCredentialTypes()).thenReturn(basicTypes); + when(jwtAuth.getCredentialTypes()).thenReturn(jwtTypes); + + Set> result = mechanism.getCredentialTypes(); + + assertNotNull(result); + assertTrue(result.containsAll(basicAuth.getCredentialTypes())); + assertTrue(result.containsAll(jwtAuth.getCredentialTypes())); + + verify(basicAuth, times(2)).getCredentialTypes(); + verify(jwtAuth, times(2)).getCredentialTypes(); + } + + @Test + void testGetCredentialTransport_withBearerToken() { + when(request.getHeader("Authorization")).thenReturn("Bearer token"); + Uni expected = Uni.createFrom().nullItem(); + when(jwtAuth.getCredentialTransport(any())).thenReturn(expected); + + Uni result = mechanism.getCredentialTransport(context); + + verify(jwtAuth, times(1)).getCredentialTransport(any()); + assertEquals(expected, result); + } + + @Test + void testGetCredentialTransport_withBasicAuth() { + when(request.getHeader("Authorization")).thenReturn("Basic abc"); + Uni expected = Uni.createFrom().nullItem(); + when(basicAuth.getCredentialTransport(any())).thenReturn(expected); + + Uni result = mechanism.getCredentialTransport(context); + + verify(basicAuth, times(1)).getCredentialTransport(any()); + assertEquals(expected, result); + } + + @Test + void testIsBearerTokenPresent_privateMethodCoverage() throws Exception { + // Reflectively test private method + var method = BasicAndKubernetesAuthMechanism.class.getDeclaredMethod("isBearerTokenPresent", RoutingContext.class); + method.setAccessible(true); + + when(request.getHeader("Authorization")).thenReturn("Bearer something"); + assertTrue((Boolean) method.invoke(mechanism, context)); + + when(request.getHeader("Authorization")).thenReturn("Basic something"); + assertFalse((Boolean) method.invoke(mechanism, context)); + + when(request.getHeader("Authorization")).thenReturn(null); + assertFalse((Boolean) method.invoke(mechanism, context)); + } +} diff --git a/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/config/security/KubernetesJWTCallerPrincipalFactoryTest.java b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/config/security/KubernetesJWTCallerPrincipalFactoryTest.java new file mode 100644 index 00000000..b16110aa --- /dev/null +++ b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/config/security/KubernetesJWTCallerPrincipalFactoryTest.java @@ -0,0 +1,29 @@ +package com.netcracker.cloud.dbaas.config.security; + +import com.netcracker.cloud.security.core.utils.k8s.KubernetesTokenVerificationException; +import com.netcracker.cloud.security.core.utils.k8s.KubernetesTokenVerifier; +import io.smallrye.jwt.auth.principal.ParseException; +import org.jose4j.jwt.JwtClaims; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MockitoExtension.class) +class KubernetesJWTCallerPrincipalFactoryTest { + @Mock + KubernetesTokenVerifier verifier; + KubernetesJWTCallerPrincipalFactory factory; + + @Test + void parse() throws ParseException, KubernetesTokenVerificationException { + Mockito.when(verifier.verify("token")).thenReturn(new JwtClaims()); + Mockito.when(verifier.verify("invalidToken")).thenThrow(new KubernetesTokenVerificationException("invalid token")); + factory = new KubernetesJWTCallerPrincipalFactory(verifier); + assertNotNull(factory.parse("token", null)); + assertThrows(ParseException.class, () -> factory.parse("invalidToken", null)); + } +} diff --git a/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/controller/v3/AggregatedDatabaseAdministrationControllerV3Test.java b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/controller/v3/AggregatedDatabaseAdministrationControllerV3Test.java index 878f5b60..99f24052 100644 --- a/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/controller/v3/AggregatedDatabaseAdministrationControllerV3Test.java +++ b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/controller/v3/AggregatedDatabaseAdministrationControllerV3Test.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.netcracker.cloud.dbaas.dto.ClassifierWithRolesRequest; +import com.netcracker.cloud.dbaas.dto.role.Role; import com.netcracker.cloud.dbaas.dto.v3.*; import com.netcracker.cloud.dbaas.entity.pg.*; import com.netcracker.cloud.dbaas.exceptions.ErrorCodes; @@ -39,6 +40,11 @@ import static com.netcracker.cloud.dbaas.DatabaseType.POSTGRESQL; import static com.netcracker.cloud.dbaas.DbaasApiPath.*; import static com.netcracker.cloud.dbaas.dto.role.Role.ADMIN; +import static com.netcracker.cloud.dbaas.Constants.*; +import static com.netcracker.cloud.dbaas.DbaasApiPath.ASYNC_PARAMETER; +import static com.netcracker.cloud.dbaas.DbaasApiPath.LIST_DATABASES_PATH; +import static com.netcracker.cloud.dbaas.DbaasApiPath.NAMESPACE_PARAMETER; +import static com.netcracker.cloud.framework.contexts.tenant.TenantContextObject.TENANT_HEADER; import static io.restassured.RestAssured.given; import static jakarta.ws.rs.core.Response.Status.*; import static java.util.Collections.singletonList; @@ -500,6 +506,53 @@ void testClassifierWithTenantScopeToCreateDatabase() throws JsonProcessingExcept .statusCode(BAD_REQUEST.getStatusCode()); } + @Test + void testClassifierWithTenantIdToCreateDatabase() throws JsonProcessingException { + when(dBaaService.getConnectionPropertiesService()).thenReturn(processConnectionPropertiesService); + final DatabaseCreateRequestV3 databaseCreateRequest = getDatabaseCreateRequestSample(); + when(declarativeConfigRepository.findFirstByClassifierAndType(any(), any())).thenReturn(Optional.empty()); + Mockito.when(databaseRolesService.getSupportedRoleFromRequest(any(DatabaseCreateRequestV3.class), any(), any())).thenReturn(Role.ADMIN.toString()); + when(databaseRegistryDbaasRepository.saveAnyTypeLogDb(any(DatabaseRegistry.class))).thenThrow(new ConstraintViolationException("constraint violation", new PSQLException("constraint violation", PSQLState.UNIQUE_VIOLATION), "database_registry_classifier_and_type_index")); + when(databaseRegistryDbaasRepository.getDatabaseByClassifierAndType(anyMap(), anyString())).thenReturn(Optional.of(Mockito.mock(DatabaseRegistry.class))); + + final DatabaseRegistry database = getDatabaseSample(); + when(dBaaService.findDatabaseByClassifierAndType(any(), any(), anyBoolean())).thenReturn(database.getDatabaseRegistry().get(0)); + + String tenantId = UUID.randomUUID().toString(); + databaseCreateRequest.getClassifier().put("scope", "tenant"); + databaseCreateRequest.getClassifier().put(TENANT_ID, tenantId); + + when(dBaaService.detach(database)).thenReturn(database); + when(dBaaService.isModifiedFields(any(), any())).thenReturn(false); + DatabaseResponseV3 response = new DatabaseResponseV3SingleCP(database.getDatabaseRegistry().get(0), PHYSICAL_DATABASE_ID, Role.ADMIN.toString()); + when(dBaaService.processConnectionPropertiesV3(any(DatabaseRegistry.class), any())).thenReturn(response); + + given().auth().preemptive().basic("cluster-dba", "someDefaultPassword") + .pathParam(NAMESPACE_PARAMETER, TEST_NAMESPACE) + .contentType(MediaType.APPLICATION_JSON) + .header(TENANT_HEADER, tenantId) + .body(objectMapper.writeValueAsString(databaseCreateRequest)) + .when().put() + .then() + .statusCode(OK.getStatusCode()); + } + + @Test + void testClassifierWithInvalidTenantIdToCreateDatabase() throws JsonProcessingException { + Map classifier = getSampleClassifier(); + classifier.put("scope", "tenant"); + classifier.put(TENANT_ID, UUID.randomUUID().toString()); + DatabaseCreateRequestV3 databaseCreateRequest = getDatabaseCreateRequestSample(classifier); + given().auth().preemptive().basic("cluster-dba", "someDefaultPassword") + .pathParam(NAMESPACE_PARAMETER, TEST_NAMESPACE) + .contentType(MediaType.APPLICATION_JSON) + .header(TENANT_HEADER, UUID.randomUUID().toString()) + .body(objectMapper.writeValueAsString(databaseCreateRequest)) + .when().put() + .then() + .statusCode(FORBIDDEN.getStatusCode()); + } + @Test void testClassifierWithWrongScopeToCreateDatabase() throws JsonProcessingException { Map classifier = getSampleClassifier(); diff --git a/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/integration/SecurityTest.java b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/integration/SecurityTest.java index f28848e9..2238e749 100644 --- a/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/integration/SecurityTest.java +++ b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/integration/SecurityTest.java @@ -13,20 +13,29 @@ import com.netcracker.cloud.dbaas.entity.pg.DatabaseRegistry; import com.netcracker.cloud.dbaas.entity.pg.ExternalAdapterRegistrationEntry; import com.netcracker.cloud.dbaas.entity.pg.PhysicalDatabase; +import com.netcracker.cloud.dbaas.integration.config.JwtUtilsTestResource; import com.netcracker.cloud.dbaas.integration.config.PostgresqlContainerResource; +import com.netcracker.cloud.dbaas.integration.config.SecurityTestProfile; +import com.netcracker.cloud.dbaas.integration.utils.TestJwtUtils; import com.netcracker.cloud.dbaas.repositories.dbaas.DatabaseDbaasRepository; import com.netcracker.cloud.dbaas.repositories.dbaas.DatabaseRegistryDbaasRepository; import com.netcracker.cloud.dbaas.repositories.pg.jpa.BgNamespaceRepository; import com.netcracker.cloud.dbaas.repositories.pg.jpa.DatabaseDeclarativeConfigRepository; import com.netcracker.cloud.dbaas.service.*; +import com.netcracker.cloud.dbaas.utils.JwtUtils; +import com.netcracker.cloud.security.core.utils.k8s.KubernetesServiceAccountToken; import io.quarkus.test.InjectMock; import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; import io.quarkus.test.junit.mockito.MockitoConfig; +import io.quarkus.test.oidc.server.OidcWiremockTestResource; import io.restassured.response.ValidatableResponse; import jakarta.inject.Inject; import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -49,6 +58,8 @@ @QuarkusTest @QuarkusTestResource(PostgresqlContainerResource.class) +@QuarkusTestResource(JwtUtilsTestResource.class) +@TestProfile(SecurityTestProfile.class) class SecurityTest { private static final String TEST_TYPE = "mongodbtest"; @@ -77,8 +88,12 @@ class SecurityTest { @Inject PasswordEncryption passwordEncryption; + TestJwtUtils jwtUtils; + @BeforeEach void prepareMock() { + jwtUtils = JwtUtilsTestResource.JWT_UTILS; + testDbaasAdapter = mock(DbaasAdapter.class); when(dbaasAdapterRESTClientFactory.createDbaasAdapterClientV2(any(), any(), any(), any(), any(), any(), any())).thenReturn(testDbaasAdapter); CreatedDatabaseV3 testDatabase = new CreatedDatabaseV3(); @@ -245,6 +260,18 @@ void testDiscrToolUserCanAccessGetByClassifier() throws JsonProcessingException getByClassifier("discr-tool-user", "someDefaultPassword", classifier).statusCode(OK.getStatusCode()); } + @Test + void testCreateDatabaseWithKubernetesToken() { + createDatabaseWithKubernetesToken(jwtUtils.getJwt("test-name", "unit-test-namespace")) + .statusCode(CREATED.getStatusCode()); + } + + @Test + void testCreateDatabaseWithInvalidKubernetesToken() { + createDatabaseWithKubernetesToken(jwtUtils.getJwt("test-name", "unit-test-namespace")+"pad-to-make-invalid-signature") + .statusCode(UNAUTHORIZED.getStatusCode()); + } + private ValidatableResponse updateClassifier(String user, String password, UpdateClassifierRequestV3 updateClassifierRequest, String namespace, String type) throws JsonProcessingException { return given().auth().preemptive().basic(user, password) .contentType(MediaType.APPLICATION_JSON) @@ -272,6 +299,15 @@ private ValidatableResponse createDatabase(String user, String password) { .then(); } + private ValidatableResponse createDatabaseWithKubernetesToken(String token) { + return given().auth().preemptive().oauth2(token) + .contentType(MediaType.APPLICATION_JSON) + .body("{\"type\":\"" + TEST_TYPE + "\", \"classifier\":{\"scope\":\"service\", \"microserviceName\":\"test-name\", \"namespace\":\"unit-test-namespace\"}, \"originService\":\"test-name\"}") + .accept(MediaType.APPLICATION_JSON) + .put(DBAAS_PATH_V3 + "/unit-test-namespace/databases") + .then(); + } + private ValidatableResponse cleanNamespace(String user, String password) { return given().auth().preemptive().basic(user, password) diff --git a/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/integration/config/JwtUtilsTestResource.java b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/integration/config/JwtUtilsTestResource.java new file mode 100644 index 00000000..09df3630 --- /dev/null +++ b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/integration/config/JwtUtilsTestResource.java @@ -0,0 +1,31 @@ +package com.netcracker.cloud.dbaas.integration.config; + +import com.netcracker.cloud.dbaas.integration.utils.TestJwtUtils; +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; +import io.quarkus.test.oidc.server.OidcWiremockTestResource; +import org.eclipse.microprofile.config.ConfigProvider; + +import java.time.Duration; +import java.util.Map; + +public class JwtUtilsTestResource implements QuarkusTestResourceLifecycleManager { + public static final String tokenDir = "./src/test/resources/mock-oidc-token"; + public static TestJwtUtils JWT_UTILS; + private final OidcWiremockTestResource oidcWiremock = new OidcWiremockTestResource(); + + @Override + public Map start() { + var conf = oidcWiremock.start(); + String issuer = conf.get("keycloak.url")+"/realms/quarkus"; + String audience = ConfigProvider.getConfig() + .getValue("dbaas.security.k8s.jwt.audience", String.class); + System.setProperty("smallrye.jwt.sign.key.location", conf.get("smallrye.jwt.sign.key.location")); + JWT_UTILS = new TestJwtUtils(issuer, audience, tokenDir); + return conf; + } + + @Override + public void stop() { + JWT_UTILS = null; + } +} diff --git a/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/integration/config/SecurityTestProfile.java b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/integration/config/SecurityTestProfile.java new file mode 100644 index 00000000..127e6119 --- /dev/null +++ b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/integration/config/SecurityTestProfile.java @@ -0,0 +1,15 @@ +package com.netcracker.cloud.dbaas.integration.config; + +import io.quarkus.test.junit.QuarkusTestProfile; + +import java.util.Map; + +public class SecurityTestProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "dbaas.security.k8s.jwt.enabled", "true", + "com.netcracker.cloud.security.kubernetes.service.account.token.dir", JwtUtilsTestResource.tokenDir + ); + } +} diff --git a/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/integration/utils/TestJwtUtils.java b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/integration/utils/TestJwtUtils.java new file mode 100644 index 00000000..5c606d62 --- /dev/null +++ b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/integration/utils/TestJwtUtils.java @@ -0,0 +1,46 @@ +package com.netcracker.cloud.dbaas.integration.utils; + +import io.smallrye.jwt.build.Jwt; +import jakarta.enterprise.context.ApplicationScoped; +import lombok.SneakyThrows; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; + +public class TestJwtUtils { + private final String issuer; + private final String audience; + + @SneakyThrows + public TestJwtUtils(String issuer, String audience, String tokenDir) { + this.issuer = issuer; + this.audience = audience; + + Files.createDirectories(Path.of(tokenDir)); + Files.writeString( + Path.of(tokenDir).resolve("token"), + getJwt("default", "test-namespace"), + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING + ); + } + + public String getJwt(String serviceAccountName, String namespace) { + return Jwt.subject("some-service") + .issuer(issuer) + .audience(audience) + .claim("jti", java.util.UUID.randomUUID().toString()) + .claim("kubernetes.io", Map.of( + "namespace", namespace, + "serviceaccount", Map.of("name", serviceAccountName) + )) + .expiresIn(Duration.ofDays(1)) + .issuedAt(Instant.now()) + .sign(); + } +} diff --git a/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/rest/SecureDbaasAdapterRestClientV2Test.java b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/rest/SecureDbaasAdapterRestClientV2Test.java new file mode 100644 index 00000000..2df74a53 --- /dev/null +++ b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/rest/SecureDbaasAdapterRestClientV2Test.java @@ -0,0 +1,123 @@ +package com.netcracker.cloud.dbaas.rest; + +import com.netcracker.cloud.dbaas.monitoring.AdapterHealthStatus; +import com.netcracker.cloud.dbaas.security.filters.AuthFilterSelector; +import com.netcracker.cloud.dbaas.security.filters.BasicAuthFilter; +import com.netcracker.cloud.dbaas.security.filters.KubernetesTokenAuthFilter; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SecureDbaasAdapterRestClientV2Test { + + @Mock + private DbaasAdapterRestClientV2 restClient; + + @Mock + private BasicAuthFilter basicAuthFilter; + + @Mock + private KubernetesTokenAuthFilter kubernetesTokenAuthFilter; + + @Mock + private AuthFilterSelector authFilterSelector; + + @Test + void shouldExecuteRequestWithBasicAuthWhenJwtDisabled() { + SecureDbaasAdapterRestClientV2 secureClient = new SecureDbaasAdapterRestClientV2( + restClient, basicAuthFilter, kubernetesTokenAuthFilter, authFilterSelector, false); + AdapterHealthStatus healthStatus = new AdapterHealthStatus("ok"); + when(restClient.getHealth()).thenReturn(healthStatus); + + AdapterHealthStatus result = secureClient.getHealth(); + + assertSame(healthStatus, result); + verify(restClient).getHealth(); + verify(authFilterSelector, never()).selectAuthFilter(any()); + } + + @Test + void shouldSwitchToBasicAuthOn401WhenUsingTokenAuth() { + when(authFilterSelector.getAuthFilter()).thenReturn(kubernetesTokenAuthFilter); + SecureDbaasAdapterRestClientV2 secureClient = new SecureDbaasAdapterRestClientV2( + restClient, basicAuthFilter, kubernetesTokenAuthFilter, authFilterSelector, true); + + Response unauthorizedResponse = mock(Response.class); + when(unauthorizedResponse.getStatus()).thenReturn(401); + when(unauthorizedResponse.getStatusInfo()).thenReturn(getStatusType(401)); + WebApplicationException unauthorizedException = new WebApplicationException(unauthorizedResponse); + + AdapterHealthStatus healthStatus = new AdapterHealthStatus("ok"); + when(restClient.getHealth()) + .thenThrow(unauthorizedException) + .thenReturn(healthStatus); + + AdapterHealthStatus result = secureClient.getHealth(); + + assertSame(healthStatus, result); + verify(authFilterSelector).selectAuthFilter(basicAuthFilter); + verify(restClient, times(2)).getHealth(); + } + + @Test + void shouldRethrowExceptionWhenNotUnauthorizedOrNotTokenAuth() { + when(authFilterSelector.getAuthFilter()).thenReturn(basicAuthFilter); + SecureDbaasAdapterRestClientV2 secureClient = new SecureDbaasAdapterRestClientV2( + restClient, basicAuthFilter, kubernetesTokenAuthFilter, authFilterSelector, true); + + Response forbiddenResponse = mock(Response.class); + when(forbiddenResponse.getStatus()).thenReturn(403); + when(forbiddenResponse.getStatusInfo()).thenReturn(getStatusType(403)); + WebApplicationException forbiddenException = new WebApplicationException(forbiddenResponse); + when(restClient.getHealth()).thenThrow(forbiddenException); + + assertThrows(WebApplicationException.class, secureClient::getHealth); + verify(authFilterSelector, never()).selectAuthFilter(any()); + verify(restClient, times(1)).getHealth(); + } + + @Test + void shouldDelegateAllMethodsToRestClient() throws Exception { + SecureDbaasAdapterRestClientV2 secureClient = new SecureDbaasAdapterRestClientV2( + restClient, basicAuthFilter, kubernetesTokenAuthFilter, authFilterSelector, false); + + secureClient.handshake("postgres"); + verify(restClient).handshake("postgres"); + + secureClient.supports("postgres"); + verify(restClient).supports("postgres"); + + secureClient.getDatabases("postgres"); + verify(restClient).getDatabases("postgres"); + + secureClient.close(); + verify(restClient).close(); + } + + private Response.StatusType getStatusType(int code) { + return new Response.StatusType() { + @Override + public int getStatusCode() { + return 403; + } + + @Override + public Response.Status.Family getFamily() { + return null; + } + + @Override + public String getReasonPhrase() { + return ""; + } + }; + } +} diff --git a/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/security/ServiceAccountRolesAugmentorTest.java b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/security/ServiceAccountRolesAugmentorTest.java new file mode 100644 index 00000000..633dded0 --- /dev/null +++ b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/security/ServiceAccountRolesAugmentorTest.java @@ -0,0 +1,63 @@ +package com.netcracker.cloud.dbaas.security; + +import com.netcracker.cloud.dbaas.Constants; +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.mutiny.Uni; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.security.Principal; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ServiceAccountRolesAugmentorTest { + @Mock + ServiceAccountRolesManager rolesManager; + @Mock + SecurityIdentity mockIdentity; + @Mock + Principal mockPrincipal; + + @InjectMocks + ServiceAccountRolesAugmentor augmentor; + + @Test + void augment() { + Map> serviceAccounts = new HashMap<>(); + + serviceAccounts.put( + "service-account-1", + Set.of("NAMESPACE_CLEANER", "DB_CLIENT", "MIGRATION_CLIENT") + ); + + serviceAccounts.put( + "service-account-2", + Set.of("NAMESPACE_CLEANER", "MIGRATION_CLIENT") + ); + + when(mockIdentity.getPrincipal()).thenReturn(mockPrincipal); + + serviceAccounts.forEach((name, roles) -> { + when(rolesManager.getRolesByServiceAccountName(name)).thenReturn(roles); + when(mockPrincipal.getName()).thenReturn(name); + + Uni identityUni = augmentor.augment(mockIdentity, null); + assertEquals(identityUni.await().indefinitely().getRoles(), roles); + + when(mockPrincipal.getName()).thenReturn("someOtherName"); + identityUni = augmentor.augment(mockIdentity, null); + assertEquals(identityUni.await().indefinitely().getRoles(), Set.of(Constants.DB_CLIENT)); + }); + } +} diff --git a/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/security/ServiceAccountRolesManagerTest.java b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/security/ServiceAccountRolesManagerTest.java new file mode 100644 index 00000000..4a324994 --- /dev/null +++ b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/security/ServiceAccountRolesManagerTest.java @@ -0,0 +1,27 @@ +package com.netcracker.cloud.dbaas.security; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +class ServiceAccountRolesManagerTest { + ServiceAccountRolesManager serviceAccountRolesManager; + + @BeforeEach + void setUp() { + serviceAccountRolesManager = new ServiceAccountRolesManager(); + serviceAccountRolesManager.onStart(null, "./src/test/resources/service-account-roles-secret.yaml"); + } + + @Test + void getRolesByServiceAccountName() { + Set roles0 = serviceAccountRolesManager.getRolesByServiceAccountName("service-account-1"); + assertArrayEquals(new String[]{"NAMESPACE_CLEANER", "DB_CLIENT", "MIGRATION_CLIENT"}, roles0.toArray()); + + Set roles1 = serviceAccountRolesManager.getRolesByServiceAccountName("service-account-2"); + assertArrayEquals(new String[]{"NAMESPACE_CLEANER", "MIGRATION_CLIENT"}, roles1.toArray()); + } +} diff --git a/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/security/filters/DynamicAuthFilterTest.java b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/security/filters/DynamicAuthFilterTest.java new file mode 100644 index 00000000..303a157a --- /dev/null +++ b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/security/filters/DynamicAuthFilterTest.java @@ -0,0 +1,49 @@ +package com.netcracker.cloud.dbaas.security.filters; + +import jakarta.ws.rs.client.ClientRequestContext; +import jakarta.ws.rs.client.ClientRequestFilter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DynamicAuthFilterTest { + + @Mock + private ClientRequestFilter defaultFilter; + + @Mock + private ClientRequestFilter newFilter; + + @Mock + private ClientRequestContext requestContext; + + @Test + void shouldConstructWithDefaultFilterAndAllowDynamicFilterSelection() throws IOException { + DynamicAuthFilter dynamicFilter = new DynamicAuthFilter(defaultFilter); + + assertSame(defaultFilter, dynamicFilter.getAuthFilter()); + + dynamicFilter.filter(requestContext); + + verify(defaultFilter).filter(requestContext); + verifyNoInteractions(newFilter); + + dynamicFilter.selectAuthFilter(newFilter); + + assertSame(newFilter, dynamicFilter.getAuthFilter()); + assertNotSame(defaultFilter, dynamicFilter.getAuthFilter()); + + dynamicFilter.filter(requestContext); + + verify(newFilter).filter(requestContext); + verify(defaultFilter, times(1)).filter(requestContext); + } +} diff --git a/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/security/filters/KubernetesTokenAuthFilterTest.java b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/security/filters/KubernetesTokenAuthFilterTest.java new file mode 100644 index 00000000..244ac265 --- /dev/null +++ b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/security/filters/KubernetesTokenAuthFilterTest.java @@ -0,0 +1,42 @@ +package com.netcracker.cloud.dbaas.security.filters; + +import jakarta.ws.rs.client.ClientRequestContext; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class KubernetesTokenAuthFilterTest { + + @Mock + private ClientRequestContext requestContext; + + @Test + void shouldAddBearerTokenToAuthorizationHeader() throws IOException { + String token = "test-k8s-token-12345"; + Supplier tokenSupplier = () -> token; + MultivaluedMap headers = new MultivaluedHashMap<>(); + + when(requestContext.getHeaders()).thenReturn(headers); + + KubernetesTokenAuthFilter filter = new KubernetesTokenAuthFilter(tokenSupplier); + + filter.filter(requestContext); + + verify(requestContext).getHeaders(); + assertTrue(headers.containsKey(HttpHeaders.AUTHORIZATION)); + assertEquals("Bearer " + token, headers.getFirst(HttpHeaders.AUTHORIZATION)); + } +} diff --git a/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/security/validators/NamespaceValidationRequestFilterTest.java b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/security/validators/NamespaceValidationRequestFilterTest.java new file mode 100644 index 00000000..61393bbb --- /dev/null +++ b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/security/validators/NamespaceValidationRequestFilterTest.java @@ -0,0 +1,105 @@ +package com.netcracker.cloud.dbaas.security.validators; + +import com.netcracker.cloud.dbaas.DbaasApiPath; +import com.netcracker.cloud.dbaas.exceptions.FailedNamespaceIsolationCheckException; +import com.netcracker.cloud.dbaas.utils.JwtUtils; +import io.smallrye.jwt.auth.principal.JWTCallerPrincipal; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.UriInfo; +import org.junit.Assert; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.mockito.Mockito.*; + +class NamespaceValidationRequestFilterTest { + + private NamespaceValidationRequestFilter filter; + private NamespaceValidator namespaceValidator; + private ContainerRequestContext requestContext; + private UriInfo uriInfo; + private MultivaluedMap pathParams; + private SecurityContext securityContext; + + @BeforeEach + void setUp() { + filter = new NamespaceValidationRequestFilter(); + namespaceValidator = mock(NamespaceValidator.class); + filter.namespaceValidator = namespaceValidator; + + requestContext = mock(ContainerRequestContext.class); + uriInfo = mock(UriInfo.class); + pathParams = mock(MultivaluedMap.class); + securityContext = mock(SecurityContext.class); + + when(requestContext.getUriInfo()).thenReturn(uriInfo); + when(uriInfo.getPathParameters()).thenReturn(pathParams); + when(requestContext.getSecurityContext()).thenReturn(securityContext); + } + + @Test + void testFilter_noNamespaceInPath() throws IOException { + when(pathParams.getFirst(DbaasApiPath.NAMESPACE_PARAMETER)).thenReturn(null); + + filter.filter(requestContext); + + verify(namespaceValidator, never()).checkNamespaceIsolation(any(), any()); + verify(requestContext, never()).abortWith(any()); + } + + @Test + void testFilter_notJwtPrincipal() throws IOException { + when(pathParams.getFirst(DbaasApiPath.NAMESPACE_PARAMETER)).thenReturn("ns1"); + when(securityContext.getUserPrincipal()).thenReturn(() -> "basicUser"); + + filter.filter(requestContext); + + verify(namespaceValidator, never()).checkNamespaceIsolation(any(), any()); + verify(requestContext, never()).abortWith(any()); + } + + @Test + void testFilter_validNamespaceIsolation() throws IOException { + String nsFromPath = "ns1"; + when(pathParams.getFirst(DbaasApiPath.NAMESPACE_PARAMETER)).thenReturn(nsFromPath); + + JWTCallerPrincipal jwtPrincipal = mock(JWTCallerPrincipal.class); + when(securityContext.getUserPrincipal()).thenReturn(jwtPrincipal); + + try (var mocked = mockStatic(JwtUtils.class)) { + mocked.when(() -> JwtUtils.getNamespace(securityContext)).thenReturn("ns1"); + + when(namespaceValidator.checkNamespaceIsolation("ns1", "ns1")).thenReturn(true); + + filter.filter(requestContext); + + verify(namespaceValidator, times(1)).checkNamespaceIsolation("ns1", "ns1"); + verify(requestContext, never()).abortWith(any()); + } + } + + @Test + void testFilter_invalidNamespaceIsolation() throws IOException { + String nsFromPath = "ns1"; + when(pathParams.getFirst(DbaasApiPath.NAMESPACE_PARAMETER)).thenReturn(nsFromPath); + + JWTCallerPrincipal jwtPrincipal = mock(JWTCallerPrincipal.class); + when(securityContext.getUserPrincipal()).thenReturn(jwtPrincipal); + + try (var mocked = mockStatic(JwtUtils.class)) { + mocked.when(() -> JwtUtils.getNamespace(securityContext)).thenReturn("other-ns"); + + when(namespaceValidator.checkNamespaceIsolation("ns1", "other-ns")).thenReturn(false); + + Assert.assertThrows(FailedNamespaceIsolationCheckException.class, () -> { + filter.filter(requestContext); + }); + + verify(namespaceValidator, times(1)).checkNamespaceIsolation("ns1", "other-ns"); + } + } +} diff --git a/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/security/validators/NamespaceValidatorTest.java b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/security/validators/NamespaceValidatorTest.java new file mode 100644 index 00000000..2739aaf0 --- /dev/null +++ b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/security/validators/NamespaceValidatorTest.java @@ -0,0 +1,85 @@ +package com.netcracker.cloud.dbaas.security.validators; + +import com.netcracker.cloud.dbaas.entity.pg.composite.CompositeStructure; +import com.netcracker.cloud.dbaas.service.composite.CompositeNamespaceService; +import com.netcracker.cloud.dbaas.utils.JwtUtils; +import io.smallrye.jwt.auth.principal.JWTCallerPrincipal; +import jakarta.ws.rs.core.SecurityContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collections; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NamespaceValidatorTest { + private static final String defaultBaseLine = "default"; + private static final String defaultNamespace = "default"; + private static final String otherNamespaceInComposite = "otherNamespaceInComposite"; + private static final String someOtherNamespace = "someOtherNamespace"; + + @Mock + CompositeNamespaceService compositeNamespaceService; + + @Mock + SecurityContext securityContext; + + @Mock + JWTCallerPrincipal principal; + + @InjectMocks + NamespaceValidator namespaceValidator; + + @BeforeEach + void setUp() { + namespaceValidator.namespaceIsolationEnabled = true; + } + + @AfterEach + void tearDown() { + } + + @Test + void checkNamespaceIsolation() { + Set namespaces = Set.of(defaultNamespace, otherNamespaceInComposite); + CompositeStructure defaultCompositeStructure = new CompositeStructure(defaultBaseLine, namespaces); + + when(compositeNamespaceService.getCompositeStructure(defaultBaseLine)).thenReturn(Optional.of(defaultCompositeStructure)); + + when(compositeNamespaceService.getBaselineByNamespace(defaultNamespace)).thenReturn(Optional.of(defaultBaseLine)); + when(compositeNamespaceService.getBaselineByNamespace(otherNamespaceInComposite)).thenReturn(Optional.of(defaultBaseLine)); + when(compositeNamespaceService.getBaselineByNamespace(someOtherNamespace)).thenReturn(Optional.empty()); + + assertTrue(namespaceValidator.checkNamespaceIsolation(defaultNamespace, defaultNamespace)); + assertTrue(namespaceValidator.checkNamespaceIsolation(someOtherNamespace, someOtherNamespace)); + assertFalse(namespaceValidator.checkNamespaceIsolation(someOtherNamespace, "notEqualSomeOtherNamespace")); + assertFalse(namespaceValidator.checkNamespaceIsolation(defaultNamespace, someOtherNamespace)); + assertFalse(namespaceValidator.checkNamespaceIsolation(otherNamespaceInComposite, someOtherNamespace)); + assertTrue(namespaceValidator.checkNamespaceIsolation(otherNamespaceInComposite, defaultNamespace)); + } + + @Test + void checkNamespaceFromClassifier() { + when(securityContext.getUserPrincipal()).thenReturn(principal); + when(compositeNamespaceService.getBaselineByNamespace(otherNamespaceInComposite)).thenReturn(Optional.of(defaultBaseLine)); + when(compositeNamespaceService.getBaselineByNamespace(someOtherNamespace)).thenReturn(Optional.empty()); + + try (var jwtMock = mockStatic(JwtUtils.class)) { + jwtMock.when(() -> JwtUtils.getNamespace(securityContext)).thenReturn(defaultNamespace); + assertTrue(namespaceValidator.checkNamespaceFromClassifier(securityContext, Collections.singletonMap("namespace", defaultNamespace))); + assertTrue(namespaceValidator.checkNamespaceFromClassifier(securityContext, Collections.singletonMap("namespace", otherNamespaceInComposite))); + assertFalse(namespaceValidator.checkNamespaceFromClassifier(securityContext, Collections.singletonMap("namespace", someOtherNamespace))); + } + } +} diff --git a/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/utils/JwtUtilsTest.java b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/utils/JwtUtilsTest.java new file mode 100644 index 00000000..270b4488 --- /dev/null +++ b/dbaas/dbaas-aggregator/src/test/java/com/netcracker/cloud/dbaas/utils/JwtUtilsTest.java @@ -0,0 +1,65 @@ +package com.netcracker.cloud.dbaas.utils; + +import io.smallrye.jwt.auth.principal.DefaultJWTCallerPrincipal; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.json.JsonString; +import jakarta.ws.rs.core.SecurityContext; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +class JwtUtilsTest { + + @Test + void testGetServiceAccountName() { + JsonWebToken token = mock(JsonWebToken.class); + + JsonObject serviceAccount = Json.createObjectBuilder() + .add("name", "test-service-account") + .build(); + + Map kubernetesClaims = new HashMap<>(); + kubernetesClaims.put("serviceaccount", serviceAccount); + + when(token.getClaim("kubernetes.io")).thenReturn(kubernetesClaims); + + String result = JwtUtils.getServiceAccountName(token); + + assertEquals("test-service-account", result); + verify(token, times(1)).getClaim("kubernetes.io"); + } + + @Test + void testGetNamespace_withValidPrincipal() { + SecurityContext securityContext = mock(SecurityContext.class); + DefaultJWTCallerPrincipal principal = mock(DefaultJWTCallerPrincipal.class); + + JsonString namespace = Json.createValue("test-namespace"); + Map kubernetesClaims = new HashMap<>(); + kubernetesClaims.put("namespace", namespace); + + when(principal.getClaim("kubernetes.io")).thenReturn(kubernetesClaims); + when(securityContext.getUserPrincipal()).thenReturn(principal); + + String result = JwtUtils.getNamespace(securityContext); + + assertEquals("test-namespace", result); + verify(principal, times(1)).getClaim("kubernetes.io"); + } + + @Test + void testGetNamespace_withInvalidPrincipal() { + SecurityContext securityContext = mock(SecurityContext.class); + when(securityContext.getUserPrincipal()).thenReturn(() -> "someUser"); + + String result = JwtUtils.getNamespace(securityContext); + + assertEquals("", result); + } +} diff --git a/dbaas/dbaas-aggregator/src/test/resources/application.properties b/dbaas/dbaas-aggregator/src/test/resources/application.properties index e2122e0c..e1fca822 100644 --- a/dbaas/dbaas-aggregator/src/test/resources/application.properties +++ b/dbaas/dbaas-aggregator/src/test/resources/application.properties @@ -4,4 +4,6 @@ dbaas.process-orchestrator.enabled=false dbaas.h2.sync.every=86400 dbaas.security.users.configuration.location=/users.json +dbaas.security.k8s.service.account.roles.path=./src/test/resources/service-account-roles-secret.yaml +dbaas.security.k8s.jwt.enabled=false dbaas.global-permissions.configuration.location=/services.json diff --git a/dbaas/dbaas-aggregator/src/test/resources/service-account-roles-secret.yaml b/dbaas/dbaas-aggregator/src/test/resources/service-account-roles-secret.yaml new file mode 100644 index 00000000..823f14b6 --- /dev/null +++ b/dbaas/dbaas-aggregator/src/test/resources/service-account-roles-secret.yaml @@ -0,0 +1,7 @@ +service-account-1: + - NAMESPACE_CLEANER + - DB_CLIENT + - MIGRATION_CLIENT +service-account-2: + - NAMESPACE_CLEANER + - MIGRATION_CLIENT diff --git a/docs/OpenAPI.json b/docs/OpenAPI.json index f45181c5..25167184 100644 --- a/docs/OpenAPI.json +++ b/docs/OpenAPI.json @@ -657,8 +657,7 @@ "ClassifierWithRolesRequest": { "type": "object", "required": [ - "classifier", - "originService" + "classifier" ], "properties": { "classifier": { @@ -921,8 +920,7 @@ "type": "object", "required": [ "classifier", - "type", - "originService" + "type" ], "description": "V3 Request model for adding database to DBaaS", "properties": { @@ -4153,6 +4151,14 @@ } } } + }, + "securitySchemes": { + "SecurityScheme": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Authentication" + } } }, "tags": [ diff --git a/helm-templates/dbaas-aggregator/data/service-account-roles.yaml b/helm-templates/dbaas-aggregator/data/service-account-roles.yaml new file mode 100644 index 00000000..5bc59fcc --- /dev/null +++ b/helm-templates/dbaas-aggregator/data/service-account-roles.yaml @@ -0,0 +1,17 @@ +{{ .Values.BACKUP_DAEMON_DBAAS_ACCESS_USERNAME | default "backup-daemon" }}: + - BACKUP_MANAGER + +{{ .Values.DBAAS_CLUSTER_DBA_CREDENTIALS_USERNAME | default "cluster-dba" }}: + - NAMESPACE_CLEANER + - DB_CLIENT + - MIGRATION_CLIENT + +{{ .Values.DBAAS_TENANT_USERNAME | default "dbaas-tenant" }}: + - NAMESPACE_CLEANER + - DB_CLIENT + +{{ .Values.DBAAS_DB_EDITOR_CREDENTIALS_USERNAME | default "dbaas-db-editor" }}: + - DBAAS_DB_EDITOR + +{{ .Values.DISCR_TOOL_USER_USERNAME | default "discr_tool_user" }}: + - DISCR_TOOL_CLIENT diff --git a/helm-templates/dbaas-aggregator/templates/Deployment.yaml b/helm-templates/dbaas-aggregator/templates/Deployment.yaml index 5e7736a2..3a054a4d 100644 --- a/helm-templates/dbaas-aggregator/templates/Deployment.yaml +++ b/helm-templates/dbaas-aggregator/templates/Deployment.yaml @@ -36,6 +36,9 @@ spec: - name: dbaas-global-permissions-configuration-volume secret: secretName: 'dbaas-global-permissions-configuration-secret' + - name: dbaas-security-service-account-roles-volume + secret: + secretName: 'dbaas-security-service-account-roles-secret' {{ if .Values.READONLY_CONTAINER_FILE_SYSTEM_ENABLED }} - name: tmp emptyDir: {} @@ -59,6 +62,9 @@ spec: - name: dbaas-security-configuration-volume mountPath: '{{ .Values.DBAAS_SECURITY_CONFIGURATION_LOCATION }}' readOnly: true + - name: dbaas-security-service-account-roles-volume + mountPath: '{{ .Values.DBAAS_SECURITY_CONFIGURATION_LOCATION }}' + readOnly: true - name: dbaas-global-permissions-configuration-volume mountPath: '{{ .Values.DBAAS_GLOBAL_PERMISSIONS_CONFIGURATION_LOCATION }}' readOnly: true diff --git a/helm-templates/dbaas-aggregator/templates/ServiceRolesSecret.yaml b/helm-templates/dbaas-aggregator/templates/ServiceRolesSecret.yaml new file mode 100644 index 00000000..cd27d2b8 --- /dev/null +++ b/helm-templates/dbaas-aggregator/templates/ServiceRolesSecret.yaml @@ -0,0 +1,12 @@ +{{- $b64p := tpl (.Files.Get "data/service-account-roles.yaml") $ | b64enc }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: dbaas-security-service-account-roles-secret + labels: + app.kubernetes.io/name: "{{ .Values.SERVICE_NAME }}" + deployment.qubership.org/sessionId: {{ .Values.DEPLOYMENT_SESSION_ID | default "unimplemented" }} +type: Opaque +data: + "service-account-roles.yaml": {{ $b64p }}