diff --git a/.github/workflows/gradle-auth.yml b/.github/workflows/gradle-auth.yml new file mode 100644 index 000000000..e4217ba85 --- /dev/null +++ b/.github/workflows/gradle-auth.yml @@ -0,0 +1,60 @@ +# Copyright 2023 Libre311 Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Build And Test Auth With Gradle + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +env: + GRADLE_BUILD_ACTION_CACHE_DEBUG_ENABLED: true + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + checks: write + pull-requests: write + + steps: + - uses: actions/checkout@v6 + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + gradle-version: '9.2.1' + - name: Ensure Docker Client Version + run: echo "api.version=1.44" > "$HOME/.docker-java.properties" + - name: Execute Gradle build + run: gradle :auth:build + - name: Publish Test Report + uses: mikepenz/action-junit-report@v6 + if: always() + with: + report_paths: 'auth/build/test-results/**/*.xml' + detailed_summary: true + include_passed: true diff --git a/app/build.gradle b/app/build.gradle index 289ccc053..3d72aa2c6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -100,7 +100,7 @@ micronaut { annotations("app.*") } testResources { - enabled = System.getenv("MICRONAUT_TEST_RESOURCES_ENABLED") == "false" ? false : true + enabled = !gradle.startParameter.taskNames.any{it.endsWith(":run")} } } diff --git a/app/docker-compose.deps.yml b/app/docker-compose.deps.yml index 0d6449fe8..64c1df470 100644 --- a/app/docker-compose.deps.yml +++ b/app/docker-compose.deps.yml @@ -11,6 +11,9 @@ services: environment: MYSQL_ROOT_PASSWORD: test MYSQL_DATABASE: libre311 + configs: + - source: mysql-init + target: /docker-entrypoint-initdb.d/01-create-databases.sql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-ptest"] @@ -85,6 +88,11 @@ services: # After writing the file, start Nginx nginx -g 'daemon off;' +configs: + mysql-init: + content: | + CREATE DATABASE IF NOT EXISTS unity_auth; + networks: default: name: unity-network diff --git a/app/src/main/java/app/ProjectAdminController.java b/app/src/main/java/app/ProjectAdminController.java index 95cf0cd9a..2e155e2e0 100644 --- a/app/src/main/java/app/ProjectAdminController.java +++ b/app/src/main/java/app/ProjectAdminController.java @@ -43,7 +43,8 @@ public ProjectAdminController(ProjectService projectService) { @Get(uris = { "{?jurisdiction_id}", ".json{?jurisdiction_id}" }) @ExecuteOn(TaskExecutors.IO) - @RequiresPermissions({LIBRE311_ADMIN_VIEW_SYSTEM, LIBRE311_ADMIN_VIEW_TENANT, LIBRE311_ADMIN_VIEW_SUBTENANT}) + @RequiresPermissions({LIBRE311_ADMIN_VIEW_SYSTEM, LIBRE311_ADMIN_VIEW_TENANT, LIBRE311_ADMIN_VIEW_SUBTENANT, + LIBRE311_REQUEST_VIEW_SYSTEM, LIBRE311_REQUEST_VIEW_TENANT, LIBRE311_REQUEST_VIEW_SUBTENANT}) public List index(@Nullable @QueryValue("jurisdiction_id") String jurisdictionId) { return projectService.getProjects(jurisdictionId); } diff --git a/app/src/main/java/app/TenantAdminController.java b/app/src/main/java/app/TenantAdminController.java index 5359a445b..3d2e51894 100644 --- a/app/src/main/java/app/TenantAdminController.java +++ b/app/src/main/java/app/TenantAdminController.java @@ -57,11 +57,11 @@ public JurisdictionDTO createJurisdictionRemoteHostsJson(String jurisdictionId, return jurisdictionService.setJurisdictionRemoteHosts(jurisdictionId, remoteHosts); } - @Patch(uris = {"/jurisdictions/{jurisdictionId}{?tenant_id}", "/jurisdictions/{jurisdictionId}.json{?tenant_id}"}) + @Patch(uris = {"/jurisdictions/{jurisdictionId}{?jurisdiction_id}", "/jurisdictions/{jurisdictionId}.json{?jurisdiction_id}"}) @ExecuteOn(TaskExecutors.IO) - @RequiresPermissions({LIBRE311_ADMIN_EDIT_SYSTEM, LIBRE311_ADMIN_EDIT_TENANT}) + @RequiresPermissions({LIBRE311_ADMIN_EDIT_SYSTEM, LIBRE311_ADMIN_EDIT_TENANT, LIBRE311_ADMIN_EDIT_SUBTENANT}) public JurisdictionDTO updateJurisdictionJson(String jurisdictionId, @Valid @Body PatchJurisdictionDTO requestDTO, - @Nullable @QueryValue("tenant_id") Long tenant_id) { + @Nullable @QueryValue("jurisdiction_id") String jurisdiction_id) { return jurisdictionService.updateJurisdiction(jurisdictionId, requestDTO); } } diff --git a/app/src/main/java/app/security/UnityAuthClient.java b/app/src/main/java/app/security/UnityAuthClient.java index 02f730674..34af912cf 100644 --- a/app/src/main/java/app/security/UnityAuthClient.java +++ b/app/src/main/java/app/security/UnityAuthClient.java @@ -26,24 +26,24 @@ import static io.micronaut.context.env.Environment.TEST; -@Client(id = "auth", path = "api/") +@Client(id = "auth") @Requires(notEnv = TEST) public interface UnityAuthClient { - @Post( "/hasPermission") + @Post("/auth/hasPermission") HttpResponse hasPermission(@Body HasPermissionRequest requestDTO, @Header("Authorization") String authorizationHeader); - @Post("/principal/permissions") + @Post("/auth/principal/permissions") HttpResponse getUserPermissions( @Body UnityAuthUserPermissionsRequest requestDTO, @Header("Authorization") String authorizationHeader); - @Post("/password-reset/generate") + @Post("/auth/password-reset/generate") HttpResponse generateToken( @Body GenerateTokenRequest request, @Header("X-Unity-Auth-Internal") String internalToken); - @Post("/password-reset/reset") + @Post("/auth/password-reset/reset") HttpResponse resetPassword(@Body ResetPasswordRequest request, @Header("X-Unity-Auth-Internal") String internalToken); } \ No newline at end of file diff --git a/app/src/main/java/app/service/jurisdiction/JurisdictionService.java b/app/src/main/java/app/service/jurisdiction/JurisdictionService.java index ca7ba14e4..9b25f3527 100644 --- a/app/src/main/java/app/service/jurisdiction/JurisdictionService.java +++ b/app/src/main/java/app/service/jurisdiction/JurisdictionService.java @@ -59,7 +59,7 @@ public JurisdictionAlreadyExists(String id) { private static final Logger LOG = LoggerFactory.getLogger(JurisdictionService.class); - @Property(name = "micronaut.http.services.auth.url") + @Property(name = "app.auth-base-url") protected String authUrl; private final JurisdictionRepository jurisdictionRepository; diff --git a/app/src/main/resources/application-docker.yml b/app/src/main/resources/application-docker.yml index 12b68020e..f7e29bbc0 100644 --- a/app/src/main/resources/application-docker.yml +++ b/app/src/main/resources/application-docker.yml @@ -1,13 +1,4 @@ --- -# NOTE: In the Docker environment, micronaut.http.services.auth.url is used by both -# the libre311-api service to get user permission (see PermissionsController.java) and -# the browser on host (it is returned to the browser by the frontend). -# So the setup must be so that this URL is resolved correctly by both of them. -# For example, if it is set to http://unity-auth-api:9090, the host must be able -# to access the unity-auth-api service from port 9090 on host, and the libre311-api -# service also must be able to access unity-auth-api using port 9090 on its container. -# This means the container for unity-auth-api must bind port 9090 on host to port 9090 -# on the container. micronaut: http: services: @@ -21,7 +12,7 @@ micronaut: signatures: jwks: unity: - url: ${AUTH_JWKS:`http://unity-auth-api:9090/keys`} + url: ${AUTH_JWKS:`http://unity-auth-api:9090/auth/keys`} redirect: login-success: http://localhost:3000 login-failure: http://localhost:3000/?login-failed=true diff --git a/app/src/main/resources/application-local.yml b/app/src/main/resources/application-local.yml index 251d062d5..59d696f9c 100644 --- a/app/src/main/resources/application-local.yml +++ b/app/src/main/resources/application-local.yml @@ -16,7 +16,7 @@ micronaut: signatures: jwks: unity: - url: ${AUTH_JWKS:`http://localhost:9090/keys`} + url: ${AUTH_JWKS:`http://localhost:9090/auth/keys`} redirect: login-success: http://localhost:3000 login-failure: http://localhost:3000/?login-failed=true diff --git a/app/src/main/resources/application.yml b/app/src/main/resources/application.yml index 62847b95e..34327c001 100644 --- a/app/src/main/resources/application.yml +++ b/app/src/main/resources/application.yml @@ -100,6 +100,7 @@ unity: app: service-id: ${LIBRE311_SERVICE_ID} base-url: ${APP_BASE_URL} + auth-base-url: /auth discovery: changeset: ${LIBRE311_DISCOVERY_CHANGESET_DATETIME:`2012-09-14T08:00:00-07:00`} diff --git a/app/src/test/java/app/RootControllerTest.java b/app/src/test/java/app/RootControllerTest.java index b3c50e9a5..6e2b6f2f4 100644 --- a/app/src/test/java/app/RootControllerTest.java +++ b/app/src/test/java/app/RootControllerTest.java @@ -266,7 +266,7 @@ private void setupBikeLaneServiceDefinition(Jurisdiction city, ServiceGroup infr private void addValuesToAttribute(ServiceDefinitionAttribute serviceDefinitionAttribute, Set values) { values.forEach(s -> serviceDefinitionAttribute.addAttributeValue( - attributeValueRepository.save(new AttributeValue(sidewalkMultiValueAttr, s)) + attributeValueRepository.save(new AttributeValue(serviceDefinitionAttribute, s)) )); } @@ -377,9 +377,12 @@ public void canCreateServiceRequestWithVaryingDatatypes() { ServiceRequestDTO serviceRequestDTO = serviceRequestDTOS[0]; assertFalse(serviceRequestDTO.getSelectedValues().isEmpty()); - ServiceDefinitionAttributeDTO serviceDefinitionAttributeDTO = serviceRequestDTO.getSelectedValues().get(0); - assertNotNull(serviceDefinitionAttributeDTO.getValues()); - assertFalse(serviceDefinitionAttributeDTO.getValues().isEmpty()); + ServiceDefinitionAttributeDTO multiValueAttrDTO = serviceRequestDTO.getSelectedValues().stream() + .filter(a -> a.getId().equals(sidewalkMultiValueAttr.getId())) + .findFirst() + .orElseThrow(() -> new AssertionError("Multi-value attribute not found in response")); + assertNotNull(multiValueAttrDTO.getValues()); + assertFalse(multiValueAttrDTO.getValues().isEmpty()); } @Test @@ -689,8 +692,8 @@ public void getJurisdictionTest() { JurisdictionDTO infoResponse = response.getBody().get(); assertEquals("1", infoResponse.getJurisdictionId()); assertEquals("jurisdiction1", infoResponse.getName()); - // from application-test.yml's property `micronaut.http.services.auth.urls` - assertEquals("http://localhost:8080", infoResponse.getUnityAuthUrl()); + // hardcoded in application.yml as the frontend-facing relative path prefix + assertEquals("/auth", infoResponse.getUnityAuthUrl()); assertNotNull(infoResponse.getBounds()); assertTrue(infoResponse.getBounds().length > 0); } diff --git a/auth/DockerfileAPI b/auth/DockerfileAPI new file mode 100644 index 000000000..0f82d944b --- /dev/null +++ b/auth/DockerfileAPI @@ -0,0 +1,33 @@ +# Stage 1: Build with Gradle +FROM gradle:9-jdk25 AS builder +WORKDIR /workspace + +ARG CLOUD_DB=false +ENV UNITYAUTH_CLOUD_DB=${CLOUD_DB} + +# Copy Gradle config files +COPY auth/gradle.properties auth/settings.gradle ./ + +# Download Gradle +RUN gradle --version --no-daemon + +COPY --chown=gradle:gradle ./auth . + +RUN gradle buildLayers --no-daemon + +# Stage 2: Create the final image +FROM eclipse-temurin:25-jre + +WORKDIR /home/app + +# Micronaut 4.x layer structure +COPY --from=builder /workspace/build/docker/main/layers/libs /home/app/libs +COPY --from=builder /workspace/build/docker/main/layers/resources /home/app/resources +COPY --from=builder /workspace/build/docker/main/layers/app/application.jar /home/app/application.jar + +RUN useradd -u 8877 unity +# Change to non-root privilege +USER unity + +EXPOSE 9090 +ENTRYPOINT ["java", "-jar", "/home/app/application.jar"] diff --git a/auth/build.gradle b/auth/build.gradle new file mode 100644 index 000000000..caa40fe33 --- /dev/null +++ b/auth/build.gradle @@ -0,0 +1,87 @@ +plugins { + id("com.gradleup.shadow") version "8.3.9" + id("io.micronaut.application") version "4.6.1" + id("io.micronaut.test-resources") version "4.6.1" + id("io.micronaut.aot") version "4.6.1" +} + +version = "0.1" +group = "io.unityfoundation" + +repositories { + mavenCentral() +} + +dependencies { + annotationProcessor("io.micronaut.data:micronaut-data-processor") + annotationProcessor("io.micronaut:micronaut-http-validation") + annotationProcessor("io.micronaut.security:micronaut-security-annotations") + annotationProcessor("io.micronaut.serde:micronaut-serde-processor") + implementation("io.micronaut.security:micronaut-security-jwt") + implementation("io.micronaut.data:micronaut-data-jdbc") + implementation("io.micronaut.sql:micronaut-jdbc-hikari") + implementation("io.micronaut.flyway:micronaut-flyway") + implementation("io.micronaut.serde:micronaut-serde-jackson") + implementation("io.micronaut.reactor:micronaut-reactor") + implementation("at.favre.lib:bcrypt:0.10.2") + compileOnly("io.micronaut:micronaut-http-client") + runtimeOnly("ch.qos.logback:logback-classic") + runtimeOnly("org.yaml:snakeyaml") + + Boolean cloudDb = Boolean.parseBoolean(System.getenv("UNITYAUTH_CLOUD_DB")) + runtimeOnly("mysql:mysql-connector-java") + runtimeOnly("org.flywaydb:flyway-mysql") + if (cloudDb) { + runtimeOnly('com.google.cloud.sql:mysql-socket-factory-connector-j-8:1.7.2') + } + testImplementation("io.micronaut:micronaut-http-client") + + aotPlugins platform("io.micronaut.platform:micronaut-platform:${micronautVersion}") + aotPlugins("io.micronaut.security:micronaut-security-aot") +} + +application { + mainClass.set("io.unityfoundation.Application") +} + +java { + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 +} +run { + systemProperties([ + 'micronaut.environments': 'local' + ]) +} +graalvmNative.toolchainDetection = false + +micronaut { + runtime("netty") + testRuntime("junit5") + processing { + incremental(true) + annotations("io.unityfoundation.*") + } + testResources { + enabled = !gradle.startParameter.taskNames.any{it.endsWith(":run")} + additionalModules.add("jdbc-mysql") + } + aot { + optimizeServiceLoading = false + convertYamlToJava = false + precomputeOperations = true + cacheEnvironment = true + optimizeClassLoading = true + deduceEnvironment = true + optimizeNetty = true + configurationProperties.put("micronaut.security.jwks.enabled", "false") + } +} + +tasks.withType(io.micronaut.gradle.testresources.StartTestResourcesService).configureEach { + useClassDataSharing = false +} + +tasks.named('shadowJar') { + zip64 = true +} diff --git a/auth/gradle.properties b/auth/gradle.properties new file mode 100644 index 000000000..8ce6479ff --- /dev/null +++ b/auth/gradle.properties @@ -0,0 +1 @@ +micronautVersion=4.6.1 diff --git a/auth/settings.gradle b/auth/settings.gradle new file mode 100644 index 000000000..28c16667e --- /dev/null +++ b/auth/settings.gradle @@ -0,0 +1 @@ +rootProject.name = "auth" diff --git a/auth/src/main/java/io/unityfoundation/Application.java b/auth/src/main/java/io/unityfoundation/Application.java new file mode 100644 index 000000000..057e5b88f --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/Application.java @@ -0,0 +1,10 @@ +package io.unityfoundation; + +import io.micronaut.runtime.Micronaut; + +public class Application { + + public static void main(String[] args) { + Micronaut.run(Application.class, args); + } +} \ No newline at end of file diff --git a/auth/src/main/java/io/unityfoundation/auth/AuthController.java b/auth/src/main/java/io/unityfoundation/auth/AuthController.java new file mode 100644 index 000000000..cd3fc39cd --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/AuthController.java @@ -0,0 +1,233 @@ +package io.unityfoundation.auth; + +import io.micronaut.core.annotation.Nullable; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.*; +import io.micronaut.http.exceptions.HttpStatusException; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.serde.annotation.Serdeable; +import io.unityfoundation.auth.entities.*; +import io.unityfoundation.auth.entities.Service.ServiceStatus; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import java.util.Optional; + +@Secured("USER") +@Controller +public class AuthController { + + private final UserRepo userRepo; + private final ServiceRepo serviceRepo; + private final TenantRepo tenantRepo; + private final RoleRepo roleRepo; + private final PermissionsService permissionsService; + + public AuthController(UserRepo userRepo, ServiceRepo serviceRepo, TenantRepo tenantRepo, RoleRepo roleRepo, PermissionsService permissionsService) { + this.userRepo = userRepo; + this.serviceRepo = serviceRepo; + this.tenantRepo = tenantRepo; + this.roleRepo = roleRepo; + this.permissionsService = permissionsService; + } + + @Post("/principal/permissions") + public UserPermissionsResponse permissions(@Body UserPermissionsRequest requestDTO, + Authentication authentication) { + Optional maybeTenant = tenantRepo.findById(requestDTO.tenantId()); + if (maybeTenant.isEmpty()){ + return new UserPermissionsResponse.Failure("No tenant found."); + } + Tenant tenant = maybeTenant.get(); + + if (!tenant.getStatus().equals(Tenant.TenantStatus.ENABLED)){ + return new UserPermissionsResponse.Failure("The tenant is not enabled."); + } + + User user = userRepo.findByEmail(authentication.getName()).orElse(null); + if (checkUserStatus(user)) { + return new UserPermissionsResponse.Failure("The user's account has been disabled."); + } + + Service service = serviceRepo.findById(requestDTO.serviceId()) + .orElseThrow(() -> new HttpStatusException(HttpStatus.NOT_FOUND, "Service not found.")); + + if (service.getStatus() == ServiceStatus.DISABLED) { + throw new HttpStatusException(HttpStatus.FORBIDDEN, "The service is disabled."); + } else if (service.getStatus() == ServiceStatus.DOWN_FOR_MAINTENANCE) { + + throw new HttpStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "The service is down for maintenance."); + } + + if (!userRepo.isServiceAvailable(user.getId(), service.getId())) { + return new UserPermissionsResponse.Failure( + "The Tenant and/or Service is not available for this user"); + } + + return new UserPermissionsResponse.Success(permissionsService.getPermissionsFor(user, tenant)); + } + + @Post("/hasPermission") + public HttpResponse hasPermission(@Body HasPermissionRequest requestDTO, + Authentication authentication) { + + Optional tenantOptional = tenantRepo.findById(requestDTO.tenantId()); + if (tenantOptional.isEmpty()) { + return createHasPermissionResponse(false, authentication.getName(),"Cannot find tenant!", List.of()); + } + + User user = userRepo.findByEmail(authentication.getName()).orElse(null); + if (checkUserStatus(user)) { + return createHasPermissionResponse(false, authentication.getName(), "The user’s account has been disabled!", List.of()); + } + + Optional service = serviceRepo.findById(requestDTO.serviceId()); + + String serviceStatusCheckResult = checkServiceStatus(service); + if (serviceStatusCheckResult != null) { + return createHasPermissionResponse(false, user.getEmail(), serviceStatusCheckResult, List.of()); + } + + if (!userRepo.isServiceAvailable(user.getId(), service.get().getId())) { + return createHasPermissionResponse(false, user.getEmail(), "The requested service is not enabled for the requested tenant!", List.of()); + } + + List commonPermissions = permissionsService.checkUserPermission(user, tenantOptional.get(), requestDTO.permissions()); + if (commonPermissions.isEmpty()) { + return createHasPermissionResponse(false, user.getEmail(), "The user does not have permission!", commonPermissions); + } + + return createHasPermissionResponse(true, user.getEmail(), null, commonPermissions); + } + + @Get("/roles") + public HttpResponse> getRoles(Authentication authentication) { + + User user = userRepo.findByEmail(authentication.getName()).orElse(null); + if (checkUserStatus(user)) { + throw new HttpStatusException(HttpStatus.FORBIDDEN, "The user is disabled."); + } + + List commonPermissions = permissionsService.checkUserPermissionsAcrossAllTenants( + user, List.of("AUTH_SERVICE_VIEW-SYSTEM", "AUTH_SERVICE_VIEW-TENANT")); + if (commonPermissions.isEmpty()) { + throw new HttpStatusException(HttpStatus.FORBIDDEN, "The user does not have permission!"); + } + + return HttpResponse.ok(roleRepo.findAll().stream() + .map(role -> new RoleDTO(role.getId(), role.getName(), role.getDescription())) + .toList()); + } + + @Get("/tenants") + public HttpResponse> getTenants(Authentication authentication) { + + String authenticatedUserEmail = authentication.getName(); + User user = userRepo.findByEmail(authenticatedUserEmail).orElse(null); + if (checkUserStatus(user)) { + throw new HttpStatusException(HttpStatus.FORBIDDEN, "The user is disabled."); + } + + List commonPermissions = permissionsService.checkUserPermissionsAcrossAllTenants( + user, List.of("AUTH_SERVICE_VIEW-SYSTEM", "AUTH_SERVICE_VIEW-TENANT")); + if (commonPermissions.isEmpty()) { + throw new HttpStatusException(HttpStatus.FORBIDDEN, "The user does not have permission!"); + } + + List tenants = userRepo.existsByEmailAndRoleEqualsUnityAdmin(authenticatedUserEmail) ? + tenantRepo.findAll() : tenantRepo.findAllByUserEmail(authenticatedUserEmail); + + return HttpResponse.ok(tenants.stream() + .map(tenant -> new TenantDTO(tenant.getId(), tenant.getName())) + .toList()); + } + + @Get("/tenants/{id}/users") + public HttpResponse> getTenantUsers(@PathVariable Long id, Authentication authentication) { + + // reject if the declared tenant does not exist + Optional tenantOptional = tenantRepo.findById(id); + if (tenantOptional.isEmpty()) { + throw new HttpStatusException(HttpStatus.NOT_FOUND, "Tenant not found."); + } + + User user = userRepo.findByEmail(authentication.getName()).orElse(null); + if (checkUserStatus(user)) { + throw new HttpStatusException(HttpStatus.FORBIDDEN, "The user is disabled."); + } + + List commonPermissions = permissionsService.checkUserPermission(user, tenantOptional.get(), + List.of("AUTH_SERVICE_VIEW-SYSTEM", "AUTH_SERVICE_VIEW-TENANT")); + if (commonPermissions.isEmpty()) { + throw new HttpStatusException(HttpStatus.FORBIDDEN, "The user does not have permission!"); + } + + // todo: it would be nice to capture the roles and have them automatically mapped to UserResponse.roles + List tenantUsers = userRepo.findAllByTenantId(id).stream().map(tenantUser -> + new UserResponse(tenantUser.getId(), tenantUser.getEmail(), tenantUser.getFirstName(), tenantUser.getLastName(), + userRepo.getUserRolesByUserId(tenantUser.getId()) + )).toList(); + + return HttpResponse.ok(tenantUsers); + } + + private boolean checkUserStatus(User user) { + return user == null || user.getStatus() != User.UserStatus.ENABLED; + } + + private String checkServiceStatus(Optional service) { + if (service.isEmpty()) { + return "The service does not exists!"; + } else { + ServiceStatus status = service.get().getStatus(); + if (ServiceStatus.DISABLED.equals(status)) { + return "The service is disabled!"; + } else if (ServiceStatus.DOWN_FOR_MAINTENANCE.equals(status)) { + return "The service is temporarily down for maintenance!"; + } + } + return null; + } + + private HttpResponse createHasPermissionResponse(boolean hasPermission, + String userEmail, + String message, + List permissions) { + return HttpResponse.ok(new HasPermissionResponse(hasPermission, userEmail, message, permissions)); + } + + @Serdeable + public record TenantDTO( + Long id, + String name + ) {} + + @Serdeable + public record RoleDTO( + Long id, + String name, + String description + ) {} + + @Serdeable + public record HasPermissionResponse( + boolean hasPermission, + @Nullable String userEmail, + @Nullable String errorMessage, + List permissions + ) {} + + public sealed interface UserPermissionsResponse { + @Serdeable + record Success(List permissions) implements UserPermissionsResponse {} + @Serdeable + record Failure(String errorMessage) implements UserPermissionsResponse {} + } + + @Serdeable + public record UserPermissionsRequest(@NotNull Long tenantId, + @NotNull Long serviceId) {} + +} diff --git a/auth/src/main/java/io/unityfoundation/auth/BCryptPasswordEncoder.java b/auth/src/main/java/io/unityfoundation/auth/BCryptPasswordEncoder.java new file mode 100644 index 000000000..f35143bde --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/BCryptPasswordEncoder.java @@ -0,0 +1,32 @@ +package io.unityfoundation.auth; + +import io.micronaut.core.annotation.NonNull; +import jakarta.inject.Singleton; +import jakarta.validation.constraints.NotBlank; +import at.favre.lib.crypto.bcrypt.BCrypt; + +@Singleton +class BCryptPasswordEncoder implements PasswordEncoder { + + private final BCrypt.Verifyer pwdVerifier = BCrypt.verifyer(); + + public String encode(@NotBlank @NonNull String rawPassword) { + char[] passwordCharacters = rawPassword.toCharArray(); + return hashPassword(passwordCharacters); + } + + private String hashPassword(char[] passwordCharacters) { + return BCrypt.withDefaults().hashToString(10, passwordCharacters); + } + + @Override + public boolean matches(@NotBlank @NonNull String rawPassword, + @NotBlank @NonNull String encodedPassword) { + BCrypt.Result verificationResult = verifyPassword(rawPassword, encodedPassword); + return verificationResult.verified; + } + + private BCrypt.Result verifyPassword(String rawPassword, String encodedPassword) { + return pwdVerifier.verify(rawPassword.getBytes(), encodedPassword.getBytes()); + } +} \ No newline at end of file diff --git a/auth/src/main/java/io/unityfoundation/auth/HasPermissionRequest.java b/auth/src/main/java/io/unityfoundation/auth/HasPermissionRequest.java new file mode 100644 index 000000000..41a89e381 --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/HasPermissionRequest.java @@ -0,0 +1,13 @@ +package io.unityfoundation.auth; + +import io.micronaut.serde.annotation.Serdeable; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +@Serdeable +public record HasPermissionRequest( + @NotNull Long tenantId, + @NotNull Long serviceId, + List permissions + +) {} diff --git a/auth/src/main/java/io/unityfoundation/auth/InternalAuthTokenReader.java b/auth/src/main/java/io/unityfoundation/auth/InternalAuthTokenReader.java new file mode 100644 index 000000000..668e363b3 --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/InternalAuthTokenReader.java @@ -0,0 +1,18 @@ +package io.unityfoundation.auth; + +import io.micronaut.security.token.reader.HttpHeaderTokenReader; +import jakarta.inject.Singleton; + +@Singleton +public class InternalAuthTokenReader extends HttpHeaderTokenReader { + + @Override + protected String getPrefix() { + return null; + } + + @Override + protected String getHeaderName() { + return "X-Unity-Auth-Internal"; + } +} diff --git a/auth/src/main/java/io/unityfoundation/auth/InternalAuthTokenValidator.java b/auth/src/main/java/io/unityfoundation/auth/InternalAuthTokenValidator.java new file mode 100644 index 000000000..bb710df04 --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/InternalAuthTokenValidator.java @@ -0,0 +1,30 @@ +package io.unityfoundation.auth; + +import io.micronaut.context.annotation.Value; +import io.micronaut.http.HttpRequest; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.token.validator.TokenValidator; +import jakarta.inject.Singleton; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; + +@Singleton +public class InternalAuthTokenValidator implements TokenValidator> { + + @Value("${unity.auth.internal-token}") + private String internalToken; + + @Override + public Publisher validateToken(String token, HttpRequest request) { + if (internalToken != null && MessageDigest.isEqual( + internalToken.getBytes(StandardCharsets.UTF_8), + token.getBytes(StandardCharsets.UTF_8))) { + return Mono.just(Authentication.build("internal-service", List.of("INTERNAL_SERVICE"))); + } + return Mono.empty(); + } +} diff --git a/auth/src/main/java/io/unityfoundation/auth/NullOrNotBlank.java b/auth/src/main/java/io/unityfoundation/auth/NullOrNotBlank.java new file mode 100644 index 000000000..763101319 --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/NullOrNotBlank.java @@ -0,0 +1,21 @@ +package io.unityfoundation.auth; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Target({ElementType.FIELD}) +@Retention(RUNTIME) +@Documented +@Constraint(validatedBy = NullOrNotBlankValidator.class) +public @interface NullOrNotBlank { + String message() default "{javax.validation.constraints.NullOrNotBlank.message}"; + Class[] groups() default { }; + Class[] payload() default {}; +} diff --git a/auth/src/main/java/io/unityfoundation/auth/NullOrNotBlankValidator.java b/auth/src/main/java/io/unityfoundation/auth/NullOrNotBlankValidator.java new file mode 100644 index 000000000..f373492bb --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/NullOrNotBlankValidator.java @@ -0,0 +1,12 @@ +package io.unityfoundation.auth; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class NullOrNotBlankValidator implements ConstraintValidator { + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + return value == null || !value.trim().isEmpty(); + } +} diff --git a/auth/src/main/java/io/unityfoundation/auth/PasswordEncoder.java b/auth/src/main/java/io/unityfoundation/auth/PasswordEncoder.java new file mode 100644 index 000000000..33ae0cd2a --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/PasswordEncoder.java @@ -0,0 +1,18 @@ +package io.unityfoundation.auth; + +import jakarta.validation.constraints.NotBlank; + +public interface PasswordEncoder { + + String encode(@NotBlank String rawPassword); + + /** + * Checks if the provided raw password matches the encoded password. + * + * @param rawPassword The raw password that needs to be checked. + * @param encodedPassword The encoded password to compare against. + * @return {@code true} if the raw password matches the encoded password, otherwise {@code false}. + */ + boolean matches(@NotBlank String rawPassword, + @NotBlank String encodedPassword); +} \ No newline at end of file diff --git a/auth/src/main/java/io/unityfoundation/auth/PasswordResetController.java b/auth/src/main/java/io/unityfoundation/auth/PasswordResetController.java new file mode 100644 index 000000000..068019066 --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/PasswordResetController.java @@ -0,0 +1,106 @@ +package io.unityfoundation.auth; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.*; +import io.micronaut.http.exceptions.HttpStatusException; +import io.micronaut.security.annotation.Secured; +import io.micronaut.serde.annotation.Serdeable; +import io.unityfoundation.auth.entities.PasswordResetToken; +import io.unityfoundation.auth.entities.PasswordResetTokenRepo; +import io.unityfoundation.auth.entities.User; +import io.unityfoundation.auth.entities.UserRepo; +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HexFormat; +import java.util.Optional; +import java.util.UUID; + +@Secured("INTERNAL_SERVICE") +@Controller("/password-reset") +public class PasswordResetController { + + private final UserRepo userRepo; + private final PasswordResetTokenRepo tokenRepo; + private final PasswordEncoder passwordEncoder; + + public PasswordResetController(UserRepo userRepo, PasswordResetTokenRepo tokenRepo, PasswordEncoder passwordEncoder) { + this.userRepo = userRepo; + this.tokenRepo = tokenRepo; + this.passwordEncoder = passwordEncoder; + } + + @Post("/generate") + public HttpResponse generateToken(@Body @Valid GenerateTokenRequest request) { + Optional userOptional = userRepo.findByEmail(request.email()); + if (userOptional.isEmpty()) { + throw new HttpStatusException(HttpStatus.NOT_FOUND, "User not found"); + } + + User user = userOptional.get(); + tokenRepo.deleteByUserId(user.getId()); + + String rawToken = UUID.randomUUID().toString(); + PasswordResetToken token = new PasswordResetToken(); + token.setToken(sha256(rawToken)); + token.setUserId(user.getId()); + token.setExpiry(Instant.now().plus(1, ChronoUnit.HOURS)); + tokenRepo.save(token); + + return HttpResponse.ok(new GenerateTokenResponse(rawToken)); + } + + @Post("/reset") + @Transactional + public HttpResponse resetPassword(@Body @Valid ResetPasswordRequest request) { + Optional tokenOptional = tokenRepo.findByToken(sha256(request.token())); + + if (tokenOptional.isEmpty()) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid token"); + } + + PasswordResetToken token = tokenOptional.get(); + if (token.getExpiry().isBefore(Instant.now())) { + tokenRepo.delete(token); + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Token expired"); + } + + Optional userOptional = userRepo.findById(token.getUserId()); + if (userOptional.isEmpty()) { + throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "User not found"); + } + + User user = userOptional.get(); + user.setPassword(passwordEncoder.encode(request.newPassword())); + userRepo.update(user); + + tokenRepo.delete(token); + + return HttpResponse.ok(); + } + + private static String sha256(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(input.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + @Serdeable + public record GenerateTokenRequest(@NotBlank String email) {} + + @Serdeable + public record GenerateTokenResponse(@NotBlank String token) {} + + @Serdeable + public record ResetPasswordRequest(@NotBlank String token, @NotBlank String newPassword) {} +} diff --git a/auth/src/main/java/io/unityfoundation/auth/PermissionsService.java b/auth/src/main/java/io/unityfoundation/auth/PermissionsService.java new file mode 100644 index 000000000..9b8351db1 --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/PermissionsService.java @@ -0,0 +1,61 @@ +package io.unityfoundation.auth; + +import io.micronaut.core.annotation.Introspected; +import io.unityfoundation.auth.entities.Permission; +import io.unityfoundation.auth.entities.Tenant; +import io.unityfoundation.auth.entities.User; +import io.unityfoundation.auth.entities.UserRepo; +import jakarta.inject.Singleton; + +import java.util.List; +import java.util.function.BiPredicate; +import java.util.function.Predicate; + +@Singleton +public class PermissionsService { + + private final UserRepo userRepo; + + private final BiPredicate isTenantOrSystemOrSubtenantScopeAndBelongsToTenant = (tp, t) -> + Permission.PermissionScope.SYSTEM.equals(tp.permissionScope()) || ( + (Permission.PermissionScope.TENANT.equals(tp.permissionScope()) + || Permission.PermissionScope.SUBTENANT.equals(tp.permissionScope())) + && tp.tenantId == t.getId()); + + private final Predicate isTenantOrSystemOrSubtenantScope = (tp) -> + Permission.PermissionScope.SYSTEM.equals(tp.permissionScope()) || ( + (Permission.PermissionScope.TENANT.equals(tp.permissionScope()) + || Permission.PermissionScope.SUBTENANT.equals(tp.permissionScope()))); + + public PermissionsService(UserRepo userRepo) { + this.userRepo = userRepo; + } + + public List checkUserPermission(User user, Tenant tenant, List permissions) { + return getPermissionsFor(user, tenant).stream() + .filter(permissions::contains).toList(); + } + + public List getPermissionsFor(User user, Tenant tenant) { + return userRepo.getTenantPermissionsFor(user.getId()).stream() + .filter(tenantPermission -> + isTenantOrSystemOrSubtenantScopeAndBelongsToTenant.test(tenantPermission, tenant)) + .map(TenantPermission::permissionName) + .toList(); + } + + public List checkUserPermissionsAcrossAllTenants(User user, List permissions) { + return userRepo.getTenantPermissionsFor(user.getId()).stream() + .filter(isTenantOrSystemOrSubtenantScope) + .map(TenantPermission::permissionName) + .filter(permissions::contains) + .toList(); + } + + @Introspected + public record TenantPermission( + long tenantId, + String permissionName, + Permission.PermissionScope permissionScope + ) {} +} diff --git a/auth/src/main/java/io/unityfoundation/auth/UnityAuthenticationProvider.java b/auth/src/main/java/io/unityfoundation/auth/UnityAuthenticationProvider.java new file mode 100644 index 000000000..c32109fab --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/UnityAuthenticationProvider.java @@ -0,0 +1,82 @@ +package io.unityfoundation.auth; + +import static io.micronaut.security.authentication.AuthenticationFailureReason.CREDENTIALS_DO_NOT_MATCH; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.http.HttpRequest; +import io.micronaut.security.authentication.AuthenticationFailed; +import io.micronaut.security.authentication.AuthenticationRequest; +import io.micronaut.security.authentication.AuthenticationResponse; +import io.micronaut.security.authentication.provider.ReactiveAuthenticationProvider; +import io.unityfoundation.auth.entities.User; +import io.unityfoundation.auth.entities.UserRepo; +import jakarta.inject.Singleton; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Singleton +public class UnityAuthenticationProvider implements ReactiveAuthenticationProvider,Object,Object> { + + private final UserRepo userRepo; + private final PasswordEncoder passwordEncoder; + + public UnityAuthenticationProvider(UserRepo userRepo, + PasswordEncoder passwordEncoder) { + this.userRepo = userRepo; + this.passwordEncoder = passwordEncoder; + } + + + private AuthenticationFailed validate(User user, + AuthenticationRequest authenticationRequest) { + AuthenticationFailed authenticationFailed = null; + if (user == null) { + authenticationFailed = new AuthenticationFailed(CREDENTIALS_DO_NOT_MATCH); + } else if (!passwordEncoder.matches(authenticationRequest.getSecret().toString(), + user.getPassword())) { + authenticationFailed = new AuthenticationFailed(CREDENTIALS_DO_NOT_MATCH); + } + + return authenticationFailed; + } + + private User findUser(AuthenticationRequest authRequest) { + final Object username = authRequest.getIdentity(); + return userRepo.findUserForAuthentication(username.toString()).orElse(null); + } + + @Override + public @NonNull Publisher authenticate(@Nullable HttpRequest requestContext, + @NonNull AuthenticationRequest authenticationRequest) { + return authenticate(authenticationRequest); + } + + @Override + public @NonNull Publisher authenticate( + @NonNull AuthenticationRequest authenticationRequest) { + return Mono.fromCallable(() -> findUser(authenticationRequest)) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(user -> { + AuthenticationFailed authenticationFailed = validate(user, authenticationRequest); + if (authenticationFailed != null) { + return Mono.just(AuthenticationResponse.failure(authenticationFailed.getReason().toString())); + } else { + return Mono.just(AuthenticationResponse.success( + (String) authenticationRequest.getIdentity(), + List.of("USER"), + Map.of( + "first_name", Objects.toString(user.getFirstName(), ""), + "last_name", Objects.toString(user.getLastName(), "") + ) + )); + } + }); + } +} + diff --git a/auth/src/main/java/io/unityfoundation/auth/UserController.java b/auth/src/main/java/io/unityfoundation/auth/UserController.java new file mode 100644 index 000000000..349c06c2a --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/UserController.java @@ -0,0 +1,221 @@ +package io.unityfoundation.auth; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.*; +import io.micronaut.http.exceptions.HttpStatusException; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.serde.annotation.Serdeable; +import io.unityfoundation.auth.entities.*; +import jakarta.transaction.Transactional; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +@Secured("USER") +@Controller("/users") +public class UserController { + + private final UserRepo userRepo; + private final TenantRepo tenantRepo; + private final RoleRepo roleRepo; + private final PasswordEncoder passwordEncoder; + private final PermissionsService permissionsService; + + public UserController(UserRepo userRepo, TenantRepo tenantRepo, RoleRepo roleRepo, PasswordEncoder passwordEncoder, PermissionsService permissionsService) { + this.userRepo = userRepo; + this.tenantRepo = tenantRepo; + this.roleRepo = roleRepo; + this.passwordEncoder = passwordEncoder; + this.permissionsService = permissionsService; + } + + @Post + public HttpResponse createUser(@Body AddUserRequest requestDTO, + Authentication authentication) { + + Long requestTenantId = requestDTO.tenantId(); + + Optional tenantOptional = tenantRepo.findById(requestTenantId); + if (tenantOptional.isEmpty()) { + throw new HttpStatusException(HttpStatus.NOT_FOUND, "Tenant not found."); + } + + Optional adminOptional = userRepo.findByEmail(authentication.getName()); + if (adminOptional.isEmpty()) { + throw new HttpStatusException(HttpStatus.FORBIDDEN, "The user is disabled."); + } + + User admin = adminOptional.get(); + + List commonPermissions = permissionsService.checkUserPermission(admin, tenantOptional.get(), + List.of("AUTH_SERVICE_EDIT-SYSTEM", "AUTH_SERVICE_EDIT-TENANT")); + if (commonPermissions.isEmpty()) { + throw new HttpStatusException(HttpStatus.FORBIDDEN, "The user does not have permission!"); + } + + // ignore roles not defined by system + List rolesIntersection = getRolesIntersection(requestDTO.roles()); + + // reject if caller is not a unity nor tenant admin of the declared tenant + if (!commonPermissions.contains("AUTH_SERVICE_EDIT-SYSTEM")) { + Role unityAdministrator = roleRepo.findByName("Unity Administrator"); + if (rolesIntersection.stream().anyMatch(roleId -> roleId.equals(unityAdministrator.getId()))){ + // authenticated tenant admin user cannot grant unity admin role + return HttpResponse.status(HttpStatus.FORBIDDEN, + "Authenticated user is not authorized to grant Unity Admin"); + } + } + + // reject if new user already exists under a tenant + if (userRepo.existsByEmailAndTenantId(requestDTO.email(), requestTenantId)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "User already exists"); + } + + // if the new user exists, create a new user-role entry + // otherwise, create the user along with user-role entry + Optional userOptional = userRepo.findByEmail(requestDTO.email()); + User user; + if (userOptional.isEmpty()) { + User newUser = new User(); + newUser.setEmail(requestDTO.email()); + newUser.setPassword(passwordEncoder.encode(requestDTO.password())); + newUser.setFirstName(requestDTO.firstName()); + newUser.setLastName(requestDTO.lastName()); + newUser.setStatus(User.UserStatus.ENABLED); + user = userRepo.save(newUser); + } else { + user = userOptional.get(); + } + + rolesIntersection.forEach(roleId -> userRepo.insertUserRole(user.getId(), requestTenantId, roleId)); + + return HttpResponse.created(new UserResponse(user.getId(), + user.getEmail(), + user.getFirstName(), + user.getLastName(), + rolesIntersection)); + } + + @Patch("{id}/roles") + public HttpResponse updateUserRoles(@PathVariable Long id, @Body UpdateUserRolesRequest requestDTO, + Authentication authentication) { + Long requestTenantId = requestDTO.tenantId(); + Optional tenantOptional = tenantRepo.findById(requestTenantId); + if (tenantOptional.isEmpty()) { + throw new HttpStatusException(HttpStatus.NOT_FOUND, "Tenant not found."); + } + + String authUserEmail = authentication.getName(); + Optional adminOptional = userRepo.findByEmail(authUserEmail); + if (adminOptional.isEmpty()) { + throw new HttpStatusException(HttpStatus.NOT_FOUND, "Authenticated user does not exist"); + } + User admin = adminOptional.get(); + + Optional userOptional = userRepo.findById(id); + if (userOptional.isEmpty()) { + throw new HttpStatusException(HttpStatus.NOT_FOUND, "User not found"); + } + User user = userOptional.get(); + + // ignore roles not defined by application + List rolesIntersection = getRolesIntersection(requestDTO.roles()); + + // if unity admin, proceed; otherwise, reject if roles exceed authenticated user's under same tenant. + List commonPermissions = permissionsService.checkUserPermission(admin, tenantOptional.get(), + List.of("AUTH_SERVICE_EDIT-SYSTEM", "AUTH_SERVICE_EDIT-TENANT")); + if (commonPermissions.isEmpty()) { + throw new HttpStatusException(HttpStatus.FORBIDDEN, "The user does not have permission!"); + } + + if (!commonPermissions.contains("AUTH_SERVICE_VIEW-SYSTEM")) { + Role unityAdministrator = roleRepo.findByName("Unity Administrator"); + if (rolesIntersection.stream().anyMatch(roleId -> roleId.equals(unityAdministrator.getId()))){ + // authenticated tenant admin user cannot grant unity admin role + return HttpResponse.status(HttpStatus.FORBIDDEN, + "Authenticated user is not authorized to grant Unity Admin"); + } + } + + applyRolesPatch(rolesIntersection, requestTenantId, user.getId()); + + return HttpResponse.created(new UserResponse(user.getId(), + user.getEmail(), + user.getFirstName(), + user.getLastName(), + rolesIntersection)); + } + + private List getRolesIntersection(List requestRoles) { + List roles = roleRepo.findAllRoleIds(); + return requestRoles.stream() + .distinct() + .filter(roles::contains) + .toList(); + } + + + @Transactional + public void applyRolesPatch(List requestRoles, Long requestTenantId, Long userId) { + userRepo.deleteRoleByTenantIdAndUserId(requestTenantId, userId); + requestRoles.forEach(roleId -> userRepo.insertUserRole(userId, requestTenantId, roleId)); + } + + @Patch("{id}") + public HttpResponse selfPatch(@PathVariable Long id, @Body UpdateSelfRequest requestDTO, + Authentication authentication) { + + Optional userOptional = userRepo.findByEmail(authentication.getName()); + if (userOptional.isEmpty()) { + throw new HttpStatusException(HttpStatus.NOT_FOUND, "User not found"); + } + + User user = userOptional.get(); + if (!Objects.equals(user.getId(), id)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "User id mismatch."); + } + + if (requestDTO.firstName != null) { + user.setFirstName(requestDTO.firstName); + } + if (requestDTO.lastName != null) { + user.setLastName(requestDTO.lastName); + } + if (requestDTO.password != null) { + user.setPassword(passwordEncoder.encode(requestDTO.password())); + } + + User saved = userRepo.update(user); + return HttpResponse.ok(new UserResponse(saved.getId(), saved.getEmail(), saved.getFirstName(), saved.getLastName(), + userRepo.getUserRolesByUserId(saved.getId()))); + } + + @Serdeable + public record UpdateUserRolesRequest( + @NotNull Long tenantId, + List roles) { + } + + @Serdeable + public record AddUserRequest( + @NotBlank String email, + @NotBlank String firstName, + @NotBlank String lastName, + @NotNull Long tenantId, + @NotBlank String password, + @NotEmpty List roles) { + } + + @Serdeable + public record UpdateSelfRequest( + @NullOrNotBlank String firstName, + @NullOrNotBlank String lastName, + @NullOrNotBlank String password) { + } +} diff --git a/auth/src/main/java/io/unityfoundation/auth/UserResponse.java b/auth/src/main/java/io/unityfoundation/auth/UserResponse.java new file mode 100644 index 000000000..7e6fe8db0 --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/UserResponse.java @@ -0,0 +1,15 @@ +package io.unityfoundation.auth; + +import io.micronaut.serde.annotation.Serdeable; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +@Serdeable +public record UserResponse( + Long id, + String email, + String firstName, + String lastName, + List roles) { +} diff --git a/auth/src/main/java/io/unityfoundation/auth/entities/PasswordResetToken.java b/auth/src/main/java/io/unityfoundation/auth/entities/PasswordResetToken.java new file mode 100644 index 000000000..5a24c31b2 --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/entities/PasswordResetToken.java @@ -0,0 +1,59 @@ +package io.unityfoundation.auth.entities; + +import io.micronaut.core.annotation.Introspected; +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; +import io.micronaut.serde.annotation.Serdeable; +import jakarta.validation.constraints.NotNull; + +import java.time.Instant; + +@Serdeable +@MappedEntity +public class PasswordResetToken { + @Id + @GeneratedValue + private Long id; + + @NotNull + private String token; + + @NotNull + private Long userId; + + @NotNull + private Instant expiry; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public Instant getExpiry() { + return expiry; + } + + public void setExpiry(Instant expiry) { + this.expiry = expiry; + } +} diff --git a/auth/src/main/java/io/unityfoundation/auth/entities/PasswordResetTokenRepo.java b/auth/src/main/java/io/unityfoundation/auth/entities/PasswordResetTokenRepo.java new file mode 100644 index 000000000..bd263b9fe --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/entities/PasswordResetTokenRepo.java @@ -0,0 +1,13 @@ +package io.unityfoundation.auth.entities; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; + +import java.util.Optional; + +@JdbcRepository(dialect = Dialect.MYSQL) +public interface PasswordResetTokenRepo extends CrudRepository { + Optional findByToken(String token); + void deleteByUserId(Long userId); +} diff --git a/auth/src/main/java/io/unityfoundation/auth/entities/Permission.java b/auth/src/main/java/io/unityfoundation/auth/entities/Permission.java new file mode 100644 index 000000000..c2d7bda67 --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/entities/Permission.java @@ -0,0 +1,21 @@ +package io.unityfoundation.auth.entities; + +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; + +@MappedEntity() +public class Permission { + @Id + @GeneratedValue + private Long id; + + private String name; + private String description; + private PermissionScope scope; + + public enum PermissionScope { + SYSTEM, TENANT, SUBTENANT + } + +} diff --git a/auth/src/main/java/io/unityfoundation/auth/entities/Role.java b/auth/src/main/java/io/unityfoundation/auth/entities/Role.java new file mode 100644 index 000000000..ed8899ede --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/entities/Role.java @@ -0,0 +1,39 @@ +package io.unityfoundation.auth.entities; + +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; + +@MappedEntity +public class Role { + @Id + @GeneratedValue + private Long id; + + private String name; + private String description; + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/auth/src/main/java/io/unityfoundation/auth/entities/RoleRepo.java b/auth/src/main/java/io/unityfoundation/auth/entities/RoleRepo.java new file mode 100644 index 000000000..37a0612e9 --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/entities/RoleRepo.java @@ -0,0 +1,17 @@ +package io.unityfoundation.auth.entities; + + +import io.micronaut.data.annotation.Query; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; + +import java.util.List; + +@JdbcRepository(dialect = Dialect.MYSQL) +public interface RoleRepo extends CrudRepository { + Role findByName(String name); + + @Query("SELECT id FROM role") + List findAllRoleIds(); +} diff --git a/auth/src/main/java/io/unityfoundation/auth/entities/Service.java b/auth/src/main/java/io/unityfoundation/auth/entities/Service.java new file mode 100644 index 000000000..7a05e3bcf --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/entities/Service.java @@ -0,0 +1,54 @@ +package io.unityfoundation.auth.entities; + +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; + +@MappedEntity() +public class Service { + @Id + @GeneratedValue + private Long id; + + private String name; + private String description; + private ServiceStatus status; + + public enum ServiceStatus { + ENABLED, DISABLED, DOWN_FOR_MAINTENANCE + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public ServiceStatus getStatus() { + return status; + } + + public void setStatus(ServiceStatus status) { + this.status = status; + } + +} diff --git a/auth/src/main/java/io/unityfoundation/auth/entities/ServiceRepo.java b/auth/src/main/java/io/unityfoundation/auth/entities/ServiceRepo.java new file mode 100644 index 000000000..cdc9684bb --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/entities/ServiceRepo.java @@ -0,0 +1,22 @@ +package io.unityfoundation.auth.entities; + + +import io.micronaut.data.annotation.Query; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; +import java.util.Optional; + +@JdbcRepository(dialect = Dialect.MYSQL) +public interface ServiceRepo extends CrudRepository { + + @Query(""" + SELECT s.id, s.name, s.description, s.status +FROM tenant_service ts + inner join service s on s.id = ts.service_id +where ts.status <> 'DISABLED' + and ts.service_id = :serviceId + and ts.tenant_id = :tenantId + """) + Optional findByTenantId(Long serviceId, Long tenantId); +} diff --git a/auth/src/main/java/io/unityfoundation/auth/entities/Tenant.java b/auth/src/main/java/io/unityfoundation/auth/entities/Tenant.java new file mode 100644 index 000000000..c7de8a017 --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/entities/Tenant.java @@ -0,0 +1,44 @@ +package io.unityfoundation.auth.entities; + +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; + +@MappedEntity() +public class Tenant { + @Id + @GeneratedValue + private Long id; + + private String name; + private TenantStatus status; + + public enum TenantStatus { + ENABLED, DISABLED + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public TenantStatus getStatus() { + return status; + } + + public void setStatus(TenantStatus status) { + this.status = status; + } + +} diff --git a/auth/src/main/java/io/unityfoundation/auth/entities/TenantRepo.java b/auth/src/main/java/io/unityfoundation/auth/entities/TenantRepo.java new file mode 100644 index 000000000..72114699c --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/entities/TenantRepo.java @@ -0,0 +1,23 @@ +package io.unityfoundation.auth.entities; + + +import io.micronaut.data.annotation.Query; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; + +import java.util.List; + +@JdbcRepository(dialect = Dialect.MYSQL) +public interface TenantRepo extends CrudRepository { + + @Query(""" +SELECT t.* +FROM user_role ur + INNER JOIN tenant t ON t.id = ur.tenant_id + INNER JOIN user u ON u.id = ur.user_id +WHERE u.email = :email + +""") + List findAllByUserEmail(String email); +} diff --git a/auth/src/main/java/io/unityfoundation/auth/entities/User.java b/auth/src/main/java/io/unityfoundation/auth/entities/User.java new file mode 100644 index 000000000..50330c189 --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/entities/User.java @@ -0,0 +1,77 @@ +package io.unityfoundation.auth.entities; + + +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; +import jakarta.validation.constraints.NotNull; + +@MappedEntity +public class User { + @Id + @GeneratedValue + private Long id; + + @NotNull + private String email; + + private String firstName; + + private String lastName; + + private UserStatus status; + + private String password; + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public enum UserStatus { + ENABLED, DISABLED + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public UserStatus getStatus() { + return status; + } + + public void setStatus(UserStatus status) { + this.status = status; + } +} diff --git a/auth/src/main/java/io/unityfoundation/auth/entities/UserRepo.java b/auth/src/main/java/io/unityfoundation/auth/entities/UserRepo.java new file mode 100644 index 000000000..09ca9ed61 --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/entities/UserRepo.java @@ -0,0 +1,95 @@ +package io.unityfoundation.auth.entities; + + +import io.micronaut.data.annotation.Query; +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; + +import java.util.List; +import java.util.Optional; + +import static io.unityfoundation.auth.PermissionsService.*; + +@JdbcRepository(dialect = Dialect.MYSQL) +public interface UserRepo extends CrudRepository { + + Optional findByEmail(String email); + + @Query(""" + SELECT count(*) > 0 + FROM user_role ur + inner join user u on u.id = ur.user_id + WHERE u.email = :email + and ur.tenant_id = :tenantId; +""") + boolean existsByEmailAndTenantId(String email, Long tenantId); + + @Query(""" + SELECT count(*) > 0 +FROM user_role ur + inner join user u on u.id = ur.user_id + inner join tenant t on t.id = ur.tenant_id + inner join tenant_service ts on ts.tenant_id = t.id + INNER join service s on s.id = ts.service_id +where u.id = :userId + and t.status = 'ENABLED' + and s.id = :serviceId + and s.status = 'ENABLED'; +""") + Boolean isServiceAvailable(long userId, long serviceId); + + @Query(""" + SELECT id, + password, + email, + first_name, + last_name, + status +FROM user +WHERE email = :email +""") + Optional findUserForAuthentication(String email); + + @Query(""" + select ur.tenant_id as tenant_id, p.name as permission_name, p.`scope` as permission_scope +from user_role ur + inner join role_permission rp on rp.role_id = ur.role_id + inner join permission p on p.id = rp.permission_id +where ur.user_id = :userId +""") + List getTenantPermissionsFor(Long userId); + + @Query(""" + select u.* + from user_role ur inner join user u on u.id = ur.user_id + where ur.tenant_id = :tenantId""") + List findAllByTenantId(Long tenantId); + + @Query(""" +select count(*) > 0 + from user_role ur + inner join user u on u.id = ur.user_id + inner join role r on r.id = ur.role_id + where u.email = :email and ur.tenant_id = :tenantId and r.name = 'Tenant Administrator' +""") + boolean existsByEmailAndTenantEqualsAndIsTenantAdmin(String email, Long tenantId); + + @Query(""" +select count(*) > 0 + from user_role ur + inner join user u on u.id = ur.user_id + inner join role r on r.id = ur.role_id + where u.email = :email and r.name = 'Unity Administrator' +""") + boolean existsByEmailAndRoleEqualsUnityAdmin(String email); + + @Query("select role_id from user_role where user_id = :userId") + List getUserRolesByUserId(Long userId); + + @Query("INSERT INTO user_role(user_id, tenant_id, role_id) VALUES (:userId, :tenantId, :roleId)") + void insertUserRole(Long userId, Long tenantId, Long roleId); + + @Query("DELETE FROM user_role WHERE tenant_id = :tenantId and user_id = :userId") + void deleteRoleByTenantIdAndUserId(Long tenantId, Long userId); +} diff --git a/auth/src/main/java/io/unityfoundation/auth/exceptions/InvalidUserException.java b/auth/src/main/java/io/unityfoundation/auth/exceptions/InvalidUserException.java new file mode 100644 index 000000000..c7e60787a --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/exceptions/InvalidUserException.java @@ -0,0 +1,5 @@ +package io.unityfoundation.auth.exceptions; + +public class InvalidUserException extends RuntimeException { + +} diff --git a/auth/src/main/java/io/unityfoundation/auth/exceptions/UserDisabledException.java b/auth/src/main/java/io/unityfoundation/auth/exceptions/UserDisabledException.java new file mode 100644 index 000000000..0370f1c40 --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/exceptions/UserDisabledException.java @@ -0,0 +1,8 @@ +package io.unityfoundation.auth.exceptions; + +public class UserDisabledException extends RuntimeException { + + public UserDisabledException(String message) { + super(message); + } +} diff --git a/auth/src/main/java/io/unityfoundation/auth/webkeys/AbstractRSAGeneratorSignatureConfiguration.java b/auth/src/main/java/io/unityfoundation/auth/webkeys/AbstractRSAGeneratorSignatureConfiguration.java new file mode 100644 index 000000000..bbd25a65b --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/webkeys/AbstractRSAGeneratorSignatureConfiguration.java @@ -0,0 +1,38 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.unityfoundation.auth.webkeys; + +import com.nimbusds.jose.JWSAlgorithm; +import io.micronaut.security.token.jwt.signature.rsa.RSASignatureGeneratorConfiguration; +import java.security.interfaces.RSAPrivateKey; + +public abstract class AbstractRSAGeneratorSignatureConfiguration extends AbstractRSASignatureConfiguration + implements RSASignatureGeneratorConfiguration { + + protected AbstractRSAGeneratorSignatureConfiguration(String jsonJwk) { + super(jsonJwk); + } + + @Override + public RSAPrivateKey getPrivateKey() { + return privateKey; + } + + @Override + public JWSAlgorithm getJwsAlgorithm() { + return jwsAlgorithm; + } +} diff --git a/auth/src/main/java/io/unityfoundation/auth/webkeys/AbstractRSASignatureConfiguration.java b/auth/src/main/java/io/unityfoundation/auth/webkeys/AbstractRSASignatureConfiguration.java new file mode 100644 index 000000000..08f6a9e34 --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/webkeys/AbstractRSASignatureConfiguration.java @@ -0,0 +1,103 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.unityfoundation.auth.webkeys; + +import com.nimbusds.jose.Algorithm; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.RSAKey; +import io.micronaut.context.exceptions.ConfigurationException; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.security.token.jwt.signature.rsa.RSASignatureConfiguration; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.text.ParseException; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public abstract class AbstractRSASignatureConfiguration + implements RSASignatureConfiguration { + + private static final Logger LOG = LoggerFactory.getLogger(AbstractRSASignatureConfiguration.class); + + protected final JWK publicJWK; + protected final RSAPublicKey publicKey; + protected final RSAPrivateKey privateKey; + protected final JWSAlgorithm jwsAlgorithm; + + public AbstractRSASignatureConfiguration(String jsonJwk) { + RSAKey primaryRSAKey = parseRSAKey(jsonJwk) + .orElseThrow(() -> new ConfigurationException("could not parse primary JWK to RSA Key")); + + publicJWK = primaryRSAKey.toPublicJWK(); + + try { + privateKey = primaryRSAKey.toRSAPrivateKey(); + } catch (JOSEException e) { + throw new ConfigurationException("could not primary RSA private key"); + } + + try { + publicKey = primaryRSAKey.toRSAPublicKey(); + } catch (JOSEException e) { + throw new ConfigurationException("could not primary RSA public key"); + } + + jwsAlgorithm = parseJWSAlgorithm(primaryRSAKey) + .orElseThrow(() -> new ConfigurationException("could not parse JWS Algorithm from RSA Key")); + } + + @NonNull + public JWK getPublicJWK() { + return publicJWK; + } + + @Override + public RSAPublicKey getPublicKey() { + return publicKey; + } + + @NonNull + private Optional parseJWSAlgorithm(@NonNull RSAKey rsaKey) { + Algorithm algorithm = rsaKey.getAlgorithm(); + if (algorithm == null) { + return Optional.empty(); + } + + if (algorithm instanceof JWSAlgorithm) { + return Optional.of((JWSAlgorithm) algorithm); + } + + return Optional.of(JWSAlgorithm.parse(algorithm.getName())); + } + + @NonNull + private Optional parseRSAKey(@NonNull String jsonJwk) { + try { + JWK jwk = JWK.parse(jsonJwk); + if (!(jwk instanceof RSAKey)) { + LOG.warn("JWK is not an RSAKey"); + return Optional.empty(); + } + return Optional.of((RSAKey) jwk); + } catch (ParseException e) { + LOG.warn("Could not parse JWK JSON string {}", jsonJwk); + return Optional.empty(); + } + } +} diff --git a/auth/src/main/java/io/unityfoundation/auth/webkeys/JsonWebKeysProvider.java b/auth/src/main/java/io/unityfoundation/auth/webkeys/JsonWebKeysProvider.java new file mode 100644 index 000000000..c9d4020ef --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/webkeys/JsonWebKeysProvider.java @@ -0,0 +1,38 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.unityfoundation.auth.webkeys; + +import com.nimbusds.jose.jwk.JWK; +import io.micronaut.runtime.context.scope.Refreshable; +import io.micronaut.security.token.jwt.endpoints.JwkProvider; +import java.util.Arrays; +import java.util.List; + +@Refreshable +public class JsonWebKeysProvider implements JwkProvider { + + private final List jwks; + + public JsonWebKeysProvider(PrimarySignatureConfiguration primaryRsaPrivateKey, + SecondarySignatureConfiguration secondarySignatureConfiguration) { + jwks = Arrays.asList(primaryRsaPrivateKey.getPublicJWK(), secondarySignatureConfiguration.getPublicJWK()); + } + + @Override + public List retrieveJsonWebKeys() { + return jwks; + } +} diff --git a/auth/src/main/java/io/unityfoundation/auth/webkeys/JwkConfiguration.java b/auth/src/main/java/io/unityfoundation/auth/webkeys/JwkConfiguration.java new file mode 100644 index 000000000..164048244 --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/webkeys/JwkConfiguration.java @@ -0,0 +1,27 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.unityfoundation.auth.webkeys; + +import io.micronaut.core.annotation.NonNull; + +public interface JwkConfiguration { + + @NonNull + String getPrimary(); + + @NonNull + String getSecondary(); +} diff --git a/auth/src/main/java/io/unityfoundation/auth/webkeys/JwkConfigurationProperties.java b/auth/src/main/java/io/unityfoundation/auth/webkeys/JwkConfigurationProperties.java new file mode 100644 index 000000000..6cd523036 --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/webkeys/JwkConfigurationProperties.java @@ -0,0 +1,52 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.unityfoundation.auth.webkeys; + +import io.micronaut.context.annotation.ConfigurationProperties; +import io.micronaut.core.annotation.NonNull; +import jakarta.validation.constraints.NotBlank; + +@ConfigurationProperties("jwk") +public class JwkConfigurationProperties implements JwkConfiguration { + + @NonNull + @NotBlank + private String primary; + + @NonNull + @NotBlank + private String secondary; + + @Override + @NonNull + public String getPrimary() { + return primary; + } + + @Override + @NonNull + public String getSecondary() { + return secondary; + } + + public void setPrimary(@NonNull String primary) { + this.primary = primary; + } + + public void setSecondary(@NonNull String secondary) { + this.secondary = secondary; + } +} diff --git a/auth/src/main/java/io/unityfoundation/auth/webkeys/PrimarySignatureConfiguration.java b/auth/src/main/java/io/unityfoundation/auth/webkeys/PrimarySignatureConfiguration.java new file mode 100644 index 000000000..306312b50 --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/webkeys/PrimarySignatureConfiguration.java @@ -0,0 +1,28 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.unityfoundation.auth.webkeys; + +import io.micronaut.runtime.context.scope.Refreshable; +import jakarta.inject.Named; + +@Refreshable +@Named("generator") +public class PrimarySignatureConfiguration extends AbstractRSAGeneratorSignatureConfiguration { + + public PrimarySignatureConfiguration(JwkConfiguration jwkConfiguration) { + super(jwkConfiguration.getPrimary()); + } +} diff --git a/auth/src/main/java/io/unityfoundation/auth/webkeys/SecondarySignatureConfiguration.java b/auth/src/main/java/io/unityfoundation/auth/webkeys/SecondarySignatureConfiguration.java new file mode 100644 index 000000000..3be322622 --- /dev/null +++ b/auth/src/main/java/io/unityfoundation/auth/webkeys/SecondarySignatureConfiguration.java @@ -0,0 +1,28 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.unityfoundation.auth.webkeys; + +import io.micronaut.runtime.context.scope.Refreshable; +import jakarta.inject.Named; + +@Named("secondary") +@Refreshable +public class SecondarySignatureConfiguration extends AbstractRSASignatureConfiguration { + + public SecondarySignatureConfiguration(JwkConfiguration jwkConfiguration) { + super(jwkConfiguration.getSecondary()); + } +} diff --git a/auth/src/main/resources/application-docker.yml b/auth/src/main/resources/application-docker.yml new file mode 100644 index 000000000..8b8c17584 --- /dev/null +++ b/auth/src/main/resources/application-docker.yml @@ -0,0 +1,51 @@ +# Config for Docker environment. See docker-compose.local.yml. +# Hostname and port of the API and frontend containers accessed from the host machine are: +# - API server: unity-auth-api:9090 (maps to port 9090 within the container) +# - Frontend: unity-auth-ui-dev:3001 or localhost:3001 (maps to port 3000 within the container) +# Note: Add DNS entries for unity-auth-api, unity-auth-ui-dev hostnames to your host, e.g. +# /etc/hosts on Linux or Mac. +micronaut: + application: + name: unity-iam + server: + cors: + enabled: true + configurations: + web: + allowed-origins-regex: '^http:\/\/(.*?)(?:localhost|127\.0\.0\.1)(?::\d+)?$' + allowedOrigins: + - http://localhost:3000 + - localhost:3000 + - http://127.0.0.1:3000 + - http://libre311-ui:3000 + - http://libre311-ui-dev:3000 + - http://localhost:3001 + - localhost:3001 + - http://127.0.0.1:3001 + - http://unity-auth-ui:3001 + - http://unity-auth-ui-dev:3001 + localhost-pass-through: true + port: 9090 + security: + authentication: bearer +datasources: + default: + url: jdbc:mysql://unity-auth-db:3306/test?allowPublicKeyRetrieval=true&useSSL=false + driver-class-name: com.mysql.cj.jdbc.Driver + username: root + password: test + db-type: mysql +flyway: + datasources: + default: + locations: + - classpath:db/migration + - classpath:local + +unity: + auth: + internal-token: unity-internal-secret + +jwk: + primary: '{"p":"_OZyH1Mk3wR0oXw1C31t4kWOcaHFB6Njro1cYx52REnPiznn_JTtwvlAMpvV6LVCIZPgKMzdIEMY1gYs1LsO-5IFqWwegXmYJ0iKXbRrZshfWBCzRLK3QK5fER1le1XUBDhtDk7KIW_Xg-SZF4pf_LUEVKMnyUpspGI5F77jlJ8","kty":"RSA","q":"s9wvl7z8vkHQvo9xOUp-z0a2Z7LFBDil2uIjPh1FQzs34gFXH8dQPRox83TuN5d4KzdLPqQNQAfMXU9_KmxihNb_qDQahYugeELmcem04munxXqBdyZqWhWCy5YmujYqn44irwvoTbw6_RkMqjCmINPTPadptlPivsZ6RhKn8zk","d":"ok3wmhOy8NZEHAotnFiH6ecFD6xf_9x33_fMRkqa3_KE8NZM7vmvNgElox2UvcP_2K5E7jOdL2XQdJCTIW3Qlj66yE2a84SYlbvxIc4hDrIog0XNt4FhavvshxxUIfDQo6Q8qXDR5v7nwt6SCopYC3t3KVRdJh08GzKoVxysd7afJjxXxx178gY29uMRqnwxFN1OGnWaiBr-xGKb1frJ6jOI1zvuuCaljZ4aZjc9vOR4y9ZmobgrzkMFnpDAmQZ7MWcVMyodRMOA2dEOckywPhg-dIVNiVIqzJqe5Yg1ilNookjwtqj2TpNU7Z9gPqzYB73PmQ2p5LMDheAPxcOmEQ","e":"AQAB","use":"sig","kid":"e3be37177a7c42bcbadd7cc63715f216","qi":"r--nAtaYPAgJq_8R1-kynpd53E17n-loDUgtVWBCx_RmdORX4Auilv1S83dD1mbcnYCbV_LmxiEjOiz-4gS_E0qVGqakAqQrO1hVUvJa_Y2uftDgwFmuJNGbpRU-K4Td_uUzdm48za8yJCgOdYsWp6PNMCcmQgiInzkR3XYV83I","dp":"oQUcvmMSw8gzdin-IB2xW_MLecAVEgLu0dGBdD6N8HbKZQvub_xm0dAfFtnvvWXDAFwFyhR96i-uXX67Bos_Q9-6KSAE4E0KGmDucDESfPOw-QJREbl0QgOD1gLQfVGtVy6SCR0TR2zNXFWtP7bD3MNoSXdEOr5fI97CGSNaBWM","alg":"RS256","dq":"DM-WJDy10-dkMu6MpgQEXEcxHtnA5rgSODD7SaVUFaHWLSbjScQslu2SuUCO5y7GxG0_0spklzb2-356FE98BPI7a4Oqj_COEYLSXzLCS45XeN1s80utL5Vwp4eeYo0RJCQ_nDBA76iEmxp5qHWmn5f25-FQykfXUrdYZj1V8SE","n":"sa6m2i-iNvj6ZSTdSHZaBrnv6DId4AqAXhOyl0yA5fNWYe6r51h24SXqk7DsGYHHh74ii74tP1lTpmy6RD67tCK-tbN-d6yc4Z6FfM8R83v2QZUfaAixgHGtw0n2toqsiHf6EloDV-B8q4GYyKDD6cLecoaIuTmMBTY3kts59U2t9W10YoLGsmFqLSz8qNF5HkahzB6_--2DiBfVGUKAXHC-SICGZCi-8efOetv6pt9vFiWEgwU_DgjRNYzLFt1SEmbGFUU4kbjQ7tNTMkHfzfwcT6qLt4kVKy2FNYsEMk24keWtCvW_RyO_fisZc0W9smX7WtYjEXhcAjDeqHgEZw"}' + secondary: '{"p":"4qJ9RNlu6SuDT_MLArfzimvKEwmet_j12Z9EQeb5nMjZIOHTcWw__duebUytfWwxsRHhtSVXeMt-EryQAOulm2p1bfiVuparq93z9P5cPnb0oArFaw3eFNFEmX5U-lY8PzUTTsFxO4aVQYAKXD6DP7p5uPzuwpHFuNc71nNIXZE","kty":"RSA","q":"v4OhkWMbS_nq77HFanwZAT_obfJuQfOFOQBORL4ATAHGUXm2y4YqLNExZs7Wj1MA_6ya6Y00s2JBM7fWq_fPe4d9xo5aGrPdcp0G8W21kkfh9vuVPlHVQTgSP7FQ9qahvXxNwK_11yNr3p1HBmScJ5mHlMBpIJsFcvHA-uXe0Ps","d":"EunrjnQ1-jJPSCrt2L94PUpDrakup8a4pXys52YSkJY-W6XidM0roOS6kr06P3G6VQgc6AL_BkvTQ_XS0oXHbXVprDQ5Syam5p9oxHBhhW_vSqIMgUOfm28uyB3Mtw9rBxdUxW3yElHioaR8a-exYhhyVXb1QEhxL_rcnthmhAkM2NcHi2UnxGKFTsC0abQ2MuQc1OAuW5veDiIF2hfdC41qE0_d8vB6FDWbblgUpbwB6uSZaViPs15Buq2oX9dCCw54-PgzkfehDt7lyqgupktbV1psnVVhL86shzt4QFnhd3k7VpFbjCNFtiJTrufV-XBWT0pl2w3VR9wrHJ1bYQ","e":"AQAB","use":"sig","kid":"0794e938379540dc8eaa559508524a79","qi":"jy-TNyXVy_44_n4KGAwIbZO2C4r6uNWuEdehBfQKkPhiP90myG1KZVfOoKNOK9bCv2mvZJcBz4c1ArElgpuSCV4-KFac1ZzQo_ic5aoIej8Qa80y2ogc-_Yv6_ZLHC1S76M-lm4jayk2-rvuBpy2pUvHbW6Srhs_szwz7ZfSkLg","dp":"ApqdV9ortRAj7Ro8ySY17SQ56SgWI8T_hiWXUi6GNa_1FrShik8VGSSZ2GWmJKfGlmM_NaadL60e4LY77VbHy1ZYzQ-rIL60cEAXmnwFsU4Kl4AoLoe1QoX5BM53yXyOKqfAdgow898i_eKru82YEnZhCagWUjP8kpgefuNKNJE","alg":"RS256","dq":"bFF78WoXh0pMCdQHL2oPDnjh8kWa_OxKHmpA2nqIWnTqgSyRKd2xPvX2tgooqpmsx-8NEymNdCQPcrv4y_z2OgzxI3tiFRZEGs4bnjOJ7bmAYZv71mqcbi3TjHiyrT6j3jNPGrurFUpweVGFWWVQOMmKOKT3ELz9QPzhREb9Vj8","n":"qYvDpV8DRU5hx9eXpE4Ms8nUXicEwrxUUz5gb5gkXpIeY82mqfQKKCP6PSFnkKYtRFTOUSm9cgGGfOd7O4NFsIsxLwXCj34X7ORr19eXKBLvG3bZJLxqRlbYuQshDMkQOui1sDBxvYnj5p4iHne6l2btH5grHOCShUWG-bKps5Y8bKNHod1pIOOBabVCmn3sUVUkZw8nyXkQqZbv-c8x6z0TEfhNOPOIt2AmmlNgrE_8g7-dnCvqfJnhv0c7qkOJzsb7OMmvVwsQNiM59D6uaWZr-vdANo6NggiZmCKUS3tpUvdXW7ec9WMPJWhrVEkRcbWXQnZ_C7pXFrz7rLeNKw"}' diff --git a/auth/src/main/resources/application-local.yml b/auth/src/main/resources/application-local.yml new file mode 100644 index 000000000..98acaabf3 --- /dev/null +++ b/auth/src/main/resources/application-local.yml @@ -0,0 +1,44 @@ +micronaut: + application: + name: unity-iam + server: + cors: + enabled: true + configurations: + web: + allowed-origins-regex: '^http:\/\/(.*?)(?:localhost|127\.0\.0\.1)(?::\d+)?$' + allowedOrigins: + - http://localhost:3000 + - localhost:3000 + - http://127.0.0.1:3000 + - http://libre311-ui:3000 + - http://libre311-ui-dev:3000 + - http://localhost:3001 + - localhost:3001 + - http://127.0.0.1:3001 + - http://unity-auth-ui:3001 + - http://unity-auth-ui-dev:3001 + localhost-pass-through: true + port: 9090 + security: + authentication: bearer +unity: + auth: + internal-token: unity-internal-secret +datasources: + default: + url: jdbc:mysql://localhost:23306/unity_auth?allowPublicKeyRetrieval=true&useSSL=false + driver-class-name: com.mysql.cj.jdbc.Driver + username: root + password: test + db-type: mysql +flyway: + datasources: + default: + locations: + - classpath:db/migration + - classpath:local + +jwk: + primary: '{"p":"_OZyH1Mk3wR0oXw1C31t4kWOcaHFB6Njro1cYx52REnPiznn_JTtwvlAMpvV6LVCIZPgKMzdIEMY1gYs1LsO-5IFqWwegXmYJ0iKXbRrZshfWBCzRLK3QK5fER1le1XUBDhtDk7KIW_Xg-SZF4pf_LUEVKMnyUpspGI5F77jlJ8","kty":"RSA","q":"s9wvl7z8vkHQvo9xOUp-z0a2Z7LFBDil2uIjPh1FQzs34gFXH8dQPRox83TuN5d4KzdLPqQNQAfMXU9_KmxihNb_qDQahYugeELmcem04munxXqBdyZqWhWCy5YmujYqn44irwvoTbw6_RkMqjCmINPTPadptlPivsZ6RhKn8zk","d":"ok3wmhOy8NZEHAotnFiH6ecFD6xf_9x33_fMRkqa3_KE8NZM7vmvNgElox2UvcP_2K5E7jOdL2XQdJCTIW3Qlj66yE2a84SYlbvxIc4hDrIog0XNt4FhavvshxxUIfDQo6Q8qXDR5v7nwt6SCopYC3t3KVRdJh08GzKoVxysd7afJjxXxx178gY29uMRqnwxFN1OGnWaiBr-xGKb1frJ6jOI1zvuuCaljZ4aZjc9vOR4y9ZmobgrzkMFnpDAmQZ7MWcVMyodRMOA2dEOckywPhg-dIVNiVIqzJqe5Yg1ilNookjwtqj2TpNU7Z9gPqzYB73PmQ2p5LMDheAPxcOmEQ","e":"AQAB","use":"sig","kid":"e3be37177a7c42bcbadd7cc63715f216","qi":"r--nAtaYPAgJq_8R1-kynpd53E17n-loDUgtVWBCx_RmdORX4Auilv1S83dD1mbcnYCbV_LmxiEjOiz-4gS_E0qVGqakAqQrO1hVUvJa_Y2uftDgwFmuJNGbpRU-K4Td_uUzdm48za8yJCgOdYsWp6PNMCcmQgiInzkR3XYV83I","dp":"oQUcvmMSw8gzdin-IB2xW_MLecAVEgLu0dGBdD6N8HbKZQvub_xm0dAfFtnvvWXDAFwFyhR96i-uXX67Bos_Q9-6KSAE4E0KGmDucDESfPOw-QJREbl0QgOD1gLQfVGtVy6SCR0TR2zNXFWtP7bD3MNoSXdEOr5fI97CGSNaBWM","alg":"RS256","dq":"DM-WJDy10-dkMu6MpgQEXEcxHtnA5rgSODD7SaVUFaHWLSbjScQslu2SuUCO5y7GxG0_0spklzb2-356FE98BPI7a4Oqj_COEYLSXzLCS45XeN1s80utL5Vwp4eeYo0RJCQ_nDBA76iEmxp5qHWmn5f25-FQykfXUrdYZj1V8SE","n":"sa6m2i-iNvj6ZSTdSHZaBrnv6DId4AqAXhOyl0yA5fNWYe6r51h24SXqk7DsGYHHh74ii74tP1lTpmy6RD67tCK-tbN-d6yc4Z6FfM8R83v2QZUfaAixgHGtw0n2toqsiHf6EloDV-B8q4GYyKDD6cLecoaIuTmMBTY3kts59U2t9W10YoLGsmFqLSz8qNF5HkahzB6_--2DiBfVGUKAXHC-SICGZCi-8efOetv6pt9vFiWEgwU_DgjRNYzLFt1SEmbGFUU4kbjQ7tNTMkHfzfwcT6qLt4kVKy2FNYsEMk24keWtCvW_RyO_fisZc0W9smX7WtYjEXhcAjDeqHgEZw"}' + secondary: '{"p":"4qJ9RNlu6SuDT_MLArfzimvKEwmet_j12Z9EQeb5nMjZIOHTcWw__duebUytfWwxsRHhtSVXeMt-EryQAOulm2p1bfiVuparq93z9P5cPnb0oArFaw3eFNFEmX5U-lY8PzUTTsFxO4aVQYAKXD6DP7p5uPzuwpHFuNc71nNIXZE","kty":"RSA","q":"v4OhkWMbS_nq77HFanwZAT_obfJuQfOFOQBORL4ATAHGUXm2y4YqLNExZs7Wj1MA_6ya6Y00s2JBM7fWq_fPe4d9xo5aGrPdcp0G8W21kkfh9vuVPlHVQTgSP7FQ9qahvXxNwK_11yNr3p1HBmScJ5mHlMBpIJsFcvHA-uXe0Ps","d":"EunrjnQ1-jJPSCrt2L94PUpDrakup8a4pXys52YSkJY-W6XidM0roOS6kr06P3G6VQgc6AL_BkvTQ_XS0oXHbXVprDQ5Syam5p9oxHBhhW_vSqIMgUOfm28uyB3Mtw9rBxdUxW3yElHioaR8a-exYhhyVXb1QEhxL_rcnthmhAkM2NcHi2UnxGKFTsC0abQ2MuQc1OAuW5veDiIF2hfdC41qE0_d8vB6FDWbblgUpbwB6uSZaViPs15Buq2oX9dCCw54-PgzkfehDt7lyqgupktbV1psnVVhL86shzt4QFnhd3k7VpFbjCNFtiJTrufV-XBWT0pl2w3VR9wrHJ1bYQ","e":"AQAB","use":"sig","kid":"0794e938379540dc8eaa559508524a79","qi":"jy-TNyXVy_44_n4KGAwIbZO2C4r6uNWuEdehBfQKkPhiP90myG1KZVfOoKNOK9bCv2mvZJcBz4c1ArElgpuSCV4-KFac1ZzQo_ic5aoIej8Qa80y2ogc-_Yv6_ZLHC1S76M-lm4jayk2-rvuBpy2pUvHbW6Srhs_szwz7ZfSkLg","dp":"ApqdV9ortRAj7Ro8ySY17SQ56SgWI8T_hiWXUi6GNa_1FrShik8VGSSZ2GWmJKfGlmM_NaadL60e4LY77VbHy1ZYzQ-rIL60cEAXmnwFsU4Kl4AoLoe1QoX5BM53yXyOKqfAdgow898i_eKru82YEnZhCagWUjP8kpgefuNKNJE","alg":"RS256","dq":"bFF78WoXh0pMCdQHL2oPDnjh8kWa_OxKHmpA2nqIWnTqgSyRKd2xPvX2tgooqpmsx-8NEymNdCQPcrv4y_z2OgzxI3tiFRZEGs4bnjOJ7bmAYZv71mqcbi3TjHiyrT6j3jNPGrurFUpweVGFWWVQOMmKOKT3ELz9QPzhREb9Vj8","n":"qYvDpV8DRU5hx9eXpE4Ms8nUXicEwrxUUz5gb5gkXpIeY82mqfQKKCP6PSFnkKYtRFTOUSm9cgGGfOd7O4NFsIsxLwXCj34X7ORr19eXKBLvG3bZJLxqRlbYuQshDMkQOui1sDBxvYnj5p4iHne6l2btH5grHOCShUWG-bKps5Y8bKNHod1pIOOBabVCmn3sUVUkZw8nyXkQqZbv-c8x6z0TEfhNOPOIt2AmmlNgrE_8g7-dnCvqfJnhv0c7qkOJzsb7OMmvVwsQNiM59D6uaWZr-vdANo6NggiZmCKUS3tpUvdXW7ec9WMPJWhrVEkRcbWXQnZ_C7pXFrz7rLeNKw"}' diff --git a/auth/src/main/resources/application-test.yml b/auth/src/main/resources/application-test.yml new file mode 100644 index 000000000..3f9831b7c --- /dev/null +++ b/auth/src/main/resources/application-test.yml @@ -0,0 +1,29 @@ +micronaut: + application: + name: unity-iam + server: + context-path: "" + cors: + enabled: true + configurations: + web: + allowed-origins-regex: '^http:\/\/(.*?)localhost:3000$' + security: + authentication: bearer + enabled: true + token: + enabled: true + jwt: + enabled: true + signatures: + jwks: + unity: + url: http://localhost:8080/keys +datasources: + default: + driver-class-name: com.mysql.cj.jdbc.Driver + db-type: mysql +flyway: + datasources: + default: + enabled: true diff --git a/auth/src/main/resources/application.yml b/auth/src/main/resources/application.yml new file mode 100644 index 000000000..1bcaadeee --- /dev/null +++ b/auth/src/main/resources/application.yml @@ -0,0 +1,37 @@ +micronaut: + application: + name: unity-iam + security: + endpoints: + logout: + path: /logout + get-allowed: true + login: + path: /login + introspection: + path: /token_info + intercept-url-map: + - pattern: /login + http-method: POST + access: + - isAnonymous() + authentication: bearer + server: + context-path: /auth + cors: + enabled: true + configurations: + web: + allowed-origins-regex: ${LIBRE311_UI_BASE_URL} +datasources: + default: + driver-class-name: com.mysql.cj.jdbc.Driver + db-type: mysql +flyway: + datasources: + default: + enabled: true + +unity: + auth: + internal-token: ${UNITY_AUTH_INTERNAL_TOKEN} diff --git a/auth/src/main/resources/db/migration/V1__initial_schema.sql b/auth/src/main/resources/db/migration/V1__initial_schema.sql new file mode 100644 index 000000000..648dda008 --- /dev/null +++ b/auth/src/main/resources/db/migration/V1__initial_schema.sql @@ -0,0 +1,81 @@ +CREATE TABLE tenant +( + id bigint AUTO_INCREMENT PRIMARY KEY, + name varchar(255) NOT NULL, + description text, + status varchar(255) NOT NULL, + UNIQUE (name) +); + +CREATE TABLE service +( + id bigint AUTO_INCREMENT PRIMARY KEY, + name varchar(255) NOT NULL, + description text, + status varchar(255), + UNIQUE (name) +); + +CREATE TABLE tenant_service +( + tenant_id bigint NOT NULL, + service_id bigint NOT NULL, + status varchar(255), + PRIMARY KEY (tenant_id, service_id) +); +ALTER TABLE tenant_service + ADD CONSTRAINT tenant_service_tenant_FK FOREIGN KEY (tenant_id) REFERENCES tenant (id); +ALTER TABLE tenant_service + ADD CONSTRAINT tenant_service_service_FK FOREIGN KEY (service_id) REFERENCES service (id); + +CREATE TABLE permission +( + id bigint AUTO_INCREMENT PRIMARY KEY, + name varchar(255) NOT NULL, + description text, + scope varchar(255), + UNIQUE (name) +); + +CREATE TABLE role +( + id bigint AUTO_INCREMENT PRIMARY KEY, + name varchar(255) NOT NULL, + description text, + UNIQUE (name) +); + +CREATE TABLE role_permission +( + role_id bigint NOT NULL, + permission_id bigint NOT NULL, + PRIMARY KEY (role_id, permission_id) +); +ALTER TABLE role_permission + ADD CONSTRAINT role_permission_permission_FK FOREIGN KEY (permission_id) REFERENCES permission (id); +ALTER TABLE role_permission + ADD CONSTRAINT role_permission_role_FK FOREIGN KEY (role_id) REFERENCES role (id); + + +CREATE TABLE user +( + id bigint AUTO_INCREMENT PRIMARY KEY, + email varchar(255) NOT NULL, + password varchar(255), + status varchar(255), + UNIQUE KEY unique_email (email) +); + +CREATE TABLE user_role +( + tenant_id bigint NOT NULL, + user_id bigint NOT NULL, + role_id bigint NOT NULL, + PRIMARY KEY (tenant_id, user_id, role_id) +); +ALTER TABLE user_role + ADD CONSTRAINT user_role_tenant_FK FOREIGN KEY (tenant_id) REFERENCES tenant (id); +ALTER TABLE user_role + ADD CONSTRAINT user_role_user_FK FOREIGN KEY (user_id) REFERENCES user (id); +ALTER TABLE user_role + ADD CONSTRAINT user_role_role_FK FOREIGN KEY (role_id) REFERENCES role (id); diff --git a/auth/src/main/resources/db/migration/V2__add_user_first_and_last_names.sql b/auth/src/main/resources/db/migration/V2__add_user_first_and_last_names.sql new file mode 100644 index 000000000..3210acad0 --- /dev/null +++ b/auth/src/main/resources/db/migration/V2__add_user_first_and_last_names.sql @@ -0,0 +1,2 @@ +ALTER TABLE user ADD COLUMN first_name varchar(255); +ALTER TABLE user ADD COLUMN last_name varchar(255); diff --git a/auth/src/main/resources/db/migration/V3__add_password_reset_token_table.sql b/auth/src/main/resources/db/migration/V3__add_password_reset_token_table.sql new file mode 100644 index 000000000..4a5c9066b --- /dev/null +++ b/auth/src/main/resources/db/migration/V3__add_password_reset_token_table.sql @@ -0,0 +1,9 @@ +CREATE TABLE password_reset_token +( + id bigint AUTO_INCREMENT PRIMARY KEY, + token varchar(255) NOT NULL, + user_id bigint NOT NULL, + expiry timestamp NOT NULL, + UNIQUE (token), + CONSTRAINT password_reset_token_user_FK FOREIGN KEY (user_id) REFERENCES user (id) +); diff --git a/auth/src/main/resources/local/afterMigrate.sql b/auth/src/main/resources/local/afterMigrate.sql new file mode 100644 index 000000000..2424bd6d1 --- /dev/null +++ b/auth/src/main/resources/local/afterMigrate.sql @@ -0,0 +1,147 @@ +DELETE FROM user_role; +DELETE FROM role_permission; +DELETE FROM tenant_service; +DELETE FROM user; +DELETE FROM tenant; +DELETE FROM service; +DELETE FROM permission; +DELETE FROM role; + +-- Create a tenant +INSERT IGNORE INTO tenant (id, name, description, status) VALUES(1, 'stl', 'St. Louis Metro Area', 'ENABLED'); +-- Create Libre311 Service +INSERT IGNORE INTO service (id, name, description, status) VALUES(1, 'Libre311', 'Libre311', 'ENABLED'); + +-- Add Libre311 Service to stl tenant +INSERT IGNORE INTO tenant_service (tenant_id, service_id, status) VALUES(1, 1, 'ENABLED'); + + +INSERT IGNORE INTO permission (id, name, description, scope) +VALUES (1, 'AUTH_SERVICE_EDIT-SYSTEM', NULL, 'SYSTEM'), + (2, 'AUTH_SERVICE_VIEW-SYSTEM', NULL, 'SYSTEM'), + (3, 'AUTH_SERVICE_EDIT-TENANT', NULL, 'TENANT'), + (4, 'AUTH_SERVICE_VIEW-TENANT', NULL, 'TENANT'), + (5, 'LIBRE311_ADMIN_EDIT-SYSTEM', NULL, 'SYSTEM'), + (6, 'LIBRE311_ADMIN_VIEW-SYSTEM', NULL, 'SYSTEM'), + (7, 'LIBRE311_ADMIN_EDIT-TENANT', NULL, 'TENANT'), + (8, 'LIBRE311_ADMIN_VIEW-TENANT', NULL, 'TENANT'), + (9, 'LIBRE311_ADMIN_EDIT-SUBTENANT', NULL, 'SUBTENANT'), + (10, 'LIBRE311_ADMIN_VIEW-SUBTENANT', NULL, 'SUBTENANT'), + (11, 'LIBRE311_REQUEST_EDIT-SYSTEM', NULL, 'SYSTEM'), + (12, 'LIBRE311_REQUEST_VIEW-SYSTEM', NULL, 'SYSTEM'), + (13, 'LIBRE311_REQUEST_EDIT-TENANT', NULL, 'TENANT'), + (14, 'LIBRE311_REQUEST_VIEW-TENANT', NULL, 'TENANT'), + (15, 'LIBRE311_REQUEST_EDIT-SUBTENANT', NULL, 'SUBTENANT'), + (16, 'LIBRE311_REQUEST_VIEW-SUBTENANT', NULL, 'SUBTENANT'); + + +INSERT IGNORE INTO role (id, name, description) +VALUES (1, 'Unity Administrator', 'An administrator of the Unity Platform. A user with this role can perform any operation.'), + (2, 'Tenant Administrator', 'An administrator for a tenant. A user with this role can perform any operation for the tenant.'), + (3, 'Libre311 Administrator', 'An administrator for Libre311. A user with this role can perform any operation in Libre311 on behalf of their tenant.'), + (4, 'Libre311 Request Manager', 'A service request manager for Libre311. A user with this role can update and manage service requests.'), + (5, 'Libre311 Jurisdiction Administrator', 'An administrator for Libre311 that is scoped to specific jurisdictions. Additional access must be granted in Libre311 to enable access for specific jurisdictions.'), + (6, 'Libre311 Jurisdiction Request Manager', 'A service request manager for Libre311 that is scoped to specific jurisdictions. A user with this role can update and manage service requests. Additional access must be granted in Libre311 to enable access for specific jurisdictions.'); + + +-- Unity Administrator +INSERT IGNORE INTO role_permission (role_id, permission_id) +VALUES (1, 1), -- AUTH_SERVICE_EDIT-SYSTEM + (1, 2), -- AUTH_SERVICE_VIEW-SYSTEM + (1, 5), -- LIBRE311_ADMIN_EDIT-SYSTEM + (1, 6), -- LIBRE311_ADMIN_VIEW-SYSTEM + (1, 11), -- LIBRE311_REQUEST_EDIT-SYSTEM + (1, 12); -- LIBRE311_REQUEST_VIEW-SYSTEM + +-- Tenant Administrator +INSERT IGNORE INTO role_permission (role_id, permission_id) +VALUES (2, 3), -- AUTH_SERVICE_EDIT-TENANT + (2, 4), -- AUTH_SERVICE_VIEW-TENANT + (2, 7), -- LIBRE311_ADMIN_EDIT-TENANT + (2, 8), -- LIBRE311_ADMIN_VIEW-TENANT + (2, 13), -- LIBRE311_REQUEST_EDIT-TENANT + (2, 14); -- LIBRE311_REQUEST_VIEW-TENANT + +-- Libre311 Administrator +INSERT IGNORE INTO role_permission (role_id, permission_id) +VALUES (3, 7), -- LIBRE311_ADMIN_EDIT-TENANT + (3, 8), -- LIBRE311_ADMIN_VIEW-TENANT + (3, 13), -- LIBRE311_REQUEST_EDIT-TENANT + (3, 14); -- LIBRE311_REQUEST_VIEW-TENANT + +-- Libre311 Request Manager +INSERT IGNORE INTO role_permission (role_id, permission_id) +VALUES (4, 13), -- LIBRE311_REQUEST_EDIT-TENANT + (4, 14); -- LIBRE311_REQUEST_VIEW-TENANT + +-- Libre311 Jurisdiction Administrator +INSERT IGNORE INTO role_permission (role_id, permission_id) +VALUES (5, 9), -- LIBRE311_ADMIN_EDIT-SUBTENANT + (5, 10), -- LIBRE311_ADMIN_VIEW-SUBTENANT + (5, 15), -- LIBRE311_REQUEST_EDIT-SUBTENANT + (5, 16); -- LIBRE311_REQUEST_VIEW-SUBTENANT + +-- Libre311 Jurisdiction Request Manager +INSERT IGNORE INTO role_permission (role_id, permission_id) +VALUES (6, 15), -- LIBRE311_REQUEST_EDIT-SUBTENANT + (6, 16); -- LIBRE311_REQUEST_VIEW-SUBTENANT + + +-- Password for all the following accounts is 'test' +-- Unity Administrator +INSERT IGNORE INTO user (id, email, first_name, last_name, password, status) VALUES + (1, 'unity_admin@example.com', 'Unity', 'Admin', '$2a$10$YJetsyoS.EzlVlb249w07uBR8uSqgtlqVH9Hl7bsHtvvwdKAhJp82', 'ENABLED'); + +-- Tenant Administrator +INSERT IGNORE INTO user (id, email, first_name, last_name, password, status) VALUES + (2, 'tenant_admin@example.com', 'Tenant', 'Admin', '$2a$10$YJetsyoS.EzlVlb249w07uBR8uSqgtlqVH9Hl7bsHtvvwdKAhJp82', 'ENABLED'); + +-- Libre311 Administrator +INSERT IGNORE INTO user (id, email, first_name, last_name, password, status) VALUES + (3, 'libre311_admin@example.com', 'Libre', 'Admin', '$2a$10$YJetsyoS.EzlVlb249w07uBR8uSqgtlqVH9Hl7bsHtvvwdKAhJp82', 'ENABLED'); + +-- Libre311 Request Manager +INSERT IGNORE INTO user (id, email, first_name, last_name, password, status) VALUES + (4, 'libre311_request_manager@example.com', 'Request', 'Manager', '$2a$10$YJetsyoS.EzlVlb249w07uBR8uSqgtlqVH9Hl7bsHtvvwdKAhJp82', 'ENABLED'); + +-- Libre311 Jurisdiction Administrator +INSERT IGNORE INTO user (id, email, first_name, last_name, password, status) VALUES + (5, 'libre311_jurisdiction_admin@example.com', 'Jurisdiction', 'Admin', '$2a$10$YJetsyoS.EzlVlb249w07uBR8uSqgtlqVH9Hl7bsHtvvwdKAhJp82', 'ENABLED'); + +-- Libre311 Jurisdiction Request Manager +INSERT IGNORE INTO user (id, email, first_name, last_name, password, status) VALUES + (6, 'libre311_jurisdiction_request_manager@example.com', 'Jurisdiction', 'Request Manager', '$2a$10$YJetsyoS.EzlVlb249w07uBR8uSqgtlqVH9Hl7bsHtvvwdKAhJp82', 'ENABLED'); + +-- Stl sub-tenant admin +INSERT IGNORE INTO user (id, email, first_name, last_name, password, status) VALUES + (7, 'stl_subtenant_admin@example.com', 'Subtenant', 'Admin', '$2a$10$YJetsyoS.EzlVlb249w07uBR8uSqgtlqVH9Hl7bsHtvvwdKAhJp82', 'ENABLED'); + + +-- Unity Administrator +INSERT IGNORE INTO user_role (tenant_id, user_id, role_id) VALUES + (1, 1, 1); + +-- Tenant Administrator +INSERT IGNORE INTO user_role (tenant_id, user_id, role_id) VALUES + (1, 2, 2); + +-- Libre311 Administrator +INSERT IGNORE INTO user_role (tenant_id, user_id, role_id) VALUES + (1, 3, 3); + +-- Libre311 Request Manager +INSERT IGNORE INTO user_role (tenant_id, user_id, role_id) VALUES + (1, 4, 4); + +-- Libre311 Jurisdiction Administrator +INSERT IGNORE INTO user_role (tenant_id, user_id, role_id) VALUES + (1, 5, 5); + +-- Libre311 Jurisdiction Request Manager +INSERT IGNORE INTO user_role (tenant_id, user_id, role_id) VALUES + (1, 6, 6); + + +-- Stl sub-tenant admin +INSERT IGNORE INTO user_role (tenant_id, user_id, role_id) VALUES + (1, 7, 5); diff --git a/auth/src/main/resources/logback.xml b/auth/src/main/resources/logback.xml new file mode 100644 index 000000000..b75176a4f --- /dev/null +++ b/auth/src/main/resources/logback.xml @@ -0,0 +1,20 @@ + + + + + + %cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{36}) - + %msg%n + + + + + + + + + + + + diff --git a/auth/src/test/java/io/unityfoundation/UnityIamTest.java b/auth/src/test/java/io/unityfoundation/UnityIamTest.java new file mode 100644 index 000000000..60f8c8fb2 --- /dev/null +++ b/auth/src/test/java/io/unityfoundation/UnityIamTest.java @@ -0,0 +1,315 @@ +package io.unityfoundation; + +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTParser; +import io.micronaut.context.annotation.Property; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.security.authentication.UsernamePasswordCredentials; +import io.micronaut.security.token.render.BearerAccessRefreshToken; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.unityfoundation.auth.AuthController; +import io.unityfoundation.auth.AuthController.HasPermissionResponse; +import io.unityfoundation.auth.AuthController.UserPermissionsRequest; +import io.unityfoundation.auth.AuthController.UserPermissionsResponse; +import io.unityfoundation.auth.HasPermissionRequest; +import io.unityfoundation.auth.UserController; +import io.unityfoundation.auth.UserResponse; +import jakarta.inject.Inject; + +import java.text.ParseException; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@Property(name = "jwk.primary", value = "{\"p\":\"_OZyH1Mk3wR0oXw1C31t4kWOcaHFB6Njro1cYx52REnPiznn_JTtwvlAMpvV6LVCIZPgKMzdIEMY1gYs1LsO-5IFqWwegXmYJ0iKXbRrZshfWBCzRLK3QK5fER1le1XUBDhtDk7KIW_Xg-SZF4pf_LUEVKMnyUpspGI5F77jlJ8\",\"kty\":\"RSA\",\"q\":\"s9wvl7z8vkHQvo9xOUp-z0a2Z7LFBDil2uIjPh1FQzs34gFXH8dQPRox83TuN5d4KzdLPqQNQAfMXU9_KmxihNb_qDQahYugeELmcem04munxXqBdyZqWhWCy5YmujYqn44irwvoTbw6_RkMqjCmINPTPadptlPivsZ6RhKn8zk\",\"d\":\"ok3wmhOy8NZEHAotnFiH6ecFD6xf_9x33_fMRkqa3_KE8NZM7vmvNgElox2UvcP_2K5E7jOdL2XQdJCTIW3Qlj66yE2a84SYlbvxIc4hDrIog0XNt4FhavvshxxUIfDQo6Q8qXDR5v7nwt6SCopYC3t3KVRdJh08GzKoVxysd7afJjxXxx178gY29uMRqnwxFN1OGnWaiBr-xGKb1frJ6jOI1zvuuCaljZ4aZjc9vOR4y9ZmobgrzkMFnpDAmQZ7MWcVMyodRMOA2dEOckywPhg-dIVNiVIqzJqe5Yg1ilNookjwtqj2TpNU7Z9gPqzYB73PmQ2p5LMDheAPxcOmEQ\",\"e\":\"AQAB\",\"use\":\"sig\",\"kid\":\"e3be37177a7c42bcbadd7cc63715f216\",\"qi\":\"r--nAtaYPAgJq_8R1-kynpd53E17n-loDUgtVWBCx_RmdORX4Auilv1S83dD1mbcnYCbV_LmxiEjOiz-4gS_E0qVGqakAqQrO1hVUvJa_Y2uftDgwFmuJNGbpRU-K4Td_uUzdm48za8yJCgOdYsWp6PNMCcmQgiInzkR3XYV83I\",\"dp\":\"oQUcvmMSw8gzdin-IB2xW_MLecAVEgLu0dGBdD6N8HbKZQvub_xm0dAfFtnvvWXDAFwFyhR96i-uXX67Bos_Q9-6KSAE4E0KGmDucDESfPOw-QJREbl0QgOD1gLQfVGtVy6SCR0TR2zNXFWtP7bD3MNoSXdEOr5fI97CGSNaBWM\",\"alg\":\"RS256\",\"dq\":\"DM-WJDy10-dkMu6MpgQEXEcxHtnA5rgSODD7SaVUFaHWLSbjScQslu2SuUCO5y7GxG0_0spklzb2-356FE98BPI7a4Oqj_COEYLSXzLCS45XeN1s80utL5Vwp4eeYo0RJCQ_nDBA76iEmxp5qHWmn5f25-FQykfXUrdYZj1V8SE\",\"n\":\"sa6m2i-iNvj6ZSTdSHZaBrnv6DId4AqAXhOyl0yA5fNWYe6r51h24SXqk7DsGYHHh74ii74tP1lTpmy6RD67tCK-tbN-d6yc4Z6FfM8R83v2QZUfaAixgHGtw0n2toqsiHf6EloDV-B8q4GYyKDD6cLecoaIuTmMBTY3kts59U2t9W10YoLGsmFqLSz8qNF5HkahzB6_--2DiBfVGUKAXHC-SICGZCi-8efOetv6pt9vFiWEgwU_DgjRNYzLFt1SEmbGFUU4kbjQ7tNTMkHfzfwcT6qLt4kVKy2FNYsEMk24keWtCvW_RyO_fisZc0W9smX7WtYjEXhcAjDeqHgEZw\"}") +@Property(name = "jwk.secondary", value = "{\"p\":\"4qJ9RNlu6SuDT_MLArfzimvKEwmet_j12Z9EQeb5nMjZIOHTcWw__duebUytfWwxsRHhtSVXeMt-EryQAOulm2p1bfiVuparq93z9P5cPnb0oArFaw3eFNFEmX5U-lY8PzUTTsFxO4aVQYAKXD6DP7p5uPzuwpHFuNc71nNIXZE\",\"kty\":\"RSA\",\"q\":\"v4OhkWMbS_nq77HFanwZAT_obfJuQfOFOQBORL4ATAHGUXm2y4YqLNExZs7Wj1MA_6ya6Y00s2JBM7fWq_fPe4d9xo5aGrPdcp0G8W21kkfh9vuVPlHVQTgSP7FQ9qahvXxNwK_11yNr3p1HBmScJ5mHlMBpIJsFcvHA-uXe0Ps\",\"d\":\"EunrjnQ1-jJPSCrt2L94PUpDrakup8a4pXys52YSkJY-W6XidM0roOS6kr06P3G6VQgc6AL_BkvTQ_XS0oXHbXVprDQ5Syam5p9oxHBhhW_vSqIMgUOfm28uyB3Mtw9rBxdUxW3yElHioaR8a-exYhhyVXb1QEhxL_rcnthmhAkM2NcHi2UnxGKFTsC0abQ2MuQc1OAuW5veDiIF2hfdC41qE0_d8vB6FDWbblgUpbwB6uSZaViPs15Buq2oX9dCCw54-PgzkfehDt7lyqgupktbV1psnVVhL86shzt4QFnhd3k7VpFbjCNFtiJTrufV-XBWT0pl2w3VR9wrHJ1bYQ\",\"e\":\"AQAB\",\"use\":\"sig\",\"kid\":\"0794e938379540dc8eaa559508524a79\",\"qi\":\"jy-TNyXVy_44_n4KGAwIbZO2C4r6uNWuEdehBfQKkPhiP90myG1KZVfOoKNOK9bCv2mvZJcBz4c1ArElgpuSCV4-KFac1ZzQo_ic5aoIej8Qa80y2ogc-_Yv6_ZLHC1S76M-lm4jayk2-rvuBpy2pUvHbW6Srhs_szwz7ZfSkLg\",\"dp\":\"ApqdV9ortRAj7Ro8ySY17SQ56SgWI8T_hiWXUi6GNa_1FrShik8VGSSZ2GWmJKfGlmM_NaadL60e4LY77VbHy1ZYzQ-rIL60cEAXmnwFsU4Kl4AoLoe1QoX5BM53yXyOKqfAdgow898i_eKru82YEnZhCagWUjP8kpgefuNKNJE\",\"alg\":\"RS256\",\"dq\":\"bFF78WoXh0pMCdQHL2oPDnjh8kWa_OxKHmpA2nqIWnTqgSyRKd2xPvX2tgooqpmsx-8NEymNdCQPcrv4y_z2OgzxI3tiFRZEGs4bnjOJ7bmAYZv71mqcbi3TjHiyrT6j3jNPGrurFUpweVGFWWVQOMmKOKT3ELz9QPzhREb9Vj8\",\"n\":\"qYvDpV8DRU5hx9eXpE4Ms8nUXicEwrxUUz5gb5gkXpIeY82mqfQKKCP6PSFnkKYtRFTOUSm9cgGGfOd7O4NFsIsxLwXCj34X7ORr19eXKBLvG3bZJLxqRlbYuQshDMkQOui1sDBxvYnj5p4iHne6l2btH5grHOCShUWG-bKps5Y8bKNHod1pIOOBabVCmn3sUVUkZw8nyXkQqZbv-c8x6z0TEfhNOPOIt2AmmlNgrE_8g7-dnCvqfJnhv0c7qkOJzsb7OMmvVwsQNiM59D6uaWZr-vdANo6NggiZmCKUS3tpUvdXW7ec9WMPJWhrVEkRcbWXQnZ_C7pXFrz7rLeNKw\"}") +@Property(name = "unity.auth.internal-token", value = "test-secret") +@MicronautTest +class UnityIamTest { + + @Inject + @Client("/") + HttpClient client; + + @Test + void testUserDisabled() { + String accessToken = login("disabled@test.io"); + HttpRequest hasPermissionRequest = HttpRequest.POST("/hasPermission", new HasPermissionRequest(1L, 1L, List.of("AUTH_SERVICE_EDIT-SYSTEM"))) + .bearerAuth(accessToken); + HttpResponse response = client.toBlocking() + .exchange(hasPermissionRequest, HasPermissionResponse.class); + assertEquals("The user’s account has been disabled!", response.getBody().get().errorMessage()); + assertEquals(Boolean.FALSE, response.getBody().get().hasPermission()); + } + + @Test + void testHasSystemPermission() { + String accessToken = login("person1@test.io"); + HttpRequest hasPermissionRequest = HttpRequest.POST("/hasPermission", new HasPermissionRequest(1L, 1L, List.of("AUTH_SERVICE_EDIT-SYSTEM"))) + .bearerAuth(accessToken); + HttpResponse response = client.toBlocking() + .exchange(hasPermissionRequest, HasPermissionResponse.class); + assertEquals(Boolean.TRUE, response.getBody().get().hasPermission()); + assertEquals("person1@test.io", response.getBody().get().userEmail()); + assertTrue(response.getBody().get().permissions().contains("AUTH_SERVICE_EDIT-SYSTEM")); + } + + @Test + void testHasUserFirstAnLastNameJWTClaims() { + String accessToken = login("person1@test.io"); + try { + JWT parse = JWTParser.parse(accessToken); + String firstName = (String) parse.getJWTClaimsSet().getClaim("first_name"); + String lastName = (String) parse.getJWTClaimsSet().getClaim("last_name"); + assertTrue(StringUtils.isNotEmpty(firstName)); + assertTrue(StringUtils.isNotEmpty(lastName)); + } catch (ParseException e) { + throw new RuntimeException(e); + } + } + + @Test + void testHasNoSystemPermission() { + String accessToken = login("test@test.io"); + HttpRequest hasPermissionRequest = HttpRequest.POST("/hasPermission", new HasPermissionRequest(1L, 1L, List.of("AUTH_SERVICE_EDIT-SYSTEM"))) + .bearerAuth(accessToken); + HttpResponse response = client.toBlocking() + .exchange(hasPermissionRequest, HasPermissionResponse.class); + assertEquals("The requested service is not enabled for the requested tenant!", response.getBody().get().errorMessage()); + assertEquals(Boolean.FALSE, response.getBody().get().hasPermission()); + assertNull(response.getBody().get().permissions()); + } + + @Test + void testTenantDoesNotExist() { + String accessToken = login("test@test.io"); + HttpRequest hasPermissionRequest = HttpRequest.POST("/hasPermission", new HasPermissionRequest(99L, 1L, List.of("AUTH_SERVICE_EDIT-SYSTEM"))) + .bearerAuth(accessToken); + HttpResponse response = client.toBlocking() + .exchange(hasPermissionRequest, HasPermissionResponse.class); + assertEquals("Cannot find tenant!", response.getBody().get().errorMessage()); + assertEquals(Boolean.FALSE, response.getBody().get().hasPermission()); + } + + @Test + void testHasTenantPermission() { + String accessToken = login("person1@test.io"); + HttpRequest hasPermissionRequest = HttpRequest.POST("/hasPermission", new HasPermissionRequest(2L, 1L, List.of("LIBRE311_REQUEST_EDIT-TENANT"))) + .bearerAuth(accessToken); + HttpResponse response = client.toBlocking() + .exchange(hasPermissionRequest, HasPermissionResponse.class); + assertEquals(Boolean.TRUE, response.getBody().get().hasPermission()); + assertTrue(response.getBody().get().permissions().contains("LIBRE311_REQUEST_EDIT-TENANT")); + } + + @Test + void testHasNoTenantPermission() { + String accessToken = login("person1@test.io"); + HttpRequest hasPermissionRequest = HttpRequest.POST("/hasPermission", new HasPermissionRequest(2L, 1L, List.of("LIBRE311_REQUEST_VIEW-TENANT"))) + .bearerAuth(accessToken); + HttpResponse response = client.toBlocking() + .exchange(hasPermissionRequest, HasPermissionResponse.class); + assertEquals("The user does not have permission!", response.getBody().get().errorMessage()); + assertEquals(Boolean.FALSE, response.getBody().get().hasPermission()); + assertNull(response.getBody().get().permissions()); + } + + @Test + void testHasSubtenantPermission() { + String accessToken = login("person1@test.io"); + HttpRequest hasPermissionRequest = HttpRequest.POST("/hasPermission", new HasPermissionRequest(2L, 1L, List.of("LIBRE311_REQUEST_EDIT-SUBTENANT"))) + .bearerAuth(accessToken); + HttpResponse response = client.toBlocking() + .exchange(hasPermissionRequest, HasPermissionResponse.class); + assertEquals(Boolean.TRUE, response.getBody().get().hasPermission()); + assertTrue(response.getBody().get().permissions().contains("LIBRE311_REQUEST_EDIT-SUBTENANT")); + } + + @Test + void testHasNoSubtenantPermission() { + String accessToken = login("person1@test.io"); + HttpRequest hasPermissionRequest = HttpRequest.POST("/hasPermission", new HasPermissionRequest(2L, 2L, List.of("LIBRE311_REQUEST_EDIT-SUBTENANT"))) + .bearerAuth(accessToken); + HttpResponse response = client.toBlocking() + .exchange(hasPermissionRequest, HasPermissionResponse.class); + assertEquals(Boolean.FALSE, response.getBody().get().hasPermission()); + assertEquals("The requested service is not enabled for the requested tenant!", response.getBody().get().errorMessage()); + assertNull(response.getBody().get().permissions()); + } + + + @Test + void testGetUserPermissionsDisabled() { + String accessToken = login("disabled@test.io"); + HttpRequest hasPermissionRequest = HttpRequest.POST("/principal/permissions", + new UserPermissionsRequest(1L, 1L)) + .bearerAuth(accessToken); + HttpResponse response = client.toBlocking() + .exchange(hasPermissionRequest, UserPermissionsResponse.Failure.class); + UserPermissionsResponse.Failure failure = response.getBody().orElseThrow(); + assertEquals("The user's account has been disabled.", failure.errorMessage()); + } + + @Test + void testGetUserPermissionsHappyPath() { + String accessToken = login("person1@test.io"); + HttpRequest hasPermissionRequest = HttpRequest.POST("/principal/permissions", + new UserPermissionsRequest(1L, 1L)) + .bearerAuth(accessToken); + HttpResponse response = client.toBlocking() + .exchange(hasPermissionRequest, UserPermissionsResponse.Success.class); + + assertTrue(response.getBody().get().permissions().contains("AUTH_SERVICE_EDIT-SYSTEM")); + } + + @Test + void testGetTenantsList() { + String accessToken = login("person1@test.io"); + HttpRequest getTenantsRequest = HttpRequest.GET("/tenants") + .bearerAuth(accessToken); + HttpResponse response = client.toBlocking() + .exchange(getTenantsRequest, AuthController.TenantDTO[].class); + + assertTrue(response.getBody(AuthController.TenantDTO[].class).isPresent()); + assertFalse(Arrays.stream(response.getBody(AuthController.TenantDTO[].class).get()).findAny().isEmpty()); + } + + @Test + void testGetTenantsUsers() { + String accessToken = login("person1@test.io"); + HttpRequest getTenantUsersRequest = HttpRequest.GET("/tenants/2/users") + .bearerAuth(accessToken); + HttpResponse response = client.toBlocking() + .exchange(getTenantUsersRequest, UserResponse[].class); + + + assertTrue(response.getBody().isPresent()); + + UserResponse[] userResponses = response.getBody().get(); + assertFalse(Arrays.stream(userResponses).findAny().isEmpty()); + + Optional first = Arrays.stream(userResponses).filter(userResponse -> userResponse.id().equals(4L)).findFirst(); + assertTrue(first.isPresent()); + assertEquals("acme-tenant-admin@test.io", first.get().email()); + } + + @Test + void testGetRoles() { + String accessToken = login("person1@test.io"); + HttpRequest getRolesRequest = HttpRequest.GET("/roles") + .bearerAuth(accessToken); + HttpResponse response = client.toBlocking() + .exchange(getRolesRequest, AuthController.RoleDTO[].class); + + assertTrue(response.getBody().isPresent()); + assertFalse(Arrays.stream(response.getBody().get()).findAny().isEmpty()); + } + + @Test + void testCreateUsers() { + String accessToken = login("person1@test.io"); + + //Case: User does not exist in system. + HttpRequest createUserRequest = HttpRequest.POST("/users", + new UserController.AddUserRequest( + "tmpuser@test.io", + "Donald", + "Duck", + 1L, + "test", + List.of(1L, 2L) + )) + .bearerAuth(accessToken); + + HttpResponse response = client.toBlocking() + .exchange(createUserRequest, UserResponse.class); + + assertTrue(response.getBody(UserResponse.class).isPresent()); + UserResponse user = response.getBody(UserResponse.class).get(); + assertFalse(user.roles().isEmpty()); + + // Case: User does exist in system but not under tenant. + // Given that tenant = 2, this confirms that a Unity Admin can perform actions in a Tenant + // they are not associated to. (See afterMigrate.sql user_role table) + createUserRequest = HttpRequest.POST("/users", + new UserController.AddUserRequest( + "tmpuser@test.io", + "Donald", + "Duck", + 2L, + "test", + List.of(2L) + )) + .bearerAuth(accessToken); + + response = client.toBlocking() + .exchange(createUserRequest, UserResponse.class); + + assertTrue(response.getBody(UserResponse.class).isPresent()); + user = response.getBody(UserResponse.class).get(); + assertFalse(user.roles().isEmpty()); + assertEquals(1, user.roles().size()); + } + + @Test + void testUpdateUserRoles() { + String accessToken = login("person1@test.io"); + + HttpRequest updateRequest = HttpRequest.PATCH("/users/4/roles", + new UserController.UpdateUserRolesRequest( + 2L, + List.of(3L) + )) + .bearerAuth(accessToken); + + HttpResponse response = client.toBlocking() + .exchange(updateRequest, UserResponse.class); + + assertTrue(response.getBody(UserResponse.class).isPresent()); + UserResponse user = response.getBody(UserResponse.class).get(); + assertFalse(user.roles().isEmpty()); + assertEquals(1, user.roles().size()); + assertEquals(3L, user.roles().get(0)); + } + + @Test + void testUpdateSelfDetails() { + String accessToken = login("person1@test.io"); + + HttpRequest updateRequest = HttpRequest.PATCH("/users/1", + new UserController.UpdateSelfRequest( + "Maui", + "Mallard", + null + )) + .bearerAuth(accessToken); + + HttpResponse response = client.toBlocking() + .exchange(updateRequest, UserResponse.class); + + assertTrue(response.getBody(UserResponse.class).isPresent()); + UserResponse user = response.getBody(UserResponse.class).get(); + assertFalse(user.roles().isEmpty()); + assertEquals("Maui", user.firstName()); + assertEquals("Mallard", user.lastName()); + } + + private String login(String username) { + UsernamePasswordCredentials creds = new UsernamePasswordCredentials(username, "test"); + HttpRequest request = HttpRequest.POST("/login", creds); + HttpResponse rsp = client.toBlocking() + .exchange(request, BearerAccessRefreshToken.class); + assertEquals(HttpStatus.OK, rsp.getStatus()); + BearerAccessRefreshToken bearer = rsp.body(); + return bearer.getAccessToken(); + } +} diff --git a/auth/src/test/java/io/unityfoundation/UnityPasswordResetTest.java b/auth/src/test/java/io/unityfoundation/UnityPasswordResetTest.java new file mode 100644 index 000000000..09f61e465 --- /dev/null +++ b/auth/src/test/java/io/unityfoundation/UnityPasswordResetTest.java @@ -0,0 +1,96 @@ +package io.unityfoundation; + +import io.micronaut.context.annotation.Property; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.unityfoundation.auth.PasswordResetController; +import io.unityfoundation.auth.entities.User; +import io.unityfoundation.auth.entities.UserRepo; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@Property(name = "jwk.primary", value = "{\"p\":\"_OZyH1Mk3wR0oXw1C31t4kWOcaHFB6Njro1cYx52REnPiznn_JTtwvlAMpvV6LVCIZPgKMzdIEMY1gYs1LsO-5IFqWwegXmYJ0iKXbRrZshfWBCzRLK3QK5fER1le1XUBDhtDk7KIW_Xg-SZF4pf_LUEVKMnyUpspGI5F77jlJ8\",\"kty\":\"RSA\",\"q\":\"s9wvl7z8vkHQvo9xOUp-z0a2Z7LFBDil2uIjPh1FQzs34gFXH8dQPRox83TuN5d4KzdLPqQNQAfMXU9_KmxihNb_qDQahYugeELmcem04munxXqBdyZqWhWCy5YmujYqn44irwvoTbw6_RkMqjCmINPTPadptlPivsZ6RhKn8zk\",\"d\":\"ok3wmhOy8NZEHAotnFiH6ecFD6xf_9x33_fMRkqa3_KE8NZM7vmvNgElox2UvcP_2K5E7jOdL2XQdJCTIW3Qlj66yE2a84SYlbvxIc4hDrIog0XNt4FhavvshxxUIfDQo6Q8qXDR5v7nwt6SCopYC3t3KVRdJh08GzKoVxysd7afJjxXxx178gY29uMRqnwxFN1OGnWaiBr-xGKb1frJ6jOI1zvuuCaljZ4aZjc9vOR4y9ZmobgrzkMFnpDAmQZ7MWcVMyodRMOA2dEOckywPhg-dIVNiVIqzJqe5Yg1ilNookjwtqj2TpNU7Z9gPqzYB73PmQ2p5LMDheAPxcOmEQ\",\"e\":\"AQAB\",\"use\":\"sig\",\"kid\":\"e3be37177a7c42bcbadd7cc63715f216\",\"qi\":\"r--nAtaYPAgJq_8R1-kynpd53E17n-loDUgtVWBCx_RmdORX4Auilv1S83dD1mbcnYCbV_LmxiEjOiz-4gS_E0qVGqakAqQrO1hVUvJa_Y2uftDgwFmuJNGbpRU-K4Td_uUzdm48za8yJCgOdYsWp6PNMCcmQgiInzkR3XYV83I\",\"dp\":\"oQUcvmMSw8gzdin-IB2xW_MLecAVEgLu0dGBdD6N8HbKZQvub_xm0dAfFtnvvWXDAFwFyhR96i-uXX67Bos_Q9-6KSAE4E0KGmDucDESfPOw-QJREbl0QgOD1gLQfVGtVy6SCR0TR2zNXFWtP7bD3MNoSXdEOr5fI97CGSNaBWM\",\"alg\":\"RS256\",\"dq\":\"DM-WJDy10-dkMu6MpgQEXEcxHtnA5rgSODD7SaVUFaHWLSbjScQslu2SuUCO5y7GxG0_0spklzb2-356FE98BPI7a4Oqj_COEYLSXzLCS45XeN1s80utL5Vwp4eeYo0RJCQ_nDBA76iEmxp5qHWmn5f25-FQykfXUrdYZj1V8SE\",\"n\":\"sa6m2i-iNvj6ZSTdSHZaBrnv6DId4AqAXhOyl0yA5fNWYe6r51h24SXqk7DsGYHHh74ii74tP1lTpmy6RD67tCK-tbN-d6yc4Z6FfM8R83v2QZUfaAixgHGtw0n2toqsiHf6EloDV-B8q4GYyKDD6cLecoaIuTmMBTY3kts59U2t9W10YoLGsmFqLSz8qNF5HkahzB6_--2DiBfVGUKAXHC-SICGZCi-8efOetv6pt9vFiWEgwU_DgjRNYzLFt1SEmbGFUU4kbjQ7tNTMkHfzfwcT6qLt4kVKy2FNYsEMk24keWtCvW_RyO_fisZc0W9smX7WtYjEXhcAjDeqHgEZw\"}") +@Property(name = "jwk.secondary", value = "{\"p\":\"4qJ9RNlu6SuDT_MLArfzimvKEwmet_j12Z9EQeb5nMjZIOHTcWw__duebUytfWwxsRHhtSVXeMt-EryQAOulm2p1bfiVuparq93z9P5cPnb0oArFaw3eFNFEmX5U-lY8PzUTTsFxO4aVQYAKXD6DP7p5uPzuwpHFuNc71nNIXZE\",\"kty\":\"RSA\",\"q\":\"v4OhkWMbS_nq77HFanwZAT_obfJuQfOFOQBORL4ATAHGUXm2y4YqLNExZs7Wj1MA_6ya6Y00s2JBM7fWq_fPe4d9xo5aGrPdcp0G8W21kkfh9vuVPlHVQTgSP7FQ9qahvXxNwK_11yNr3p1HBmScJ5mHlMBpIJsFcvHA-uXe0Ps\",\"d\":\"EunrjnQ1-jJPSCrt2L94PUpDrakup8a4pXys52YSkJY-W6XidM0roOS6kr06P3G6VQgc6AL_BkvTQ_XS0oXHbXVprDQ5Syam5p9oxHBhhW_vSqIMgUOfm28uyB3Mtw9rBxdUxW3yElHioaR8a-exYhhyVXb1QEhxL_rcnthmhAkM2NcHi2UnxGKFTsC0abQ2MuQc1OAuW5veDiIF2hfdC41qE0_d8vB6FDWbblgUpbwB6uSZaViPs15Buq2oX9dCCw54-PgzkfehDt7lyqgupktbV1psnVVhL86shzt4QFnhd3k7VpFbjCNFtiJTrufV-XBWT0pl2w3VR9wrHJ1bYQ\",\"e\":\"AQAB\",\"use\":\"sig\",\"kid\":\"0794e938379540dc8eaa559508524a79\",\"qi\":\"jy-TNyXVy_44_n4KGAwIbZO2C4r6uNWuEdehBfQKkPhiP90myG1KZVfOoKNOK9bCv2mvZJcBz4c1ArElgpuSCV4-KFac1ZzQo_ic5aoIej8Qa80y2ogc-_Yv6_ZLHC1S76M-lm4jayk2-rvuBpy2pUvHbW6Srhs_szwz7ZfSkLg\",\"dp\":\"ApqdV9ortRAj7Ro8ySY17SQ56SgWI8T_hiWXUi6GNa_1FrShik8VGSSZ2GWmJKfGlmM_NaadL60e4LY77VbHy1ZYzQ-rIL60cEAXmnwFsU4Kl4AoLoe1QoX5BM53yXyOKqfAdgow898i_eKru82YEnZhCagWUjP8kpgefuNKNJE\",\"alg\":\"RS256\",\"dq\":\"bFF78WoXh0pMCdQHL2oPDnjh8kWa_OxKHmpA2nqIWnTqgSyRKd2xPvX2tgooqpmsx-8NEymNdCQPcrv4y_z2OgzxI3tiFRZEGs4bnjOJ7bmAYZv71mqcbi3TjHiyrT6j3jNPGrurFUpweVGFWWVQOMmKOKT3ELz9QPzhREb9Vj8\",\"n\":\"qYvDpV8DRU5hx9eXpE4Ms8nUXicEwrxUUz5gb5gkXpIeY82mqfQKKCP6PSFnkKYtRFTOUSm9cgGGfOd7O4NFsIsxLwXCj34X7ORr19eXKBLvG3bZJLxqRlbYuQshDMkQOui1sDBxvYnj5p4iHne6l2btH5grHOCShUWG-bKps5Y8bKNHod1pIOOBabVCmn3sUVUkZw8nyXkQqZbv-c8x6z0TEfhNOPOIt2AmmlNgrE_8g7-dnCvqfJnhv0c7qkOJzsb7OMmvVwsQNiM59D6uaWZr-vdANo6NggiZmCKUS3tpUvdXW7ec9WMPJWhrVEkRcbWXQnZ_C7pXFrz7rLeNKw\"}") +@Property(name = "unity.auth.internal-token", value = "test-secret") +@MicronautTest +public class UnityPasswordResetTest { + + @Inject + @Client("/") + HttpClient client; + + @Inject + UserRepo userRepo; + + @Test + void testPasswordResetFlow() { + // 1. Generate token + HttpRequest generateRequest = HttpRequest.POST("/password-reset/generate", + new PasswordResetController.GenerateTokenRequest("person1@test.io")) + .header("X-Unity-Auth-Internal", "test-secret"); + + HttpResponse generateResponse = client.toBlocking() + .exchange(generateRequest, PasswordResetController.GenerateTokenResponse.class); + + assertEquals(HttpStatus.OK, generateResponse.getStatus()); + String token = generateResponse.getBody().get().token(); + assertNotNull(token); + + // 2. Reset password + HttpRequest resetRequest = HttpRequest.POST("/password-reset/reset", + new PasswordResetController.ResetPasswordRequest(token, "new-secure-password")) + .header("X-Unity-Auth-Internal", "test-secret"); + + HttpResponse resetResponse = client.toBlocking().exchange(resetRequest); + assertEquals(HttpStatus.OK, resetResponse.getStatus()); + + // 3. Verify password was changed + Optional userOptional = userRepo.findByEmail("person1@test.io"); + assertTrue(userOptional.isPresent()); + } + + @Test + void testGenerateTokenInvalidSecret() { + HttpRequest generateRequest = HttpRequest.POST("/password-reset/generate", + new PasswordResetController.GenerateTokenRequest("person1@test.io")) + .header("X-Unity-Auth-Internal", "wrong-secret"); + + HttpClientResponseException exception = assertThrows(HttpClientResponseException.class, () -> { + client.toBlocking().exchange(generateRequest); + }); + assertEquals(HttpStatus.UNAUTHORIZED, exception.getStatus()); + } + + @Test + void testResetWithInvalidToken() { + HttpRequest resetRequest = HttpRequest.POST("/password-reset/reset", + new PasswordResetController.ResetPasswordRequest("invalid-token", "new-password")) + .header("X-Unity-Auth-Internal", "test-secret"); + + HttpClientResponseException exception = assertThrows(HttpClientResponseException.class, () -> { + client.toBlocking().exchange(resetRequest); + }); + assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus()); + } + + @Test + void testResetWithInvalidSecret() { + HttpRequest resetRequest = HttpRequest.POST("/password-reset/reset", + new PasswordResetController.ResetPasswordRequest("any-token", "new-password")) + .header("X-Unity-Auth-Internal", "wrong-secret"); + + HttpClientResponseException exception = assertThrows(HttpClientResponseException.class, () -> { + client.toBlocking().exchange(resetRequest); + }); + assertEquals(HttpStatus.UNAUTHORIZED, exception.getStatus()); + } +} diff --git a/auth/src/test/resources/db/migration/afterMigrate.sql b/auth/src/test/resources/db/migration/afterMigrate.sql new file mode 100644 index 000000000..1e13f4ded --- /dev/null +++ b/auth/src/test/resources/db/migration/afterMigrate.sql @@ -0,0 +1,32 @@ +DELETE FROM user_role; +DELETE FROM role_permission; +DELETE FROM tenant_service; +DELETE FROM user; +DELETE FROM tenant; +DELETE FROM service; +DELETE FROM permission; +DELETE FROM role; +INSERT INTO user (id, email, first_name, last_name, password, status) VALUES(1, 'person1@test.io', 'Person', 'One', '$2a$10$YJetsyoS.EzlVlb249w07uBR8uSqgtlqVH9Hl7bsHtvvwdKAhJp82', 'ENABLED'); +INSERT INTO user (id, email, first_name, last_name, password, status) VALUES(2, 'test@test.io', 'Test', 'Test', '$2a$10$YJetsyoS.EzlVlb249w07uBR8uSqgtlqVH9Hl7bsHtvvwdKAhJp82', 'ENABLED'); +INSERT INTO user (id, email, first_name, last_name, password, status) VALUES(3, 'disabled@test.io', 'Disabled', 'User', '$2a$10$YJetsyoS.EzlVlb249w07uBR8uSqgtlqVH9Hl7bsHtvvwdKAhJp82', 'DISABLED'); +INSERT INTO user (id, email, first_name, last_name, password, status) VALUES(4, 'acme-tenant-admin@test.io', 'Acme Tenant', 'Admin', '$2a$10$YJetsyoS.EzlVlb249w07uBR8uSqgtlqVH9Hl7bsHtvvwdKAhJp82', 'ENABLED'); +INSERT INTO tenant (id, name, description, status) VALUES(1, 'SYSTEM', 'SYSTEM', 'ENABLED'); +INSERT INTO tenant (id, name, description, status) VALUES(2, 'acme', 'Acme Corporation', 'ENABLED'); +INSERT INTO service (id, name, description, status) VALUES(1, 'Libre311', 'Libre311', 'ENABLED'); +INSERT INTO service (id, name, description, status) VALUES(2, 'Application2', 'Application2', 'ENABLED'); +INSERT INTO tenant_service (tenant_id, service_id, status) VALUES(2, 1, 'ENABLED'); +INSERT INTO permission (id, name, description, scope) VALUES(1, 'AUTH_SERVICE_EDIT-SYSTEM', 'Description', 'SYSTEM'); +INSERT INTO permission (id, name, description, scope) VALUES(2, 'LIBRE311_REQUEST_EDIT-TENANT', 'Description', 'TENANT'); +INSERT INTO permission (id, name, description, scope) VALUES(3, 'LIBRE311_REQUEST_EDIT-SUBTENANT', 'Description', 'SUBTENANT'); +INSERT INTO permission (id, name, description, scope) VALUES(4, 'AUTH_SERVICE_VIEW-SYSTEM', 'Description', 'SYSTEM'); +INSERT INTO role (id, name, description) VALUES(1, 'Unity Administrator', 'System role'); +INSERT INTO role (id, name, description) VALUES(2, 'Tenant role', 'Tenant role'); +INSERT INTO role (id, name, description) VALUES(3, 'Subtenant role', 'Subtenant role'); +INSERT INTO role_permission (role_id, permission_id) VALUES(1, 1); +INSERT INTO role_permission (role_id, permission_id) VALUES(2, 2); +INSERT INTO role_permission (role_id, permission_id) VALUES(3, 3); +INSERT INTO role_permission (role_id, permission_id) VALUES(1, 4); +INSERT INTO user_role (tenant_id, user_id, role_id) VALUES(1, 1, 1); +INSERT INTO user_role (tenant_id, user_id, role_id) VALUES(2, 1, 2); +INSERT INTO user_role (tenant_id, user_id, role_id) VALUES(2, 1, 3); +INSERT INTO user_role (tenant_id, user_id, role_id) VALUES(2, 4, 2); diff --git a/auth/src/test/resources/db/migration/application-test.yml b/auth/src/test/resources/db/migration/application-test.yml new file mode 100644 index 000000000..4cad87e7c --- /dev/null +++ b/auth/src/test/resources/db/migration/application-test.yml @@ -0,0 +1,9 @@ +micronaut: + http: + client: + read-timeout: 1m + + +datasources: + default: + password: "test" diff --git a/frontend/src/lib/components/MenuDrawer.svelte b/frontend/src/lib/components/MenuDrawer.svelte index 56706f688..bc77064fa 100644 --- a/frontend/src/lib/components/MenuDrawer.svelte +++ b/frontend/src/lib/components/MenuDrawer.svelte @@ -93,7 +93,18 @@ > - {#if $jurisdiction.project_feature && $jurisdiction.project_feature !== 'DISABLED'} + + {#if $jurisdiction.project_feature && $jurisdiction.project_feature !== 'DISABLED'} + - {/if} - + + {/if} - + { - const res = await this.axiosInstance.post('/api/login', { + const res = await this.axiosInstance.post('/login', { username: email, password: password }); diff --git a/frontend/src/routes/admin/system/+page.svelte b/frontend/src/routes/admin/system/+page.svelte index 8dc83c5cc..102715786 100644 --- a/frontend/src/routes/admin/system/+page.svelte +++ b/frontend/src/routes/admin/system/+page.svelte @@ -9,7 +9,15 @@ const jurisdiction = useJurisdiction(); $: if ($user !== undefined) { - if (!$user?.permissions.includes('LIBRE311_ADMIN_EDIT-SYSTEM')) { + if ( + !$user?.permissions.some((p) => + [ + 'LIBRE311_ADMIN_EDIT-SYSTEM', + 'LIBRE311_ADMIN_EDIT-TENANT', + 'LIBRE311_ADMIN_EDIT-SUBTENANT' + ].includes(p) + ) + ) { goto('/'); } } diff --git a/frontend/src/routes/projects/+page.svelte b/frontend/src/routes/projects/+page.svelte index d4e38d5be..5ff22e722 100644 --- a/frontend/src/routes/projects/+page.svelte +++ b/frontend/src/routes/projects/+page.svelte @@ -71,7 +71,10 @@ requires={[ 'LIBRE311_ADMIN_VIEW-TENANT', 'LIBRE311_ADMIN_VIEW-SYSTEM', - 'LIBRE311_ADMIN_VIEW-SUBTENANT' + 'LIBRE311_ADMIN_VIEW-SUBTENANT', + 'LIBRE311_REQUEST_VIEW-SYSTEM', + 'LIBRE311_REQUEST_VIEW-TENANT', + 'LIBRE311_REQUEST_VIEW-SUBTENANT' ]} >
@@ -99,10 +102,12 @@ > - + {#if isAdmin} + + {/if}
diff --git a/frontend/src/routes/projects/[project_id]/(view)/+layout.svelte b/frontend/src/routes/projects/[project_id]/(view)/+layout.svelte index 606b7d6b5..5cdabf982 100644 --- a/frontend/src/routes/projects/[project_id]/(view)/+layout.svelte +++ b/frontend/src/routes/projects/[project_id]/(view)/+layout.svelte @@ -23,7 +23,7 @@ import { useLibre311Context } from '$lib/context/Libre311Context'; const libre311 = useLibre311Service(); - const { projects: allProjectsStore, fetchProjectsAdmin } = useLibre311Context(); + const { projects: allProjectsStore, fetchProjectsAdmin, user } = useLibre311Context(); let project: Project | undefined; let showCloseModal = false; @@ -114,13 +114,24 @@ $: canReopen = project && project.status === 'CLOSED' && new Date(project.end_date) > new Date(); $: canClose = project && project.status === 'OPEN'; + + $: isAdmin = !!$user?.permissions.some((p) => + [ + 'LIBRE311_ADMIN_VIEW-SYSTEM', + 'LIBRE311_ADMIN_VIEW-TENANT', + 'LIBRE311_ADMIN_VIEW-SUBTENANT' + ].includes(p) + );
@@ -131,21 +142,23 @@ ← All Projects
- {#if canReopen} - + {/if} + {#if canClose} + + {/if} + {/if} - {#if canClose} - - {/if} -

{project.name}

diff --git a/frontend/static/5152bf7d8c21721325e9cc3adf2b9a8e.txt b/frontend/static/5152bf7d8c21721325e9cc3adf2b9a8e.txt deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 2f2e3ae73..c7b111053 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -11,7 +11,11 @@ export default defineConfig({ server: { port: 3000, host: true, - allowedHosts: ['stlma.localhost', 'lomocomo.localhost'] + allowedHosts: ['stlma.localhost', 'lomocomo.localhost'], + proxy: { + '/api': 'http://localhost:8080', + '/auth': 'http://localhost:9090' + } }, optimizeDeps: { include: [ diff --git a/settings.gradle b/settings.gradle index 65dd9dd69..250b9e08c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -include 'app', 'frontend' \ No newline at end of file +include 'app', 'frontend', 'auth' \ No newline at end of file