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