diff --git a/calm-hub/keycloak-dev/device-code-flow-entraid.sh b/calm-hub/keycloak-dev/device-code-flow-entraid.sh new file mode 100755 index 000000000..5dc377135 --- /dev/null +++ b/calm-hub/keycloak-dev/device-code-flow-entraid.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +CLIENT_ID="0cda28f4-102e-4913-b61c-d57a664e1b2b" #calm-hub-device-flow +SCOPE="api://calm-hub-producer-app/architectures:read api://calm-hub-producer-app/architectures:all" +TENANT_ID="3c9baf76-e5a3-42b6-8b21-46660e5d2cfb" + +DEVICE_AUTH_ENDPOINT="https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/devicecode" +DEVICE_AUTH_RESPONSE=$(curl -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=$CLIENT_ID" -d "scope=$SCOPE" \ + $DEVICE_AUTH_ENDPOINT) + +# Extract values from the device auth response. +DEVICE_CODE=$(echo "$DEVICE_AUTH_RESPONSE" | jq -r '.device_code') +USER_CODE=$(echo "$DEVICE_AUTH_RESPONSE" | jq -r '.user_code') +VERIFICATION_URI=$(echo "$DEVICE_AUTH_RESPONSE" | jq -r '.verification_uri') +VERIFICATION_URI_COMPLETE=$(echo "$DEVICE_AUTH_RESPONSE" | jq -r '.verification_uri_complete') +EXPIRES_IN=$(echo "$DEVICE_AUTH_RESPONSE" | jq -r '.expires_in') +INTERVAL=$(echo "$DEVICE_AUTH_RESPONSE" | jq -r '.interval') + +echo -e "Please open the link on a browser $VERIFICATION_URI, User Code:[$USER_CODE] \nCorresponding device code [$DEVICE_CODE] will expires in $EXPIRES_IN seconds.\n" + +# Poll the token endpoint +# TrialTenantm0w91qAV.onmicrosoft.com +TOKEN_URL="https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token" +ACCESS_TOKEN="" +POLL_INTERVAL=15 #Seconds +poll_token() { + while true; do + RESPONSE=$(curl -X POST "$TOKEN_URL" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=$CLIENT_ID" \ + -d "device_code=$DEVICE_CODE" \ + -d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \ + -d "scope=$SCOPE") + + echo "Response: $RESPONSE \n" + + ERROR=$(echo "$RESPONSE" | jq -r '.error') + if [[ "$ERROR" == "authorization_pending" ]]; then + echo "Waiting for user authorization..." + sleep $POLL_INTERVAL + elif [[ "$ERROR" == "expired_token" ]]; then + echo "Device code expired. Restart the flow." + exit 1 + elif [[ "$ERROR" == "slow_down" ]]; then + echo "Server requested slower polling. " + POLL_INTERVAL=$((POLL_INTERVAL + 10)) + sleep $POLL_INTERVAL + else + ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r '.access_token') + echo "Access Token: $ACCESS_TOKEN" + break; + fi + done +} + +#Start token polling +poll_token + +echo "Proceed to get patterns" +read +if [[ -n $ACCESS_TOKEN ]]; then + curl --insecure -v -X GET "https://localhost:8443/calm/namespaces/finos/patterns" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $ACCESS_TOKEN" +fi +#Reference: https://github.com/keycloak/keycloak-community/blob/main/design/oauth2-device-authorization-grant.md \ No newline at end of file diff --git a/calm-hub/keycloak-dev/device-code-flow.sh b/calm-hub/keycloak-dev/device-code-flow.sh index 9a614f4c3..a2ca82c08 100755 --- a/calm-hub/keycloak-dev/device-code-flow.sh +++ b/calm-hub/keycloak-dev/device-code-flow.sh @@ -1,7 +1,7 @@ #!/bin/bash -CLIENT_ID="calm-hub-device-flow" -SCOPE="architectures:all architectures:read" +CLIENT_ID="calm-hub-admin-app" +SCOPE="namespace:admin" DEVICE_AUTH_ENDPOINT="https://localhost:9443/realms/calm-hub-realm/protocol/openid-connect/auth/device" DEVICE_AUTH_RESPONSE=$(curl --insecure -X POST \ -H "Content-Type: application/x-www-form-urlencoded" \ @@ -54,11 +54,25 @@ poll_token() { #Start token polling poll_token -echo -e "\nPress enter to get patterns" +echo -e "\nPositive Case: Press enter to create a sample user-access for finos resources." read if [[ -n $ACCESS_TOKEN ]]; then - curl --insecure -v "https://localhost:8443/calm/namespaces/finos/patterns" \ + curl -X POST --insecure -v "https://localhost:8443/calm/namespaces/finos/user-access" \ -H "Content-Type: application/json" \ - -H "Authorization: Bearer $ACCESS_TOKEN" + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -d '{ "namespace": "finos", "resourceType": "patterns", "permission": "read", "username": "demo" }' + + echo -e "\nPositive Case: Press enter to get list of user-access details associated to namespace:finos" + read + curl --insecure -v "https://localhost:8443/calm/namespaces/finos/user-access" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $ACCESS_TOKEN" + + echo + echo -e "\nFailure Case: Press enter to create a sample user-access for traderx namespace." + read + curl -X POST --insecure -v "https://localhost:8443/calm/namespaces/traderx/user-access" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -d '{ "namespace": "traderx", "resourceType": "patterns", "permission": "read", "username": "demo" }' fi -#Reference: https://github.com/keycloak/keycloak-community/blob/main/design/oauth2-device-authorization-grant.md \ No newline at end of file diff --git a/calm-hub/keycloak-dev/imports/realm.json b/calm-hub/keycloak-dev/imports/realm.json index 753613bc5..08203becb 100644 --- a/calm-hub/keycloak-dev/imports/realm.json +++ b/calm-hub/keycloak-dev/imports/realm.json @@ -174,6 +174,45 @@ "adrs:read", "architectures:all" ] + }, + { + "clientId": "calm-hub-admin-app", + "name": "calm-hub-admin-app", + "description": "CalmHub Admin for Managing UserAccess", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": true, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "post.logout.redirect.uris": "+", + "display.on.consent.screen": "false", + "oauth2.device.authorization.grant.enabled": "true", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "profile" + ], + "optionalClientScopes": [ + "namespace:admin" + ] } ], "clientScopes": [ @@ -559,6 +598,22 @@ } } ] + }, + { + "name": "namespace:admin", + "protocol": "openid-connect", + "protocolMappers": [ + { + "name": "audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.client.audience": "calm-hub-producer-app", + "id.token.claim": "true", + "access.token.claim": "true" + } + } + ] } ], "defaultOptionalClientScopes": [ @@ -566,6 +621,7 @@ "adrs:all", "architectures:read", "architectures:all", + "namespace:admin", "offline_access", "service_account" ], @@ -591,6 +647,12 @@ "roles": [ "calm-hub-readonly" ] + }, + { + "clientScope": "namespace:admin", + "roles": [ + "calm-hub-admin" + ] } ], "roles": { @@ -601,6 +663,13 @@ "description": "Readonly role with access to read scopes", "composite": false, "attributes": {} + }, + { + "name": "calm-hub-admin", + "clientRole": false, + "description": "Admin role to grant access to calm-hub users", + "composite": false, + "attributes": {} } ] }, @@ -625,6 +694,27 @@ "realmRoles": [ "calm-hub-readonly" ] + }, + { + "username": "demo_admin", + "enabled": true, + "email": "demo_admin@calm-hub.finos.org", + "firstName": "Demo User", + "lastName": "Admin", + "attributes": { + "department": "Technology", + "location": "HQ" + }, + "credentials": [ + { + "type": "password", + "value": "changeme", + "temporary": true + } + ], + "realmRoles": [ + "calm-hub-admin" + ] } ] } \ No newline at end of file diff --git a/calm-hub/mongo/init-mongo.js b/calm-hub/mongo/init-mongo.js index e836428fa..18bd3d14b 100644 --- a/calm-hub/mongo/init-mongo.js +++ b/calm-hub/mongo/init-mongo.js @@ -42,6 +42,16 @@ if (db.counters.countDocuments({ _id: "flowStoreCounter" }) === 0) { print("flowStoreCounter already exists, no initialization needed"); } +if (db.counters.countDocuments({ _id: "userAccessStoreCounter" }) === 0) { + db.counters.insertOne({ + _id: "userAccessStoreCounter", + sequence_value: 6 + }); + print("Initialized userAccessStoreCounter with sequence_value 6"); +} else { + print("userAccessStoreCounter already exists, no initialization needed"); +} + db.schemas.insertMany([ // Insert initial documents into the schemas collection { version: "2025-03", @@ -2665,3 +2675,48 @@ db.architectures.insertMany([ }] } ]); + +db.userAccess.insertMany([ + { + "userAccessId": NumberInt(1), + "username": "demo_admin", + "permission": "write", + "namespace": "finos", + "resourceType": "all" + }, + { + "userAccessId": NumberInt(2), + "username": "demo_admin", + "permission": "write", + "namespace": "workshop", + "resourceType": "patterns" + }, + { + "userAccessId": NumberInt(3), + "username": "demo_admin", + "permission": "read", + "namespace": "traderx", + "resourceType": "all" + }, + { + "userAccessId": NumberInt(4), + "username": "demo", + "permission": "read", + "namespace": "finos", + "resourceType": "all" + }, + { + "userAccessId": NumberInt(5), + "username": "demo", + "permission": "read", + "namespace": "traderx", + "resourceType": "all" + }, + { + "userAccessId": NumberInt(6), + "username": "demo", + "permission": "read", + "namespace": "workshop", + "resourceType": "all" + } +]); \ No newline at end of file diff --git a/calm-hub/src/integration-test/java/integration/MongoSetup.java b/calm-hub/src/integration-test/java/integration/MongoSetup.java index 530e7e561..7e705dff5 100644 --- a/calm-hub/src/integration-test/java/integration/MongoSetup.java +++ b/calm-hub/src/integration-test/java/integration/MongoSetup.java @@ -37,9 +37,11 @@ public static void counterSetup(MongoDatabase database) { Document patternStoreCounter = new Document("_id", "patternStoreCounter").append("sequence_value", 0); Document architectureStoreCounter = new Document("_id", "architectureStoreCounter").append("sequence_value", 0); Document adrStoreCounter = new Document("_id", "adrStoreCounter").append("sequence_value", 0); + Document userAccessStoreCounter = new Document("_id", "userAccessStoreCounter").append("sequence_value", 0); database.getCollection("counters").insertOne(patternStoreCounter); database.getCollection("counters").insertOne(architectureStoreCounter); database.getCollection("counters").insertOne(adrStoreCounter); + database.getCollection("counters").insertOne(userAccessStoreCounter); } } } diff --git a/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java b/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java index d65503a0b..81e4acf98 100644 --- a/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java +++ b/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java @@ -7,6 +7,7 @@ import io.quarkus.test.junit.TestProfile; import org.bson.Document; import org.eclipse.microprofile.config.ConfigProvider; +import org.finos.calm.domain.UserAccess; import org.finos.calm.security.CalmHubScopes; import org.junit.jupiter.api.*; import org.junit.jupiter.params.ParameterizedTest; @@ -48,6 +49,17 @@ void setupPatterns() { new Document("namespace", "finos").append("patterns", new ArrayList<>()) ); } + + if (!database.listCollectionNames().into(new ArrayList<>()).contains("userAccess")) { + database.createCollection("userAccess"); + Document document1 = new Document("username", "test-user") + .append("namespace", "finos") + .append("permission", UserAccess.Permission.read.name()) + .append("resourceType", UserAccess.ResourceType.patterns.name()) + .append("userAccessId", 101); + + database.getCollection("userAccess").insertOne(document1); + } counterSetup(database); namespaceSetup(database); } @@ -58,7 +70,7 @@ void setupPatterns() { void end_to_end_get_patterns_with_valid_scopes() { String authServerUrl = ConfigProvider.getConfig().getValue("quarkus.oidc.auth-server-url", String.class); logger.info("authServerUrl {}", authServerUrl); - String accessToken = getAccessToken(authServerUrl, CalmHubScopes.ARCHITECTURES_READ); + String accessToken = generateAccessTokenWithPasswordGrantType(authServerUrl, CalmHubScopes.ARCHITECTURES_READ); given() .auth().oauth2(accessToken) .when().get("/calm/namespaces/finos/patterns") @@ -67,7 +79,7 @@ void end_to_end_get_patterns_with_valid_scopes() { .body("values", empty()); } - private String getAccessToken(String authServerUrl, String scope) { + private String generateAccessTokenWithClientCredentialGrant(String authServerUrl, String scope) { String accessToken = given() .auth() .preemptive() @@ -83,12 +95,34 @@ private String getAccessToken(String authServerUrl, String scope) { return accessToken; } + /** + * This grant type is not recommended from production, + * the password grant type is using to enrich preferred_username in jwt token to perform RBAC checks after jwt validation. + */ + private String generateAccessTokenWithPasswordGrantType(String authServerUrl, String scope) { + String accessToken = given() + .auth() + .preemptive() + .basic("calm-hub-client-app", "calm-hub-client-app-secret") + .formParam("grant_type", "password") + .formParam("username", "test-user") + .formParam("password", "changeme") + .formParam("scope", scope) + .when() + .post(authServerUrl.concat("/protocol/openid-connect/token")) + .then() + .statusCode(200) + .extract() + .path("access_token"); + return accessToken; + } + @Test @Order(2) void end_to_end_forbidden_create_pattern_when_matching_scopes_notfound() { String authServerUrl = ConfigProvider.getConfig().getValue("quarkus.oidc.auth-server-url", String.class); logger.info("authServerUrl {}", authServerUrl); - String accessToken = getAccessToken(authServerUrl, CalmHubScopes.ARCHITECTURES_READ); + String accessToken = generateAccessTokenWithClientCredentialGrant(authServerUrl, CalmHubScopes.ARCHITECTURES_READ); given() .auth().oauth2(accessToken) @@ -118,7 +152,7 @@ void end_to_end_unauthorize_create_pattern_request_with_no_access_token() { void end_to_end_get_namespaces_with_valid_access_token(String scope) { String authServerUrl = ConfigProvider.getConfig().getValue("quarkus.oidc.auth-server-url", String.class); logger.info("authServerUrl {}", authServerUrl); - String accessToken = getAccessToken(authServerUrl, scope); + String accessToken = generateAccessTokenWithClientCredentialGrant(authServerUrl, scope); given() .auth().oauth2(accessToken) .when().get("/calm/namespaces") @@ -132,11 +166,27 @@ void end_to_end_get_namespaces_with_valid_access_token(String scope) { void end_to_end_forbidden_get_namespaces_when_matching_scopes_notfound() { String authServerUrl = ConfigProvider.getConfig().getValue("quarkus.oidc.auth-server-url", String.class); logger.info("authServerUrl {}", authServerUrl); - String accessToken = getAccessToken(authServerUrl, "deny:all"); + String accessToken = generateAccessTokenWithClientCredentialGrant(authServerUrl, "deny:all"); given() .auth().oauth2(accessToken) .when().get("/calm/namespaces") .then() .statusCode(403); } + + @Test + @Order(6) + void end_to_end_forbidden_create_pattern_with_matching_scopes_but_no_user_permissions() { + String authServerUrl = ConfigProvider.getConfig().getValue("quarkus.oidc.auth-server-url", String.class); + logger.info("authServerUrl {}", authServerUrl); + String accessToken = generateAccessTokenWithPasswordGrantType(authServerUrl, CalmHubScopes.ARCHITECTURES_ALL); + logger.info("accessToken: {}", accessToken); + given() + .auth().oauth2(accessToken) + .body(PATTERN) + .header("Content-Type", "application/json") + .when().post("/calm/namespaces/finos/patterns") + .then() + .statusCode(403); + } } \ No newline at end of file diff --git a/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java b/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java new file mode 100644 index 000000000..a5d54aa14 --- /dev/null +++ b/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java @@ -0,0 +1,131 @@ +package integration; + +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoDatabase; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.bson.Document; +import org.eclipse.microprofile.config.ConfigProvider; +import org.finos.calm.domain.UserAccess; +import org.finos.calm.security.CalmHubScopes; +import org.junit.jupiter.api.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; + +import static integration.MongoSetup.counterSetup; +import static integration.MongoSetup.namespaceSetup; +import static io.restassured.RestAssured.given; + +@QuarkusTest +@TestProfile(IntegrationTestSecureProfile.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class UserAccessGrantsIntegration { + + private static final Logger logger = LoggerFactory.getLogger(UserAccessGrantsIntegration.class); + private static final String CREATE_USER_ACCESS_REQUEST = """ + { + "username": "testuser1", + "permission": "read", + "namespace": "finos", + "resourceType": "all" + } + """; + + private static final String CREATE_USER_ACCESS_REQUEST_2 = """ + { + "username": "testuser1", + "permission": "read", + "namespace": "workshop", + "resourceType": "all" + } + """; + + @BeforeEach + void setupPatterns() { + String mongoUri = ConfigProvider.getConfig().getValue("quarkus.mongodb.connection-string", String.class); + + // Safeguard: Fail fast if URI is not set + if (mongoUri == null || mongoUri.isBlank()) { + logger.error("MongoDB URI is not set. Check the EndToEndResource configuration."); + throw new IllegalStateException("MongoDB URI is not set. Check the EndToEndResource configuration."); + } + + try (MongoClient mongoClient = MongoClients.create(mongoUri)) { + MongoDatabase database = mongoClient.getDatabase("calmSchemas"); + if (!database.listCollectionNames().into(new ArrayList<>()).contains("patterns")) { + database.createCollection("patterns"); + database.getCollection("patterns").insertOne( + new Document("namespace", "finos").append("patterns", new ArrayList<>()) + ); + } + + if (!database.listCollectionNames().into(new ArrayList<>()).contains("userAccess")) { + database.createCollection("userAccess"); + } + Document document1 = new Document("username", "test-user") + .append("namespace", "finos") + .append("permission", UserAccess.Permission.write.name()) + .append("resourceType", UserAccess.ResourceType.all.name()) + .append("userAccessId", 101); + + database.getCollection("userAccess").insertOne(document1); + counterSetup(database); + namespaceSetup(database); + } + } + + /** + * This grant type is not recommended from production, + * the password grant type is using to enrich preferred_username in jwt token to perform RBAC checks after jwt validation. + */ + private String generateAccessTokenWithPasswordGrantType(String authServerUrl, String scope) { + String accessToken = given() + .auth() + .preemptive() + .basic("calm-hub-client-app", "calm-hub-client-app-secret") + .formParam("grant_type", "password") + .formParam("username", "test-user") + .formParam("password", "changeme") + .formParam("scope", scope) + .when() + .post(authServerUrl.concat("/protocol/openid-connect/token")) + .then() + .statusCode(200) + .extract() + .path("access_token"); + return accessToken; + } + + @Test + @Order(1) + void end_to_end_create_user_access_with_namespace_admin_scope_and_with_admin_user_grants() { + String authServerUrl = ConfigProvider.getConfig().getValue("quarkus.oidc.auth-server-url", String.class); + logger.info("authServerUrl {}", authServerUrl); + String accessToken = generateAccessTokenWithPasswordGrantType(authServerUrl, CalmHubScopes.NAMESPACE_ADMIN); + given() + .auth().oauth2(accessToken) + .body(CREATE_USER_ACCESS_REQUEST) + .header("Content-Type", "application/json") + .when().post("/calm/namespaces/finos/user-access") + .then() + .statusCode(201); + } + + @Test + @Order(2) + void end_to_end_forbidden_create_user_access_when_admin_has_no_access_on_namespace() { + + String authServerUrl = ConfigProvider.getConfig().getValue("quarkus.oidc.auth-server-url", String.class); + String accessToken = generateAccessTokenWithPasswordGrantType(authServerUrl, CalmHubScopes.NAMESPACE_ADMIN); + given() + .auth().oauth2(accessToken) + .body(CREATE_USER_ACCESS_REQUEST_2) + .header("Content-Type", "application/json") + .when().post("/calm/namespaces/workshop/patterns") + .then() + .statusCode(403); + } +} \ No newline at end of file diff --git a/calm-hub/src/main/java/org/finos/calm/domain/UserAccess.java b/calm-hub/src/main/java/org/finos/calm/domain/UserAccess.java new file mode 100644 index 000000000..a54c64ebe --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/domain/UserAccess.java @@ -0,0 +1,184 @@ +package org.finos.calm.domain; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Represents a CalmHub user access on resources associated to a namespace. + */ +public class UserAccess { + + public enum Permission { + read, + write + } + + public enum ResourceType { + patterns, + flows, + adrs, + architectures, + all + } + + private String username; + private Permission permission; + private String namespace; + private ResourceType resourceType; + private int userAccessId; + + @JsonDeserialize(using = LocalDateTimeDeserializer.class) + @JsonSerialize(using = LocalDateTimeSerializer.class) + private LocalDateTime creationDateTime; + + @JsonDeserialize(using = LocalDateTimeDeserializer.class) + @JsonSerialize(using = LocalDateTimeSerializer.class) + private LocalDateTime updateDateTime; + + public UserAccess(String username, Permission permission, String namespace, ResourceType resourceType, int userAccessId) { + this.username = username; + this.permission = permission; + this.namespace = namespace; + this.resourceType = resourceType; + this.userAccessId = userAccessId; + } + + public UserAccess(String username, Permission permission, String namespace, ResourceType resourceType) { + this.username = username; + this.permission = permission; + this.namespace = namespace; + this.resourceType = resourceType; + } + + public UserAccess(){ + + } + + public static class UserAccessBuilder { + + private String username; + private Permission permission; + private String namespace; + private ResourceType resourceType; + private int userAccessId; + + public UserAccessBuilder setUsername(String username) { + this.username = username; + return this; + } + + public UserAccessBuilder setPermission(Permission permission) { + this.permission = permission; + return this; + } + + public UserAccessBuilder setNamespace(String namespace) { + this.namespace = namespace; + return this; + } + + public UserAccessBuilder setResourceType(ResourceType resourceType) { + this.resourceType = resourceType; + return this; + } + + public UserAccessBuilder setUserAccessId(int userAccessId) { + this.userAccessId = userAccessId; + return this; + } + + public UserAccess build(){ + return new UserAccess(username, permission, namespace, resourceType, userAccessId); + } + } + + public String getUsername() { + return username; + } + + public Permission getPermission() { + return permission; + } + + public String getNamespace() { + return namespace; + } + + public ResourceType getResourceType() { + return resourceType; + } + + public int getUserAccessId() { + return userAccessId; + } + + public LocalDateTime getCreationDateTime() { + return creationDateTime; + } + + public LocalDateTime getUpdateDateTime() { + return updateDateTime; + } + + public void setCreationDateTime(LocalDateTime creationDateTime) { + this.creationDateTime = creationDateTime; + } + + public void setUpdateDateTime(LocalDateTime updateDateTime) { + this.updateDateTime = updateDateTime; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + UserAccess that = (UserAccess) o; + if (userAccessId != that.userAccessId) return false; + if (!Objects.equals(username, that.username)) return false; + if (!Objects.equals(permission, that.permission)) return false; + if (!Objects.equals(namespace, that.namespace)) return false; + return Objects.equals(resourceType, that.resourceType); + } + + @Override + public int hashCode() { + return Objects.hash(username, permission, namespace, resourceType, userAccessId); + } + + @Override + public String toString() { + return "UserAccess{" + + "username='" + username + '\'' + + ", permission='" + permission + '\'' + + ", namespace='" + namespace + '\'' + + ", resourceType='" + resourceType + '\'' + + ", userAccessId=" + userAccessId + + '}'; + } + + public void setUsername(String username) { + this.username = username; + } + + public void setPermission(Permission permission) { + this.permission = permission; + } + + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + public void setResourceType(ResourceType resourceType) { + this.resourceType = resourceType; + } + + public void setUserAccessId(int userAccessId) { + this.userAccessId = userAccessId; + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/domain/exception/UserAccessNotFoundException.java b/calm-hub/src/main/java/org/finos/calm/domain/exception/UserAccessNotFoundException.java new file mode 100644 index 000000000..6b50d0da6 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/domain/exception/UserAccessNotFoundException.java @@ -0,0 +1,7 @@ +package org.finos.calm.domain.exception; + +/** + * Exception thrown when the user access details are not found. + */ +public class UserAccessNotFoundException extends Exception { +} diff --git a/calm-hub/src/main/java/org/finos/calm/resources/UserAccessResource.java b/calm-hub/src/main/java/org/finos/calm/resources/UserAccessResource.java new file mode 100644 index 000000000..6fc641fe2 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/resources/UserAccessResource.java @@ -0,0 +1,125 @@ +package org.finos.calm.resources; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.finos.calm.domain.UserAccess; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.exception.UserAccessNotFoundException; +import org.finos.calm.security.CalmHubScopes; +import org.finos.calm.security.PermittedScopes; +import org.finos.calm.store.UserAccessStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.net.URISyntaxException; +import java.time.LocalDateTime; + +@Path("/calm/namespaces") +public class UserAccessResource { + + private final UserAccessStore store; + private final Logger logger = LoggerFactory.getLogger(UserAccessResource.class); + + public UserAccessResource(UserAccessStore userAccessStore) { + this.store = userAccessStore; + } + + @POST + @Path("{namespace}/user-access") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Create user access for namespace", + description = "Creates a user-access for a given namespace on a particular resource type" + ) + @PermittedScopes({CalmHubScopes.NAMESPACE_ADMIN}) + public Response createUserAccessForNamespace(@PathParam("namespace") String namespace, + UserAccess createUserAccessRequest) { + + createUserAccessRequest.setCreationDateTime(LocalDateTime.now()); + createUserAccessRequest.setUpdateDateTime(LocalDateTime.now()); + if (!namespace.equals(createUserAccessRequest.getNamespace())) { + logger.error("Request contains an invalid namespace [{}]", createUserAccessRequest.getNamespace()); + return Response.status(Response.Status.BAD_REQUEST) + .entity("Bad Request").build(); + } + try { + return locationResponse(store.createUserAccessForNamespace(createUserAccessRequest)); + } catch (NamespaceNotFoundException exception) { + logger.error("Invalid namespace [{}] when creating user access", namespace, exception); + return invalidNamespaceResponse(namespace); + } catch (URISyntaxException ex) { + logger.error("Failed to create user-access for namespace: [{}] ", namespace, ex); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity("System Malfunction failed to create user-access").build(); + } + } + + @GET + @Path("{namespace}/user-access") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Get user-access for a given namespace", + description = "Get user-access details for a given namespace" + ) + @PermittedScopes({CalmHubScopes.NAMESPACE_ADMIN}) + public Response getUserAccessForNamespace(@PathParam("namespace") String namespace) { + + try { + return Response.ok(store.getUserAccessForNamespace(namespace)) + .build(); + } catch (NamespaceNotFoundException exception) { + logger.error("Invalid namespace [{}] when getting user-access details", namespace, exception); + return invalidNamespaceResponse(namespace); + } catch (UserAccessNotFoundException ex) { + logger.error("Use-access details are not found [{}]", namespace, ex); + return Response.status(Response.Status.NOT_FOUND) + .entity("No access permissions found") + .build(); + } + } + + @GET + @Path("{namespace}/user-access/{userAccessId}") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Get the user-access record for a given namespace and Id", + description = "Get user-access details for a given namespace and Id" + ) + @PermittedScopes({CalmHubScopes.NAMESPACE_ADMIN}) + public Response getUserAccessForNamespaceAndId(@PathParam("namespace") String namespace, + @PathParam("userAccessId") Integer userAccessId) { + + try { + return Response.ok(store.getUserAccessForNamespaceAndId(namespace, userAccessId)) + .build(); + } catch (NamespaceNotFoundException exception) { + logger.error("Invalid namespace [{}] when getting user-access details", namespace, exception); + return invalidNamespaceResponse(namespace); + } catch (UserAccessNotFoundException ex) { + logger.error("Use-access details are not found [{}]", namespace, ex); + return Response.status(Response.Status.NOT_FOUND) + .entity("No access permissions found").build(); + } + } + + private Response locationResponse(UserAccess userAccess) throws URISyntaxException { + return Response.created(new URI( + String.format("/calm/namespaces/%s/user-access/%s", userAccess.getNamespace(), userAccess.getUserAccessId()))) + .build(); + } + + private Response invalidNamespaceResponse(String namespace) { + return Response.status(Response.Status.NOT_FOUND) + .entity("Invalid namespace provided: " + namespace) + .build(); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/security/AccessControlFilter.java b/calm-hub/src/main/java/org/finos/calm/security/AccessControlFilter.java index 0eb938b91..bc79be2cc 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/AccessControlFilter.java +++ b/calm-hub/src/main/java/org/finos/calm/security/AccessControlFilter.java @@ -45,11 +45,14 @@ public class AccessControlFilter implements ContainerRequestFilter { private final JsonWebToken jwt; private final ResourceInfo resourceInfo; + private final UserAccessValidator userAccessValidator; private final Logger logger = LoggerFactory.getLogger(AccessControlFilter.class); - public AccessControlFilter(JsonWebToken jwt, ResourceInfo resourceInfo) { + public AccessControlFilter(JsonWebToken jwt, ResourceInfo resourceInfo, + UserAccessValidator userAccessValidator) { this.jwt = jwt; this.resourceInfo = resourceInfo; + this.userAccessValidator = userAccessValidator; } @Override @@ -67,6 +70,36 @@ public void filter(ContainerRequestContext requestContext) { requestContext.abortWith(Response.status(Response.Status.FORBIDDEN) .entity("Forbidden: JWT does not have required scopes.") .build()); + return; + } + + authorizeUserRequest(requestContext); + } + + /** + * Validates whether the requesting user has the required access permissions based on the request context. + * + *

This method extracts the HTTP method, username (from JWT), request path, and namespace + * from the incoming request and checks whether the user is authorized to access the requested resource. + * If the user lacks the necessary permissions, the request is aborted with a 403 Forbidden response. + * + * @param requestContext the container request context containing request metadata and parameters + */ + private void authorizeUserRequest(ContainerRequestContext requestContext) { + String requestMethod = requestContext.getMethod(); + String username = jwt.getClaim("preferred_username"); + String path = requestContext.getUriInfo().getPath(); + String namespace = requestContext.getUriInfo().getPathParameters().getFirst("namespace"); + + UserRequestAttributes userRequestAttributes = new UserRequestAttributes(requestMethod, + username, path, namespace); + logger.debug("User request attributes: {}", userRequestAttributes); + + if (!userAccessValidator.isUserAuthorized(userRequestAttributes)) { + logger.warn("No access permissions assigned to the user: [{}]", userRequestAttributes.username()); + requestContext.abortWith(Response.status(Response.Status.FORBIDDEN) + .entity("Forbidden: user does not have required access grants") + .build()); } } @@ -83,7 +116,7 @@ private boolean hasRequiredScope(String tokenScopes, String[] requiredScopes) { .anyMatch(tokenScopes::contains); if (hasMatch) { - logger.info("Request allowed, PermittedScopes are: {}, there is a matching scope found in accessToken: [{}]", requiredScopes, tokenScopes); + logger.debug("Request allowed, PermittedScopes are: {}, there is a matching scope found in accessToken: [{}]", requiredScopes, tokenScopes); } else { logger.error("Request denied, PermittedScopes are: {}, no matching scopes found in accessToken: [{}]", requiredScopes, tokenScopes); } diff --git a/calm-hub/src/main/java/org/finos/calm/security/CalmHubScopes.java b/calm-hub/src/main/java/org/finos/calm/security/CalmHubScopes.java index 912f96b4b..1377a2657 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/CalmHubScopes.java +++ b/calm-hub/src/main/java/org/finos/calm/security/CalmHubScopes.java @@ -28,4 +28,9 @@ private CalmHubScopes() { * Allows full access (read, write, delete) on Adrs and read operation on Namespaces. */ public static final String ADRS_ALL = "adrs:all"; + + /** + * Allow to grant access to users on namespace associated resources and for the admin operations. + */ + public static final String NAMESPACE_ADMIN = "namespace:admin"; } diff --git a/calm-hub/src/main/java/org/finos/calm/security/UserAccessValidator.java b/calm-hub/src/main/java/org/finos/calm/security/UserAccessValidator.java new file mode 100644 index 000000000..4c84ef749 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/security/UserAccessValidator.java @@ -0,0 +1,122 @@ +package org.finos.calm.security; + +import io.netty.util.internal.StringUtil; +import io.quarkus.arc.profile.IfBuildProfile; +import jakarta.enterprise.context.ApplicationScoped; +import org.finos.calm.domain.UserAccess; +import org.finos.calm.domain.exception.UserAccessNotFoundException; +import org.finos.calm.store.UserAccessStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +/** + * Validates whether a user is authorized to access a particular resource based on + * their assigned permissions and namespaces. + * + * This validator is active only when the 'secure' profile is enabled. + */ +@ApplicationScoped +@IfBuildProfile("secure") +public class UserAccessValidator { + + private static final Logger logger = LoggerFactory.getLogger(UserAccessValidator.class); + + private static final String READ_ACTION = "read"; + private static final String WRITE_ACTION = "write"; + + private final UserAccessStore userAccessStore; + + /** + * Constructs a new UserAccessValidator with the provided UserAccessStore. + * + * @param userAccessStore the store used to retrieve user access permissions + */ + public UserAccessValidator(UserAccessStore userAccessStore) { + this.userAccessStore = userAccessStore; + } + + /** + * Determines whether the user is authorized to perform an action based on their request attributes. + * + *

If the user does not have any access entries or an exception is thrown during validation, + * access is denied and the method returns {@code false}. + * + * @param userRequestAttributes encapsulates the HTTP method, username, resource path, and namespace + * @return {@code true} if the user is authorized to perform the action; {@code false} otherwise + */ + public boolean isUserAuthorized(UserRequestAttributes userRequestAttributes) { + String action = mapHttpMethodToPermission(userRequestAttributes.requestMethod()); + if (isDefaultAccessibleResource(userRequestAttributes)) { + logger.debug("The GET /calm/namespaces endpoint is accessible by default to all authenticated users"); + return true; + } + try { + return hasAccessForActionOnResource(userRequestAttributes, action); + } catch (UserAccessNotFoundException ex) { + logger.error("No access permissions assigned to the user: [{}]", userRequestAttributes.username(), ex); + return false; + } + } + + /** + * Determines whether the user has sufficient access to perform the specified action + * on a resource, based on the user's access grants, request path, and namespace. + * + * @param requestAttributes the user request attributes, including username, request path, and namespace + * @param action the action the user is attempting to perform (e.g., "read", "write".) + * @return true if the user has valid access for the action on the requested resource, false otherwise + * @throws UserAccessNotFoundException if the user has no associated access records in the system + */ + private boolean hasAccessForActionOnResource(UserRequestAttributes requestAttributes, String action) throws UserAccessNotFoundException { + List userAccesses = userAccessStore.getUserAccessForUsername(requestAttributes.username()); + return userAccesses.stream().anyMatch(userAccess -> { + boolean resourceMatches = (UserAccess.ResourceType.all == userAccess.getResourceType()) + || requestAttributes.path().contains(userAccess.getResourceType().name()); + boolean permissionSufficient = permissionAllows(userAccess.getPermission(), action); + boolean namespaceMatches = !StringUtil.isNullOrEmpty(requestAttributes.namespace()) + && requestAttributes.namespace().equals(userAccess.getNamespace()); + return resourceMatches && permissionSufficient && namespaceMatches; + }); + } + + /** + * Checks whether the request targets the default-accessible endpoint. + * + * @param userRequestAttributes the attributes of the incoming user request + * @return true if the endpoint is accessible by default, false otherwise + */ + private boolean isDefaultAccessibleResource(UserRequestAttributes userRequestAttributes) { + //TODO: How to protect GET - /calm/namespaces endpoint, by maintaining namespace specific user grants. + return "/calm/namespaces".equals(userRequestAttributes.path()) && + "get".equalsIgnoreCase(userRequestAttributes.requestMethod().toLowerCase()); + } + + /** + * Maps HTTP methods to access permissions. + * + * @param method the HTTP method + * @return "write" for modifying methods, "read" otherwise + */ + private String mapHttpMethodToPermission(String method) { + return switch (method) { + case "POST", "PUT", "PATCH", "DELETE" -> WRITE_ACTION; + default -> READ_ACTION; + }; + } + + /** + * Checks whether the user's permission level allows the requested action. + * + * @param userPermission the user's assigned permission + * @param requestedAction the action the user is attempting to perform + * @return true if the permission is sufficient, false otherwise + */ + private boolean permissionAllows(UserAccess.Permission userPermission, String requestedAction) { + return switch (userPermission) { + case write -> WRITE_ACTION.equals(requestedAction) || READ_ACTION.equals(requestedAction); + case read -> READ_ACTION.equals(requestedAction); + }; + } +} \ No newline at end of file diff --git a/calm-hub/src/main/java/org/finos/calm/security/UserRequestAttributes.java b/calm-hub/src/main/java/org/finos/calm/security/UserRequestAttributes.java new file mode 100644 index 000000000..99002f978 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/security/UserRequestAttributes.java @@ -0,0 +1,5 @@ +package org.finos.calm.security; + +public record UserRequestAttributes(String requestMethod, String username, String path, String namespace) { + +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/UserAccessStore.java b/calm-hub/src/main/java/org/finos/calm/store/UserAccessStore.java new file mode 100644 index 000000000..027957b83 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/UserAccessStore.java @@ -0,0 +1,48 @@ +package org.finos.calm.store; + +import org.finos.calm.domain.UserAccess; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.exception.UserAccessNotFoundException; + +import java.util.List; + +/** + * Interface for managing user-access grants in the CALM system. + * Provides methods to retrieve and create user permissions through admin APIs. + */ +public interface UserAccessStore { + + /** + * Store a new UserAccess with the specified userAccess details. + * + * @param userAccess the UserAccess details to create. + * @return the created UserAccess object. + * @throws NamespaceNotFoundException if UserAccess request contains an invalid namespace name. + */ + UserAccess createUserAccessForNamespace(UserAccess userAccess) throws NamespaceNotFoundException; + + /** + * Retrieves a list of all UserAccess details mapped to username. + * + * @param username the name of the user to fetch UserAccess records. + * @return a list of UserAccess details + */ + List getUserAccessForUsername(String username) throws UserAccessNotFoundException; + + /** + * Retrieves a list of all UserAccess details associated to a namespace. + * + * @param namespace the name of the namespace to fetch associated UserAccess records. + * @return a list of UserAccess details + */ + List getUserAccessForNamespace(String namespace) throws NamespaceNotFoundException, UserAccessNotFoundException; + + /** + * Retrieve a UserAccess object that is mapped to a namespace and userAccessId. + * + * @param namespace the name of the namespace to fetch associated UserAccess records. + * @param userAccessId the sequence number of a UserAccess record. + * @return a list of UserAccess details + */ + UserAccess getUserAccessForNamespaceAndId(String namespace, Integer userAccessId) throws NamespaceNotFoundException, UserAccessNotFoundException; +} diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoCounterStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoCounterStore.java index 1c88b1790..f5ffd7071 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoCounterStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoCounterStore.java @@ -37,6 +37,10 @@ public int getNextFlowSequenceValue() { return nextValueForCounter("flowStoreCounter"); } + public int getNextUserAccessSequenceValue() { + return nextValueForCounter("userAccessStoreCounter"); + } + private int nextValueForCounter(String counterId) { Document filter = new Document("_id", counterId); Document update = new Document("$inc", new Document("sequence_value", 1)); diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoUserAccessStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoUserAccessStore.java new file mode 100644 index 000000000..e3f2ffd7e --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoUserAccessStore.java @@ -0,0 +1,139 @@ +package org.finos.calm.store.mongo; + +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.Filters; +import jakarta.enterprise.context.ApplicationScoped; +import org.bson.Document; +import org.finos.calm.domain.UserAccess; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.exception.UserAccessNotFoundException; +import org.finos.calm.store.UserAccessStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +@ApplicationScoped +public class MongoUserAccessStore implements UserAccessStore { + + private final MongoCollection userAccessCollection; + private final MongoNamespaceStore namespaceStore; + private final MongoCounterStore counterStore; + private final Logger log = LoggerFactory.getLogger(getClass()); + + public MongoUserAccessStore(MongoClient mongoClient, MongoNamespaceStore namespaceStore, MongoCounterStore counterStore) { + this.namespaceStore = namespaceStore; + MongoDatabase database = mongoClient.getDatabase("calmSchemas"); + this.userAccessCollection = database.getCollection("userAccess"); + this.counterStore = counterStore; + } + + @Override + public UserAccess createUserAccessForNamespace(UserAccess userAccess) + throws NamespaceNotFoundException { + + log.info("User-access details: {}", userAccess); + if (!namespaceStore.namespaceExists(userAccess.getNamespace())) { + throw new NamespaceNotFoundException(); + } + + int userAccessId = counterStore.getNextUserAccessSequenceValue(); + Document userAccessDoc = new Document("username", userAccess.getUsername()) + .append("permission", userAccess.getPermission().name()) + .append("namespace", userAccess.getNamespace()) + .append("resourceType", userAccess.getResourceType().name()) + .append("createdAt", userAccess.getCreationDateTime()) + .append("lastUpdated", userAccess.getUpdateDateTime()) + .append("userAccessId", userAccessId); + + userAccessCollection.insertOne(userAccessDoc); + log.info("UserAccess has been created for namespace: {}, resource: {}, permission: {}, username: {}", + userAccess.getNamespace(), userAccess.getResourceType(), userAccess.getPermission(), userAccess.getUsername()); + + UserAccess persistedUserAccess = new UserAccess.UserAccessBuilder() + .setUserAccessId(userAccessId) + .setResourceType(userAccess.getResourceType()) + .setNamespace(userAccess.getNamespace()) + .setPermission(userAccess.getPermission()) + .setUsername(userAccess.getUsername()) + .build(); + return persistedUserAccess; + } + + @Override + public List getUserAccessForUsername(String username) + throws UserAccessNotFoundException { + + List userAccessList = new ArrayList<>(); + for (Document doc : userAccessCollection.find(Filters.eq("username", username))) { + String namespace = doc.getString("namespace"); + + UserAccess userAccess = new UserAccess.UserAccessBuilder() + .setUsername(doc.getString("username")) + .setPermission(UserAccess.Permission.valueOf(doc.getString("permission"))) + .setNamespace(namespace) + .setResourceType(UserAccess.ResourceType.valueOf(doc.getString("resourceType"))) + .setUserAccessId(doc.getInteger("userAccessId")) + .build(); + userAccessList.add(userAccess); + } + + if (userAccessList.isEmpty()) { + throw new UserAccessNotFoundException(); + } + return userAccessList; + } + + @Override + public List getUserAccessForNamespace(String namespace) + throws NamespaceNotFoundException, UserAccessNotFoundException { + + if (!namespaceStore.namespaceExists(namespace)) { + throw new NamespaceNotFoundException(); + } + List userAccessList = new ArrayList<>(); + for (Document doc : userAccessCollection.find(Filters.eq("namespace", namespace))) { + UserAccess userAccess = new UserAccess.UserAccessBuilder() + .setUsername(doc.getString("username")) + .setPermission(UserAccess.Permission.valueOf(doc.getString("permission"))) + .setNamespace(namespace) + .setResourceType(UserAccess.ResourceType.valueOf(doc.getString("resourceType"))) + .setUserAccessId(doc.getInteger("userAccessId")) + .build(); + userAccessList.add(userAccess); + } + + if (userAccessList.isEmpty()) { + throw new UserAccessNotFoundException(); + } + return userAccessList; + } + + @Override + public UserAccess getUserAccessForNamespaceAndId(String namespace, Integer userAccessId) + throws NamespaceNotFoundException, UserAccessNotFoundException { + + if (!namespaceStore.namespaceExists(namespace)) { + throw new NamespaceNotFoundException(); + } + + Document document = userAccessCollection.find(Filters.and(Filters.eq("namespace", namespace), + Filters.eq("userAccessId", userAccessId))) + .first(); + + if (null == document) { + throw new UserAccessNotFoundException(); + } else { + return new UserAccess.UserAccessBuilder() + .setUsername(document.getString("username")) + .setPermission(UserAccess.Permission.valueOf(document.getString("permission"))) + .setNamespace(namespace) + .setResourceType(UserAccess.ResourceType.valueOf(document.getString("resourceType"))) + .setUserAccessId(document.getInteger("userAccessId")) + .build(); + } + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/domain/TestUserAccessShould.java b/calm-hub/src/test/java/org/finos/calm/domain/TestUserAccessShould.java new file mode 100644 index 000000000..ec142311a --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/domain/TestUserAccessShould.java @@ -0,0 +1,77 @@ +package org.finos.calm.domain; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.Matchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +public class TestUserAccessShould { + + @Test + void return_built_instance_values() { + Integer expectedUserAccessId = 100; + String expectedNamespace = "finos"; + String expectedUsername = "test_user"; + UserAccess.ResourceType expectedResourceType = UserAccess.ResourceType.patterns; + UserAccess.Permission expectedPermissionType = UserAccess.Permission.read; + + UserAccess actual = new UserAccess.UserAccessBuilder() + .setUserAccessId(100) + .setResourceType(UserAccess.ResourceType.patterns) + .setNamespace("finos") + .setUsername("test_user") + .setPermission(UserAccess.Permission.read) + .build(); + + assertThat(actual.getUserAccessId(), equalTo(expectedUserAccessId)); + assertThat(actual.getUsername(), equalTo(expectedUsername)); + assertThat(actual.getNamespace(), equalTo(expectedNamespace)); + assertThat(actual.getResourceType(), equalTo(expectedResourceType)); + assertThat(actual.getPermission(), equalTo(expectedPermissionType)); + } + + @Test + void return_true_for_same_user_access_instances() { + + UserAccess userAccess1 = new UserAccess.UserAccessBuilder() + .setUserAccessId(100) + .setResourceType(UserAccess.ResourceType.patterns) + .setNamespace("finos") + .setUsername("test_user") + .setPermission(UserAccess.Permission.read) + .build(); + + UserAccess userAccess2 = new UserAccess.UserAccessBuilder() + .setUserAccessId(100) + .setResourceType(UserAccess.ResourceType.patterns) + .setNamespace("finos") + .setUsername("test_user") + .setPermission(UserAccess.Permission.read) + .build(); + + assertThat(userAccess1.equals(userAccess2), is(true)); + } + + @Test + void return_false_for_different_user_access_instances() { + + UserAccess userAccess1 = new UserAccess.UserAccessBuilder() + .setUserAccessId(100) + .setResourceType(UserAccess.ResourceType.patterns) + .setNamespace("finos") + .setUsername("test_user1") + .setPermission(UserAccess.Permission.read) + .build(); + + UserAccess userAccess2 = new UserAccess.UserAccessBuilder() + .setUserAccessId(101) + .setResourceType(UserAccess.ResourceType.patterns) + .setNamespace("finos") + .setUsername("test_user2") + .setPermission(UserAccess.Permission.read) + .build(); + + assertThat(userAccess1.equals(userAccess2), is(false)); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestUserAccessResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestUserAccessResourceShould.java new file mode 100644 index 000000000..3709d1599 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestUserAccessResourceShould.java @@ -0,0 +1,250 @@ +package org.finos.calm.resources; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import org.finos.calm.domain.UserAccess; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.exception.UserAccessNotFoundException; +import org.finos.calm.store.UserAccessStore; +import org.junit.jupiter.api.Test; +import java.util.List; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@QuarkusTest +public class TestUserAccessResourceShould { + + @InjectMock + UserAccessStore mockUserAccessStore; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Test + void return_201_created_with_location_header_when_user_access_is_created() throws Exception { + + UserAccess userAccess = new UserAccess(); + userAccess.setNamespace("finos"); + userAccess.setPermission(UserAccess.Permission.read); + userAccess.setResourceType(UserAccess.ResourceType.patterns); + userAccess.setUsername("test_user"); + String requestBody = OBJECT_MAPPER.writeValueAsString(userAccess); + + userAccess.setUserAccessId(101); + when(mockUserAccessStore.createUserAccessForNamespace(any(UserAccess.class))) + .thenReturn(userAccess); + + given() + .header("Content-Type", "application/json") + .body(requestBody) + .when() + .post("/calm/namespaces/finos/user-access") + .then() + .statusCode(201) + .header("Location", containsString("/calm/namespaces/finos/user-access/101")); + + verify(mockUserAccessStore, times(1)) + .createUserAccessForNamespace(any(UserAccess.class)); + } + + @Test + void return_404_when_creating_user_access_with_invalid_namespace() throws Exception { + when(mockUserAccessStore.createUserAccessForNamespace(any(UserAccess.class))) + .thenThrow(new NamespaceNotFoundException()); + + UserAccess userAccess = new UserAccess(); + userAccess.setNamespace("invalid"); + userAccess.setPermission(UserAccess.Permission.read); + userAccess.setResourceType(UserAccess.ResourceType.all); + userAccess.setUsername("test_user"); + String requestBody = OBJECT_MAPPER.writeValueAsString(userAccess); + + given() + .header("Content-Type", "application/json") + .body(requestBody) + .when() + .post("/calm/namespaces/invalid/user-access") + .then() + .statusCode(404) + .body(containsString("Invalid namespace")); + + verify(mockUserAccessStore, times(1)) + .createUserAccessForNamespace(any(UserAccess.class)); + } + + @Test + void return_400_when_creating_user_access_with_invalid_namespace() throws Exception { + when(mockUserAccessStore.createUserAccessForNamespace(any(UserAccess.class))) + .thenThrow(new NamespaceNotFoundException()); + + UserAccess userAccess = new UserAccess(); + userAccess.setNamespace("invalid"); + userAccess.setPermission(UserAccess.Permission.read); + userAccess.setResourceType(UserAccess.ResourceType.all); + userAccess.setUsername("test_user"); + String requestBody = OBJECT_MAPPER.writeValueAsString(userAccess); + + given() + .header("Content-Type", "application/json") + .body(requestBody) + .when() + .post("/calm/namespaces/test/user-access") + .then() + .statusCode(400) + .body(containsString("Bad Request")); + + verify(mockUserAccessStore, times(0)) + .createUserAccessForNamespace(any(UserAccess.class)); + } + + @Test + void return_500_when_internal_error_occurs_during_user_access_creation() throws Exception { + when(mockUserAccessStore.createUserAccessForNamespace(any(UserAccess.class))) + .thenThrow(new RuntimeException("Unexpected error")); + + UserAccess userAccess = new UserAccess(); + userAccess.setNamespace("finos"); + userAccess.setPermission(UserAccess.Permission.read); + userAccess.setResourceType(UserAccess.ResourceType.all); + userAccess.setUsername("test_user"); + String requestBody = OBJECT_MAPPER.writeValueAsString(userAccess); + + given() + .header("Content-Type", "application/json") + .body(requestBody) + .when() + .post("/calm/namespaces/finos/user-access") + .then() + .statusCode(500); + + verify(mockUserAccessStore, times(1)) + .createUserAccessForNamespace(any(UserAccess.class)); + } + + @Test + void return_200_with_user_access_list_when_namespace_is_valid() throws Exception { + UserAccess userAccess1 = new UserAccess(); + userAccess1.setUserAccessId(1); + userAccess1.setUsername("test_user1"); + userAccess1.setNamespace("test"); + + UserAccess userAccess2 = new UserAccess(); + userAccess2.setUserAccessId(2); + userAccess2.setUsername("test_user2"); + userAccess2.setNamespace("test"); + + when(mockUserAccessStore.getUserAccessForNamespace("test")) + .thenReturn(List.of(userAccess1, userAccess2)); + + given() + .when() + .get("/calm/namespaces/test/user-access") + .then() + .statusCode(200) + .body(containsString("test_user1")) + .body(containsString("test_user2")); + + verify(mockUserAccessStore, times(1)) + .getUserAccessForNamespace("test"); + } + + @Test + void return_404_when_getting_user_access_for_invalid_namespace() throws Exception { + when(mockUserAccessStore.getUserAccessForNamespace("invalid")) + .thenThrow(new NamespaceNotFoundException()); + + given() + .when() + .get("/calm/namespaces/invalid/user-access") + .then() + .statusCode(404) + .body(containsString("Invalid namespace")); + + verify(mockUserAccessStore, times(1)).getUserAccessForNamespace("invalid"); + } + + @Test + void return_404_when_no_user_access_associated_to_provided_namespace() throws Exception { + when(mockUserAccessStore.getUserAccessForNamespace("test")) + .thenThrow(new UserAccessNotFoundException()); + + given() + .when() + .get("/calm/namespaces/test/user-access") + .then() + .statusCode(404) + .body(containsString("No access permissions found")); + + verify(mockUserAccessStore, times(1)).getUserAccessForNamespace("test"); + } + + @Test + void return_500_when_internal_error_occurs_while_getting_user_access() throws Exception { + when(mockUserAccessStore.getUserAccessForNamespace("test")) + .thenThrow(new RuntimeException("DB error")); + + given() + .when() + .get("/calm/namespaces/test/user-access") + .then() + .statusCode(500); + + verify(mockUserAccessStore, times(1)).getUserAccessForNamespace("test"); + } + + @Test + void return_200_with_user_access_record_for_provided_namespace_and_user_access_id() throws Exception { + UserAccess userAccess = new UserAccess(); + userAccess.setUserAccessId(101); + userAccess.setUsername("test_user1"); + userAccess.setNamespace("test"); + + when(mockUserAccessStore.getUserAccessForNamespaceAndId("test", 101)) + .thenReturn(userAccess); + + given() + .when() + .get("/calm/namespaces/test/user-access/101") + .then() + .statusCode(200) + .body(containsString("test_user1")); + + verify(mockUserAccessStore, times(1)) + .getUserAccessForNamespaceAndId("test", 101); + } + + @Test + void return_404_when_user_access_for_invalid_namespace_and_user_access_id() throws Exception { + when(mockUserAccessStore.getUserAccessForNamespaceAndId("invalid", 0)) + .thenThrow(new NamespaceNotFoundException()); + + given() + .when() + .get("/calm/namespaces/invalid/user-access/0") + .then() + .statusCode(404) + .body(containsString("Invalid namespace")); + + verify(mockUserAccessStore, times(1)) + .getUserAccessForNamespaceAndId("invalid", 0); + } + + @Test + void return_404_when_user_access_for_namespace_and_user_access_id_not_exists() throws Exception { + when(mockUserAccessStore.getUserAccessForNamespaceAndId("test", 1)) + .thenThrow(new UserAccessNotFoundException()); + + given() + .when() + .get("/calm/namespaces/test/user-access/1") + .then() + .statusCode(404) + .body(containsString("No access permissions found")); + + verify(mockUserAccessStore, times(1)) + .getUserAccessForNamespaceAndId("test", 1); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/security/TestAccessControlFilterShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestAccessControlFilterShould.java index c355e999c..55bbd9f36 100644 --- a/calm-hub/src/test/java/org/finos/calm/security/TestAccessControlFilterShould.java +++ b/calm-hub/src/test/java/org/finos/calm/security/TestAccessControlFilterShould.java @@ -3,6 +3,9 @@ import io.quarkus.test.junit.QuarkusTest; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.container.ResourceInfo; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.UriInfo; import org.apache.http.HttpStatus; import org.eclipse.microprofile.jwt.JsonWebToken; import org.junit.jupiter.api.BeforeEach; @@ -15,9 +18,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.mockito.Mockito.never; +import static org.mockito.Mockito.*; @QuarkusTest public class TestAccessControlFilterShould { @@ -28,13 +29,15 @@ public class TestAccessControlFilterShould { ContainerRequestContext requestContext; @Mock ResourceInfo resourceInfo; + @Mock + UserAccessValidator userAccessValidator; private AccessControlFilter accessControlFilter; @BeforeEach void setup() { MockitoAnnotations.openMocks(this); - accessControlFilter = new AccessControlFilter(jwt, resourceInfo); + accessControlFilter = new AccessControlFilter(jwt, resourceInfo, userAccessValidator); } @Test @@ -47,21 +50,60 @@ void allow_the_request_when_scopes_not_defined_on_resource() throws NoSuchMethod } @Test - void allow_the_request_when_token_scopes_matching() throws NoSuchMethodException { + void allow_the_request_when_token_scopes_matching_and_user_has_required_permissions() throws NoSuchMethodException { Method method = TestNamespaceResource.class.getMethod("createNamespace"); + when(resourceInfo.getResourceMethod()).thenReturn(method); when(jwt.getClaim("scope")).thenReturn("openid architectures:all"); + when(jwt.getClaim("preferred_username")).thenReturn("test"); + + UriInfo mockUriInfo = mock(UriInfo.class); + when(mockUriInfo.getPath()).thenReturn("/calm/namespaces"); + MultivaluedMap multivaluedMap = new MultivaluedHashMap<>(); + multivaluedMap.add("namespace", "test"); + when(mockUriInfo.getPathParameters()).thenReturn(multivaluedMap); + when(requestContext.getUriInfo()).thenReturn(mockUriInfo); + when(userAccessValidator.isUserAuthorized(any(UserRequestAttributes.class))).thenReturn(true); accessControlFilter.filter(requestContext); verify(requestContext, never()).abortWith(any()); } + @Test + void abort_the_request_when_token_scopes_matching_and_user_has_no_required_access() throws NoSuchMethodException { + Method method = TestNamespaceResource.class.getMethod("createNamespace"); + + when(resourceInfo.getResourceMethod()).thenReturn(method); + when(jwt.getClaim("scope")).thenReturn("openid architectures:all"); + when(jwt.getClaim("preferred_username")).thenReturn("test"); + + UriInfo mockUriInfo = mock(UriInfo.class); + when(mockUriInfo.getPath()).thenReturn("/calm/namespaces"); + MultivaluedMap multivaluedMap = new MultivaluedHashMap<>(); + multivaluedMap.add("namespace", "test"); + when(mockUriInfo.getPathParameters()).thenReturn(multivaluedMap); + when(requestContext.getUriInfo()).thenReturn(mockUriInfo); + when(userAccessValidator.isUserAuthorized(any(UserRequestAttributes.class))) + .thenReturn(false); + + accessControlFilter.filter(requestContext); + verify(requestContext) + .abortWith(argThat(response -> response.getStatus() == HttpStatus.SC_FORBIDDEN)); + } + @Test void abort_the_request_when_token_scopes_not_matching() throws NoSuchMethodException { Method method = TestNamespaceResource.class.getMethod("createNamespace"); when(resourceInfo.getResourceMethod()).thenReturn(method); when(jwt.getClaim("scope")).thenReturn("openid architectures:read"); + UriInfo mockUriInfo = mock(UriInfo.class); + when(mockUriInfo.getPath()).thenReturn("/calm/namespaces"); + MultivaluedMap multivaluedMap = new MultivaluedHashMap<>(); + multivaluedMap.add("namespace", "test"); + when(mockUriInfo.getPathParameters()).thenReturn(multivaluedMap); + when(requestContext.getUriInfo()).thenReturn(mockUriInfo); + accessControlFilter.filter(requestContext); verify(requestContext) .abortWith(argThat(response -> response.getStatus() == HttpStatus.SC_FORBIDDEN)); diff --git a/calm-hub/src/test/java/org/finos/calm/security/TestUserAccessValidatorShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestUserAccessValidatorShould.java new file mode 100644 index 000000000..8f3eac6ac --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/security/TestUserAccessValidatorShould.java @@ -0,0 +1,81 @@ +package org.finos.calm.security; + +import org.finos.calm.domain.UserAccess; +import org.finos.calm.domain.UserAccess.Permission; +import org.finos.calm.domain.UserAccess.ResourceType; +import org.finos.calm.domain.exception.UserAccessNotFoundException; +import org.finos.calm.store.UserAccessStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class TestUserAccessValidatorShould { + + private UserAccessStore userAccessStore; + private UserAccessValidator validator; + + @BeforeEach + void setUp() { + userAccessStore = mock(UserAccessStore.class); + validator = new UserAccessValidator(userAccessStore); + } + + @Test + void return_true_when_user_has_sufficient_permissions() throws Exception { + UserRequestAttributes requestAttributes = new UserRequestAttributes("GET", "testuser", + "/calm/namespace/finos/patterns", "finos"); + UserAccess userAccess = new UserAccess("testuser", Permission.read, "finos", ResourceType.patterns); + when(userAccessStore.getUserAccessForUsername("testuser")) + .thenReturn(List.of(userAccess)); + + boolean actual = validator.isUserAuthorized(requestAttributes); + assertTrue(actual); + } + + @Test + void return_false_when_user_has_no_matching_permission() throws Exception { + UserRequestAttributes requestAttributes = new UserRequestAttributes("GET", "testuser", + "/calm/namespace/finos/patterns", "finos"); + UserAccess userAccess = new UserAccess("testuser", Permission.read, "workshop", ResourceType.patterns); + when(userAccessStore.getUserAccessForUsername("testuser")) + .thenReturn(List.of(userAccess)); + + boolean actual = validator.isUserAuthorized(requestAttributes); + assertFalse(actual); + } + + @Test + void return_true_when_user_has_write_permission() throws Exception { + UserRequestAttributes requestAttributes = new UserRequestAttributes("GET", "testuser", + "/calm/namespace/finos/patterns", "finos"); + UserAccess userAccess = new UserAccess("testuser", Permission.write, "finos", ResourceType.patterns); + when(userAccessStore.getUserAccessForUsername("testuser")) + .thenReturn(List.of(userAccess)); + + boolean actual = validator.isUserAuthorized(requestAttributes); + assertTrue(actual); + } + + @Test + void return_true_when_user_accessing_default_get_namespaces_endpoint() throws Exception { + UserRequestAttributes requestAttributes = new UserRequestAttributes("GET", "testuser", + "/calm/namespaces", null); + + boolean actual = validator.isUserAuthorized(requestAttributes); + assertTrue(actual); + } + + @Test + void return_false_when_no_permissions_are_mapped_to_user() throws Exception { + UserRequestAttributes requestAttributes = new UserRequestAttributes("GET", "testuser", + "/calm/namespaces/test/finos", "finos"); + when(userAccessStore.getUserAccessForUsername("testuser")) + .thenThrow(new UserAccessNotFoundException()); + + boolean actual = validator.isUserAuthorized(requestAttributes); + assertFalse(actual); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoCounterStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoCounterStoreShould.java index 9cefcd492..390ddce08 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoCounterStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoCounterStoreShould.java @@ -81,4 +81,19 @@ void return_the_next_value_in_sequence_for_flows() { assertThat(counterStore.getNextFlowSequenceValue(), equalTo(25)); } + + @Test + void return_the_next_value_in_sequence_for_user_access_collection() { + Document document = new Document("sequence_value", 3); + + when(counterCollection.findOneAndUpdate( + argThat(arg -> arg instanceof Document && + ((Document) arg).containsKey("_id") && + "userAccessStoreCounter".equals(((Document) arg).get("_id"))), + any(Document.class), + any(FindOneAndUpdateOptions.class) + )).thenReturn(document); + + assertThat(counterStore.getNextUserAccessSequenceValue(), equalTo(3)); + } } diff --git a/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoUserAccessStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoUserAccessStoreShould.java new file mode 100644 index 000000000..81efa1f67 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/store/mongo/TestMongoUserAccessStoreShould.java @@ -0,0 +1,209 @@ +package org.finos.calm.store.mongo; + +import com.mongodb.client.*; +import com.mongodb.client.model.Filters; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import org.bson.Document; +import org.finos.calm.domain.UserAccess; +import org.finos.calm.domain.UserAccess.Permission; +import org.finos.calm.domain.UserAccess.ResourceType; +import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.exception.UserAccessNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; + +import java.util.*; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@QuarkusTest +public class TestMongoUserAccessStoreShould { + + @InjectMock + MongoClient mongoClient; + @InjectMock + MongoNamespaceStore namespaceStore; + @InjectMock + MongoCounterStore counterStore; + + private MongoDatabase mongoDatabase; + private MongoCollection userAccessCollection; + private MongoUserAccessStore mongoUserAccessStore; + + @BeforeEach + void setup() { + mongoDatabase = Mockito.mock(MongoDatabase.class); + userAccessCollection = Mockito.mock(MongoCollection.class); + + when(mongoClient.getDatabase("calmSchemas")).thenReturn(mongoDatabase); + when(mongoDatabase.getCollection("userAccess")).thenReturn(userAccessCollection); + mongoUserAccessStore = new MongoUserAccessStore(mongoClient, namespaceStore, counterStore); + } + + @Test + void throw_exception_when_namespace_does_not_exist_on_create() { + when(namespaceStore.namespaceExists(anyString())).thenReturn(false); + UserAccess access = new UserAccess.UserAccessBuilder() + .setNamespace("invalid") + .setUsername("test") + .setPermission(Permission.read) + .setResourceType(ResourceType.architectures) + .build(); + + assertThrows(NamespaceNotFoundException.class, + () -> mongoUserAccessStore.createUserAccessForNamespace(access)); + } + + @Test + void create_user_access_when_namespace_is_exists() throws NamespaceNotFoundException { + when(namespaceStore.namespaceExists(anyString())).thenReturn(true); + when(counterStore.getNextUserAccessSequenceValue()).thenReturn(101); + + UserAccess userAccess = new UserAccess.UserAccessBuilder() + .setNamespace("finos") + .setUsername("test") + .setPermission(Permission.write) + .setResourceType(ResourceType.patterns) + .build(); + + UserAccess actual = mongoUserAccessStore.createUserAccessForNamespace(userAccess); + assertThat(actual.getUserAccessId(), is(101)); + verify(userAccessCollection).insertOne(ArgumentMatchers.any(Document.class)); + } + + @Test + void throw_exception_if_user_access_not_found_for_username() { + FindIterable findIterable = mock(FindIterable.class); + MongoCursor mockMongoCursor = mock(MongoCursor.class); + when(mockMongoCursor.hasNext()).thenReturn(false); + when(findIterable.iterator()).thenReturn(mockMongoCursor); + when(userAccessCollection.find(Filters.eq("username", "test"))) + .thenReturn(findIterable); + + assertThrows(UserAccessNotFoundException.class, + () -> mongoUserAccessStore.getUserAccessForUsername("test")); + } + + @Test + void return_user_access_for_valid_username() throws Exception { + String username = "test"; + String namespace = "finos"; + + Document doc = new Document("username", username) + .append("namespace", namespace) + .append("permission", Permission.read.name()) + .append("resourceType", ResourceType.patterns.name()) + .append("userAccessId", 101); + + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + FindIterable findIterable = mock(FindIterable.class); + MongoCursor cursor = mock(MongoCursor.class); + when(cursor.hasNext()).thenReturn(true, false); + when(cursor.next()).thenReturn(doc); + when(findIterable.iterator()).thenReturn(cursor); + + when(userAccessCollection.find(Filters.eq("username", username))).thenReturn(findIterable); + + List actual = mongoUserAccessStore.getUserAccessForUsername(username); + assertThat(actual, hasSize(1)); + assertThat(actual.get(0).getNamespace(), is(namespace)); + } + + @Test + void throw_exception_if_no_user_access_found_for_namespace() { + String namespace = "finos"; + FindIterable findIterable = mock(FindIterable.class); + MongoCursor mockMongoCursor = mock(MongoCursor.class); + when(mockMongoCursor.hasNext()).thenReturn(false); + when(findIterable.iterator()).thenReturn(mockMongoCursor); + + when(userAccessCollection.find(Filters.eq("namespace", namespace))) + .thenReturn(findIterable); + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + + assertThrows(UserAccessNotFoundException.class, + () -> mongoUserAccessStore.getUserAccessForNamespace(namespace)); + } + + @Test + void return_user_access_list_for_namespace() throws Exception { + String namespace = "finos"; + Document doc = new Document("username", "test") + .append("namespace", namespace) + .append("permission", Permission.read.name()) + .append("resourceType", ResourceType.flows.name()) + .append("userAccessId", 111); + + FindIterable findIterable = mock(FindIterable.class); + MongoCursor cursor = mock(MongoCursor.class); + when(cursor.hasNext()).thenReturn(true, false); + when(cursor.next()).thenReturn(doc); + + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + when(userAccessCollection.find(Filters.eq("namespace", namespace))) + .thenReturn(findIterable); + when(findIterable.iterator()).thenReturn(cursor); + + List actual = mongoUserAccessStore.getUserAccessForNamespace(namespace); + + assertThat(actual, hasSize(1)); + assertThat(actual.get(0).getUsername(), is("test")); + assertThat(actual.get(0).getPermission(), is(Permission.read)); + assertThat(actual.get(0).getResourceType(), is(ResourceType.flows)); + } + + @Test + void throw_exception_if_no_user_access_found_for_namespace_and_user_access_id() { + String namespace = "finos"; + Integer userAccessId = 101; + FindIterable findIterable = mock(FindIterable.class); + MongoCursor mockMongoCursor = mock(MongoCursor.class); + when(mockMongoCursor.hasNext()).thenReturn(false); + when(findIterable.iterator()).thenReturn(mockMongoCursor); + + when(mockMongoCursor.hasNext()).thenReturn(false); + when(findIterable.iterator()).thenReturn(mockMongoCursor); + when(userAccessCollection.find(Filters.and(Filters.eq("namespace", namespace), + Filters.eq("userAccessId", userAccessId)))) + .thenReturn(findIterable); + + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + assertThrows(UserAccessNotFoundException.class, + () -> mongoUserAccessStore.getUserAccessForNamespaceAndId(namespace, userAccessId)); + } + + @Test + void return_user_access_for_namespace_and_user_access_id() throws Exception { + String namespace = "finos"; + Integer userAccessId = 101; + + Document document = new Document("username", "test") + .append("namespace", namespace) + .append("permission", Permission.read.name()) + .append("resourceType", ResourceType.flows.name()) + .append("userAccessId", userAccessId); + + FindIterable mockFindIterable = mock(FindIterable.class); + when(namespaceStore.namespaceExists(namespace)).thenReturn(true); + when(userAccessCollection.find(Filters.and( + Filters.eq("namespace", namespace), + Filters.eq("userAccessId", userAccessId) + ))).thenReturn(mockFindIterable); + when(mockFindIterable.first()).thenReturn(document); + + UserAccess actual = mongoUserAccessStore.getUserAccessForNamespaceAndId(namespace, userAccessId); + + assertThat(actual.getUsername(), is("test")); + assertThat(actual.getPermission(), is(Permission.read)); + assertThat(actual.getResourceType(), is(ResourceType.flows)); + assertThat(actual.getNamespace(), is(namespace)); + assertThat(actual.getUserAccessId(), is(userAccessId)); + } +} \ No newline at end of file diff --git a/calm-hub/src/test/resources/secure-profile/realm.json b/calm-hub/src/test/resources/secure-profile/realm.json index ecffb648a..911ae2c6b 100644 --- a/calm-hub/src/test/resources/secure-profile/realm.json +++ b/calm-hub/src/test/resources/secure-profile/realm.json @@ -24,6 +24,7 @@ "phone", "architectures:read", "architectures:all", + "namespace:admin", "adrs:read", "adrs:all", "deny:all" @@ -159,6 +160,65 @@ } } ] + }, + { + "name": "namespace:admin", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true" + }, + "protocolMappers": [ + { + "name": "audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.client.audience": "calm-hub-producer-app", + "id.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${profileScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + }, + { + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String", + "userinfo.token.claim": "true" + } + } + ] } ], "scopeMappings": [ @@ -197,5 +257,30 @@ "deny:all" ] } + ], + "users": [ + { + "username": "test-user", + "enabled": true, + "emailVerified": true, + "firstName": "Test", + "lastName": "User", + "email": "test-user@finos.org", + "credentials": [ + { + "type": "password", + "value": "changeme", + "temporary": false + } + ], + "realmRoles": [ + "admin" + ], + "clientRoles": { + "calm-hub-client-app": [ + "architectures:read" + ] + } + } ] } \ No newline at end of file