diff --git a/calm-hub/PERMISSIONS.md b/calm-hub/PERMISSIONS.md new file mode 100644 index 000000000..898230316 --- /dev/null +++ b/calm-hub/PERMISSIONS.md @@ -0,0 +1,34 @@ +# Structure of CalmHub permissions system + +CalmHub drives its permission system from the in-memory database. +Entitlements are stored as `UserAccess` records. + +## Structure of entitlements model + +Entitlements are applied at a per-namespace level, at domain level for control requirements and configurations. + +The available actions are the following. + +| Action | Description | +|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `read` | Can read any documents of that type in the namespace. | +| `write` | Can write any documents of that type in the namespace. This includes deleting them. Note that by default resources in CalmHub are immutable, so this usually means 'create' only. +| `admin` | Can do anything to all resource types, and also grant entitlements to other users in the namespace. | + +For example, `read` means the user can read all resources in that NS. + +Please note that each entitlement implies all previous levels - i.e. `write` implies `read`. +`admin` implies `read` and `write` on all resource types. + +## Global admin + +Some resources aren't tied to any one namespace. +Creating namespaces and managing core schemas requires the `admin` role, with the namespace `GLOBAL` in the database. + +## Global READ mode + +It's possible to configure CalmHub to grant `read` to all users by default. + +To do this, set the property `calm.hub.allow.public.read=true`. +By default this property is `false`. + diff --git a/calm-hub/README.md b/calm-hub/README.md index 2ec9bb696..a8a6e0285 100644 --- a/calm-hub/README.md +++ b/calm-hub/README.md @@ -36,6 +36,16 @@ mvn -P integration verify Development mode is designed to provide a great developer experience from using modern tools and build systems. +### Skipping `npm` build + +CalmHub will install and build the frontend every time you run it by default. +This takes a while and can be rather tedious. +To disable this, set `skip.npm`, which disables the NPM commands of the frontend Maven plugin: + +```shell +../mvnw quarkus:dev -Dskip.npm +``` + ### Storage Modes Calm Hub supports two different storage modes: @@ -128,6 +138,37 @@ From the `calm-hub` directory ### Secure profile +There are two secure profiles, `secure` and `proxy`. +Using either will enable entitlements driven by the database. + +The two modes are slightly different: +- `secure`: Enables JWT-based authentication using Quarkus' OAuth 2 libraries. + - User identities will be extracted from the provided JWT which will be validated against the Authorization Server's Json Web Key Set (JWKS.) +- `proxy-auth`: Assumes CalmHub will be deployed behind an additional proxy component, such as `nginx` or `apache`, that performs OAuth 2 (or other) authentication for you. + - User identity is expected to be passed via a header; by default this is `Remote-User`. + +#### Proxy profile + +To launch CalmHub with the proxy auth mode enabled: + +```bash +../mvnw quarkus:dev -Dquarkus.profile=proxy-auth +``` + +**Important notes**: +- It is strongly recommended that the sidecar runs in the same pod or container as CalmHub, and that **CalmHub should only be accessible via this proxy.** + +- This is because if users can directly call CalmHub, they can simply set the header to trivially impersonate any identity. + +#### Secure profile + +The secure profile requires an Identity Provider (IdP) to authenticate users. +The IdP will most likely be managed by your organisation in an enterprise environment, or by your Cloud Service Provider if you're deploying on public cloud. + +However, for local testing and development purposes, CalmHub includes a simple pre-configured IdP, Keycloak, that you can spin up locally to simulate a real IdP. + +The following sections descibe how to start Keycloak, and how to configure CalmHub to use it correctly. + #### Launch keycloak From the `keycloak-dev` directory in `calm-hub` diff --git a/calm-hub/mongo/init-mongo.js b/calm-hub/mongo/init-mongo.js index 41c1f4ca7..acc8d1857 100644 --- a/calm-hub/mongo/init-mongo.js +++ b/calm-hub/mongo/init-mongo.js @@ -2500,44 +2500,38 @@ if (db.userAccess.countDocuments() === 0) { { "userAccessId": NumberInt(1), "username": "demo_admin", - "permission": "write", - "namespace": "finos", - "resourceType": "all" + "permission": "admin", + "namespace": "finos" }, { "userAccessId": NumberInt(2), "username": "demo_admin", - "permission": "write", - "namespace": "workshop", - "resourceType": "patterns" + "permission": "admin", + "namespace": "workshop" }, { "userAccessId": NumberInt(3), "username": "demo_admin", "permission": "read", - "namespace": "traderx", - "resourceType": "all" + "namespace": "traderx" }, { "userAccessId": NumberInt(4), "username": "demo", "permission": "read", - "namespace": "finos", - "resourceType": "all" + "namespace": "finos" }, { "userAccessId": NumberInt(5), "username": "demo", "permission": "read", - "namespace": "traderx", - "resourceType": "all" + "namespace": "traderx" }, { "userAccessId": NumberInt(6), "username": "demo", "permission": "read", - "namespace": "workshop", - "resourceType": "all" + "namespace": "workshop" } ]); logSuccess("Initialized user access for demo_admin and demo users"); diff --git a/calm-hub/pom.xml b/calm-hub/pom.xml index 6c0737765..296b13305 100644 --- a/calm-hub/pom.xml +++ b/calm-hub/pom.xml @@ -129,6 +129,11 @@ quarkus-junit5-mockito test + + io.quarkus + quarkus-test-security + test + org.hamcrest hamcrest diff --git a/calm-hub/src/integration-test/java/integration/IntegrationTestProfile.java b/calm-hub/src/integration-test/java/integration/IntegrationTestProfile.java index c8ae9058f..8fc19922d 100644 --- a/calm-hub/src/integration-test/java/integration/IntegrationTestProfile.java +++ b/calm-hub/src/integration-test/java/integration/IntegrationTestProfile.java @@ -27,7 +27,8 @@ public Map getConfigOverrides() { // equivalent REST PUT endpoint can be exercised by the integration suite. return Map.of( "allow.put.operations", "true", - "calm.mcp.enabled", "true" + "calm.mcp.enabled", "true", + "calm.hub.no.auth.enabled","true" ); } } diff --git a/calm-hub/src/integration-test/java/integration/IntegrationTestProxyAuthProfile.java b/calm-hub/src/integration-test/java/integration/IntegrationTestProxyAuthProfile.java new file mode 100644 index 000000000..18a1c1f97 --- /dev/null +++ b/calm-hub/src/integration-test/java/integration/IntegrationTestProxyAuthProfile.java @@ -0,0 +1,26 @@ +package integration; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusTestProfile; + +import java.util.Map; +import java.util.Set; + +@QuarkusTestResource(EndToEndResource.class) +public class IntegrationTestProxyAuthProfile implements QuarkusTestProfile { + + @Override + public Set> getEnabledAlternatives() { + return Set.of(); + } + + @Override + public String getConfigProfile() { + return "proxy-auth"; + } + + @Override + public Map getConfigOverrides() { + return Map.of("allow.put.operations", "true"); + } +} diff --git a/calm-hub/src/integration-test/java/integration/MongoMcpIntegration.java b/calm-hub/src/integration-test/java/integration/MongoMcpIntegration.java index 0dea3acf9..5438f8808 100644 --- a/calm-hub/src/integration-test/java/integration/MongoMcpIntegration.java +++ b/calm-hub/src/integration-test/java/integration/MongoMcpIntegration.java @@ -7,6 +7,7 @@ import io.quarkiverse.mcp.server.ToolResponse; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.security.TestSecurity; import jakarta.inject.Inject; import org.bson.Document; import org.eclipse.microprofile.config.ConfigProvider; @@ -40,6 +41,7 @@ @QuarkusTest @TestProfile(IntegrationTestProfile.class) @TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@TestSecurity(authorizationEnabled = false) public class MongoMcpIntegration { private static final Logger logger = LoggerFactory.getLogger(MongoMcpIntegration.class); diff --git a/calm-hub/src/integration-test/java/integration/NitriteMcpIntegration.java b/calm-hub/src/integration-test/java/integration/NitriteMcpIntegration.java index 549ddbfc7..e3da9773b 100644 --- a/calm-hub/src/integration-test/java/integration/NitriteMcpIntegration.java +++ b/calm-hub/src/integration-test/java/integration/NitriteMcpIntegration.java @@ -4,6 +4,7 @@ import io.quarkiverse.mcp.server.ToolResponse; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.security.TestSecurity; import jakarta.inject.Inject; import org.finos.calm.mcp.tools.ArchitectureTools; import org.finos.calm.mcp.tools.ControlTools; @@ -34,6 +35,7 @@ @QuarkusTest @TestProfile(NitriteIntegrationTestProfile.class) @TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@TestSecurity(authorizationEnabled = false) public class NitriteMcpIntegration { private static final Logger logger = LoggerFactory.getLogger(NitriteMcpIntegration.class); diff --git a/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java b/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java index c4d2aad3e..5e4df3d6b 100644 --- a/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java +++ b/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java @@ -8,10 +8,7 @@ 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; -import org.junit.jupiter.params.provider.ValueSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -23,6 +20,12 @@ import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.hasItem; +/** + * Verifies that the secure profile enforces JWT authentication and that access decisions + * are made from the DB (UserAccess), not from JWT scopes. + * + * test-user has READ access on namespace "finos" seeded in @BeforeEach. + */ @QuarkusTest @TestProfile(IntegrationTestSecureProfile.class) @TestMethodOrder(MethodOrderer.OrderAnnotation.class) @@ -32,18 +35,17 @@ public class PermittedScopesIntegration { private static final String PATTERN = "{\"name\": \"demo-pattern\"}"; @BeforeEach - void setupPatterns() { + void setup() { String mongoUri = ConfigProvider.getConfig().getValue("quarkus.mongodb.connection-string", String.class); String mongoDatabase = ConfigProvider.getConfig().getValue("quarkus.mongodb.database", 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(mongoDatabase); + if (!database.listCollectionNames().into(new ArrayList<>()).contains("patterns")) { database.createCollection("patterns"); database.getCollection("patterns").insertOne( @@ -53,80 +55,57 @@ void setupPatterns() { 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); + database.getCollection("userAccess").insertOne( + new Document("username", "test-user") + .append("namespace", "finos") + .append("permission", UserAccess.Permission.read.name()) + .append("userAccessId", 101) + ); } + counterSetup(database); namespaceSetup(database); } } - @Test - @Order(1) - 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 = generateAccessTokenWithPasswordGrantType(authServerUrl, CalmHubScopes.ARCHITECTURES_READ); - given() - .auth().oauth2(accessToken) - .when().get("/calm/namespaces/finos/patterns") - .then() - .statusCode(200) - .body("values", empty()); - } - - private String generateAccessTokenWithClientCredentialGrant(String authServerUrl, String scope) { - String accessToken = given() - .auth() - .preemptive() - .basic("calm-hub-client-app", "calm-hub-client-app-secret") - .formParam("grant_type", "client_credentials") - .formParam("scope", scope) + /** Gets a user token for test-user. The JWT identifies the user; no application-specific + * scopes are needed because access decisions are made from the DB. */ + private String tokenForTestUser(String authServerUrl) { + return given() + .auth().preemptive().basic("calm-hub-client-app", "calm-hub-client-app-secret") + .formParam("grant_type", "password") + .formParam("username", "test-user") + .formParam("password", "changeme") .when() - .post(authServerUrl.concat("/protocol/openid-connect/token")) + .post(authServerUrl + "/protocol/openid-connect/token") .then() .statusCode(200) .extract() .path("access_token"); - 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")) + @Test + @Order(1) + void authenticated_user_with_db_read_access_can_read_patterns() { + String authServerUrl = ConfigProvider.getConfig().getValue("quarkus.oidc.auth-server-url", String.class); + String token = tokenForTestUser(authServerUrl); + + given() + .auth().oauth2(token) + .when().get("/calm/namespaces/finos/patterns") .then() .statusCode(200) - .extract() - .path("access_token"); - return accessToken; + .body("values", empty()); } @Test @Order(2) - void end_to_end_forbidden_create_pattern_when_matching_scopes_notfound() { + void authenticated_user_with_read_only_db_access_cannot_create_pattern() { String authServerUrl = ConfigProvider.getConfig().getValue("quarkus.oidc.auth-server-url", String.class); - logger.info("authServerUrl {}", authServerUrl); - String accessToken = generateAccessTokenWithClientCredentialGrant(authServerUrl, CalmHubScopes.ARCHITECTURES_READ); + String token = tokenForTestUser(authServerUrl); given() - .auth().oauth2(accessToken) + .auth().oauth2(token) .body(PATTERN) .header("Content-Type", "application/json") .when().post("/calm/namespaces/finos/patterns") @@ -136,8 +115,7 @@ void end_to_end_forbidden_create_pattern_when_matching_scopes_notfound() { @Test @Order(3) - void end_to_end_unauthorize_create_pattern_request_with_no_access_token() { - + void unauthenticated_request_is_rejected() { given() .body(PATTERN) .header("Content-Type", "application/json") @@ -146,16 +124,14 @@ void end_to_end_unauthorize_create_pattern_request_with_no_access_token() { .statusCode(401); } - @ParameterizedTest - @ValueSource(strings = {CalmHubScopes.ADRS_READ, CalmHubScopes.ADRS_ALL, - CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @Test @Order(4) - void end_to_end_get_namespaces_with_valid_access_token(String scope) { + void authenticated_user_with_db_read_access_can_read_namespaces() { String authServerUrl = ConfigProvider.getConfig().getValue("quarkus.oidc.auth-server-url", String.class); - logger.info("authServerUrl {}", authServerUrl); - String accessToken = generateAccessTokenWithClientCredentialGrant(authServerUrl, scope); + String token = tokenForTestUser(authServerUrl); + given() - .auth().oauth2(accessToken) + .auth().oauth2(token) .when().get("/calm/namespaces") .then() .statusCode(200) @@ -165,30 +141,10 @@ void end_to_end_get_namespaces_with_valid_access_token(String scope) { @Test @Order(5) - 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 = generateAccessTokenWithClientCredentialGrant(authServerUrl, "deny:all"); + void unauthenticated_request_for_namespaces_is_rejected() { 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); + .statusCode(401); } -} \ No newline at end of file +} diff --git a/calm-hub/src/integration-test/java/integration/ProxyAuthIntegration.java b/calm-hub/src/integration-test/java/integration/ProxyAuthIntegration.java new file mode 100644 index 000000000..7eb6ab131 --- /dev/null +++ b/calm-hub/src/integration-test/java/integration/ProxyAuthIntegration.java @@ -0,0 +1,305 @@ +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.junit.jupiter.api.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +import static integration.MongoSetup.*; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.notNullValue; + +/** + * Verifies that the proxy-auth profile authenticates users via the Remote-User + * HTTP header and that all access decisions are driven exclusively by the + * DB-level entitlements stored in userAccess — not by any token or scope. + * + * Users seeded in @BeforeEach: + * alice — namespace finos, permission READ + * bob — namespace finos, permission WRITE + * charlie — namespace finos, permission ADMIN + * dave — namespace GLOBAL, permission ADMIN (global admin) + * eve — domain security, permission READ + */ +@QuarkusTest +@TestProfile(IntegrationTestProxyAuthProfile.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class ProxyAuthIntegration { + + private static final Logger logger = LoggerFactory.getLogger(ProxyAuthIntegration.class); + + private static final String PROXY_HEADER = "Remote-User"; + + private static final String USER_ALICE = "alice"; // namespace finos READ + private static final String USER_BOB = "bob"; // namespace finos WRITE + private static final String USER_CHARLIE = "charlie"; // namespace finos ADMIN + private static final String USER_DAVE = "dave"; // namespace GLOBAL ADMIN + private static final String USER_EVE = "eve"; // domain security READ + + private static final String PATTERN_JSON = """ + { + "name": "proxy-auth-test-pattern", + "description": "created during proxy-auth integration test", + "patternJson": "{\\"nodes\\":[]}" + } + """; + + private static final String CONTROL_JSON = """ + { + "name": "proxy-auth-control", + "description": "created during proxy-auth integration test", + "requirementJson": "{\\"rule\\":\\"test\\"}" + } + """; + + private static final String USER_ACCESS_FINOS_JSON = """ + { "username": "new-grantee", "permission": "read", "namespace": "finos" } + """; + + private static final String USER_ACCESS_WORKSHOP_JSON = """ + { "username": "new-grantee", "permission": "read", "namespace": "workshop" } + """; + + private static final String NAMESPACE_JSON = """ + { "name": "proxy-auth-ns", "description": "created by global admin under test" } + """; + + @BeforeEach + void setup() { + String mongoUri = ConfigProvider.getConfig().getValue("quarkus.mongodb.connection-string", String.class); + String mongoDatabase = ConfigProvider.getConfig().getValue("quarkus.mongodb.database", String.class); + + if (mongoUri == null || mongoUri.isBlank()) { + throw new IllegalStateException("MongoDB URI is not set. Check EndToEndResource configuration."); + } + + try (MongoClient mongoClient = MongoClients.create(mongoUri)) { + MongoDatabase database = mongoClient.getDatabase(mongoDatabase); + + 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"); + database.getCollection("userAccess").insertMany(List.of( + new Document("username", USER_ALICE) + .append("namespace", "finos") + .append("permission", UserAccess.Permission.read.name()) + .append("userAccessId", 101), + new Document("username", USER_BOB) + .append("namespace", "finos") + .append("permission", UserAccess.Permission.write.name()) + .append("userAccessId", 102), + new Document("username", USER_CHARLIE) + .append("namespace", "finos") + .append("permission", UserAccess.Permission.admin.name()) + .append("userAccessId", 103), + new Document("username", USER_DAVE) + .append("namespace", "GLOBAL") + .append("permission", UserAccess.Permission.admin.name()) + .append("userAccessId", 104), + new Document("username", USER_EVE) + .append("domain", "security") + .append("permission", UserAccess.Permission.read.name()) + .append("userAccessId", 105) + )); + } + + counterSetup(database); + namespaceSetup(database); + domainSetup(database); + } + } + + // ── Authentication ──────────────────────────────────────────────────────── + + @Test + @Order(1) + void request_without_proxy_header_is_rejected_with_401() { + given() + .when().get("/calm/namespaces/finos/patterns") + .then() + .statusCode(401); + } + + @Test + @Order(2) + void request_with_unknown_user_who_has_no_db_grants_is_forbidden() { + given() + .header(PROXY_HEADER, "unknown-user") + .when().get("/calm/namespaces/finos/patterns") + .then() + .statusCode(403); + } + + // ── Namespace READ entitlement ──────────────────────────────────────────── + + @Test + @Order(3) + void user_with_read_grant_can_read_patterns_for_their_namespace() { + given() + .header(PROXY_HEADER, USER_ALICE) + .when().get("/calm/namespaces/finos/patterns") + .then() + .statusCode(200) + .body("values", notNullValue()); + } + + @Test + @Order(4) + void user_with_read_grant_is_forbidden_from_creating_a_pattern() { + given() + .header(PROXY_HEADER, USER_ALICE) + .header("Content-Type", "application/json") + .body(PATTERN_JSON) + .when().post("/calm/namespaces/finos/patterns") + .then() + .statusCode(403); + } + + @Test + @Order(5) + void user_with_read_grant_is_forbidden_from_accessing_a_different_namespace() { + given() + .header(PROXY_HEADER, USER_ALICE) + .when().get("/calm/namespaces/workshop/patterns") + .then() + .statusCode(403); + } + + // ── Namespace WRITE entitlement ─────────────────────────────────────────── + + @Test + @Order(6) + void user_with_write_grant_can_read_patterns_because_write_implies_read() { + given() + .header(PROXY_HEADER, USER_BOB) + .when().get("/calm/namespaces/finos/patterns") + .then() + .statusCode(200); + } + + @Test + @Order(7) + void user_with_write_grant_can_create_a_pattern() { + given() + .header(PROXY_HEADER, USER_BOB) + .header("Content-Type", "application/json") + .body(PATTERN_JSON) + .when().post("/calm/namespaces/finos/patterns") + .then() + .statusCode(201); + } + + @Test + @Order(8) + void user_with_write_grant_on_finos_is_forbidden_from_writing_to_another_namespace() { + given() + .header(PROXY_HEADER, USER_BOB) + .header("Content-Type", "application/json") + .body(PATTERN_JSON) + .when().post("/calm/namespaces/workshop/patterns") + .then() + .statusCode(403); + } + + // ── Namespace ADMIN entitlement ─────────────────────────────────────────── + + @Test + @Order(9) + void user_with_admin_grant_can_create_user_access_on_their_namespace() { + given() + .header(PROXY_HEADER, USER_CHARLIE) + .header("Content-Type", "application/json") + .body(USER_ACCESS_FINOS_JSON) + .when().post("/calm/namespaces/finos/user-access") + .then() + .statusCode(201); + } + + @Test + @Order(10) + void user_with_admin_on_finos_is_forbidden_from_managing_access_on_another_namespace() { + given() + .header(PROXY_HEADER, USER_CHARLIE) + .header("Content-Type", "application/json") + .body(USER_ACCESS_WORKSHOP_JSON) + .when().post("/calm/namespaces/workshop/user-access") + .then() + .statusCode(403); + } + + // ── GLOBAL ADMIN entitlement ────────────────────────────────────────────── + + @Test + @Order(11) + void user_with_global_admin_grant_can_create_a_namespace() { + given() + .header(PROXY_HEADER, USER_DAVE) + .header("Content-Type", "application/json") + .body(NAMESPACE_JSON) + .when().post("/calm/namespaces") + .then() + .statusCode(201); + } + + @Test + @Order(12) + void user_with_only_namespace_write_grant_is_forbidden_from_creating_namespaces() { + given() + .header(PROXY_HEADER, USER_BOB) + .header("Content-Type", "application/json") + .body("{\"name\": \"blocked-ns\", \"description\": \"should be blocked\"}") + .when().post("/calm/namespaces") + .then() + .statusCode(403); + } + + // ── Domain READ entitlement ─────────────────────────────────────────────── + + @Test + @Order(13) + void user_with_domain_read_grant_can_read_controls_for_their_domain() { + given() + .header(PROXY_HEADER, USER_EVE) + .when().get("/calm/domains/security/controls") + .then() + .statusCode(200); + } + + @Test + @Order(14) + void user_with_domain_read_grant_is_forbidden_from_creating_a_control() { + given() + .header(PROXY_HEADER, USER_EVE) + .header("Content-Type", "application/json") + .body(CONTROL_JSON) + .when().post("/calm/domains/security/controls") + .then() + .statusCode(403); + } + + @Test + @Order(15) + void user_with_namespace_grant_only_is_forbidden_from_reading_domain_controls() { + given() + .header(PROXY_HEADER, USER_ALICE) + .when().get("/calm/domains/security/controls") + .then() + .statusCode(403); + } +} diff --git a/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java b/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java index b02f20df0..abd7284fa 100644 --- a/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java +++ b/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java @@ -8,7 +8,6 @@ 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; @@ -19,43 +18,46 @@ import static integration.MongoSetup.namespaceSetup; import static io.restassured.RestAssured.given; +/** + * Verifies that user-access grant operations respect DB-level permissions. + * + * test-user is seeded with ADMIN access on namespace "finos" in @BeforeEach. + */ @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 GRANT_FOR_FINOS = """ + { + "username": "testuser1", + "permission": "read", + "namespace": "finos" + } """; - private static final String CREATE_USER_ACCESS_REQUEST_2 = """ - { - "username": "testuser1", - "permission": "read", - "namespace": "workshop", - "resourceType": "all" - } + private static final String GRANT_FOR_WORKSHOP = """ + { + "username": "testuser1", + "permission": "read", + "namespace": "workshop" + } """; @BeforeEach - void setupPatterns() { + void setup() { String mongoUri = ConfigProvider.getConfig().getValue("quarkus.mongodb.connection-string", String.class); String mongoDatabase = ConfigProvider.getConfig().getValue("quarkus.mongodb.database", 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(mongoDatabase); + if (!database.listCollectionNames().into(new ArrayList<>()).contains("patterns")) { database.createCollection("patterns"); database.getCollection("patterns").insertOne( @@ -66,49 +68,44 @@ void setupPatterns() { 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); + // test-user has admin access on finos, so they can manage grants there + database.getCollection("userAccess").insertOne( + new Document("username", "test-user") + .append("namespace", "finos") + .append("permission", UserAccess.Permission.admin.name()) + .append("userAccessId", 101) + ); + 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") + /** Gets a user token for test-user. The JWT identifies the user; authorization is from the DB. */ + private String tokenForTestUser(String authServerUrl) { + return 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")) + .post(authServerUrl + "/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() { + void user_with_namespace_admin_access_can_create_user_access_grant() { String authServerUrl = ConfigProvider.getConfig().getValue("quarkus.oidc.auth-server-url", String.class); - logger.info("authServerUrl {}", authServerUrl); - String accessToken = generateAccessTokenWithPasswordGrantType(authServerUrl, CalmHubScopes.NAMESPACE_ADMIN); + String token = tokenForTestUser(authServerUrl); + given() - .auth().oauth2(accessToken) - .body(CREATE_USER_ACCESS_REQUEST) + .auth().oauth2(token) + .body(GRANT_FOR_FINOS) .header("Content-Type", "application/json") .when().post("/calm/namespaces/finos/user-access") .then() @@ -117,16 +114,16 @@ void end_to_end_create_user_access_with_namespace_admin_scope_and_with_admin_use @Test @Order(2) - void end_to_end_forbidden_create_user_access_when_admin_has_no_access_on_namespace() { - + void user_with_admin_access_on_one_namespace_cannot_create_grant_on_another() { String authServerUrl = ConfigProvider.getConfig().getValue("quarkus.oidc.auth-server-url", String.class); - String accessToken = generateAccessTokenWithPasswordGrantType(authServerUrl, CalmHubScopes.NAMESPACE_ADMIN); + String token = tokenForTestUser(authServerUrl); + given() - .auth().oauth2(accessToken) - .body(CREATE_USER_ACCESS_REQUEST_2) + .auth().oauth2(token) + .body(GRANT_FOR_WORKSHOP) .header("Content-Type", "application/json") - .when().post("/calm/namespaces/workshop/patterns") + .when().post("/calm/namespaces/workshop/user-access") .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 index a54c64ebe..860e857c6 100644 --- a/calm-hub/src/main/java/org/finos/calm/domain/UserAccess.java +++ b/calm-hub/src/main/java/org/finos/calm/domain/UserAccess.java @@ -9,27 +9,20 @@ import java.util.Objects; /** - * Represents a CalmHub user access on resources associated to a namespace. + * Represents a CalmHub user access grant, scoped to either a namespace or a domain. */ public class UserAccess { public enum Permission { read, - write - } - - public enum ResourceType { - patterns, - flows, - adrs, - architectures, - all + write, + admin } private String username; private Permission permission; private String namespace; - private ResourceType resourceType; + private String domain; private int userAccessId; @JsonDeserialize(using = LocalDateTimeDeserializer.class) @@ -40,61 +33,24 @@ public enum ResourceType { @JsonSerialize(using = LocalDateTimeSerializer.class) private LocalDateTime updateDateTime; - public UserAccess(String username, Permission permission, String namespace, ResourceType resourceType, int userAccessId) { + public UserAccess(String username, Permission permission, String namespace, 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) { + public UserAccess(String username, Permission permission, String namespace) { this.username = username; this.permission = permission; this.namespace = namespace; - this.resourceType = resourceType; } - public UserAccess(){ - + 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 getDomain() { + return domain; } public String getUsername() { @@ -109,8 +65,8 @@ public String getNamespace() { return namespace; } - public ResourceType getResourceType() { - return resourceType; + public void setDomain(String domain) { + this.domain = domain; } public int getUserAccessId() { @@ -125,30 +81,45 @@ public LocalDateTime getUpdateDateTime() { return updateDateTime; } - public void setCreationDateTime(LocalDateTime creationDateTime) { - this.creationDateTime = creationDateTime; + public void setUsername(String username) { + this.username = username; } - public void setUpdateDateTime(LocalDateTime updateDateTime) { - this.updateDateTime = updateDateTime; + public void setPermission(Permission permission) { + this.permission = permission; + } + + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + public void setUserAccessId(int userAccessId) { + this.userAccessId = userAccessId; } @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); + return Objects.equals(domain, that.domain); + } + + public void setCreationDateTime(LocalDateTime creationDateTime) { + this.creationDateTime = creationDateTime; + } + + public void setUpdateDateTime(LocalDateTime updateDateTime) { + this.updateDateTime = updateDateTime; } @Override public int hashCode() { - return Objects.hash(username, permission, namespace, resourceType, userAccessId); + return Objects.hash(username, permission, namespace, domain, userAccessId); } @Override @@ -157,28 +128,48 @@ public String toString() { "username='" + username + '\'' + ", permission='" + permission + '\'' + ", namespace='" + namespace + '\'' + - ", resourceType='" + resourceType + '\'' + + ", domain='" + domain + '\'' + ", userAccessId=" + userAccessId + '}'; } - public void setUsername(String username) { - this.username = username; - } + public static class UserAccessBuilder { - public void setPermission(Permission permission) { - this.permission = permission; - } + private String username; + private Permission permission; + private String namespace; + private String domain; + private int userAccessId; - public void setNamespace(String namespace) { - this.namespace = namespace; - } + public UserAccessBuilder setUsername(String username) { + this.username = username; + return this; + } - public void setResourceType(ResourceType resourceType) { - this.resourceType = resourceType; - } + public UserAccessBuilder setPermission(Permission permission) { + this.permission = permission; + return this; + } - public void setUserAccessId(int userAccessId) { - this.userAccessId = userAccessId; + public UserAccessBuilder setNamespace(String namespace) { + this.namespace = namespace; + return this; + } + + public UserAccessBuilder setDomain(String domain) { + this.domain = domain; + return this; + } + + public UserAccessBuilder setUserAccessId(int userAccessId) { + this.userAccessId = userAccessId; + return this; + } + + public UserAccess build() { + UserAccess ua = new UserAccess(username, permission, namespace, userAccessId); + ua.domain = this.domain; + return ua; + } } } diff --git a/calm-hub/src/main/java/org/finos/calm/domain/UserAction.java b/calm-hub/src/main/java/org/finos/calm/domain/UserAction.java new file mode 100644 index 000000000..3e88e1826 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/domain/UserAction.java @@ -0,0 +1,17 @@ +package org.finos.calm.domain; + +public enum UserAction { + READ("read"), + WRITE("write"), + ADMIN("admin"); + + private final String value; + + UserAction(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/mcp/tools/AdrTools.java b/calm-hub/src/main/java/org/finos/calm/mcp/tools/AdrTools.java index f9f087da1..02cc7583d 100644 --- a/calm-hub/src/main/java/org/finos/calm/mcp/tools/AdrTools.java +++ b/calm-hub/src/main/java/org/finos/calm/mcp/tools/AdrTools.java @@ -5,6 +5,7 @@ import io.quarkiverse.mcp.server.Tool; import io.quarkiverse.mcp.server.ToolArg; import io.quarkiverse.mcp.server.ToolResponse; +import io.quarkus.security.PermissionsAllowed; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -12,11 +13,8 @@ import org.finos.calm.domain.adr.AdrMeta; import org.finos.calm.domain.adr.NewAdrRequest; import org.finos.calm.domain.adr.Status; -import org.finos.calm.domain.exception.AdrNotFoundException; -import org.finos.calm.domain.exception.AdrParseException; -import org.finos.calm.domain.exception.AdrPersistenceException; -import org.finos.calm.domain.exception.AdrRevisionNotFoundException; -import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.exception.*; +import org.finos.calm.security.CalmHubScopes; import org.finos.calm.store.AdrStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -49,6 +47,7 @@ public class AdrTools { AdrStore adrStore; @Tool(description = "List all ADRs in a CalmHub namespace. Returns ADR IDs, titles, and current status.") + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse listAdrs( @ToolArg(description = "The namespace to list ADRs from (e.g. 'workshop', 'finos')") String namespace) { Optional err = McpValidationHelper.firstError( @@ -69,6 +68,7 @@ public ToolResponse listAdrs( } @Tool(description = "Get the latest revision of an ADR. Returns the full ADR content as a JSON object.") + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse getAdr( @ToolArg(description = "The namespace containing the ADR") String namespace, @ToolArg(description = "The ADR ID (positive integer)") int adrId) { @@ -105,6 +105,7 @@ public ToolResponse getAdr( } @Tool(description = "List all revision numbers for an ADR.") + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse listAdrRevisions( @ToolArg(description = "The namespace containing the ADR") String namespace, @ToolArg(description = "The ADR ID (positive integer)") int adrId) { @@ -135,6 +136,7 @@ public ToolResponse listAdrRevisions( } @Tool(description = "Get a specific revision of an ADR. Returns the ADR content at that revision as a JSON object.") + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse getAdrRevision( @ToolArg(description = "The namespace containing the ADR") String namespace, @ToolArg(description = "The ADR ID (positive integer)") int adrId, @@ -174,6 +176,7 @@ public ToolResponse getAdrRevision( } @Tool(description = "Create a new ADR in draft status. Accept the ADR content as a JSON string matching the NewAdrRequest structure: {\"title\":\"...\",\"contextAndProblemStatement\":\"...\",\"decisionDrivers\":[],\"consideredOptions\":[],\"decisionOutcome\":{},\"links\":[]}. Returns the allocated ADR ID.") + @PermissionsAllowed(CalmHubScopes.WRITE) public ToolResponse createAdr( @ToolArg(description = "The namespace to create the ADR in") String namespace, @ToolArg(description = "The ADR content as JSON (NewAdrRequest structure)") String adrJson) { @@ -215,6 +218,7 @@ public ToolResponse createAdr( } @Tool(description = "Update an existing ADR's content. Creates a new revision. Accepts the ADR content as a JSON string matching the NewAdrRequest structure.") + @PermissionsAllowed(CalmHubScopes.WRITE) public ToolResponse updateAdr( @ToolArg(description = "The namespace containing the ADR") String namespace, @ToolArg(description = "The ADR ID (positive integer)") int adrId, @@ -263,6 +267,7 @@ public ToolResponse updateAdr( } @Tool(description = "Update the status of an ADR. Valid statuses: draft, proposed, accepted, superseded, rejected, deprecated.") + @PermissionsAllowed(CalmHubScopes.WRITE) public ToolResponse updateAdrStatus( @ToolArg(description = "The namespace containing the ADR") String namespace, @ToolArg(description = "The ADR ID (positive integer)") int adrId, diff --git a/calm-hub/src/main/java/org/finos/calm/mcp/tools/ArchitectureTools.java b/calm-hub/src/main/java/org/finos/calm/mcp/tools/ArchitectureTools.java index db55b0f2d..e65410661 100644 --- a/calm-hub/src/main/java/org/finos/calm/mcp/tools/ArchitectureTools.java +++ b/calm-hub/src/main/java/org/finos/calm/mcp/tools/ArchitectureTools.java @@ -3,6 +3,7 @@ import io.quarkiverse.mcp.server.Tool; import io.quarkiverse.mcp.server.ToolArg; import io.quarkiverse.mcp.server.ToolResponse; +import io.quarkus.security.PermissionsAllowed; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -12,6 +13,7 @@ import org.finos.calm.domain.exception.ArchitectureVersionExistsException; import org.finos.calm.domain.exception.ArchitectureVersionNotFoundException; import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.security.CalmHubScopes; import org.finos.calm.store.ArchitectureStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,6 +43,7 @@ public class ArchitectureTools { ArchitectureStore architectureStore; @Tool(description = "List all architectures in a CalmHub namespace. Returns architecture IDs, names, and descriptions.") + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse listArchitectures( @ToolArg(description = "The namespace to list architectures from (e.g. 'workshop', 'finos')") String namespace) { Optional err = McpValidationHelper.firstError( @@ -61,6 +64,7 @@ public ToolResponse listArchitectures( } @Tool(description = "List available versions of an architecture in a CalmHub namespace.") + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse listArchitectureVersions( @ToolArg(description = "The namespace containing the architecture") String namespace, @ToolArg(description = "The architecture ID (positive integer)") int architectureId) { @@ -87,6 +91,7 @@ public ToolResponse listArchitectureVersions( } @Tool(description = "Get the full JSON content of a specific architecture version. Use this to analyse architecture nodes, relationships, and controls.") + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse getArchitecture( @ToolArg(description = "The namespace containing the architecture") String namespace, @ToolArg(description = "The architecture ID (positive integer)") int architectureId, @@ -118,6 +123,7 @@ public ToolResponse getArchitecture( } @Tool(description = "Publish a new version of an existing architecture. Use this to add a new semantic version (e.g. '1.1.0') against an existing architecture ID without allocating a new identity.") + @PermissionsAllowed(CalmHubScopes.WRITE) public ToolResponse createArchitectureVersion( @ToolArg(description = "The namespace containing the architecture") String namespace, @ToolArg(description = "The architecture ID to publish a new version for (positive integer)") int architectureId, @@ -173,6 +179,7 @@ public ToolResponse createArchitectureVersion( } @Tool(description = "Update the content of an existing architecture version. Requires PUT operations to be enabled on this CalmHub instance.") + @PermissionsAllowed(CalmHubScopes.WRITE) public ToolResponse updateArchitecture( @ToolArg(description = "The namespace containing the architecture") String namespace, @ToolArg(description = "The architecture ID (positive integer)") int architectureId, @@ -232,6 +239,7 @@ public ToolResponse updateArchitecture( } @Tool(description = "Create a new architecture in a namespace. Returns the allocated architecture ID and version.") + @PermissionsAllowed(CalmHubScopes.WRITE) public ToolResponse createArchitecture( @ToolArg(description = "The namespace to create the architecture in") String namespace, @ToolArg(description = "The name of the architecture") String name, diff --git a/calm-hub/src/main/java/org/finos/calm/mcp/tools/ControlTools.java b/calm-hub/src/main/java/org/finos/calm/mcp/tools/ControlTools.java index 2d670e5f7..480d93018 100644 --- a/calm-hub/src/main/java/org/finos/calm/mcp/tools/ControlTools.java +++ b/calm-hub/src/main/java/org/finos/calm/mcp/tools/ControlTools.java @@ -3,6 +3,7 @@ import io.quarkiverse.mcp.server.Tool; import io.quarkiverse.mcp.server.ToolArg; import io.quarkiverse.mcp.server.ToolResponse; +import io.quarkus.security.PermissionsAllowed; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -12,6 +13,7 @@ import org.finos.calm.domain.exception.ControlNotFoundException; import org.finos.calm.domain.exception.ControlRequirementVersionNotFoundException; import org.finos.calm.domain.exception.DomainNotFoundException; +import org.finos.calm.security.CalmHubScopes; import org.finos.calm.store.ControlStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,6 +38,7 @@ public class ControlTools { @Inject ControlStore controlStore; + @PermissionsAllowed(CalmHubScopes.DOMAIN_READ) @Tool(description = "List all control requirements in a domain (e.g. 'security'). Returns control IDs, names, and descriptions.") public ToolResponse listControls( @ToolArg(description = "The domain to list controls for (e.g. 'security')") String domain) { @@ -56,6 +59,7 @@ public ToolResponse listControls( } } + @PermissionsAllowed(CalmHubScopes.DOMAIN_READ) @Tool(description = "Get the full JSON content of a specific control requirement version.") public ToolResponse getControl( @ToolArg(description = "The domain containing the control (e.g. 'security')") String domain, @@ -82,6 +86,7 @@ public ToolResponse getControl( } } + @PermissionsAllowed(CalmHubScopes.DOMAIN_READ) @Tool(description = "List available versions for a specific control requirement.") public ToolResponse listControlVersions( @ToolArg(description = "The domain containing the control (e.g. 'security')") String domain, @@ -104,6 +109,7 @@ public ToolResponse listControlVersions( } } + @PermissionsAllowed(CalmHubScopes.DOMAIN_WRITE) @Tool(description = "Create a new control requirement in a domain. The requirement is created with an initial version 1.0.0 from the supplied requirement JSON. Returns the assigned control ID.") public ToolResponse createControlRequirement( @ToolArg(description = "The domain to create the control requirement in (e.g. 'security')") String domain, @@ -133,6 +139,7 @@ public ToolResponse createControlRequirement( } } + @PermissionsAllowed(CalmHubScopes.DOMAIN_WRITE) @Tool(description = "Create a new control configuration for an existing control requirement. The configuration is created with an initial version 1.0.0 from the supplied configuration JSON. Returns the assigned configuration ID.") public ToolResponse createControlConfiguration( @ToolArg(description = "The domain containing the control (e.g. 'security')") String domain, diff --git a/calm-hub/src/main/java/org/finos/calm/mcp/tools/DecoratorTools.java b/calm-hub/src/main/java/org/finos/calm/mcp/tools/DecoratorTools.java index 190781104..30dd7fb4e 100644 --- a/calm-hub/src/main/java/org/finos/calm/mcp/tools/DecoratorTools.java +++ b/calm-hub/src/main/java/org/finos/calm/mcp/tools/DecoratorTools.java @@ -3,12 +3,14 @@ import io.quarkiverse.mcp.server.Tool; import io.quarkiverse.mcp.server.ToolArg; import io.quarkiverse.mcp.server.ToolResponse; +import io.quarkus.security.PermissionsAllowed; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.finos.calm.domain.Decorator; import org.finos.calm.domain.exception.DecoratorNotFoundException; import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.security.CalmHubScopes; import org.finos.calm.store.DecoratorStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,6 +39,7 @@ public class DecoratorTools { @Inject DecoratorStore decoratorStore; + @PermissionsAllowed(CalmHubScopes.READ) @Tool(description = "List decorators in a namespace, optionally filtered by target architecture path and/or type (e.g. 'threat-model', 'deployment').") public ToolResponse listDecorators( @ToolArg(description = "The namespace to list decorators from") String namespace, @@ -73,6 +76,7 @@ public ToolResponse listDecorators( } } + @PermissionsAllowed(CalmHubScopes.READ) @Tool(description = "Get a specific decorator by its numeric ID in a namespace. Returns the full decorator JSON including data payload.") public ToolResponse getDecorator( @ToolArg(description = "The namespace containing the decorator") String namespace, @@ -102,6 +106,7 @@ public ToolResponse getDecorator( } } + @PermissionsAllowed(CalmHubScopes.WRITE) @Tool(description = "Create a new decorator in a namespace. Use this to store threat model results, deployments, or other decorator data. Returns the assigned decorator ID.") public ToolResponse createDecorator( @ToolArg(description = "The namespace to create the decorator in") String namespace, @@ -124,6 +129,7 @@ public ToolResponse createDecorator( } } + @PermissionsAllowed(CalmHubScopes.WRITE) @Tool(description = "Update an existing decorator in a namespace. Returns the updated decorator representation.") public ToolResponse updateDecorator( @ToolArg(description = "The namespace containing the decorator") String namespace, diff --git a/calm-hub/src/main/java/org/finos/calm/mcp/tools/DomainTools.java b/calm-hub/src/main/java/org/finos/calm/mcp/tools/DomainTools.java index 27cc5ac09..aac69c4d8 100644 --- a/calm-hub/src/main/java/org/finos/calm/mcp/tools/DomainTools.java +++ b/calm-hub/src/main/java/org/finos/calm/mcp/tools/DomainTools.java @@ -3,11 +3,14 @@ import io.quarkiverse.mcp.server.Tool; import io.quarkiverse.mcp.server.ToolArg; import io.quarkiverse.mcp.server.ToolResponse; +import io.quarkus.security.Authenticated; +import io.quarkus.security.PermissionsAllowed; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.finos.calm.domain.Domain; import org.finos.calm.domain.exception.DomainAlreadyExistsException; +import org.finos.calm.security.CalmHubScopes; import org.finos.calm.store.DomainStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,6 +34,7 @@ public class DomainTools { @Inject DomainStore domainStore; + @Authenticated @Tool(description = "List all control domains available in CalmHub (e.g. 'security'). Domains group related control requirements.") public ToolResponse listDomains() { Optional err = McpValidationHelper.firstError( @@ -47,6 +51,7 @@ public ToolResponse listDomains() { return ToolResponse.success(sb.toString()); } + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) @Tool(description = "Create a new control domain in CalmHub (e.g. 'security'). Domains group related control requirements and are independent of namespaces.") public ToolResponse createDomain( @ToolArg(description = "Name for the new domain (alphanumeric with optional hyphens, e.g. 'security')") String name) { diff --git a/calm-hub/src/main/java/org/finos/calm/mcp/tools/InterfaceTools.java b/calm-hub/src/main/java/org/finos/calm/mcp/tools/InterfaceTools.java index c63b0b34c..e97cf641b 100644 --- a/calm-hub/src/main/java/org/finos/calm/mcp/tools/InterfaceTools.java +++ b/calm-hub/src/main/java/org/finos/calm/mcp/tools/InterfaceTools.java @@ -3,6 +3,7 @@ import io.quarkiverse.mcp.server.Tool; import io.quarkiverse.mcp.server.ToolArg; import io.quarkiverse.mcp.server.ToolResponse; +import io.quarkus.security.PermissionsAllowed; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -13,6 +14,7 @@ import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.interfaces.CreateInterfaceRequest; import org.finos.calm.domain.interfaces.NamespaceInterfaceSummary; +import org.finos.calm.security.CalmHubScopes; import org.finos.calm.store.InterfaceStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,6 +40,7 @@ public class InterfaceTools { InterfaceStore interfaceStore; @Tool(description = "List all interfaces in a CalmHub namespace. Returns interface IDs, names, and descriptions.") + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse listInterfaces( @ToolArg(description = "The namespace to list interfaces from (e.g. 'workshop', 'finos')") String namespace) { Optional err = McpValidationHelper.firstError( @@ -58,6 +61,7 @@ public ToolResponse listInterfaces( } @Tool(description = "List available versions of an interface in a CalmHub namespace.") + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse listInterfaceVersions( @ToolArg(description = "The namespace containing the interface") String namespace, @ToolArg(description = "The interface ID (positive integer)") int interfaceId) { @@ -80,6 +84,7 @@ public ToolResponse listInterfaceVersions( } @Tool(description = "Get the full JSON content of a specific interface version.") + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse getInterface( @ToolArg(description = "The namespace containing the interface") String namespace, @ToolArg(description = "The interface ID (positive integer)") int interfaceId, @@ -106,6 +111,7 @@ public ToolResponse getInterface( } @Tool(description = "Create a new interface in a namespace. Returns the allocated interface ID and version.") + @PermissionsAllowed(CalmHubScopes.WRITE) public ToolResponse createInterface( @ToolArg(description = "The namespace to create the interface in") String namespace, @ToolArg(description = "The name of the interface") String name, @@ -134,6 +140,7 @@ public ToolResponse createInterface( } @Tool(description = "Publish a new version of an existing interface. Use this to add a new semantic version (e.g. '1.1.0') without allocating a new identity.") + @PermissionsAllowed(CalmHubScopes.WRITE) public ToolResponse createInterfaceVersion( @ToolArg(description = "The namespace containing the interface") String namespace, @ToolArg(description = "The interface ID (positive integer)") int interfaceId, diff --git a/calm-hub/src/main/java/org/finos/calm/mcp/tools/NamespaceTools.java b/calm-hub/src/main/java/org/finos/calm/mcp/tools/NamespaceTools.java index 3d72d07a5..f15af6c02 100644 --- a/calm-hub/src/main/java/org/finos/calm/mcp/tools/NamespaceTools.java +++ b/calm-hub/src/main/java/org/finos/calm/mcp/tools/NamespaceTools.java @@ -3,11 +3,14 @@ import io.quarkiverse.mcp.server.Tool; import io.quarkiverse.mcp.server.ToolArg; import io.quarkiverse.mcp.server.ToolResponse; +import io.quarkus.security.Authenticated; +import io.quarkus.security.PermissionsAllowed; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.finos.calm.domain.exception.NamespaceAlreadyExistsException; import org.finos.calm.domain.namespaces.NamespaceInfo; +import org.finos.calm.security.CalmHubScopes; import org.finos.calm.store.NamespaceStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,6 +34,7 @@ public class NamespaceTools { @Inject NamespaceStore namespaceStore; + @Authenticated @Tool(description = "List all namespaces available in CalmHub. Returns namespace names and descriptions.") public ToolResponse listNamespaces() { Optional err = McpValidationHelper.firstError( @@ -51,6 +55,7 @@ public ToolResponse listNamespaces() { return ToolResponse.success(sb.toString()); } + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) @Tool(description = "Create a new namespace in CalmHub.") public ToolResponse createNamespace( @ToolArg(description = "Name for the new namespace (alphanumeric with optional hyphens and dotted segments, case-sensitive, e.g. 'my-org.team1')") String name, diff --git a/calm-hub/src/main/java/org/finos/calm/mcp/tools/PatternTools.java b/calm-hub/src/main/java/org/finos/calm/mcp/tools/PatternTools.java index 8fcb0c430..e3e8ea38f 100644 --- a/calm-hub/src/main/java/org/finos/calm/mcp/tools/PatternTools.java +++ b/calm-hub/src/main/java/org/finos/calm/mcp/tools/PatternTools.java @@ -3,6 +3,7 @@ import io.quarkiverse.mcp.server.Tool; import io.quarkiverse.mcp.server.ToolArg; import io.quarkiverse.mcp.server.ToolResponse; +import io.quarkus.security.PermissionsAllowed; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -13,6 +14,7 @@ import org.finos.calm.domain.exception.PatternVersionNotFoundException; import org.finos.calm.domain.pattern.CreatePatternRequest; import org.finos.calm.domain.pattern.NamespacePatternSummary; +import org.finos.calm.security.CalmHubScopes; import org.finos.calm.store.PatternStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,6 +43,7 @@ public class PatternTools { PatternStore patternStore; @Tool(description = "List all patterns in a CalmHub namespace. Returns pattern IDs, names, and descriptions.") + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse listPatterns( @ToolArg(description = "The namespace to list patterns from (e.g. 'workshop', 'finos')") String namespace) { Optional err = McpValidationHelper.firstError( @@ -61,6 +64,7 @@ public ToolResponse listPatterns( } @Tool(description = "List available versions of a pattern in a CalmHub namespace.") + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse listPatternVersions( @ToolArg(description = "The namespace containing the pattern") String namespace, @ToolArg(description = "The pattern ID (positive integer)") int patternId) { @@ -87,6 +91,7 @@ public ToolResponse listPatternVersions( } @Tool(description = "Get the full JSON content of a specific pattern version.") + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse getPattern( @ToolArg(description = "The namespace containing the pattern") String namespace, @ToolArg(description = "The pattern ID (positive integer)") int patternId, @@ -118,6 +123,7 @@ public ToolResponse getPattern( } @Tool(description = "Create a new pattern in a namespace. Returns the allocated pattern ID and version.") + @PermissionsAllowed(CalmHubScopes.WRITE) public ToolResponse createPattern( @ToolArg(description = "The namespace to create the pattern in") String namespace, @ToolArg(description = "The name of the pattern") String name, @@ -146,6 +152,7 @@ public ToolResponse createPattern( } @Tool(description = "Publish a new version of an existing pattern. Use this to add a new semantic version (e.g. '1.1.0') against an existing pattern ID without allocating a new identity.") + @PermissionsAllowed(CalmHubScopes.WRITE) public ToolResponse createPatternVersion( @ToolArg(description = "The namespace containing the pattern") String namespace, @ToolArg(description = "The pattern ID to publish a new version for (positive integer)") int patternId, @@ -184,6 +191,7 @@ public ToolResponse createPatternVersion( } @Tool(description = "Update the content of an existing pattern version. Requires PUT operations to be enabled on this CalmHub instance.") + @PermissionsAllowed(CalmHubScopes.WRITE) public ToolResponse updatePattern( @ToolArg(description = "The namespace containing the pattern") String namespace, @ToolArg(description = "The pattern ID (positive integer)") int patternId, diff --git a/calm-hub/src/main/java/org/finos/calm/mcp/tools/SearchTools.java b/calm-hub/src/main/java/org/finos/calm/mcp/tools/SearchTools.java index 1fcd70388..c0438038a 100644 --- a/calm-hub/src/main/java/org/finos/calm/mcp/tools/SearchTools.java +++ b/calm-hub/src/main/java/org/finos/calm/mcp/tools/SearchTools.java @@ -3,6 +3,7 @@ import io.quarkiverse.mcp.server.Tool; import io.quarkiverse.mcp.server.ToolArg; import io.quarkiverse.mcp.server.ToolResponse; +import io.quarkus.security.Authenticated; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -36,6 +37,7 @@ public class SearchTools { @Inject SearchStore searchStore; + @Authenticated @Tool(description = "Search across all resource types in CalmHub. Performs a global search across architectures, patterns, flows, standards, interfaces, controls, and ADRs. Results are grouped by type.") public ToolResponse searchHub( @ToolArg(description = "The search query string (1-200 characters)") String query) { diff --git a/calm-hub/src/main/java/org/finos/calm/mcp/tools/StandardTools.java b/calm-hub/src/main/java/org/finos/calm/mcp/tools/StandardTools.java index fbd3c3f4b..1a576da7b 100644 --- a/calm-hub/src/main/java/org/finos/calm/mcp/tools/StandardTools.java +++ b/calm-hub/src/main/java/org/finos/calm/mcp/tools/StandardTools.java @@ -3,6 +3,7 @@ import io.quarkiverse.mcp.server.Tool; import io.quarkiverse.mcp.server.ToolArg; import io.quarkiverse.mcp.server.ToolResponse; +import io.quarkus.security.PermissionsAllowed; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -13,6 +14,7 @@ import org.finos.calm.domain.exception.StandardVersionNotFoundException; import org.finos.calm.domain.standards.CreateStandardRequest; import org.finos.calm.domain.standards.NamespaceStandardSummary; +import org.finos.calm.security.CalmHubScopes; import org.finos.calm.store.StandardStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,6 +40,7 @@ public class StandardTools { StandardStore standardStore; @Tool(description = "List all standards in a CalmHub namespace. Returns standard IDs, names, and descriptions.") + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse listStandards( @ToolArg(description = "The namespace to list standards from (e.g. 'workshop', 'finos')") String namespace) { Optional err = McpValidationHelper.firstError( @@ -58,6 +61,7 @@ public ToolResponse listStandards( } @Tool(description = "List available versions of a standard in a CalmHub namespace.") + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse listStandardVersions( @ToolArg(description = "The namespace containing the standard") String namespace, @ToolArg(description = "The standard ID (positive integer)") int standardId) { @@ -80,6 +84,7 @@ public ToolResponse listStandardVersions( } @Tool(description = "Get the full JSON content of a specific standard version.") + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse getStandard( @ToolArg(description = "The namespace containing the standard") String namespace, @ToolArg(description = "The standard ID (positive integer)") int standardId, @@ -106,6 +111,7 @@ public ToolResponse getStandard( } @Tool(description = "Create a new standard in a namespace. Returns the allocated standard ID and version.") + @PermissionsAllowed(CalmHubScopes.WRITE) public ToolResponse createStandard( @ToolArg(description = "The namespace to create the standard in") String namespace, @ToolArg(description = "The name of the standard") String name, @@ -133,6 +139,7 @@ public ToolResponse createStandard( } @Tool(description = "Publish a new version of an existing standard. Use this to add a new semantic version (e.g. '1.1.0') without allocating a new identity.") + @PermissionsAllowed(CalmHubScopes.WRITE) public ToolResponse createStandardVersion( @ToolArg(description = "The namespace containing the standard") String namespace, @ToolArg(description = "The standard ID (positive integer)") int standardId, diff --git a/calm-hub/src/main/java/org/finos/calm/mcp/tools/TimelineTools.java b/calm-hub/src/main/java/org/finos/calm/mcp/tools/TimelineTools.java index 6d737cfbf..5c1325bc0 100644 --- a/calm-hub/src/main/java/org/finos/calm/mcp/tools/TimelineTools.java +++ b/calm-hub/src/main/java/org/finos/calm/mcp/tools/TimelineTools.java @@ -3,6 +3,7 @@ import io.quarkiverse.mcp.server.Tool; import io.quarkiverse.mcp.server.ToolArg; import io.quarkiverse.mcp.server.ToolResponse; +import io.quarkus.security.PermissionsAllowed; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -13,6 +14,7 @@ import org.finos.calm.domain.timeline.CreateTimelineRequest; import org.finos.calm.domain.timeline.NamespaceTimelineSummary; import org.finos.calm.domain.timeline.Timeline; +import org.finos.calm.security.CalmHubScopes; import org.finos.calm.store.TimelineStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,6 +43,7 @@ public class TimelineTools { TimelineStore timelineStore; @Tool(description = "List all timelines in a CalmHub namespace. Returns timeline IDs, names, and descriptions.") + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse listTimelines( @ToolArg(description = "The namespace to list timelines from (e.g. 'workshop', 'finos')") String namespace) { Optional err = McpValidationHelper.firstError( @@ -61,6 +64,7 @@ public ToolResponse listTimelines( } @Tool(description = "List available versions of a timeline in a CalmHub namespace.") + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse listTimelineVersions( @ToolArg(description = "The namespace containing the timeline") String namespace, @ToolArg(description = "The timeline ID (positive integer)") int timelineId) { @@ -87,6 +91,7 @@ public ToolResponse listTimelineVersions( } @Tool(description = "Get the full JSON content of a specific timeline version.") + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse getTimeline( @ToolArg(description = "The namespace containing the timeline") String namespace, @ToolArg(description = "The timeline ID (positive integer)") int timelineId, @@ -118,6 +123,7 @@ public ToolResponse getTimeline( } @Tool(description = "Create a new timeline in a namespace. Returns the allocated timeline ID and version.") + @PermissionsAllowed(CalmHubScopes.WRITE) public ToolResponse createTimeline( @ToolArg(description = "The namespace to create the timeline in") String namespace, @ToolArg(description = "The name of the timeline") String name, @@ -146,6 +152,7 @@ public ToolResponse createTimeline( } @Tool(description = "Publish a new version of an existing timeline. Use this to add a new semantic version (e.g. '1.1.0') against an existing timeline ID without allocating a new identity.") + @PermissionsAllowed(CalmHubScopes.WRITE) public ToolResponse createTimelineVersion( @ToolArg(description = "The namespace containing the timeline") String namespace, @ToolArg(description = "The timeline ID to publish a new version for (positive integer)") int timelineId, @@ -184,6 +191,7 @@ public ToolResponse createTimelineVersion( } @Tool(description = "Update the content of an existing timeline version. Requires PUT operations to be enabled on this CalmHub instance.") + @PermissionsAllowed(CalmHubScopes.WRITE) public ToolResponse updateTimeline( @ToolArg(description = "The namespace containing the timeline") String namespace, @ToolArg(description = "The timeline ID (positive integer)") int timelineId, diff --git a/calm-hub/src/main/java/org/finos/calm/resources/AdrResource.java b/calm-hub/src/main/java/org/finos/calm/resources/AdrResource.java index bb93e31d8..f5fa2755e 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/AdrResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/AdrResource.java @@ -1,13 +1,9 @@ package org.finos.calm.resources; +import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; import jakarta.validation.constraints.Pattern; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.PathParam; -import jakarta.ws.rs.Produces; +import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.openapi.annotations.Operation; @@ -15,18 +11,13 @@ import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.finos.calm.domain.ValueWrapper; import org.finos.calm.domain.adr.Adr; import org.finos.calm.domain.adr.AdrMeta; -import org.finos.calm.domain.adr.Status; import org.finos.calm.domain.adr.NewAdrRequest; -import org.finos.calm.domain.ValueWrapper; -import org.finos.calm.domain.exception.AdrNotFoundException; -import org.finos.calm.domain.exception.AdrParseException; -import org.finos.calm.domain.exception.AdrPersistenceException; -import org.finos.calm.domain.exception.AdrRevisionNotFoundException; -import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.adr.Status; +import org.finos.calm.domain.exception.*; import org.finos.calm.security.CalmHubScopes; -import org.finos.calm.security.PermittedScopes; import org.finos.calm.store.AdrStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -67,7 +58,7 @@ public AdrResource(AdrStore store) { summary = "Retrieve ADRs in a given namespace", description = "ADRs stored in a given namespace" ) - @PermittedScopes({CalmHubScopes.ADRS_ALL, CalmHubScopes.ADRS_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getAdrsForNamespace( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -93,7 +84,7 @@ public Response getAdrsForNamespace( summary = "Create ADR for namespace", description = "Creates an ADR for a given namespace with an allocated ID and revision 1" ) - @PermittedScopes({CalmHubScopes.ADRS_ALL}) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response createAdrForNamespace( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, NewAdrRequest newAdrRequest @@ -133,7 +124,7 @@ public Response createAdrForNamespace( summary = "Update ADR for namespace", description = "Updates an ADR for a given namespace. Creates a new revision." ) - @PermittedScopes({CalmHubScopes.ADRS_ALL}) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response updateAdrForNamespace( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("adrId") int adrId, @@ -176,7 +167,7 @@ public Response updateAdrForNamespace( content = @Content(schema = @Schema(implementation = AdrMeta.class)) ) }) - @PermittedScopes({CalmHubScopes.ADRS_ALL, CalmHubScopes.ADRS_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getAdr( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("adrId") int adrId @@ -207,7 +198,7 @@ public Response getAdr( summary = "Retrieve a list of revisions for a given ADR", description = "The most recent revision is the canonical ADR, with others available for audit or exploring changes." ) - @PermittedScopes({CalmHubScopes.ADRS_ALL, CalmHubScopes.ADRS_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getAdrRevisions( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("adrId") int adrId @@ -245,7 +236,7 @@ public Response getAdrRevisions( content = @Content(schema = @Schema(implementation = AdrMeta.class)) ) }) - @PermittedScopes({CalmHubScopes.ADRS_ALL, CalmHubScopes.ADRS_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getAdrRevision( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("adrId") int adrId, @@ -279,7 +270,7 @@ public Response getAdrRevision( summary = "Update the status of ADR for namespace", description = "Updates the status of an ADR for a given namespace. Creates a new revision." ) - @PermittedScopes({CalmHubScopes.ADRS_ALL}) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response updateAdrStatusForNamespace( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("adrId") int adrId, diff --git a/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java b/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java index 4f1e2fc53..dedee7729 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java @@ -1,5 +1,7 @@ package org.finos.calm.resources; +import io.quarkus.security.Authenticated; +import io.quarkus.security.PermissionsAllowed; import com.fasterxml.jackson.core.JsonProcessingException; import jakarta.inject.Inject; import jakarta.validation.constraints.Pattern; @@ -23,7 +25,6 @@ import org.finos.calm.domain.exception.ArchitectureVersionNotFoundException; import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.security.CalmHubScopes; -import org.finos.calm.security.PermittedScopes; import org.finos.calm.services.ArchitectureTimelineService; import org.finos.calm.store.ArchitectureStore; import org.slf4j.Logger; @@ -42,6 +43,7 @@ * Resource for managing architectures in a given namespace */ @Path("/calm/namespaces") +@Authenticated public class ArchitectureResource { private final ArchitectureStore store; @@ -71,7 +73,7 @@ public ArchitectureResource(ArchitectureStore store, ArchitectureTimelineService summary = "Retrieve architectures in a given namespace", description = "Architecture stored in a given namespace" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getArchitecturesForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -91,7 +93,7 @@ public Response getArchitecturesForNamespace( summary = "Create architecture for namespace", description = "Creates a architecture for a given namespace with an allocated ID and version 1.0.0" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response createArchitectureForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @@ -122,7 +124,7 @@ public Response createArchitectureForNamespace( summary = "Retrieve a list of versions for a given architecture", description = "Architecture versions are not opinionated, outside of the first version created" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getArchitectureVersions( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId @@ -150,7 +152,7 @@ public Response getArchitectureVersions( summary = "Retrieve a specific architecture at a given version", description = "Retrieve architectures at a specific version" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getArchitecture( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId, @@ -179,7 +181,7 @@ public Response getArchitecture( @Path("{namespace}/architectures/{architectureId}/versions/{version}") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response createVersionedArchitecture( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId, @@ -218,7 +220,7 @@ public Response createVersionedArchitecture( summary = "Updates an architecture (if available)", description = "In mutable version stores architecture updates are supported by this endpoint, operation unavailable returned in repositories without configuration specified" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response updateVersionedArchitecture( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId, @@ -260,7 +262,7 @@ public Response updateVersionedArchitecture( + "architecture it is returned; otherwise an implied timeline projecting the architecture's " + "version history is returned." ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getArchitectureTimeline( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId diff --git a/calm-hub/src/main/java/org/finos/calm/resources/ControlResource.java b/calm-hub/src/main/java/org/finos/calm/resources/ControlResource.java index 317954fea..8bb1db6cb 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/ControlResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/ControlResource.java @@ -1,5 +1,6 @@ package org.finos.calm.resources; +import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; @@ -7,33 +8,21 @@ import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import org.bson.json.JsonParseException; import org.eclipse.microprofile.openapi.annotations.Operation; import org.finos.calm.domain.ValueWrapper; import org.finos.calm.domain.controls.ControlDetail; import org.finos.calm.domain.controls.CreateControlConfiguration; import org.finos.calm.domain.controls.CreateControlRequirement; -import org.finos.calm.domain.exception.ControlConfigurationNotFoundException; -import org.finos.calm.domain.exception.ControlConfigurationVersionExistsException; -import org.finos.calm.domain.exception.ControlConfigurationVersionNotFoundException; -import org.finos.calm.domain.exception.ControlNotFoundException; -import org.finos.calm.domain.exception.ControlRequirementVersionExistsException; -import org.finos.calm.domain.exception.ControlRequirementVersionNotFoundException; -import org.finos.calm.domain.exception.DomainNotFoundException; +import org.finos.calm.domain.exception.*; import org.finos.calm.security.CalmHubScopes; -import org.finos.calm.security.PermittedScopes; import org.finos.calm.store.ControlStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.bson.json.JsonParseException; - import java.net.URI; -import static org.finos.calm.resources.ResourceValidationConstants.DOMAIN_NAME_MESSAGE; -import static org.finos.calm.resources.ResourceValidationConstants.DOMAIN_NAME_REGEX; -import static org.finos.calm.resources.ResourceValidationConstants.STRICT_SANITIZATION_POLICY; -import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; -import static org.finos.calm.resources.ResourceValidationConstants.VERSION_REGEX; +import static org.finos.calm.resources.ResourceValidationConstants.*; /** * REST resource for managing controls within domains. @@ -57,7 +46,7 @@ public ControlResource(ControlStore store) { summary = "Retrieve controls for a given domain", description = "Controls stored in a given domain" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.DOMAIN_READ) public Response getControlsForDomain( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -78,7 +67,7 @@ public Response getControlsForDomain( summary = "Create a control requirement for a given domain", description = "Creates a new control requirement within the specified domain" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.DOMAIN_WRITE) public Response createControlForDomain( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -100,7 +89,7 @@ public Response createControlForDomain( summary = "Retrieve requirement versions for a control", description = "Returns the list of versions for a control requirement" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.DOMAIN_READ) public Response getRequirementVersions( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -124,7 +113,7 @@ public Response getRequirementVersions( summary = "Retrieve requirement at a specific version", description = "Returns the requirement JSON for a control at a given version" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.DOMAIN_READ) public Response getRequirementForVersion( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -155,7 +144,7 @@ public Response getRequirementForVersion( summary = "Create a new requirement version for a control", description = "Creates a new version of the requirement for an existing control. The request body is an envelope containing the wrapper-level `name`, `description`, and inner `requirementJson` document; only the inner document is persisted as the version contents, and the wrapper-level name/description used by the control listing endpoint are taken directly from the envelope fields." ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.DOMAIN_WRITE) public Response createRequirementForVersion( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -190,7 +179,7 @@ public Response createRequirementForVersion( summary = "Retrieve configurations for a control", description = "Returns the list of configuration IDs for a given control" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.DOMAIN_READ) public Response getConfigurationsForControl( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -215,7 +204,7 @@ public Response getConfigurationsForControl( summary = "Create a new configuration for a control", description = "Creates a new configuration within the specified control with an initial version 1.0.0" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.DOMAIN_WRITE) public Response createControlConfiguration( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -241,7 +230,7 @@ public Response createControlConfiguration( summary = "Retrieve versions for a control configuration", description = "Returns the list of versions for a specific control configuration" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.DOMAIN_READ) public Response getConfigurationVersions( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -269,7 +258,7 @@ public Response getConfigurationVersions( summary = "Retrieve a specific configuration version", description = "Returns the configuration JSON at a specific version" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.DOMAIN_READ) public Response getConfigurationForVersion( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -304,7 +293,7 @@ public Response getConfigurationForVersion( summary = "Create a new version of a control configuration", description = "Creates a new version of the configuration for an existing control configuration. The request body is an envelope containing the inner `configurationJson` document; only the inner document is persisted as the version contents." ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.DOMAIN_WRITE) public Response createConfigurationForVersion( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) diff --git a/calm-hub/src/main/java/org/finos/calm/resources/CoreSchemaResource.java b/calm-hub/src/main/java/org/finos/calm/resources/CoreSchemaResource.java index 728d8232d..436c8ab79 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/CoreSchemaResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/CoreSchemaResource.java @@ -1,20 +1,17 @@ package org.finos.calm.resources; +import io.quarkus.security.Authenticated; +import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.PathParam; -import jakarta.ws.rs.Produces; +import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.openapi.annotations.Operation; import org.finos.calm.domain.ValueWrapper; import org.finos.calm.security.CalmHubScopes; -import org.finos.calm.security.PermittedScopes; import org.finos.calm.store.CoreSchemaStore; import org.owasp.html.PolicyFactory; + import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; @@ -37,7 +34,7 @@ public CoreSchemaResource(CoreSchemaStore coreSchemaStore) { summary = "Published CALM Schema Versions", description = "Retrieve the CALM Schema versions published by this CALM Hub" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @Authenticated public ValueWrapper schemaVersions() { return new ValueWrapper<>(coreSchemaStore.getVersions()); } @@ -48,7 +45,7 @@ public ValueWrapper schemaVersions() { summary = "Published CALM Schemas for Version", description = "Retrieve the names of CALM Schemas in a given version" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @Authenticated public Response schemasForVersion(@PathParam("version") String version) { Map schemas = coreSchemaStore.getSchemasForVersion(version); if (schemas == null) { @@ -65,7 +62,7 @@ public Response schemasForVersion(@PathParam("version") String version) { summary = "Retrieve a specific schema by schema name", description = "Retrieve a specific schema from the CALM Hub" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @Authenticated public Response getSchema(@PathParam("version") String version, @PathParam("schemaName") String schemaName) { Map schemas = coreSchemaStore.getSchemasForVersion(version); @@ -89,7 +86,7 @@ public Response getSchema(@PathParam("version") String version, summary = "Create Schema Version", description = "Create a new schema version with associated schemas" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) public Response createSchemaVersion(SchemaVersionRequest request) throws URISyntaxException { if (request == null || request.getVersion() == null || request.getVersion().trim().isEmpty()) { return Response.status(Response.Status.BAD_REQUEST) diff --git a/calm-hub/src/main/java/org/finos/calm/resources/DecoratorResource.java b/calm-hub/src/main/java/org/finos/calm/resources/DecoratorResource.java index acfe93277..b84cd72db 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/DecoratorResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/DecoratorResource.java @@ -1,27 +1,19 @@ package org.finos.calm.resources; +import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.PUT; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.PathParam; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.bson.json.JsonParseException; import org.eclipse.microprofile.openapi.annotations.Operation; -import org.finos.calm.domain.Decorator; import org.finos.calm.domain.ValueWrapper; import org.finos.calm.domain.exception.DecoratorNotFoundException; import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.security.CalmHubScopes; -import org.finos.calm.security.PermittedScopes; import org.finos.calm.store.DecoratorStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,10 +22,7 @@ import java.net.URISyntaxException; import java.util.Map; -import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; -import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_REGEX; -import static org.finos.calm.resources.ResourceValidationConstants.QUERY_PARAM_NO_WHITESPACE_MESSAGE; -import static org.finos.calm.resources.ResourceValidationConstants.QUERY_PARAM_NO_WHITESPACE_REGEX; +import static org.finos.calm.resources.ResourceValidationConstants.*; /** * Resource for managing decorators in a given namespace @@ -64,7 +53,7 @@ public DecoratorResource(DecoratorStore decoratorStore) { summary = "Retrieve decorators in a given namespace", description = "Decorator IDs stored in a given namespace, optionally filtered by target and/or type" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getDecoratorsForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @QueryParam("target") @Size(max = 500) @Pattern(regexp = QUERY_PARAM_NO_WHITESPACE_REGEX, message = QUERY_PARAM_NO_WHITESPACE_MESSAGE) String target, @@ -93,7 +82,7 @@ public Response getDecoratorsForNamespace( summary = "Retrieve decorator values in a given namespace", description = "Decorator values stored in a given namespace, optionally filtered by target and/or type" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getDecoratorValuesForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @QueryParam("target") @Size(max = 500) @Pattern(regexp = QUERY_PARAM_NO_WHITESPACE_REGEX, message = QUERY_PARAM_NO_WHITESPACE_MESSAGE) String target, @@ -121,7 +110,7 @@ public Response getDecoratorValuesForNamespace( summary = "Retrieve a decorator by its ID in a given namespace", description = "A decorator stored in a given namespace" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getDecoratorById( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("id") @Min(value = 1, message = "ID must be a positive integer") int id @@ -151,7 +140,7 @@ public Response getDecoratorById( summary = "Create a decorator in a given namespace", description = "Creates a decorator, validating the namespace exists and the JSON is well-formed" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response createDecoratorForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, String decoratorJson @@ -185,7 +174,7 @@ public Response createDecoratorForNamespace( summary = "Update a decorator by ID in a given namespace", description = "Updates an existing decorator, validating the namespace and ID exist and the JSON is well-formed" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response updateDecoratorForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("id") @Min(value = 1, message = "ID must be a positive integer") int id, diff --git a/calm-hub/src/main/java/org/finos/calm/resources/DomainResource.java b/calm-hub/src/main/java/org/finos/calm/resources/DomainResource.java index 98bea2c51..465b5c7f7 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/DomainResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/DomainResource.java @@ -1,5 +1,7 @@ package org.finos.calm.resources; +import io.quarkus.security.Authenticated; +import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; @@ -11,7 +13,6 @@ import org.finos.calm.domain.ValueWrapper; import org.finos.calm.domain.exception.DomainAlreadyExistsException; import org.finos.calm.security.CalmHubScopes; -import org.finos.calm.security.PermittedScopes; import org.finos.calm.store.DomainStore; import java.net.URI; @@ -45,7 +46,7 @@ public DomainResource(DomainStore store) { summary = "Available Domains", description = "The available domains in this Calm Hub" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @Authenticated public Response getDomains() { return Response.ok(new ValueWrapper<>(store.getDomains())).build(); } @@ -62,7 +63,7 @@ public Response getDomains() { summary = "Create Domain", description = "Create a new domain in the Calm Hub" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) public Response createDomain(@Valid @NotNull(message = "Request must not be null") Domain domain) { String domainName = domain.getName(); diff --git a/calm-hub/src/main/java/org/finos/calm/resources/DomainUserAccessResource.java b/calm-hub/src/main/java/org/finos/calm/resources/DomainUserAccessResource.java new file mode 100644 index 000000000..c12328c27 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/resources/DomainUserAccessResource.java @@ -0,0 +1,101 @@ +package org.finos.calm.resources; + +import io.quarkus.security.PermissionsAllowed; +import jakarta.ws.rs.*; +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.UserAccessNotFoundException; +import org.finos.calm.security.CalmHubScopes; +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/domains") +public class DomainUserAccessResource { + + private final UserAccessStore store; + private final Logger logger = LoggerFactory.getLogger(DomainUserAccessResource.class); + + public DomainUserAccessResource(UserAccessStore userAccessStore) { + this.store = userAccessStore; + } + + @POST + @Path("{domain}/user-access") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Create user access for domain", + description = "Creates a user-access grant for a given domain" + ) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) // TODO domain level admin scope needed + public Response createUserAccessForDomain(@PathParam("domain") String domain, + UserAccess createUserAccessRequest) { + + createUserAccessRequest.setCreationDateTime(LocalDateTime.now()); + createUserAccessRequest.setUpdateDateTime(LocalDateTime.now()); + + if (!domain.equals(createUserAccessRequest.getDomain())) { + logger.error("Request contains an invalid domain [{}]", createUserAccessRequest.getDomain()); + return Response.status(Response.Status.BAD_REQUEST) + .entity("Bad Request").build(); + } + try { + return locationResponse(store.createUserAccessForDomain(createUserAccessRequest)); + } catch (URISyntaxException ex) { + logger.error("Failed to create user-access for domain: [{}]", domain, ex); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity("System Malfunction failed to create user-access").build(); + } + } + + @GET + @Path("{domain}/user-access") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Get user-access for a given domain", + description = "Get user-access details for a given domain" + ) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) + public Response getUserAccessForDomain(@PathParam("domain") String domain) { + try { + return Response.ok(store.getUserAccessForDomain(domain)).build(); + } catch (UserAccessNotFoundException ex) { + logger.error("User-access details not found for domain [{}]", domain, ex); + return Response.status(Response.Status.NOT_FOUND) + .entity("No access permissions found") + .build(); + } + } + + @GET + @Path("{domain}/user-access/{userAccessId}") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Get the user-access record for a given domain and Id", + description = "Get user-access details for a given domain and Id" + ) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) + public Response getUserAccessForDomainAndId(@PathParam("domain") String domain, + @PathParam("userAccessId") Integer userAccessId) { + try { + return Response.ok(store.getUserAccessForDomainAndId(domain, userAccessId)).build(); + } catch (UserAccessNotFoundException ex) { + logger.error("User-access details not found for domain [{}] id [{}]", domain, userAccessId, 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/domains/%s/user-access/%s", userAccess.getDomain(), userAccess.getUserAccessId()))) + .build(); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java b/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java index 4ac8da57e..f34020308 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java @@ -1,5 +1,6 @@ package org.finos.calm.resources; +import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; @@ -10,14 +11,14 @@ import org.bson.json.JsonParseException; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.openapi.annotations.Operation; -import org.finos.calm.domain.*; -import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.Flow; +import org.finos.calm.domain.ValueWrapper; import org.finos.calm.domain.exception.FlowNotFoundException; import org.finos.calm.domain.exception.FlowVersionExistsException; import org.finos.calm.domain.exception.FlowVersionNotFoundException; +import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.flow.CreateFlowRequest; import org.finos.calm.security.CalmHubScopes; -import org.finos.calm.security.PermittedScopes; import org.finos.calm.store.FlowStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -54,7 +55,7 @@ public FlowResource(FlowStore store) { summary = "Retrieve flows in a given namespace", description = "Flows stored in a given namespace" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getFlowsForNamespace( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -74,7 +75,7 @@ public Response getFlowsForNamespace( summary = "Create flow for namespace", description = "Creates a flow for a given namespace with an allocated ID and version 1.0.0" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response createFlowForNamespace( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @Valid @NotNull(message = "Request must not be null") CreateFlowRequest flowRequest @@ -98,7 +99,7 @@ public Response createFlowForNamespace( summary = "Retrieve the latest flow version", description = "Fetch the latest version of the flow by flowId" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getLatestFlow( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("flowId") int flowId @@ -129,7 +130,7 @@ public Response getLatestFlow( summary = "Retrieve a list of versions for a given flow", description = "Flow versions are not opinionated, outside of the first version created" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getFlowVersions( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("flowId") int flowId @@ -157,7 +158,7 @@ public Response getFlowVersions( summary = "Retrieve a specific flow at a given version", description = "Retrieve flows at a specific version" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getFlow( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("flowId") int flowId, @@ -191,11 +192,11 @@ private Response getFlowInternal(String namespace, int flowId, String version) { @Path("{namespace}/flows/{flowId}/versions/{version}") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) + @PermissionsAllowed(CalmHubScopes.WRITE) @Operation( summary = "Create a new version of an existing flow", description = "Stores a new version of the flow under the supplied {version}. The request body is an envelope containing `name`, optional `description`, and the inner `flowJson` document; only the inner document is persisted as the version contents." ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) public Response createVersionedFlow( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("flowId") int flowId, @@ -234,7 +235,7 @@ public Response createVersionedFlow( summary = "Updates a Flow (if available)", description = "In mutable version stores flow updates are supported by this endpoint, operation unavailable returned in repositories without configuration specified. The request body uses the same envelope as POST." ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response updateVersionedFlow( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("flowId") int flowId, diff --git a/calm-hub/src/main/java/org/finos/calm/resources/FrontControllerResource.java b/calm-hub/src/main/java/org/finos/calm/resources/FrontControllerResource.java index 591915ab2..123785476 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/FrontControllerResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/FrontControllerResource.java @@ -4,27 +4,22 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.openapi.annotations.Operation; import org.finos.calm.domain.*; -import org.finos.calm.domain.Semver; -import org.finos.calm.domain.architecture.ArchitectureRequest; import org.finos.calm.domain.exception.*; import org.finos.calm.domain.flow.CreateFlowRequest; -import org.finos.calm.domain.frontcontroller.ChangeType; import org.finos.calm.domain.frontcontroller.FrontControllerCreateRequest; import org.finos.calm.domain.frontcontroller.FrontControllerUpdateRequest; import org.finos.calm.domain.interfaces.CreateInterfaceRequest; import org.finos.calm.domain.pattern.CreatePatternRequest; import org.finos.calm.domain.standards.CreateStandardRequest; import org.finos.calm.security.CalmHubScopes; -import org.finos.calm.security.PermittedScopes; import org.finos.calm.store.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -83,7 +78,7 @@ public FrontControllerResource(ResourceMappingStore mappingStore, summary = "Create or update a resource by custom ID", description = "First POST creates the resource at version 1.0.0. Subsequent POSTs require a changeType to bump the version." ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response createOrUpdateResource( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("customId") @jakarta.validation.constraints.Pattern(regexp = CUSTOM_ID_REGEX, message = CUSTOM_ID_MESSAGE) String customId, @@ -107,7 +102,7 @@ public Response createOrUpdateResource( summary = "Get the latest version of a resource by custom ID", description = "Resolves the custom ID to a resource and returns the latest version" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getLatestResource( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("customId") @jakarta.validation.constraints.Pattern(regexp = CUSTOM_ID_REGEX, message = CUSTOM_ID_MESSAGE) String customId @@ -142,7 +137,7 @@ public Response getLatestResource( summary = "Get a specific version of a resource by custom ID", description = "Resolves the custom ID and returns the resource at the specified version" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getResourceVersion( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("customId") @jakarta.validation.constraints.Pattern(regexp = CUSTOM_ID_REGEX, message = CUSTOM_ID_MESSAGE) String customId, @@ -175,7 +170,7 @@ public Response getResourceVersion( summary = "List versions of a resource by custom ID", description = "Resolves the custom ID and returns all available versions" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response listResourceVersions( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("customId") @jakarta.validation.constraints.Pattern(regexp = CUSTOM_ID_REGEX, message = CUSTOM_ID_MESSAGE) String customId @@ -207,7 +202,7 @@ public Response listResourceVersions( summary = "Look up resource mappings", description = "Returns all resource mappings for a namespace, optionally filtered by type and/or numeric ID" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response lookupMappings( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @QueryParam("type") String type, diff --git a/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java b/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java index 2d2cb5497..ed9ac1f5c 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/InterfaceResource.java @@ -1,5 +1,6 @@ package org.finos.calm.resources; +import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; @@ -16,7 +17,6 @@ import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.interfaces.CreateInterfaceRequest; import org.finos.calm.security.CalmHubScopes; -import org.finos.calm.security.PermittedScopes; import org.finos.calm.store.InterfaceStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,11 +24,7 @@ import java.net.URI; import java.net.URISyntaxException; -import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; -import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_REGEX; -import static org.finos.calm.resources.ResourceValidationConstants.STRICT_SANITIZATION_POLICY; -import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; -import static org.finos.calm.resources.ResourceValidationConstants.VERSION_REGEX; +import static org.finos.calm.resources.ResourceValidationConstants.*; @Path("/calm/namespaces") public class InterfaceResource { @@ -45,7 +41,7 @@ public InterfaceResource(InterfaceStore interfaceStore) { @GET @Path("{namespace}/interfaces") @Produces(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getInterfacesForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -61,7 +57,7 @@ public Response getInterfacesForNamespace( @Path("{namespace}/interfaces") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response createInterfaceForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @Valid @NotNull(message = "Request must not be null") CreateInterfaceRequest interfaceRequest @@ -81,7 +77,7 @@ public Response createInterfaceForNamespace( @GET @Path("{namespace}/interfaces/{interfaceId}/versions") @Produces(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getInterfaceVersions( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("interfaceId") Integer interfaceId @@ -100,7 +96,7 @@ public Response getInterfaceVersions( @GET @Path("{namespace}/interfaces/{interfaceId}/versions/{version}") @Produces(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getInterfaceForVersion( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("interfaceId") Integer interfaceId, @@ -124,7 +120,7 @@ public Response getInterfaceForVersion( @Path("{namespace}/interfaces/{interfaceId}/versions/{version}") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response createInterfaceForVersion( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("interfaceId") Integer interfaceId, diff --git a/calm-hub/src/main/java/org/finos/calm/resources/NamespaceResource.java b/calm-hub/src/main/java/org/finos/calm/resources/NamespaceResource.java index dfd9c9916..8359c4bd4 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/NamespaceResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/NamespaceResource.java @@ -1,13 +1,11 @@ package org.finos.calm.resources; +import io.quarkus.security.Authenticated; +import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; +import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.openapi.annotations.Operation; @@ -16,7 +14,6 @@ import org.finos.calm.domain.exception.NamespaceAlreadyExistsException; import org.finos.calm.domain.namespaces.NamespaceInfo; import org.finos.calm.security.CalmHubScopes; -import org.finos.calm.security.PermittedScopes; import org.finos.calm.store.NamespaceStore; import java.net.URI; @@ -37,8 +34,7 @@ public NamespaceResource(NamespaceStore store) { summary = "Available Namespaces", description = "The available namespaces available in this Calm Hub" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, - CalmHubScopes.ARCHITECTURES_READ, CalmHubScopes.ADRS_ALL, CalmHubScopes.ADRS_READ}) + @Authenticated public ValueWrapper namespaces() { return new ValueWrapper<>(namespaceStore.getNamespaces()); } @@ -50,7 +46,7 @@ public ValueWrapper namespaces() { summary = "Create Namespace", description = "Create a new namespace in the Calm Hub" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) public Response createNamespace(@Valid @NotNull(message = "Request must not be null") NamespaceRequest request) throws URISyntaxException { String name = request.getName().trim(); diff --git a/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java b/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java index 7edbadfc5..a360f156f 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java @@ -1,5 +1,6 @@ package org.finos.calm.resources; +import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; @@ -9,14 +10,14 @@ import org.bson.json.JsonParseException; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.openapi.annotations.Operation; -import org.finos.calm.domain.*; +import org.finos.calm.domain.Pattern; +import org.finos.calm.domain.ValueWrapper; import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.exception.PatternNotFoundException; import org.finos.calm.domain.exception.PatternVersionExistsException; import org.finos.calm.domain.exception.PatternVersionNotFoundException; import org.finos.calm.domain.pattern.CreatePatternRequest; import org.finos.calm.security.CalmHubScopes; -import org.finos.calm.security.PermittedScopes; import org.finos.calm.store.PatternStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,7 +53,7 @@ public PatternResource(PatternStore store) { summary = "Retrieve patterns in a given namespace", description = "Patterns stored in a given namespace" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getPatternsForNamespace( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -72,7 +73,7 @@ public Response getPatternsForNamespace( summary = "Create pattern for namespace", description = "Creates a pattern for a given namespace with an allocated ID and version 1.0.0" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response createPatternForNamespace( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @Valid @NotNull(message = "Request must not be null") CreatePatternRequest patternRequest @@ -95,7 +96,7 @@ public Response createPatternForNamespace( summary = "Retrieve a list of versions for a given pattern", description = "Pattern versions are not opinionated, outside of the first version created" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getPatternVersions( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("patternId") int patternId @@ -124,7 +125,7 @@ public Response getPatternVersions( summary = "Retrieve a specific pattern at a given version", description = "Retrieve patterns at a specific version" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getPattern( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("patternId") int patternId, @@ -158,7 +159,7 @@ public Response getPattern( summary = "Create a new version of an existing pattern", description = "Stores a new version of the pattern under the supplied {version}. The request body is an envelope containing the wrapper-level `name`, optional `description`, and the inner CALM `patternJson` document; only the inner document is persisted as the version contents, and the wrapper-level name/description used by the pattern listing endpoint are taken directly from the envelope fields." ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response createVersionedPattern( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("patternId") int patternId, @@ -197,7 +198,7 @@ public Response createVersionedPattern( summary = "Updates a Pattern (if available)", description = "In mutable version stores pattern updates are supported by this endpoint, operation unavailable returned in repositories without configuration specified. The request body is the same envelope used by POST: only the inner `patternJson` is persisted as the new version contents, and the wrapper-level `name`/`description` shown by the listing endpoint are taken from the envelope fields." ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed({CalmHubScopes.WRITE}) public Response updateVersionedPattern( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("patternId") int patternId, diff --git a/calm-hub/src/main/java/org/finos/calm/resources/SearchResource.java b/calm-hub/src/main/java/org/finos/calm/resources/SearchResource.java index e5f2498f9..8d3f92605 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/SearchResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/SearchResource.java @@ -1,18 +1,20 @@ package org.finos.calm.resources; +import io.quarkus.security.Authenticated; import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.openapi.annotations.Operation; import org.finos.calm.domain.search.GroupedSearchResults; -import org.finos.calm.security.CalmHubScopes; -import org.finos.calm.security.PermittedScopes; import org.finos.calm.security.UserAccessValidator; import org.finos.calm.store.SearchStore; import org.slf4j.Logger; @@ -31,14 +33,20 @@ public class SearchResource { private final SearchStore searchStore; private final Instance userAccessValidatorInstance; private final Instance jwtInstance; + private final String proxyUsernameHeader; + + @Context + HttpHeaders httpHeaders; @Inject public SearchResource(SearchStore searchStore, Instance userAccessValidatorInstance, - Instance jwtInstance) { + Instance jwtInstance, + @ConfigProperty(name = "calm.security.proxy.username-header", defaultValue = "Remote-User") String proxyUsernameHeader) { this.searchStore = searchStore; this.userAccessValidatorInstance = userAccessValidatorInstance; this.jwtInstance = jwtInstance; + this.proxyUsernameHeader = proxyUsernameHeader; } @GET @@ -47,7 +55,7 @@ public SearchResource(SearchStore searchStore, summary = "Global Search", description = "Search across all resource types (architectures, patterns, flows, standards, interfaces, controls, ADRs) with results grouped by type" ) - @PermittedScopes({CalmHubScopes.SEARCH_READ}) + @Authenticated public Response search(@QueryParam("q") String query) { if (query == null || query.isBlank()) { return Response.status(Response.Status.BAD_REQUEST) @@ -76,13 +84,20 @@ public Response search(@QueryParam("q") String query) { /** * Returns the set of namespaces the current caller is permitted to read, or * {@link Optional#empty()} when no namespace-based filtering should be applied - * (i.e. the secure profile is not active or the JWT has no username). + * (i.e. the secure profile is not active or the JWT has no username in the event of JWT validation, or + * simply no remote user header for the proxy profile.) */ private Optional> resolveReadableNamespaces() { - if (!userAccessValidatorInstance.isResolvable() || !jwtInstance.isResolvable()) { + if (!userAccessValidatorInstance.isResolvable()) { return Optional.empty(); } - String username = jwtInstance.get().getClaim("preferred_username"); + String username = null; + if (jwtInstance.isResolvable()) { + username = jwtInstance.get().getClaim("preferred_username"); + } + if (username == null && httpHeaders != null) { + username = httpHeaders.getHeaderString(proxyUsernameHeader); + } if (username == null) { return Optional.empty(); } diff --git a/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java b/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java index 2de36b583..d977253ac 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java @@ -1,5 +1,6 @@ package org.finos.calm.resources; +import io.quarkus.security.PermissionsAllowed; import jakarta.validation.constraints.Pattern; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; @@ -12,7 +13,6 @@ import org.finos.calm.domain.exception.StandardVersionNotFoundException; import org.finos.calm.domain.standards.CreateStandardRequest; import org.finos.calm.security.CalmHubScopes; -import org.finos.calm.security.PermittedScopes; import org.finos.calm.store.StandardStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,10 +20,7 @@ import java.net.URI; import java.net.URISyntaxException; -import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; -import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_REGEX; -import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; -import static org.finos.calm.resources.ResourceValidationConstants.VERSION_REGEX; +import static org.finos.calm.resources.ResourceValidationConstants.*; @Path("/calm/namespaces") public class StandardResource { @@ -39,7 +36,7 @@ public StandardResource(StandardStore standardStore) { @GET @Path("{namespace}/standards") @Produces(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getStandardsForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -55,7 +52,7 @@ public Response getStandardsForNamespace( @Path("{namespace}/standards") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response createStandardForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, CreateStandardRequest standard @@ -72,7 +69,7 @@ public Response createStandardForNamespace( @GET @Path("{namespace}/standards/{standardId}/versions") @Produces(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getStandardVersions( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("standardId") Integer standardId @@ -91,7 +88,7 @@ public Response getStandardVersions( @GET @Path("{namespace}/standards/{standardId}/versions/{version}") @Produces(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getStandardForVersion( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("standardId") Integer standardId, @@ -115,7 +112,7 @@ public Response getStandardForVersion( @Path("{namespace}/standards/{standardId}/versions/{version}") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response createStandardForVersion( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("standardId") Integer standardId, diff --git a/calm-hub/src/main/java/org/finos/calm/resources/TimelineResource.java b/calm-hub/src/main/java/org/finos/calm/resources/TimelineResource.java index 64fc7097e..3f7767417 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/TimelineResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/TimelineResource.java @@ -1,5 +1,6 @@ package org.finos.calm.resources; +import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; @@ -18,7 +19,6 @@ import org.finos.calm.domain.timeline.CreateTimelineRequest; import org.finos.calm.domain.timeline.Timeline; import org.finos.calm.security.CalmHubScopes; -import org.finos.calm.security.PermittedScopes; import org.finos.calm.store.TimelineStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,11 +26,7 @@ import java.net.URI; import java.net.URISyntaxException; -import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; -import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_REGEX; -import static org.finos.calm.resources.ResourceValidationConstants.STRICT_SANITIZATION_POLICY; -import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; -import static org.finos.calm.resources.ResourceValidationConstants.VERSION_REGEX; +import static org.finos.calm.resources.ResourceValidationConstants.*; /** * Resource for managing explicit timelines in a given namespace. @@ -57,7 +53,7 @@ public TimelineResource(TimelineStore store) { summary = "Retrieve timelines in a given namespace", description = "Timelines stored in a given namespace" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getTimelinesForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -77,7 +73,7 @@ public Response getTimelinesForNamespace( summary = "Create timeline for namespace", description = "Creates a timeline for a given namespace with an allocated ID and version 1.0.0" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.READ) public Response createTimelineForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @Valid @NotNull(message = "Request must not be null") CreateTimelineRequest timelineRequest @@ -101,7 +97,7 @@ public Response createTimelineForNamespace( summary = "Retrieve a list of versions for a given timeline", description = "Timeline versions are not opinionated, outside of the first version created" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getTimelineVersions( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("timelineId") int timelineId @@ -129,7 +125,7 @@ public Response getTimelineVersions( summary = "Retrieve a specific timeline at a given version", description = "Retrieve timelines at a specific version" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.READ) public Response getTimeline( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("timelineId") int timelineId, @@ -163,7 +159,7 @@ public Response getTimeline( summary = "Create a new version of an existing timeline", description = "Stores a new version of the timeline under the supplied {version}. The request body is an envelope containing `name`, optional `description`, and the inner `timelineJson` document; only the inner document is persisted as the version contents." ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response createVersionedTimeline( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("timelineId") int timelineId, @@ -202,7 +198,7 @@ public Response createVersionedTimeline( summary = "Updates a Timeline (if available)", description = "In mutable version stores timeline updates are supported by this endpoint, operation unavailable returned in repositories without configuration specified. The request body uses the same envelope as POST." ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response updateVersionedTimeline( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("timelineId") int timelineId, 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 index 6fc641fe2..2ec331cbe 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/UserAccessResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/UserAccessResource.java @@ -1,11 +1,7 @@ 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 io.quarkus.security.PermissionsAllowed; +import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.openapi.annotations.Operation; @@ -13,7 +9,6 @@ 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; @@ -40,7 +35,7 @@ public UserAccessResource(UserAccessStore userAccessStore) { summary = "Create user access for namespace", description = "Creates a user-access for a given namespace on a particular resource type" ) - @PermittedScopes({CalmHubScopes.NAMESPACE_ADMIN}) + @PermissionsAllowed(CalmHubScopes.ADMIN) public Response createUserAccessForNamespace(@PathParam("namespace") String namespace, UserAccess createUserAccessRequest) { @@ -70,7 +65,7 @@ public Response createUserAccessForNamespace(@PathParam("namespace") String name summary = "Get user-access for a given namespace", description = "Get user-access details for a given namespace" ) - @PermittedScopes({CalmHubScopes.NAMESPACE_ADMIN}) + @PermissionsAllowed(CalmHubScopes.ADMIN) public Response getUserAccessForNamespace(@PathParam("namespace") String namespace) { try { @@ -94,7 +89,7 @@ public Response getUserAccessForNamespace(@PathParam("namespace") String namespa 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}) + @PermissionsAllowed(CalmHubScopes.ADMIN) public Response getUserAccessForNamespaceAndId(@PathParam("namespace") String namespace, @PathParam("userAccessId") Integer userAccessId) { 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 deleted file mode 100644 index bc79be2cc..000000000 --- a/calm-hub/src/main/java/org/finos/calm/security/AccessControlFilter.java +++ /dev/null @@ -1,125 +0,0 @@ -package org.finos.calm.security; - - -import io.quarkus.arc.profile.IfBuildProfile; -import io.quarkus.runtime.util.StringUtil; -import jakarta.annotation.Priority; -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.ws.rs.container.ContainerRequestContext; -import jakarta.ws.rs.container.ContainerRequestFilter; -import jakarta.ws.rs.container.ResourceInfo; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.ext.Provider; -import org.eclipse.microprofile.jwt.JsonWebToken; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.List; -import java.util.Objects; - - -/** - * This filter is responsible for validating incoming JWT tokens and enforcing access control rules - * based on OAuth 2.0 authorization flows and RBAC (Role-Based Access Control). - * - * The Resource Server supports: - * 1. **Authorization Code Flow / Device Code Flow (End-User Authentication)** - * - Tokens issued for an authenticated user **must undergo additional RBAC checks** - * to ensure the user has the necessary permissions. - * - OAuth scopes provide high-level access control, but **RBAC is required for fine-grained permissions**. - * - * 🔹 **TODO: Implement RBAC checks to enforce role-based access control on top of scopes.** - * - * Why RBAC? - * - Scopes define **what** actions a user can perform but do not control **who** can perform them. - * - RBAC ensures that even if a user has a valid scope, their role (e.g., Admin, Viewer) - * dictates whether they can execute the request. - * - * This filter currently validates JWT tokens and scopes. **RBAC enforcement is a pending task.** - */ -@ApplicationScoped -@Provider -@Priority(1) -@IfBuildProfile("secure") -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, - UserAccessValidator userAccessValidator) { - this.jwt = jwt; - this.resourceInfo = resourceInfo; - this.userAccessValidator = userAccessValidator; - } - - @Override - public void filter(ContainerRequestContext requestContext) { - PermittedScopes annotation = resourceInfo.getResourceMethod().getAnnotation(PermittedScopes.class); - if (Objects.isNull(annotation)) { - logger.warn("Unsecured endpoint accessed: {}", resourceInfo.getResourceMethod()); - return; - } - - String[] requiredScopes = annotation.value(); - String tokenScopes = jwt.getClaim("scope"); - - if (StringUtil.isNullOrEmpty(tokenScopes) || !hasRequiredScope(tokenScopes, requiredScopes)) { - 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()); - } - } - - /** - * Check if the JWT token has at least one of the required scopes. - * - * @param tokenScopes The scopes in the JWT token. - * @param requiredScopes The scopes required for the resource. - * @return true if the token contains one of the required scopes, false otherwise. - */ - private boolean hasRequiredScope(String tokenScopes, String[] requiredScopes) { - List requiredScopesList = List.of(requiredScopes); - boolean hasMatch = requiredScopesList.stream() - .anyMatch(tokenScopes::contains); - - if (hasMatch) { - 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); - } - return hasMatch; - } -} \ No newline at end of file diff --git a/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java b/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java new file mode 100644 index 000000000..2a34ff698 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java @@ -0,0 +1,132 @@ +package org.finos.calm.security; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.finos.calm.domain.UserAccess; +import org.finos.calm.domain.UserAction; +import org.finos.calm.domain.exception.UserAccessNotFoundException; +import org.finos.calm.store.UserAccessStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.function.Predicate; + + +@ApplicationScoped +public class CalmHubPermissionChecker { + private static final Logger logger = LoggerFactory.getLogger(CalmHubPermissionChecker.class); + + private final UserAccessStore userAccessStore; + + @Inject + @ConfigProperty(name = "calm.hub.no.auth.enabled", defaultValue = "false") + boolean noAuthEnabled; + + @Inject + @ConfigProperty(name = "calm.hub.allow.public.read", defaultValue = "false") + boolean allowPublicRead; + + public CalmHubPermissionChecker(UserAccessStore userAccessStore) { + this.userAccessStore = userAccessStore; + } + + @PermissionChecker(CalmHubScopes.ADMIN) + public boolean allowNamespaceAdmin(SecurityIdentity identity, String namespace) { + if (noAuthEnabled) return true; + return hasNamespaceAccess(identity, namespace, UserAction.ADMIN); + } + + @PermissionChecker(CalmHubScopes.READ) + public boolean canRead(SecurityIdentity identity, String namespace) { + if (noAuthEnabled) return true; + if (allowPublicRead) return true; + return hasNamespaceAccess(identity, namespace, UserAction.READ); + } + + @PermissionChecker(CalmHubScopes.DOMAIN_READ) + public boolean canReadByDomain(SecurityIdentity identity, String domain) { + if (noAuthEnabled) return true; + if (allowPublicRead) return true; + return hasDomainAccess(identity, domain, UserAction.READ); + } + + @PermissionChecker(CalmHubScopes.WRITE) + public boolean canWrite(SecurityIdentity identity, String namespace) { + return noAuthEnabled + || hasNamespaceAccess(identity, namespace, UserAction.WRITE); + } + + @PermissionChecker(CalmHubScopes.DOMAIN_WRITE) + public boolean canWriteByDomain(SecurityIdentity identity, String domain) { + return noAuthEnabled + || hasDomainAccess(identity, domain, UserAction.WRITE); + } + + @PermissionChecker(CalmHubScopes.GLOBAL_ADMIN) + public boolean hasGlobalAdmin(SecurityIdentity identity) { + if (noAuthEnabled) { + logger.warn("CalmHub is running with no authentication. Granting user access unconditionally."); + return true; + } + String username = identity.getPrincipal().getName(); + logger.debug("Checking global admin access for user [{}]", username); + try { + boolean granted = + userAccessStore.getUserAccessForUsername(username) + .stream() + .anyMatch(grant -> "GLOBAL".equals(grant.getNamespace()) + && permissionSufficient(grant, UserAction.ADMIN)); + + if (granted) { + logger.info("User [{}] AUTHORIZED for GLOBAL admin privileges", username); + } else { + logger.warn("User [{}] DENIED for GLOBAL admin privileges", username); + } + return granted; + } catch (UserAccessNotFoundException e) { + logger.debug("No access grants found for user [{}]", username); + return false; + } + } + + private boolean hasNamespaceAccess(SecurityIdentity identity, String namespace, UserAction action) { + return hasAccess(identity, "namespace", namespace, action, + grant -> namespace != null && namespace.equals(grant.getNamespace())); + } + + private boolean hasDomainAccess(SecurityIdentity identity, String domain, UserAction action) { + return hasAccess(identity, "domain", domain, action, + grant -> domain != null && domain.equals(grant.getDomain())); + } + + private boolean hasAccess(SecurityIdentity identity, String scopeType, String scopeValue, + UserAction action, Predicate grantMatcher) { + String username = identity.getPrincipal().getName(); + logger.debug("Checking {} access for user [{}] on {} [{}] action=[{}]", + scopeType, username, scopeType, scopeValue, action); + try { + boolean result = userAccessStore.getUserAccessForUsername(username).stream() + .anyMatch(grant -> grantMatcher.test(grant) && permissionSufficient(grant, action)); + if (result) { + logger.info("User [{}] AUTHORIZED for [{}] in {} [{}]", username, action, scopeType, scopeValue); + } else { + logger.warn("User [{}] DENIED for [{}] in {} [{}]", username, action, scopeType, scopeValue); + } + return result; + } catch (UserAccessNotFoundException e) { + logger.debug("No access grants found for user [{}]", username); + return false; + } + } + + private boolean permissionSufficient(UserAccess grant, UserAction action) { + return switch (grant.getPermission()) { + case read -> action == UserAction.READ; + case write -> action == UserAction.READ || action == UserAction.WRITE; + case admin -> true; + }; + } +} 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 78d307cee..0a52bbb63 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 @@ -9,33 +9,12 @@ private CalmHubScopes() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); } - /** - * Allows read operations on Flows, Patterns, Namespaces, and Architectures resources. - */ - public static final String ARCHITECTURES_READ = "architectures:read"; - - /** - * Allows full access (read, write, delete) on Flows, Patterns, Namespaces, and Architectures resources. - */ - public static final String ARCHITECTURES_ALL = "architectures:all"; + public static final String READ = "read"; + public static final String WRITE = "write"; + public static final String ADMIN = "admin"; - /** - * Allows read operations on Adrs and Namespaces resources. - */ - public static final String ADRS_READ = "adrs:read"; + public static final String DOMAIN_READ = "domain_read"; + public static final String DOMAIN_WRITE = "domain_write"; - /** - * Allows full access (read, write, delete) on Adrs and read operation on Namespaces. - */ - public static final String ADRS_ALL = "adrs:all"; - - /** - * Allows read operations on the Search endpoint. Results are filtered based on user access grants. - */ - public static final String SEARCH_READ = "search:read"; - - /** - * Allow to grant access to users on namespace associated resources and for the admin operations. - */ - public static final String NAMESPACE_ADMIN = "namespace:admin"; + public static final String GLOBAL_ADMIN = "global_admin"; } diff --git a/calm-hub/src/main/java/org/finos/calm/security/NoAuthAuthenticationMechanism.java b/calm-hub/src/main/java/org/finos/calm/security/NoAuthAuthenticationMechanism.java new file mode 100644 index 000000000..c469fc262 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/security/NoAuthAuthenticationMechanism.java @@ -0,0 +1,48 @@ +package org.finos.calm.security; + +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.TrustedAuthenticationRequest; +import io.quarkus.security.runtime.QuarkusPrincipal; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Optional; + +/** + * When calm.hub.no.auth.enabled=true (the default profile), every request is given a + * non-anonymous identity so that Quarkus calls the @PermissionChecker methods rather than + * short-circuiting with 401. The checkers then grant access unconditionally via the same flag. + * + * In secure and proxy-auth profiles this mechanism is disabled via config and is a no-op. + */ +@ApplicationScoped +public class NoAuthAuthenticationMechanism implements HttpAuthenticationMechanism { + + private static final Logger logger = LoggerFactory.getLogger(NoAuthAuthenticationMechanism.class); + static final String NO_AUTH_PRINCIPAL = "no-auth"; + + @ConfigProperty(name = "calm.hub.no.auth.enabled", defaultValue = "false") + boolean noAuthEnabled; + + @Override + public Uni authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) { + if (!noAuthEnabled) { + return Uni.createFrom().optional(Optional.empty()); + } + logger.debug("No-auth mode: granting open identity to request for {}", context.request().path()); + return identityProviderManager.authenticate(new TrustedAuthenticationRequest(NO_AUTH_PRINCIPAL)); + } + + @Override + public Uni getChallenge(RoutingContext context) { + return Uni.createFrom().nullItem(); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/security/PermittedScopes.java b/calm-hub/src/main/java/org/finos/calm/security/PermittedScopes.java deleted file mode 100644 index b30afe83b..000000000 --- a/calm-hub/src/main/java/org/finos/calm/security/PermittedScopes.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.finos.calm.security; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Custom annotation to define required scopes for a REST endpoint. - */ -@Target({ ElementType.METHOD, ElementType.TYPE }) -@Retention(RetentionPolicy.RUNTIME) -public @interface PermittedScopes { - String[] value(); -} \ No newline at end of file diff --git a/calm-hub/src/main/java/org/finos/calm/security/ProxyAuthenticationMechanism.java b/calm-hub/src/main/java/org/finos/calm/security/ProxyAuthenticationMechanism.java new file mode 100644 index 000000000..6bb4090fb --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/security/ProxyAuthenticationMechanism.java @@ -0,0 +1,48 @@ +package org.finos.calm.security; + +import io.netty.handler.codec.http.HttpResponseStatus; +import io.quarkus.arc.profile.IfBuildProfile; +import io.quarkus.oidc.AccessTokenCredential; +import io.quarkus.oidc.BearerTokenAuthentication; +import io.quarkus.runtime.util.StringUtil; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.TrustedAuthenticationRequest; +import io.quarkus.security.runtime.QuarkusPrincipal; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Optional; + +@ApplicationScoped +@IfBuildProfile("proxy-auth") +public class ProxyAuthenticationMechanism implements HttpAuthenticationMechanism { + private static final Logger logger = LoggerFactory.getLogger(ProxyAuthenticationMechanism.class); + + @ConfigProperty(name = "calm.security.proxy.username-header", defaultValue = "Remote-User") + String usernameHeader; + + @Override + public Uni authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) { + String username = context.request().getHeader(usernameHeader); + if (StringUtil.isNullOrEmpty(username)) { + logger.error("REJECTING request with missing proxy authentication header {}. Path: {}", usernameHeader, context.request().path()); + return Uni.createFrom().optional(Optional.empty()); + } + logger.info("Authenticating user {} via proxy authentication header {}", username, usernameHeader); + return identityProviderManager.authenticate(new TrustedAuthenticationRequest(username)); + } + + @Override + public Uni getChallenge(RoutingContext context) { + return Uni.createFrom().item( + new ChallengeData(HttpResponseStatus.UNAUTHORIZED.code(), null, null)); + } +} diff --git a/calm-hub/src/main/java/org/finos/calm/security/ProxyIdentityProvider.java b/calm-hub/src/main/java/org/finos/calm/security/ProxyIdentityProvider.java new file mode 100644 index 000000000..8baa71f3d --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/security/ProxyIdentityProvider.java @@ -0,0 +1,32 @@ +package org.finos.calm.security; + +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.TrustedAuthenticationRequest; +import io.quarkus.security.runtime.QuarkusPrincipal; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ApplicationScoped +public class ProxyIdentityProvider implements IdentityProvider { + Logger logger = LoggerFactory.getLogger(ProxyIdentityProvider.class); + + @Override + public Class getRequestType() { + return TrustedAuthenticationRequest.class; + } + + @Override + public Uni authenticate(TrustedAuthenticationRequest request, + AuthenticationRequestContext context) { + logger.debug("Trusting principal [{}] — no external IDP configured for this mode.", request.getPrincipal()); + return Uni.createFrom().item(QuarkusSecurityIdentity.builder() + .setPrincipal(new QuarkusPrincipal(request.getPrincipal())) + .setAnonymous(false) + .build()); + } +} 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 index 900ff07bd..4bad37820 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/UserAccessValidator.java +++ b/calm-hub/src/main/java/org/finos/calm/security/UserAccessValidator.java @@ -1,6 +1,5 @@ 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; @@ -13,78 +12,20 @@ import java.util.Set; import java.util.stream.Collectors; -/** - * 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") +@IfBuildProfile(anyOf = {"secure", "proxy-auth"}) 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; - }); - } - - /** - * Returns the set of namespaces that the given user has read access to, + * Returns the set of namespaces the given user has read access to, * based on their access grants. Both read and write permissions grant read access. * * @param username the username to check access for @@ -101,45 +42,4 @@ public Set getReadableNamespaces(String username) { return Set.of(); } } - - /** - * Checks whether the request targets a 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. - String path = userRequestAttributes.path(); - String method = userRequestAttributes.requestMethod(); - return "get".equalsIgnoreCase(method) && - ("/calm/namespaces".equals(path) || "/calm/search".equals(path)); - } - - /** - * 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 deleted file mode 100644 index 99002f978..000000000 --- a/calm-hub/src/main/java/org/finos/calm/security/UserRequestAttributes.java +++ /dev/null @@ -1,5 +0,0 @@ -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 index 027957b83..cf862e805 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/UserAccessStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/UserAccessStore.java @@ -6,6 +6,7 @@ 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. @@ -45,4 +46,29 @@ public interface UserAccessStore { * @return a list of UserAccess details */ UserAccess getUserAccessForNamespaceAndId(String namespace, Integer userAccessId) throws NamespaceNotFoundException, UserAccessNotFoundException; + + /** + * Store a new domain-scoped UserAccess grant. + * + * @param userAccess the UserAccess details to create. + * @return the created UserAccess object. + */ + UserAccess createUserAccessForDomain(UserAccess userAccess); + + /** + * Retrieves all UserAccess grants for a given domain. + * + * @param domain the domain name to fetch associated UserAccess records. + * @return a list of UserAccess details + */ + List getUserAccessForDomain(String domain) throws UserAccessNotFoundException; + + /** + * Retrieve a specific domain-scoped UserAccess record by domain and id. + * + * @param domain the domain name. + * @param userAccessId the sequence number of a UserAccess record. + * @return the UserAccess record + */ + UserAccess getUserAccessForDomainAndId(String domain, Integer userAccessId) throws UserAccessNotFoundException; } 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 index 447a48192..7880ada58 100644 --- 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 @@ -46,23 +46,43 @@ public UserAccess createUserAccessForNamespace(UserAccess userAccess) 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()); + log.info("UserAccess has been created for namespace: {}, permission: {}, username: {}", + userAccess.getNamespace(), userAccess.getPermission(), userAccess.getUsername()); - UserAccess persistedUserAccess = new UserAccess.UserAccessBuilder() + return new UserAccess.UserAccessBuilder() .setUserAccessId(userAccessId) - .setResourceType(userAccess.getResourceType()) .setNamespace(userAccess.getNamespace()) .setPermission(userAccess.getPermission()) .setUsername(userAccess.getUsername()) .build(); - return persistedUserAccess; + } + + @Override + public UserAccess createUserAccessForDomain(UserAccess userAccess) { + log.info("User-access details: {}", userAccess); + int userAccessId = counterStore.getNextUserAccessSequenceValue(); + Document userAccessDoc = new Document("username", userAccess.getUsername()) + .append("permission", userAccess.getPermission().name()) + .append("domain", userAccess.getDomain()) + .append("createdAt", userAccess.getCreationDateTime()) + .append("lastUpdated", userAccess.getUpdateDateTime()) + .append("userAccessId", userAccessId); + + userAccessCollection.insertOne(userAccessDoc); + log.info("UserAccess has been created for domain: {}, permission: {}, username: {}", + userAccess.getDomain(), userAccess.getPermission(), userAccess.getUsername()); + + return new UserAccess.UserAccessBuilder() + .setUserAccessId(userAccessId) + .setDomain(userAccess.getDomain()) + .setPermission(userAccess.getPermission()) + .setUsername(userAccess.getUsername()) + .build(); } @Override @@ -71,16 +91,7 @@ public List getUserAccessForUsername(String username) 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); + userAccessList.add(buildFromDocument(doc)); } if (userAccessList.isEmpty()) { @@ -98,14 +109,7 @@ public List getUserAccessForNamespace(String namespace) } 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); + userAccessList.add(buildFromDocument(doc)); } if (userAccessList.isEmpty()) { @@ -122,20 +126,54 @@ public UserAccess getUserAccessForNamespaceAndId(String namespace, Integer userA throw new NamespaceNotFoundException(); } - Document document = userAccessCollection.find(Filters.and(Filters.eq("namespace", namespace), + Document document = userAccessCollection.find(Filters.and( + Filters.eq("namespace", namespace), + Filters.eq("userAccessId", userAccessId))) + .first(); + + if (document == null) { + throw new UserAccessNotFoundException(); + } + return buildFromDocument(document); + } + + @Override + public List getUserAccessForDomain(String domain) + throws UserAccessNotFoundException { + + List userAccessList = new ArrayList<>(); + for (Document doc : userAccessCollection.find(Filters.eq("domain", domain))) { + userAccessList.add(buildFromDocument(doc)); + } + + if (userAccessList.isEmpty()) { + throw new UserAccessNotFoundException(); + } + return userAccessList; + } + + @Override + public UserAccess getUserAccessForDomainAndId(String domain, Integer userAccessId) + throws UserAccessNotFoundException { + + Document document = userAccessCollection.find(Filters.and( + Filters.eq("domain", domain), Filters.eq("userAccessId", userAccessId))) .first(); - if (null == document) { + if (document == null) { 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(); } + return buildFromDocument(document); + } + + private UserAccess buildFromDocument(Document doc) { + return new UserAccess.UserAccessBuilder() + .setUsername(doc.getString("username")) + .setPermission(UserAccess.Permission.valueOf(doc.getString("permission"))) + .setNamespace(doc.getString("namespace")) + .setDomain(doc.getString("domain")) + .setUserAccessId(doc.getInteger("userAccessId")) + .build(); } } diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteUserAccessStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteUserAccessStore.java index 711b65514..f7d739039 100644 --- a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteUserAccessStore.java +++ b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteUserAccessStore.java @@ -36,8 +36,8 @@ public class NitriteUserAccessStore implements UserAccessStore { private static final String COLLECTION_NAME = "userAccess"; private static final String USERNAME_FIELD = "username"; private static final String NAMESPACE_FIELD = "namespace"; + private static final String DOMAIN_FIELD = "domain"; private static final String PERMISSION_FIELD = "permission"; - private static final String RESOURCE_TYPE_FIELD = "resourceType"; private static final String USER_ACCESS_ID_FIELD = "userAccessId"; private static final String CREATED_AT_FIELD = "createdAt"; private static final String LAST_UPDATED_FIELD = "lastUpdated"; @@ -73,19 +73,17 @@ public UserAccess createUserAccessForNamespace(UserAccess userAccess) throws Nam .put(USERNAME_FIELD, userAccess.getUsername()) .put(PERMISSION_FIELD, userAccess.getPermission().name()) .put(NAMESPACE_FIELD, userAccess.getNamespace()) - .put(RESOURCE_TYPE_FIELD, userAccess.getResourceType().name()) .put(CREATED_AT_FIELD, userAccess.getCreationDateTime()) .put(LAST_UPDATED_FIELD, userAccess.getUpdateDateTime()) .put(USER_ACCESS_ID_FIELD, userAccessId); userAccessCollection.insert(userAccessDoc); - LOG.info("UserAccess has been created for namespace: {}, resource: {}, permission: {}, username: {}", - userAccess.getNamespace(), userAccess.getResourceType(), userAccess.getPermission(), userAccess.getUsername()); + LOG.info("UserAccess has been created for namespace: {}, permission: {}, username: {}", + userAccess.getNamespace(), userAccess.getPermission(), userAccess.getUsername()); return new UserAccess.UserAccessBuilder() .setUserAccessId(userAccessId) - .setResourceType(userAccess.getResourceType()) .setNamespace(userAccess.getNamespace()) .setPermission(userAccess.getPermission()) .setUsername(userAccess.getUsername()) @@ -151,12 +149,71 @@ public UserAccess getUserAccessForNamespaceAndId(String namespace, Integer userA return buildUserAccessFromDocument(document); } + @Override + public UserAccess createUserAccessForDomain(UserAccess userAccess) { + LOG.info("User-access details: {}", userAccess); + + lock.lock(); + try { + int userAccessId = counterStore.getNextUserAccessSequenceValue(); + + Document userAccessDoc = Document.createDocument() + .put(USERNAME_FIELD, userAccess.getUsername()) + .put(PERMISSION_FIELD, userAccess.getPermission().name()) + .put(DOMAIN_FIELD, userAccess.getDomain()) + .put(CREATED_AT_FIELD, userAccess.getCreationDateTime()) + .put(LAST_UPDATED_FIELD, userAccess.getUpdateDateTime()) + .put(USER_ACCESS_ID_FIELD, userAccessId); + + userAccessCollection.insert(userAccessDoc); + + LOG.info("UserAccess has been created for domain: {}, permission: {}, username: {}", + userAccess.getDomain(), userAccess.getPermission(), userAccess.getUsername()); + + return new UserAccess.UserAccessBuilder() + .setUserAccessId(userAccessId) + .setDomain(userAccess.getDomain()) + .setPermission(userAccess.getPermission()) + .setUsername(userAccess.getUsername()) + .build(); + } finally { + lock.unlock(); + } + } + + @Override + public List getUserAccessForDomain(String domain) throws UserAccessNotFoundException { + Filter filter = where(DOMAIN_FIELD).eq(domain); + List userAccessList = new ArrayList<>(); + + for (Document doc : userAccessCollection.find(filter)) { + userAccessList.add(buildUserAccessFromDocument(doc)); + } + + if (userAccessList.isEmpty()) { + throw new UserAccessNotFoundException(); + } + return userAccessList; + } + + @Override + public UserAccess getUserAccessForDomainAndId(String domain, Integer userAccessId) throws UserAccessNotFoundException { + Filter filter = where(DOMAIN_FIELD).eq(domain).and(where(USER_ACCESS_ID_FIELD).eq(userAccessId)); + Document document = userAccessCollection.find(filter).firstOrNull(); + + if (document == null) { + throw new UserAccessNotFoundException(); + } + + return buildUserAccessFromDocument(document); + } + private UserAccess buildUserAccessFromDocument(Document doc) { return new UserAccess.UserAccessBuilder() .setUsername(doc.get(USERNAME_FIELD, String.class)) .setPermission(UserAccess.Permission.valueOf(doc.get(PERMISSION_FIELD, String.class))) .setNamespace(doc.get(NAMESPACE_FIELD, String.class)) - .setResourceType(UserAccess.ResourceType.valueOf(doc.get(RESOURCE_TYPE_FIELD, String.class))) + .setDomain(doc.get(DOMAIN_FIELD, String.class)) .setUserAccessId(doc.get(USER_ACCESS_ID_FIELD, Integer.class)) .build(); } diff --git a/calm-hub/src/main/resources/application-proxy-auth.properties b/calm-hub/src/main/resources/application-proxy-auth.properties new file mode 100644 index 000000000..2779207ea --- /dev/null +++ b/calm-hub/src/main/resources/application-proxy-auth.properties @@ -0,0 +1,5 @@ +quarkus.oidc.tenant-enabled=false +quarkus.oidc.enabled=false +calm.hub.no.auth.enabled=false +# Header injected by the upstream proxy carrying the authenticated username (default: Remote-User) +#calm.security.proxy.username-header=Remote-User \ No newline at end of file diff --git a/calm-hub/src/main/resources/application-secure.properties b/calm-hub/src/main/resources/application-secure.properties index 8a393fe7a..509b82742 100644 --- a/calm-hub/src/main/resources/application-secure.properties +++ b/calm-hub/src/main/resources/application-secure.properties @@ -16,4 +16,5 @@ quarkus.oidc.tls.verification=none quarkus.oidc.auth-server-url=https://calm-hub.finos.org:9443/realms/calm-hub-realm quarkus.oidc.client-id=calm-hub-producer-app quarkus.oidc.token.audience=calm-hub-producer-app -quarkus.oidc.tenant-enabled=true \ No newline at end of file +quarkus.oidc.tenant-enabled=true +calm.hub.no.auth.enabled=false \ No newline at end of file diff --git a/calm-hub/src/main/resources/application.properties b/calm-hub/src/main/resources/application.properties index 11a845776..3b444484b 100644 --- a/calm-hub/src/main/resources/application.properties +++ b/calm-hub/src/main/resources/application.properties @@ -33,6 +33,10 @@ quarkus.oidc.enabled=true quarkus.keycloak.devservices.enabled=false quarkus.oidc.tenant-enabled=false +# Default profile is open — all requests are granted a non-anonymous identity and all +# permission checks pass. Set to false in secure/proxy-auth profiles. +calm.hub.no.auth.enabled=true + #quarkus.http.port=8081 # Security response headers — applied to all routes @@ -47,4 +51,6 @@ quarkus.http.filter.security.header."X-Frame-Options"=DENY # environment variable CALM_MCP_ENABLED=true to enable all MCP tools. calm.mcp.enabled=false # Log every JSON-RPC message in dev mode for easier debugging of MCP clients. -%dev.quarkus.mcp.server.traffic-logging=true \ No newline at end of file +%dev.quarkus.mcp.server.traffic-logging=true +quarkus.log.category."org.finos.calm".level=DEBUG +quarkus.log.category."org.mongodb.driver".level=OFF \ No newline at end of file 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 index ec142311a..806f81c8c 100644 --- a/calm-hub/src/test/java/org/finos/calm/domain/TestUserAccessShould.java +++ b/calm-hub/src/test/java/org/finos/calm/domain/TestUserAccessShould.java @@ -2,9 +2,9 @@ import org.junit.jupiter.api.Test; -import static org.hamcrest.Matchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; public class TestUserAccessShould { @@ -13,12 +13,10 @@ 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) @@ -27,16 +25,14 @@ void return_built_instance_values() { 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) @@ -44,7 +40,6 @@ void return_true_for_same_user_access_instances() { UserAccess userAccess2 = new UserAccess.UserAccessBuilder() .setUserAccessId(100) - .setResourceType(UserAccess.ResourceType.patterns) .setNamespace("finos") .setUsername("test_user") .setPermission(UserAccess.Permission.read) @@ -58,7 +53,6 @@ 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) @@ -66,7 +60,6 @@ void return_false_for_different_user_access_instances() { UserAccess userAccess2 = new UserAccess.UserAccessBuilder() .setUserAccessId(101) - .setResourceType(UserAccess.ResourceType.patterns) .setNamespace("finos") .setUsername("test_user2") .setPermission(UserAccess.Permission.read) diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestAdrResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestAdrResourceShould.java index 114e54ce8..b0159aff9 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestAdrResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestAdrResourceShould.java @@ -2,15 +2,12 @@ import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; import org.finos.calm.domain.adr.Adr; import org.finos.calm.domain.adr.AdrMeta; import org.finos.calm.domain.adr.NamespaceAdrSummary; import org.finos.calm.domain.adr.Status; -import org.finos.calm.domain.exception.AdrNotFoundException; -import org.finos.calm.domain.exception.AdrParseException; -import org.finos.calm.domain.exception.AdrPersistenceException; -import org.finos.calm.domain.exception.AdrRevisionNotFoundException; -import org.finos.calm.domain.exception.NamespaceNotFoundException; +import org.finos.calm.domain.exception.*; import org.finos.calm.store.AdrStore; import org.hamcrest.Matcher; import org.junit.jupiter.api.Test; @@ -26,18 +23,13 @@ import java.util.stream.Stream; import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.is; - +import static org.hamcrest.Matchers.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; +@TestSecurity(authorizationEnabled = false) @QuarkusTest @ExtendWith(MockitoExtension.class) public class TestAdrResourceShould { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourcePutEnabledShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourcePutEnabledShould.java index e76cf5907..94ffe6341 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourcePutEnabledShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourcePutEnabledShould.java @@ -1,11 +1,11 @@ package org.finos.calm.resources; import com.fasterxml.jackson.core.JsonProcessingException; -import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.security.TestSecurity; import org.finos.calm.domain.Architecture; import org.finos.calm.domain.architecture.ArchitectureRequest; import org.finos.calm.domain.exception.ArchitectureNotFoundException; @@ -22,12 +22,12 @@ import java.util.stream.Stream; import static io.restassured.RestAssured.given; +import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; import static org.hamcrest.Matchers.containsString; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; +@TestSecurity(authorizationEnabled = false) @QuarkusTest @ExtendWith(MockitoExtension.class) @TestProfile(AllowPutProfile.class) diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java index 78249267c..54e3ba40b 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestArchitectureResourceShould.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; import org.bson.json.JsonParseException; import org.finos.calm.domain.Architecture; import org.finos.calm.domain.architecture.ArchitectureRequest; @@ -21,7 +22,6 @@ import org.junit.jupiter.params.provider.MethodSource; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.Arrays; import java.util.List; import java.util.stream.Stream; @@ -31,11 +31,9 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; +@TestSecurity(authorizationEnabled = false) @QuarkusTest @ExtendWith(MockitoExtension.class) public class TestArchitectureResourceShould { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java index a71d091e4..8daeffb23 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestControlResourceShould.java @@ -2,17 +2,12 @@ import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; import org.bson.json.JsonParseException; import org.finos.calm.domain.controls.ControlDetail; import org.finos.calm.domain.controls.CreateControlConfiguration; import org.finos.calm.domain.controls.CreateControlRequirement; -import org.finos.calm.domain.exception.ControlConfigurationNotFoundException; -import org.finos.calm.domain.exception.ControlConfigurationVersionExistsException; -import org.finos.calm.domain.exception.ControlConfigurationVersionNotFoundException; -import org.finos.calm.domain.exception.ControlNotFoundException; -import org.finos.calm.domain.exception.ControlRequirementVersionExistsException; -import org.finos.calm.domain.exception.ControlRequirementVersionNotFoundException; -import org.finos.calm.domain.exception.DomainNotFoundException; +import org.finos.calm.domain.exception.*; import org.finos.calm.store.ControlStore; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -29,14 +24,10 @@ import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; import static org.hamcrest.Matchers.*; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +@TestSecurity(authorizationEnabled = false) @QuarkusTest @ExtendWith(MockitoExtension.class) public class TestControlResourceShould { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestCoreSchemaResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestCoreSchemaResourceShould.java index dfe42a134..e0654966c 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestCoreSchemaResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestCoreSchemaResourceShould.java @@ -2,6 +2,7 @@ import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; import org.finos.calm.store.CoreSchemaStore; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -12,9 +13,10 @@ import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.*; -import static org.mockito.Mockito.*; import static org.mockito.Mockito.any; +import static org.mockito.Mockito.*; +@TestSecurity(authorizationEnabled = false) @QuarkusTest @ExtendWith(MockitoExtension.class) public class TestCoreSchemaResourceShould { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestDecoratorResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestDecoratorResourceShould.java index dd579e3e0..99d61788a 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestDecoratorResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestDecoratorResourceShould.java @@ -2,6 +2,7 @@ import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; import io.restassured.http.ContentType; import org.bson.json.JsonParseException; import org.finos.calm.domain.Decorator; @@ -18,16 +19,11 @@ import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.any; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; +@TestSecurity(authorizationEnabled = false) @QuarkusTest @ExtendWith(MockitoExtension.class) public class TestDecoratorResourceShould { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestDomainResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestDomainResourceShould.java index 86e543ea1..1a0af887d 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestDomainResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestDomainResourceShould.java @@ -2,6 +2,7 @@ import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; import org.finos.calm.domain.Domain; import org.finos.calm.domain.exception.DomainAlreadyExistsException; import org.finos.calm.store.DomainStore; @@ -18,6 +19,7 @@ import static org.hamcrest.Matchers.is; import static org.mockito.Mockito.*; +@TestSecurity(authorizationEnabled = false) @QuarkusTest @ExtendWith(MockitoExtension.class) public class TestDomainResourceShould { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestDomainUserAccessResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestDomainUserAccessResourceShould.java new file mode 100644 index 000000000..d3946f92f --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestDomainUserAccessResourceShould.java @@ -0,0 +1,207 @@ +package org.finos.calm.resources; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; +import org.finos.calm.domain.UserAccess; +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.containsString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@TestSecurity(authorizationEnabled = false) +@QuarkusTest +public class TestDomainUserAccessResourceShould { + + @InjectMock + UserAccessStore mockUserAccessStore; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Test + void return_201_created_with_location_header_when_domain_user_access_is_created() throws Exception { + UserAccess userAccess = new UserAccess(); + userAccess.setDomain("payments"); + userAccess.setPermission(UserAccess.Permission.write); + userAccess.setUsername("test_user"); + String requestBody = OBJECT_MAPPER.writeValueAsString(userAccess); + + UserAccess created = new UserAccess(); + created.setDomain("payments"); + created.setPermission(UserAccess.Permission.write); + created.setUsername("test_user"); + created.setUserAccessId(201); + when(mockUserAccessStore.createUserAccessForDomain(any(UserAccess.class))).thenReturn(created); + + given() + .header("Content-Type", "application/json") + .body(requestBody) + .when() + .post("/calm/domains/payments/user-access") + .then() + .statusCode(201) + .header("Location", containsString("/calm/domains/payments/user-access/201")); + + verify(mockUserAccessStore, times(1)).createUserAccessForDomain(any(UserAccess.class)); + } + + @Test + void return_400_when_creating_user_access_with_mismatched_domain() throws Exception { + UserAccess userAccess = new UserAccess(); + userAccess.setDomain("other"); + userAccess.setPermission(UserAccess.Permission.write); + userAccess.setUsername("test_user"); + String requestBody = OBJECT_MAPPER.writeValueAsString(userAccess); + + given() + .header("Content-Type", "application/json") + .body(requestBody) + .when() + .post("/calm/domains/payments/user-access") + .then() + .statusCode(400) + .body(containsString("Bad Request")); + + verify(mockUserAccessStore, times(0)).createUserAccessForDomain(any(UserAccess.class)); + } + + @Test + void return_500_when_store_returns_domain_that_causes_uri_syntax_error() throws Exception { + UserAccess invalid = new UserAccess(); + invalid.setDomain("payments invalid"); + invalid.setUserAccessId(1); + when(mockUserAccessStore.createUserAccessForDomain(any(UserAccess.class))).thenReturn(invalid); + + UserAccess userAccess = new UserAccess(); + userAccess.setDomain("payments"); + userAccess.setPermission(UserAccess.Permission.write); + userAccess.setUsername("test_user"); + + given() + .header("Content-Type", "application/json") + .body(OBJECT_MAPPER.writeValueAsString(userAccess)) + .when() + .post("/calm/domains/payments/user-access") + .then() + .statusCode(500) + .body(containsString("System Malfunction")); + + verify(mockUserAccessStore, times(1)).createUserAccessForDomain(any(UserAccess.class)); + } + + @Test + void return_500_when_internal_error_occurs_during_domain_user_access_creation() throws Exception { + when(mockUserAccessStore.createUserAccessForDomain(any(UserAccess.class))) + .thenThrow(new RuntimeException("Unexpected error")); + + UserAccess userAccess = new UserAccess(); + userAccess.setDomain("payments"); + userAccess.setPermission(UserAccess.Permission.write); + userAccess.setUsername("test_user"); + String requestBody = OBJECT_MAPPER.writeValueAsString(userAccess); + + given() + .header("Content-Type", "application/json") + .body(requestBody) + .when() + .post("/calm/domains/payments/user-access") + .then() + .statusCode(500); + + verify(mockUserAccessStore, times(1)).createUserAccessForDomain(any(UserAccess.class)); + } + + @Test + void return_200_with_user_access_list_for_domain() throws Exception { + UserAccess ua1 = new UserAccess(); + ua1.setUserAccessId(201); + ua1.setUsername("test_user1"); + ua1.setDomain("payments"); + + UserAccess ua2 = new UserAccess(); + ua2.setUserAccessId(202); + ua2.setUsername("test_user2"); + ua2.setDomain("payments"); + + when(mockUserAccessStore.getUserAccessForDomain("payments")).thenReturn(List.of(ua1, ua2)); + + given() + .when() + .get("/calm/domains/payments/user-access") + .then() + .statusCode(200) + .body(containsString("test_user1")) + .body(containsString("test_user2")); + + verify(mockUserAccessStore, times(1)).getUserAccessForDomain("payments"); + } + + @Test + void return_404_when_no_user_access_found_for_domain() throws Exception { + when(mockUserAccessStore.getUserAccessForDomain("payments")) + .thenThrow(new UserAccessNotFoundException()); + + given() + .when() + .get("/calm/domains/payments/user-access") + .then() + .statusCode(404) + .body(containsString("No access permissions found")); + + verify(mockUserAccessStore, times(1)).getUserAccessForDomain("payments"); + } + + @Test + void return_500_when_internal_error_occurs_while_getting_domain_user_access() throws Exception { + when(mockUserAccessStore.getUserAccessForDomain("payments")) + .thenThrow(new RuntimeException("DB error")); + + given() + .when() + .get("/calm/domains/payments/user-access") + .then() + .statusCode(500); + + verify(mockUserAccessStore, times(1)).getUserAccessForDomain("payments"); + } + + @Test + void return_200_with_user_access_record_for_domain_and_id() throws Exception { + UserAccess ua = new UserAccess(); + ua.setUserAccessId(201); + ua.setUsername("test_user"); + ua.setDomain("payments"); + when(mockUserAccessStore.getUserAccessForDomainAndId("payments", 201)).thenReturn(ua); + + given() + .when() + .get("/calm/domains/payments/user-access/201") + .then() + .statusCode(200) + .body(containsString("test_user")); + + verify(mockUserAccessStore, times(1)).getUserAccessForDomainAndId("payments", 201); + } + + @Test + void return_404_when_user_access_for_domain_and_id_not_found() throws Exception { + when(mockUserAccessStore.getUserAccessForDomainAndId("payments", 999)) + .thenThrow(new UserAccessNotFoundException()); + + given() + .when() + .get("/calm/domains/payments/user-access/999") + .then() + .statusCode(404) + .body(containsString("No access permissions found")); + + verify(mockUserAccessStore, times(1)).getUserAccessForDomainAndId("payments", 999); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourcePutEnabledShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourcePutEnabledShould.java index ad85481d3..d1cfe2fae 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourcePutEnabledShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourcePutEnabledShould.java @@ -3,6 +3,7 @@ import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.security.TestSecurity; import org.finos.calm.domain.Flow; import org.finos.calm.domain.exception.FlowNotFoundException; import org.finos.calm.domain.exception.NamespaceNotFoundException; @@ -21,6 +22,7 @@ import static org.hamcrest.Matchers.containsString; import static org.mockito.Mockito.when; +@TestSecurity(authorizationEnabled = false) @QuarkusTest @ExtendWith(MockitoExtension.class) @TestProfile(AllowPutProfile.class) diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java index 28b855b2d..9c08bd38a 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestFlowResourceShould.java @@ -2,8 +2,9 @@ import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; import org.bson.json.JsonParseException; -import org.finos.calm.domain.*; +import org.finos.calm.domain.Flow; import org.finos.calm.domain.exception.FlowNotFoundException; import org.finos.calm.domain.exception.FlowVersionExistsException; import org.finos.calm.domain.exception.FlowVersionNotFoundException; @@ -28,6 +29,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; +@TestSecurity(authorizationEnabled = false) @QuarkusTest public class TestFlowResourceShould { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestFrontControllerResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestFrontControllerResourceShould.java index e07082dce..b79d64cde 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestFrontControllerResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestFrontControllerResourceShould.java @@ -2,6 +2,7 @@ import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; import org.finos.calm.domain.*; import org.finos.calm.domain.exception.*; import org.finos.calm.domain.flow.CreateFlowRequest; @@ -22,6 +23,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; +@TestSecurity(authorizationEnabled = false) @QuarkusTest @ExtendWith(MockitoExtension.class) public class TestFrontControllerResourceShould { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestFrontControllerResourceWithBaseUrlShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestFrontControllerResourceWithBaseUrlShould.java index 43dcdd6ba..41bd341a0 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestFrontControllerResourceWithBaseUrlShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestFrontControllerResourceWithBaseUrlShould.java @@ -3,14 +3,11 @@ import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.security.TestSecurity; import org.finos.calm.domain.Pattern; import org.finos.calm.domain.ResourceMapping; import org.finos.calm.domain.ResourceType; -import org.finos.calm.store.ArchitectureStore; -import org.finos.calm.store.InterfaceStore; -import org.finos.calm.store.PatternStore; -import org.finos.calm.store.ResourceMappingStore; -import org.finos.calm.store.StandardStore; +import org.finos.calm.store.*; import org.junit.jupiter.api.Test; import java.util.List; @@ -20,6 +17,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; +@TestSecurity(authorizationEnabled = false) @QuarkusTest @TestProfile(BaseUrlConfiguredProfile.class) public class TestFrontControllerResourceWithBaseUrlShould { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java index da717c536..dd8c0bd55 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestInterfaceResourceShould.java @@ -1,20 +1,10 @@ package org.finos.calm.resources; -import static io.restassured.RestAssured.given; -import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; -import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.equalTo; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.List; -import java.util.stream.Stream; - +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; import org.bson.json.JsonParseException; import org.finos.calm.domain.CalmInterface; import org.finos.calm.domain.exception.InterfaceNotFoundException; @@ -32,12 +22,18 @@ import org.junit.jupiter.params.provider.MethodSource; import org.mockito.junit.jupiter.MockitoExtension; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.stream.Stream; -import io.quarkus.test.InjectMock; -import io.quarkus.test.junit.QuarkusTest; +import static io.restassured.RestAssured.given; +import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; +import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +@TestSecurity(authorizationEnabled = false) @QuarkusTest @ExtendWith(MockitoExtension.class) public class TestInterfaceResourceShould { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestNamespaceResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestNamespaceResourceShould.java index 9b1d8c2df..98594b1ff 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestNamespaceResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestNamespaceResourceShould.java @@ -2,6 +2,7 @@ import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; import org.finos.calm.domain.exception.NamespaceAlreadyExistsException; import org.finos.calm.domain.namespaces.NamespaceInfo; import org.finos.calm.store.NamespaceStore; @@ -14,10 +15,11 @@ import static io.restassured.RestAssured.given; import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; -import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; import static org.mockito.Mockito.*; +@TestSecurity(authorizationEnabled = false) @QuarkusTest @ExtendWith(MockitoExtension.class) public class TestNamespaceResourceShould { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourcePutEnabledShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourcePutEnabledShould.java index e82bcbff8..a8172fa8f 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourcePutEnabledShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourcePutEnabledShould.java @@ -1,12 +1,9 @@ package org.finos.calm.resources; -import static io.restassured.RestAssured.given; -import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; -import static org.hamcrest.Matchers.containsString; -import static org.mockito.Mockito.when; - -import java.util.stream.Stream; - +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.security.TestSecurity; import org.finos.calm.domain.Pattern; import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.exception.PatternNotFoundException; @@ -18,10 +15,14 @@ import org.junit.jupiter.params.provider.MethodSource; import org.mockito.junit.jupiter.MockitoExtension; -import io.quarkus.test.InjectMock; -import io.quarkus.test.junit.QuarkusTest; -import io.quarkus.test.junit.TestProfile; +import java.util.stream.Stream; + +import static io.restassured.RestAssured.given; +import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; +import static org.hamcrest.Matchers.containsString; +import static org.mockito.Mockito.when; +@TestSecurity(authorizationEnabled = false) @QuarkusTest @ExtendWith(MockitoExtension.class) @TestProfile(AllowPutProfile.class) diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java index 7dc959e56..4ab7a524b 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestPatternResourceShould.java @@ -2,8 +2,9 @@ import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; import org.bson.json.JsonParseException; -import org.finos.calm.domain.*; +import org.finos.calm.domain.Pattern; import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.exception.PatternNotFoundException; import org.finos.calm.domain.exception.PatternVersionExistsException; @@ -30,6 +31,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; +@TestSecurity(authorizationEnabled = false) @QuarkusTest @ExtendWith(MockitoExtension.class) public class TestPatternResourceShould { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestSearchResourceFilteringShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestSearchResourceFilteringShould.java index ddef6aa0d..07f30af30 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestSearchResourceFilteringShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestSearchResourceFilteringShould.java @@ -77,7 +77,7 @@ void pass_resolved_readable_namespaces_to_store_in_secure_mode() { ); when(mockSearchStore.search(eq("test"), any())).thenReturn(storeResults); - SearchResource resource = new SearchResource(mockSearchStore, mockValidatorInstance, mockJwtInstance); + SearchResource resource = new SearchResource(mockSearchStore, mockValidatorInstance, mockJwtInstance, "Remote-User"); var response = resource.search("test"); assertEquals(200, response.getStatus()); @@ -95,7 +95,7 @@ void pass_empty_optional_when_validator_not_resolvable() { when(mockSearchStore.search(eq("test"), any())).thenReturn(emptyResults()); - SearchResource resource = new SearchResource(mockSearchStore, mockValidatorInstance, mockJwtInstance); + SearchResource resource = new SearchResource(mockSearchStore, mockValidatorInstance, mockJwtInstance, "Remote-User"); resource.search("test"); ArgumentCaptor>> captor = namespacesCaptor(); @@ -112,7 +112,7 @@ void pass_empty_optional_when_jwt_has_no_username() { when(mockSearchStore.search(eq("test"), any())).thenReturn(emptyResults()); - SearchResource resource = new SearchResource(mockSearchStore, mockValidatorInstance, mockJwtInstance); + SearchResource resource = new SearchResource(mockSearchStore, mockValidatorInstance, mockJwtInstance, "Remote-User"); resource.search("test"); ArgumentCaptor>> captor = namespacesCaptor(); @@ -131,7 +131,7 @@ void pass_empty_set_when_user_has_no_namespace_grants() { when(mockSearchStore.search(eq("test"), any())).thenReturn(emptyResults()); - SearchResource resource = new SearchResource(mockSearchStore, mockValidatorInstance, mockJwtInstance); + SearchResource resource = new SearchResource(mockSearchStore, mockValidatorInstance, mockJwtInstance, "Remote-User"); resource.search("test"); ArgumentCaptor>> captor = namespacesCaptor(); diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestSearchResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestSearchResourceShould.java index b4b4ff8af..23cea9348 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestSearchResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestSearchResourceShould.java @@ -2,6 +2,7 @@ import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; import org.finos.calm.domain.search.GroupedSearchResults; import org.finos.calm.domain.search.SearchResult; import org.finos.calm.store.SearchStore; @@ -18,15 +19,12 @@ import java.util.stream.Stream; import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; +@TestSecurity(authorizationEnabled = false) @QuarkusTest @ExtendWith(MockitoExtension.class) public class TestSearchResourceShould { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java index b9307ff4a..3f9687d1b 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestStandardResourceShould.java @@ -1,20 +1,10 @@ package org.finos.calm.resources; -import static io.restassured.RestAssured.given; -import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; -import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.equalTo; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.List; -import java.util.stream.Stream; - +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; import org.finos.calm.domain.Standard; import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.exception.StandardNotFoundException; @@ -31,12 +21,18 @@ import org.junit.jupiter.params.provider.MethodSource; import org.mockito.junit.jupiter.MockitoExtension; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.stream.Stream; -import io.quarkus.test.InjectMock; -import io.quarkus.test.junit.QuarkusTest; +import static io.restassured.RestAssured.given; +import static org.finos.calm.resources.ResourceValidationConstants.NAMESPACE_MESSAGE; +import static org.finos.calm.resources.ResourceValidationConstants.VERSION_MESSAGE; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +@TestSecurity(authorizationEnabled = false) @QuarkusTest @ExtendWith(MockitoExtension.class) public class TestStandardResourceShould { diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourcePutEnabledShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourcePutEnabledShould.java index d93c55e73..13ff32411 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourcePutEnabledShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourcePutEnabledShould.java @@ -3,6 +3,7 @@ import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.security.TestSecurity; import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.exception.TimelineNotFoundException; import org.finos.calm.domain.timeline.Timeline; @@ -24,6 +25,7 @@ @QuarkusTest @ExtendWith(MockitoExtension.class) @TestProfile(AllowPutProfile.class) +@TestSecurity(authorizationEnabled = false) public class TestTimelineResourcePutEnabledShould { @InjectMock diff --git a/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java index 16f03e4c0..4c104e82c 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestTimelineResourceShould.java @@ -2,6 +2,7 @@ import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; import org.bson.json.JsonParseException; import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.exception.TimelineNotFoundException; @@ -29,6 +30,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; +@TestSecurity(authorizationEnabled = false) @QuarkusTest public class TestTimelineResourceShould { 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 index 3709d1599..96c03d9ab 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestUserAccessResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestUserAccessResourceShould.java @@ -3,18 +3,21 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; 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.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +@TestSecurity(authorizationEnabled = false) @QuarkusTest public class TestUserAccessResourceShould { @@ -29,7 +32,6 @@ void return_201_created_with_location_header_when_user_access_is_created() throw 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); @@ -58,7 +60,6 @@ void return_404_when_creating_user_access_with_invalid_namespace() throws Except 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); @@ -83,7 +84,6 @@ void return_400_when_creating_user_access_with_invalid_namespace() throws Except 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); @@ -108,7 +108,6 @@ void return_500_when_internal_error_occurs_during_user_access_creation() throws 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); 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 deleted file mode 100644 index 0fab452e6..000000000 --- a/calm-hub/src/test/java/org/finos/calm/security/TestAccessControlFilterShould.java +++ /dev/null @@ -1,122 +0,0 @@ -package org.finos.calm.security; - -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; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; - -import java.lang.reflect.Method; -import java.util.List; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.*; - -@QuarkusTest -public class TestAccessControlFilterShould { - - @Mock - JsonWebToken jwt; - @Mock - ContainerRequestContext requestContext; - @Mock - ResourceInfo resourceInfo; - @Mock - UserAccessValidator userAccessValidator; - - private AccessControlFilter accessControlFilter; - - @BeforeEach - void setup() { - MockitoAnnotations.openMocks(this); - accessControlFilter = new AccessControlFilter(jwt, resourceInfo, userAccessValidator); - } - - @Test - void allow_the_request_when_scopes_not_defined_on_resource() throws NoSuchMethodException { - Method method = TestNamespaceResource.class.getMethod("getNamespacesUnsecured"); - when(resourceInfo.getResourceMethod()).thenReturn(method); - - accessControlFilter.filter(requestContext); - verify(requestContext, never()).abortWith(any()); - } - - @Test - 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)); - } - - private static class TestNamespaceResource { - @SuppressWarnings("unused") - public List getNamespacesUnsecured() { - return List.of("test", "dev"); - } - - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) - public void createNamespace() { - } - } -} \ No newline at end of file diff --git a/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubPermissionCheckerShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubPermissionCheckerShould.java new file mode 100644 index 000000000..03077e7e1 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubPermissionCheckerShould.java @@ -0,0 +1,337 @@ +package org.finos.calm.security; + +import io.quarkus.security.identity.SecurityIdentity; +import org.finos.calm.domain.UserAccess; +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.security.Principal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestCalmHubPermissionCheckerShould { + + @Mock + UserAccessStore mockUserAccessStore; + + @Mock + SecurityIdentity mockIdentity; + + @Mock + Principal mockPrincipal; + + CalmHubPermissionChecker checker; + + @BeforeEach + void setUp() { + checker = new CalmHubPermissionChecker(mockUserAccessStore); + } + + private void givenAuthenticatedUser(String username) throws UserAccessNotFoundException { + when(mockIdentity.getPrincipal()).thenReturn(mockPrincipal); + when(mockPrincipal.getName()).thenReturn(username); + } + + // --- READ checks --- + + @Test + void read_grant_allows_read() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.read, "foo"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertTrue(checker.canRead(mockIdentity, "foo")); + } + + @Test + void write_grant_allows_read() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.write, "foo"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertTrue(checker.canRead(mockIdentity, "foo")); + } + + @Test + void grant_for_different_namespace_denies_read() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.write, "bar"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertFalse(checker.canRead(mockIdentity, "foo")); + } + + @Test + void user_with_no_grants_is_denied_read() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenThrow(new UserAccessNotFoundException()); + + assertFalse(checker.canRead(mockIdentity, "foo")); + } + + @Test + void any_namespace_grant_satisfies_read_regardless_of_resource_type() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.read, "foo"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertTrue(checker.canRead(mockIdentity, "foo")); + } + + // --- WRITE checks --- + + @Test + void read_grant_denies_write() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.read, "foo"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertFalse(checker.canWrite(mockIdentity, "foo")); + } + + @Test + void write_grant_allows_write() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.write, "foo"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertTrue(checker.canWrite(mockIdentity, "foo")); + } + + @Test + void admin_grant_allows_read_and_write() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.admin, "foo"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertTrue(checker.canRead(mockIdentity, "foo")); + assertTrue(checker.canWrite(mockIdentity, "foo")); + } + + // --- ADMIN checks --- + + @Test + void write_grant_denies_namespace_admin() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.write, "foo"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertFalse(checker.allowNamespaceAdmin(mockIdentity, "foo")); + } + + @Test + void admin_grant_allows_namespace_admin() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.admin, "foo"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertTrue(checker.allowNamespaceAdmin(mockIdentity, "foo")); + } + + // --- allowPublicRead --- + + @Test + void public_read_disabled_denies_user_without_grants() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenThrow(new UserAccessNotFoundException()); + + assertFalse(checker.canRead(mockIdentity, "foo")); + } + + @Test + void public_read_enabled_allows_any_authenticated_user_to_read() { + checker.allowPublicRead = true; + + assertTrue(checker.canRead(mockIdentity, "foo")); + } + + @Test + void public_read_enabled_does_not_grant_write_access() throws UserAccessNotFoundException { + checker.allowPublicRead = true; + givenAuthenticatedUser("alice"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenThrow(new UserAccessNotFoundException()); + + assertFalse(checker.canWrite(mockIdentity, "foo")); + } + + @Test + void public_read_enabled_does_not_grant_namespace_admin_access() throws UserAccessNotFoundException { + checker.allowPublicRead = true; + givenAuthenticatedUser("alice"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenThrow(new UserAccessNotFoundException()); + + assertFalse(checker.allowNamespaceAdmin(mockIdentity, "foo")); + } + + @Test + void public_read_enabled_allows_read_without_store_lookup() { + checker.allowPublicRead = true; + // No isAnonymous() stub and no store stub — confirms both are bypassed when allowPublicRead is true + + assertTrue(checker.canRead(mockIdentity, "foo")); + } + + // --- Domain READ checks --- + + @Test + void read_grant_for_domain_allows_domain_read() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess.UserAccessBuilder() + .setUsername("alice").setPermission(UserAccess.Permission.read).setDomain("payments").build(); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertTrue(checker.canReadByDomain(mockIdentity, "payments")); + } + + @Test + void write_grant_for_domain_allows_domain_read() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess.UserAccessBuilder() + .setUsername("alice").setPermission(UserAccess.Permission.write).setDomain("payments").build(); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertTrue(checker.canReadByDomain(mockIdentity, "payments")); + } + + @Test + void grant_for_different_domain_denies_domain_read() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess.UserAccessBuilder() + .setUsername("alice").setPermission(UserAccess.Permission.read).setDomain("orders").build(); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertFalse(checker.canReadByDomain(mockIdentity, "payments")); + } + + @Test + void namespace_grant_does_not_satisfy_domain_read() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.read, "payments"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertFalse(checker.canReadByDomain(mockIdentity, "payments")); + } + + @Test + void user_with_no_grants_is_denied_domain_read() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenThrow(new UserAccessNotFoundException()); + + assertFalse(checker.canReadByDomain(mockIdentity, "payments")); + } + + @Test + void public_read_enabled_allows_domain_read_without_store_lookup() { + checker.allowPublicRead = true; + + assertTrue(checker.canReadByDomain(mockIdentity, "payments")); + } + + // --- Domain WRITE checks --- + + @Test + void read_grant_for_domain_denies_domain_write() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess.UserAccessBuilder() + .setUsername("alice").setPermission(UserAccess.Permission.read).setDomain("payments").build(); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertFalse(checker.canWriteByDomain(mockIdentity, "payments")); + } + + @Test + void write_grant_for_domain_allows_domain_write() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess.UserAccessBuilder() + .setUsername("alice").setPermission(UserAccess.Permission.write).setDomain("payments").build(); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertTrue(checker.canWriteByDomain(mockIdentity, "payments")); + } + + // --- noAuthEnabled --- + + @Test + void no_auth_mode_grants_read_without_store_lookup() { + checker.noAuthEnabled = true; + + assertTrue(checker.canRead(mockIdentity, "foo")); + } + + @Test + void no_auth_mode_grants_write_without_store_lookup() { + checker.noAuthEnabled = true; + + assertTrue(checker.canWrite(mockIdentity, "foo")); + } + + @Test + void no_auth_mode_grants_namespace_admin_without_store_lookup() { + checker.noAuthEnabled = true; + + assertTrue(checker.allowNamespaceAdmin(mockIdentity, "foo")); + } + + @Test + void no_auth_mode_grants_global_admin_without_store_lookup() { + checker.noAuthEnabled = true; + + assertTrue(checker.hasGlobalAdmin(mockIdentity)); + } + + @Test + void no_auth_mode_grants_domain_read_and_write_without_store_lookup() { + checker.noAuthEnabled = true; + + assertTrue(checker.canReadByDomain(mockIdentity, "payments")); + assertTrue(checker.canWriteByDomain(mockIdentity, "payments")); + } + + // --- GLOBAL ADMIN checks --- + + @Test + void global_admin_grant_on_global_namespace_grants_global_admin() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess.UserAccessBuilder() + .setUsername("alice").setPermission(UserAccess.Permission.admin).setNamespace("GLOBAL").build(); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertTrue(checker.hasGlobalAdmin(mockIdentity)); + } + + @Test + void write_grant_on_global_namespace_denies_global_admin() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess.UserAccessBuilder() + .setUsername("alice").setPermission(UserAccess.Permission.write).setNamespace("GLOBAL").build(); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertFalse(checker.hasGlobalAdmin(mockIdentity)); + } + + @Test + void admin_grant_on_non_global_namespace_denies_global_admin() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.admin, "finos"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertFalse(checker.hasGlobalAdmin(mockIdentity)); + } + + @Test + void user_with_no_grants_is_denied_global_admin() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenThrow(new UserAccessNotFoundException()); + + assertFalse(checker.hasGlobalAdmin(mockIdentity)); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubScopesShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubScopesShould.java index 2f02511c3..b012e0360 100644 --- a/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubScopesShould.java +++ b/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubScopesShould.java @@ -18,22 +18,22 @@ void prevent_instantiation() throws Exception { } @Test - void match_architectures_read_constant() { - assertEquals("architectures:read", CalmHubScopes.ARCHITECTURES_READ); + void match_read_constant() { + assertEquals("read", CalmHubScopes.READ); } @Test - void match_architectures_all_constant() { - assertEquals("architectures:all", CalmHubScopes.ARCHITECTURES_ALL); + void match_write_constant() { + assertEquals("write", CalmHubScopes.WRITE); } @Test - void match_adrs_all_constant() { - assertEquals("adrs:all", CalmHubScopes.ADRS_ALL); + void match_admin_constant() { + assertEquals("admin", CalmHubScopes.ADMIN); } @Test - void match_adrs_read_constant() { - assertEquals("adrs:read", CalmHubScopes.ADRS_READ); + void match_global_admin_constant() { + assertEquals("global_admin", CalmHubScopes.GLOBAL_ADMIN); } } diff --git a/calm-hub/src/test/java/org/finos/calm/security/TestNoAuthAuthenticationMechanismShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestNoAuthAuthenticationMechanismShould.java new file mode 100644 index 000000000..8a4ef80d0 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/security/TestNoAuthAuthenticationMechanismShould.java @@ -0,0 +1,77 @@ +package org.finos.calm.security; + +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.TrustedAuthenticationRequest; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.smallrye.mutiny.Uni; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.ext.web.RoutingContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestNoAuthAuthenticationMechanismShould { + + @Mock + IdentityProviderManager mockIdentityProviderManager; + + @Mock + RoutingContext mockRoutingContext; + + @Mock + HttpServerRequest mockRequest; + + @Mock + SecurityIdentity mockIdentity; + + NoAuthAuthenticationMechanism mechanism; + + @BeforeEach + void setUp() { + mechanism = new NoAuthAuthenticationMechanism(); + } + + @Test + void return_empty_when_no_auth_disabled() { + mechanism.noAuthEnabled = false; + + SecurityIdentity result = mechanism.authenticate(mockRoutingContext, mockIdentityProviderManager) + .await().indefinitely(); + + assertNull(result); + verifyNoInteractions(mockIdentityProviderManager); + } + + @Test + void authenticate_with_no_auth_principal_when_no_auth_enabled() { + mechanism.noAuthEnabled = true; + when(mockRoutingContext.request()).thenReturn(mockRequest); + when(mockRequest.path()).thenReturn("/test"); + when(mockIdentityProviderManager.authenticate(any(TrustedAuthenticationRequest.class))) + .thenReturn(Uni.createFrom().item(mockIdentity)); + + SecurityIdentity result = mechanism.authenticate(mockRoutingContext, mockIdentityProviderManager) + .await().indefinitely(); + + assertNotNull(result); + verify(mockIdentityProviderManager).authenticate(any(TrustedAuthenticationRequest.class)); + } + + @Test + void return_null_challenge() { + ChallengeData challenge = mechanism.getChallenge(mockRoutingContext).await().indefinitely(); + + assertNull(challenge); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/security/TestProxyAuthenticationMechanismShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestProxyAuthenticationMechanismShould.java new file mode 100644 index 000000000..c051290e9 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/security/TestProxyAuthenticationMechanismShould.java @@ -0,0 +1,95 @@ +package org.finos.calm.security; + +import io.netty.handler.codec.http.HttpResponseStatus; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.TrustedAuthenticationRequest; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.smallrye.mutiny.Uni; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.ext.web.RoutingContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestProxyAuthenticationMechanismShould { + + @Mock + IdentityProviderManager mockIdentityProviderManager; + + @Mock + RoutingContext mockRoutingContext; + + @Mock + HttpServerRequest mockRequest; + + @Mock + SecurityIdentity mockIdentity; + + ProxyAuthenticationMechanism mechanism; + + @BeforeEach + void setUp() { + mechanism = new ProxyAuthenticationMechanism(); + mechanism.usernameHeader = "Remote-User"; + } + + @Test + void return_empty_when_username_header_is_missing() { + when(mockRoutingContext.request()).thenReturn(mockRequest); + when(mockRequest.getHeader("Remote-User")).thenReturn(null); + when(mockRequest.path()).thenReturn("/test"); + + SecurityIdentity result = mechanism.authenticate(mockRoutingContext, mockIdentityProviderManager) + .await().indefinitely(); + + assertNull(result); + verifyNoInteractions(mockIdentityProviderManager); + } + + @Test + void return_empty_when_username_header_is_empty() { + when(mockRoutingContext.request()).thenReturn(mockRequest); + when(mockRequest.getHeader("Remote-User")).thenReturn(""); + when(mockRequest.path()).thenReturn("/test"); + + SecurityIdentity result = mechanism.authenticate(mockRoutingContext, mockIdentityProviderManager) + .await().indefinitely(); + + assertNull(result); + verifyNoInteractions(mockIdentityProviderManager); + } + + @Test + void authenticate_user_when_valid_username_header_is_present() { + when(mockRoutingContext.request()).thenReturn(mockRequest); + when(mockRequest.getHeader("Remote-User")).thenReturn("alice"); + when(mockIdentityProviderManager.authenticate(any(TrustedAuthenticationRequest.class))) + .thenReturn(Uni.createFrom().item(mockIdentity)); + + SecurityIdentity result = mechanism.authenticate(mockRoutingContext, mockIdentityProviderManager) + .await().indefinitely(); + + assertNotNull(result); + verify(mockIdentityProviderManager).authenticate(any(TrustedAuthenticationRequest.class)); + } + + @Test + void return_401_challenge() { + ChallengeData challenge = mechanism.getChallenge(mockRoutingContext).await().indefinitely(); + + assertNotNull(challenge); + assertEquals(HttpResponseStatus.UNAUTHORIZED.code(), challenge.status); + } +} diff --git a/calm-hub/src/test/java/org/finos/calm/security/TestSecurityResponseHeadersShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestSecurityResponseHeadersShould.java index d3aae0e77..46dda162a 100644 --- a/calm-hub/src/test/java/org/finos/calm/security/TestSecurityResponseHeadersShould.java +++ b/calm-hub/src/test/java/org/finos/calm/security/TestSecurityResponseHeadersShould.java @@ -2,6 +2,7 @@ import io.quarkus.test.InjectMock; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; import org.finos.calm.store.NamespaceStore; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -13,6 +14,7 @@ import static org.hamcrest.Matchers.equalTo; import static org.mockito.Mockito.when; +@TestSecurity(authorizationEnabled = false) @QuarkusTest @ExtendWith(MockitoExtension.class) public class TestSecurityResponseHeadersShould { 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 index 98042c0ee..ed31445f0 100644 --- a/calm-hub/src/test/java/org/finos/calm/security/TestUserAccessValidatorShould.java +++ b/calm-hub/src/test/java/org/finos/calm/security/TestUserAccessValidatorShould.java @@ -1,122 +1,51 @@ 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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; import java.util.Set; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) class TestUserAccessValidatorShould { - private UserAccessStore userAccessStore; - private UserAccessValidator validator; + @Mock + UserAccessStore mockUserAccessStore; + + 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); + validator = new UserAccessValidator(mockUserAccessStore); } @Test - void return_true_when_user_accessing_default_get_namespaces_endpoint() throws Exception { - UserRequestAttributes requestAttributes = new UserRequestAttributes("GET", "testuser", - "/calm/namespaces", null); + void return_namespaces_for_user_with_access_grants() throws UserAccessNotFoundException { + UserAccess ua1 = new UserAccess("alice", UserAccess.Permission.read, "finos"); + UserAccess ua2 = new UserAccess("alice", UserAccess.Permission.write, "workshop"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(ua1, ua2)); - 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); - } - - @Test - void return_true_when_user_accessing_search_endpoint() throws Exception { - UserRequestAttributes requestAttributes = new UserRequestAttributes("GET", "testuser", - "/calm/search", null); + Set result = validator.getReadableNamespaces("alice"); - boolean actual = validator.isUserAuthorized(requestAttributes); - assertTrue(actual); + assertEquals(Set.of("finos", "workshop"), result); } @Test - void return_readable_namespaces_for_user_with_grants() throws Exception { - UserAccess grant1 = new UserAccess("testuser", Permission.read, "finos", ResourceType.patterns); - UserAccess grant2 = new UserAccess("testuser", Permission.write, "workshop", ResourceType.architectures); - when(userAccessStore.getUserAccessForUsername("testuser")) - .thenReturn(List.of(grant1, grant2)); + void return_empty_set_when_user_has_no_access_grants() throws UserAccessNotFoundException { + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenThrow(new UserAccessNotFoundException()); - Set namespaces = validator.getReadableNamespaces("testuser"); - assertEquals(Set.of("finos", "workshop"), namespaces); - } - - @Test - void return_empty_set_when_user_has_no_grants() throws Exception { - when(userAccessStore.getUserAccessForUsername("testuser")) - .thenThrow(new UserAccessNotFoundException()); - - Set namespaces = validator.getReadableNamespaces("testuser"); - assertTrue(namespaces.isEmpty()); - } - - @Test - void return_deduplicated_namespaces_when_user_has_multiple_grants_for_same_namespace() throws Exception { - UserAccess grant1 = new UserAccess("testuser", Permission.read, "finos", ResourceType.patterns); - UserAccess grant2 = new UserAccess("testuser", Permission.write, "finos", ResourceType.architectures); - when(userAccessStore.getUserAccessForUsername("testuser")) - .thenReturn(List.of(grant1, grant2)); + Set result = validator.getReadableNamespaces("alice"); - Set namespaces = validator.getReadableNamespaces("testuser"); - assertEquals(Set.of("finos"), namespaces); + assertTrue(result.isEmpty()); } } 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 index 408188649..e874787d6 100644 --- 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 @@ -1,13 +1,15 @@ package org.finos.calm.store.mongo; -import com.mongodb.client.*; +import com.mongodb.client.FindIterable; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoCursor; +import com.mongodb.client.MongoDatabase; 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; @@ -66,7 +68,6 @@ void return_user_access_for_valid_username() throws Exception { 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); @@ -107,7 +108,6 @@ void throw_exception_when_namespace_does_not_exist_on_create() { .setNamespace("invalid") .setUsername("test") .setPermission(Permission.read) - .setResourceType(ResourceType.architectures) .build(); assertThrows(NamespaceNotFoundException.class, @@ -123,7 +123,6 @@ void create_user_access_when_namespace_is_exists() throws NamespaceNotFoundExcep .setNamespace("finos") .setUsername("test") .setPermission(Permission.write) - .setResourceType(ResourceType.patterns) .build(); UserAccess actual = mongoUserAccessStore.createUserAccessForNamespace(userAccess); @@ -137,7 +136,6 @@ void return_user_access_list_for_namespace() throws Exception { Document doc = new Document("username", "test") .append("namespace", namespace) .append("permission", Permission.read.name()) - .append("resourceType", ResourceType.flows.name()) .append("userAccessId", 111); DocumentFindIterable findIterable = mock(DocumentFindIterable.class); @@ -155,7 +153,6 @@ void return_user_access_list_for_namespace() throws Exception { assertThat(actual, hasSize(1)); assertThat(actual.getFirst().getUsername(), is("test")); assertThat(actual.getFirst().getPermission(), is(Permission.read)); - assertThat(actual.getFirst().getResourceType(), is(ResourceType.flows)); } @Test @@ -186,7 +183,6 @@ void return_user_access_for_namespace_and_user_access_id() throws Exception { Document document = new Document("username", "test") .append("namespace", namespace) .append("permission", Permission.read.name()) - .append("resourceType", ResourceType.flows.name()) .append("userAccessId", userAccessId); DocumentFindIterable mockFindIterable = mock(DocumentFindIterable.class); @@ -201,11 +197,96 @@ void return_user_access_for_namespace_and_user_access_id() throws Exception { 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)); } + @Test + void create_user_access_for_domain() { + when(counterStore.getNextUserAccessSequenceValue()).thenReturn(201); + + UserAccess userAccess = new UserAccess.UserAccessBuilder() + .setDomain("payments") + .setUsername("test") + .setPermission(Permission.write) + .build(); + + UserAccess actual = mongoUserAccessStore.createUserAccessForDomain(userAccess); + assertThat(actual.getUserAccessId(), is(201)); + assertThat(actual.getDomain(), is("payments")); + verify(userAccessCollection).insertOne(ArgumentMatchers.any(Document.class)); + } + + @Test + void throw_exception_if_no_user_access_found_for_domain() { + String domain = "payments"; + DocumentFindIterable findIterable = mock(DocumentFindIterable.class); + DocumentMongoCursor mockMongoCursor = mock(DocumentMongoCursor.class); + when(mockMongoCursor.hasNext()).thenReturn(false); + when(findIterable.iterator()).thenReturn(mockMongoCursor); + when(userAccessCollection.find(Filters.eq("domain", domain))).thenReturn(findIterable); + + assertThrows(UserAccessNotFoundException.class, + () -> mongoUserAccessStore.getUserAccessForDomain(domain)); + } + + @Test + void return_user_access_list_for_domain() throws Exception { + String domain = "payments"; + Document doc = new Document("username", "test") + .append("domain", domain) + .append("permission", Permission.read.name()) + .append("userAccessId", 201); + + DocumentFindIterable findIterable = mock(DocumentFindIterable.class); + DocumentMongoCursor cursor = mock(DocumentMongoCursor.class); + when(cursor.hasNext()).thenReturn(true, false); + when(cursor.next()).thenReturn(doc); + when(findIterable.iterator()).thenReturn(cursor); + when(userAccessCollection.find(Filters.eq("domain", domain))).thenReturn(findIterable); + + List actual = mongoUserAccessStore.getUserAccessForDomain(domain); + + assertThat(actual, hasSize(1)); + assertThat(actual.getFirst().getDomain(), is(domain)); + } + + @Test + void throw_exception_if_no_user_access_found_for_domain_and_id() { + String domain = "payments"; + Integer userAccessId = 201; + DocumentFindIterable findIterable = mock(DocumentFindIterable.class); + when(userAccessCollection.find(Filters.and( + Filters.eq("domain", domain), + Filters.eq("userAccessId", userAccessId)))).thenReturn(findIterable); + when(findIterable.first()).thenReturn(null); + + assertThrows(UserAccessNotFoundException.class, + () -> mongoUserAccessStore.getUserAccessForDomainAndId(domain, userAccessId)); + } + + @Test + void return_user_access_for_domain_and_id() throws Exception { + String domain = "payments"; + Integer userAccessId = 201; + + Document document = new Document("username", "test") + .append("domain", domain) + .append("permission", Permission.read.name()) + .append("userAccessId", userAccessId); + + DocumentFindIterable mockFindIterable = mock(DocumentFindIterable.class); + when(userAccessCollection.find(Filters.and( + Filters.eq("domain", domain), + Filters.eq("userAccessId", userAccessId)))).thenReturn(mockFindIterable); + when(mockFindIterable.first()).thenReturn(document); + + UserAccess actual = mongoUserAccessStore.getUserAccessForDomainAndId(domain, userAccessId); + + assertThat(actual.getDomain(), is(domain)); + assertThat(actual.getUserAccessId(), is(userAccessId)); + } + private interface DocumentFindIterable extends FindIterable { } diff --git a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteUserAccessStoreShould.java b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteUserAccessStoreShould.java index 1f20bc3ee..055e76e9a 100644 --- a/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteUserAccessStoreShould.java +++ b/calm-hub/src/test/java/org/finos/calm/store/nitrite/TestNitriteUserAccessStoreShould.java @@ -57,7 +57,6 @@ public void testCreateUserAccessForNamespace() throws NamespaceNotFoundException .setNamespace("finos") .setUsername("testuser") .setPermission(UserAccess.Permission.read) - .setResourceType(UserAccess.ResourceType.patterns) .build(); userAccess.setCreationDateTime(LocalDateTime.now()); userAccess.setUpdateDateTime(LocalDateTime.now()); @@ -83,7 +82,6 @@ public void testCreateUserAccessForNamespace_ThrowsExceptionWhenNamespaceNotFoun .setNamespace("nonexistent") .setUsername("testuser") .setPermission(UserAccess.Permission.read) - .setResourceType(UserAccess.ResourceType.patterns) .build(); when(mockNamespaceStore.namespaceExists("nonexistent")).thenReturn(false); @@ -101,8 +99,8 @@ public void testGetUserAccessForUsername() throws UserAccessNotFoundException { when(mockDoc.get("username", String.class)).thenReturn("testuser"); when(mockDoc.get("namespace", String.class)).thenReturn("finos"); + when(mockDoc.get("domain", String.class)).thenReturn(null); when(mockDoc.get("permission", String.class)).thenReturn("read"); - when(mockDoc.get("resourceType", String.class)).thenReturn("patterns"); when(mockDoc.get("userAccessId", Integer.class)).thenReturn(1); // Act @@ -134,8 +132,8 @@ public void testGetUserAccessForNamespace() throws NamespaceNotFoundException, U when(mockDoc.get("username", String.class)).thenReturn("testuser"); when(mockDoc.get("namespace", String.class)).thenReturn("finos"); + when(mockDoc.get("domain", String.class)).thenReturn(null); when(mockDoc.get("permission", String.class)).thenReturn("read"); - when(mockDoc.get("resourceType", String.class)).thenReturn("patterns"); when(mockDoc.get("userAccessId", Integer.class)).thenReturn(1); // Act @@ -165,8 +163,8 @@ public void testGetUserAccessForNamespaceAndId() throws NamespaceNotFoundExcepti when(mockDoc.get("username", String.class)).thenReturn("testuser"); when(mockDoc.get("namespace", String.class)).thenReturn("finos"); + when(mockDoc.get("domain", String.class)).thenReturn(null); when(mockDoc.get("permission", String.class)).thenReturn("read"); - when(mockDoc.get("resourceType", String.class)).thenReturn("patterns"); when(mockDoc.get("userAccessId", Integer.class)).thenReturn(1); // Act @@ -188,4 +186,78 @@ public void testGetUserAccessForNamespaceAndId_ThrowsExceptionWhenNotFound() { // Act & Assert assertThrows(UserAccessNotFoundException.class, () -> userAccessStore.getUserAccessForNamespaceAndId("finos", 1)); } + + @Test + public void testCreateUserAccessForDomain() { + UserAccess userAccess = new UserAccess.UserAccessBuilder() + .setDomain("payments") + .setUsername("testuser") + .setPermission(UserAccess.Permission.write) + .build(); + userAccess.setCreationDateTime(java.time.LocalDateTime.now()); + userAccess.setUpdateDateTime(java.time.LocalDateTime.now()); + + when(mockCounterStore.getNextUserAccessSequenceValue()).thenReturn(2); + + UserAccess result = userAccessStore.createUserAccessForDomain(userAccess); + + assertThat(result, is(notNullValue())); + assertThat(result.getUserAccessId(), is(2)); + assertThat(result.getDomain(), is("payments")); + assertThat(result.getUsername(), is("testuser")); + verify(mockCollection).insert(any(Document.class)); + } + + @Test + public void testGetUserAccessForDomain() throws UserAccessNotFoundException { + Document mockDoc = mock(Document.class); + when(mockCollection.find(any(Filter.class))).thenReturn(mockCursor); + when(mockCursor.iterator()).thenReturn(Collections.singletonList(mockDoc).iterator()); + + when(mockDoc.get("username", String.class)).thenReturn("testuser"); + when(mockDoc.get("namespace", String.class)).thenReturn(null); + when(mockDoc.get("domain", String.class)).thenReturn("payments"); + when(mockDoc.get("permission", String.class)).thenReturn("write"); + when(mockDoc.get("userAccessId", Integer.class)).thenReturn(2); + + List result = userAccessStore.getUserAccessForDomain("payments"); + + assertThat(result, hasSize(1)); + assertThat(result.getFirst().getDomain(), is("payments")); + } + + @Test + public void testGetUserAccessForDomain_ThrowsExceptionWhenNotFound() { + when(mockCollection.find(any(Filter.class))).thenReturn(mockCursor); + when(mockCursor.iterator()).thenReturn(Collections.emptyIterator()); + + assertThrows(UserAccessNotFoundException.class, () -> userAccessStore.getUserAccessForDomain("payments")); + } + + @Test + public void testGetUserAccessForDomainAndId() throws UserAccessNotFoundException { + Document mockDoc = mock(Document.class); + when(mockCollection.find(any(Filter.class))).thenReturn(mockCursor); + when(mockCursor.firstOrNull()).thenReturn(mockDoc); + + when(mockDoc.get("username", String.class)).thenReturn("testuser"); + when(mockDoc.get("namespace", String.class)).thenReturn(null); + when(mockDoc.get("domain", String.class)).thenReturn("payments"); + when(mockDoc.get("permission", String.class)).thenReturn("write"); + when(mockDoc.get("userAccessId", Integer.class)).thenReturn(2); + + UserAccess result = userAccessStore.getUserAccessForDomainAndId("payments", 2); + + assertThat(result, is(notNullValue())); + assertThat(result.getDomain(), is("payments")); + assertThat(result.getUserAccessId(), is(2)); + } + + @Test + public void testGetUserAccessForDomainAndId_ThrowsExceptionWhenNotFound() { + when(mockCollection.find(any(Filter.class))).thenReturn(mockCursor); + when(mockCursor.firstOrNull()).thenReturn(null); + + assertThrows(UserAccessNotFoundException.class, () -> userAccessStore.getUserAccessForDomainAndId("payments", 2)); + } } diff --git a/calm-hub/src/test/resources/secure-profile/realm.json b/calm-hub/src/test/resources/secure-profile/realm.json index 911ae2c6b..cbf91ea3f 100644 --- a/calm-hub/src/test/resources/secure-profile/realm.json +++ b/calm-hub/src/test/resources/secure-profile/realm.json @@ -8,185 +8,40 @@ "protocol": "openid-connect", "publicClient": false, "secret": "calm-hub-client-app-secret", - "authorizationServicesEnabled": true, "directAccessGrantsEnabled": true, - "attributes": { - "access.token.lifespan": "300", - "refresh.token.lifespan": "1800" - }, "defaultClientScopes": [ "openid", "profile", "email" ], - "optionalClientScopes": [ - "address", - "phone", - "architectures:read", - "architectures:all", - "namespace:admin", - "adrs:read", - "adrs:all", - "deny:all" - ] - }, - { - "clientId": "calm-hub-producer-app", - "enabled": true, - "protocol": "openid-connect", - "publicClient": false, - "authorizationServicesEnabled": true - } - ], - "roles": { - "realm": [ - { - "name": "admin", - "clientRole": false - } - ], - "client": { - "calm-hub-client-app": [ - { - "name": "architectures:read" - }, - { - "name": "architectures:all" - }, - { - "name": "adrs:all" - }, - { - "name": "adrs:read" - }, - { - "name": "deny:all" - } - ] - } - }, - "clientScopes": [ - { - "name": "architectures:read", - "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": "architectures:all", - "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": "adrs:all", - "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": "adrs:read", - "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": "deny:all", - "protocol": "openid-connect", - "attributes": { - "include.in.token.scope": "true" - }, "protocolMappers": [ { "name": "audience", "protocol": "openid-connect", "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, "config": { "included.client.audience": "calm-hub-producer-app", - "id.token.claim": "true", + "id.token.claim": "false", "access.token.claim": "true" } } ] }, { - "name": "namespace:admin", + "clientId": "calm-hub-producer-app", + "enabled": true, "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" - } - } - ] - }, + "publicClient": false + } + ], + "clientScopes": [ { "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": [ @@ -221,43 +76,6 @@ ] } ], - "scopeMappings": [ - { - "client": "calm-hub-client-app", - "clientScope": "architectures:read", - "roles": [ - "architectures:read" - ] - }, - { - "client": "calm-hub-client-app", - "clientScope": "architectures:all", - "roles": [ - "architectures:all" - ] - }, - { - "client": "calm-hub-client-app", - "clientScope": "adrs:read", - "roles": [ - "adrs:read" - ] - }, - { - "client": "calm-hub-client-app", - "clientScope": "adrs:all", - "roles": [ - "adrs:all" - ] - }, - { - "client": "calm-hub-client-app", - "clientScope": "deny:all", - "roles": [ - "deny:all" - ] - } - ], "users": [ { "username": "test-user", @@ -272,15 +90,7 @@ "value": "changeme", "temporary": false } - ], - "realmRoles": [ - "admin" - ], - "clientRoles": { - "calm-hub-client-app": [ - "architectures:read" - ] - } + ] } ] -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index d3e92f28b..09a3e46ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15222,11 +15222,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -15238,11 +15240,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -15254,11 +15258,13 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -15270,11 +15276,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -15286,11 +15294,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -15302,11 +15312,13 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -15318,11 +15330,13 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -15334,11 +15348,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -15350,11 +15366,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -15366,11 +15384,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -15382,11 +15402,13 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -15398,11 +15420,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -37963,6 +37987,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true,