From 444b5862a152a2800e8c9a1ccd277cffd9a59edc Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Fri, 15 May 2026 17:24:08 +0100 Subject: [PATCH 01/26] feat(calm-hub): add new profile to run calmhub behind sidecar proxy and inject user via header --- .../finos/calm/resources/SearchResource.java | 24 +++- .../security/ProxyAccessControlFilter.java | 67 ++++++++++ .../calm/security/UserAccessValidator.java | 4 +- .../resources/application-proxy.properties | 4 + .../TestSearchResourceFilteringShould.java | 8 +- .../TestProxyAccessControlFilterShould.java | 126 ++++++++++++++++++ 6 files changed, 222 insertions(+), 11 deletions(-) create mode 100644 calm-hub/src/main/java/org/finos/calm/security/ProxyAccessControlFilter.java create mode 100644 calm-hub/src/main/resources/application-proxy.properties create mode 100644 calm-hub/src/test/java/org/finos/calm/security/TestProxyAccessControlFilterShould.java 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..6ace2076d 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 @@ -6,8 +6,11 @@ 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; @@ -31,14 +34,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 = "Proxy-Remote-User") String proxyUsernameHeader) { this.searchStore = searchStore; this.userAccessValidatorInstance = userAccessValidatorInstance; this.jwtInstance = jwtInstance; + this.proxyUsernameHeader = proxyUsernameHeader; } @GET @@ -76,13 +85,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/security/ProxyAccessControlFilter.java b/calm-hub/src/main/java/org/finos/calm/security/ProxyAccessControlFilter.java new file mode 100644 index 000000000..85c8c0858 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/security/ProxyAccessControlFilter.java @@ -0,0 +1,67 @@ +package org.finos.calm.security; + +import io.quarkus.arc.profile.IfBuildProfile; +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.config.inject.ConfigProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Objects; + +@ApplicationScoped +@Provider +@Priority(1) +@IfBuildProfile("proxy") +public class ProxyAccessControlFilter implements ContainerRequestFilter { + + private final ResourceInfo resourceInfo; + private final UserAccessValidator userAccessValidator; + private final String usernameHeader; + private final Logger logger = LoggerFactory.getLogger(ProxyAccessControlFilter.class); + + public ProxyAccessControlFilter(ResourceInfo resourceInfo, + UserAccessValidator userAccessValidator, + @ConfigProperty(name = "calm.security.proxy.username-header", defaultValue = "Proxy-Remote-User") String usernameHeader) { + this.resourceInfo = resourceInfo; + this.userAccessValidator = userAccessValidator; + this.usernameHeader = usernameHeader; + } + + @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 username = requestContext.getHeaderString(usernameHeader); + if (username == null || username.isBlank()) { + logger.warn("Request rejected: {} header is missing or blank", usernameHeader); + requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED) + .entity("Unauthorized: " + usernameHeader + " header is required") + .build()); + return; + } + + String requestMethod = requestContext.getMethod(); + 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: [{}]", username); + requestContext.abortWith(Response.status(Response.Status.FORBIDDEN) + .entity("Forbidden: user does not have required access grants") + .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..ea25f87ae 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,7 +1,6 @@ package org.finos.calm.security; import io.netty.util.internal.StringUtil; -import io.quarkus.arc.profile.IfBuildProfile; import jakarta.enterprise.context.ApplicationScoped; import org.finos.calm.domain.UserAccess; import org.finos.calm.domain.exception.UserAccessNotFoundException; @@ -17,10 +16,9 @@ * 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. + * This validator has no effext unless the 'secure' or 'proxy' profile is enabled. */ @ApplicationScoped -@IfBuildProfile("secure") public class UserAccessValidator { private static final Logger logger = LoggerFactory.getLogger(UserAccessValidator.class); diff --git a/calm-hub/src/main/resources/application-proxy.properties b/calm-hub/src/main/resources/application-proxy.properties new file mode 100644 index 000000000..cc16d1c5e --- /dev/null +++ b/calm-hub/src/main/resources/application-proxy.properties @@ -0,0 +1,4 @@ +quarkus.oidc.tenant-enabled=false +quarkus.oidc.enabled=false +# Header injected by the upstream proxy carrying the authenticated username (default: Proxy-Remote-User) +#calm.security.proxy.username-header=Proxy-Remote-User 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..761d721d6 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, "Proxy-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, "Proxy-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, "Proxy-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, "Proxy-Remote-User"); resource.search("test"); ArgumentCaptor>> captor = namespacesCaptor(); diff --git a/calm-hub/src/test/java/org/finos/calm/security/TestProxyAccessControlFilterShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestProxyAccessControlFilterShould.java new file mode 100644 index 000000000..645797129 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/security/TestProxyAccessControlFilterShould.java @@ -0,0 +1,126 @@ +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.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 TestProxyAccessControlFilterShould { + + @Mock + ContainerRequestContext requestContext; + @Mock + ResourceInfo resourceInfo; + @Mock + UserAccessValidator userAccessValidator; + + private ProxyAccessControlFilter filter; + + private static final String TEST_HEADER = "Proxy-Remote-User"; + + @BeforeEach + void setup() { + MockitoAnnotations.openMocks(this); + filter = new ProxyAccessControlFilter(resourceInfo, userAccessValidator, TEST_HEADER); + } + + @Test + void allow_the_request_when_scopes_not_defined_on_resource() throws NoSuchMethodException { + Method method = TestResource.class.getMethod("unsecuredEndpoint"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + + filter.filter(requestContext); + + verify(requestContext, never()).abortWith(any()); + } + + @Test + void reject_with_401_when_proxy_remote_user_header_is_absent() throws NoSuchMethodException { + Method method = TestResource.class.getMethod("securedEndpoint"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + when(requestContext.getHeaderString(TEST_HEADER)).thenReturn(null); + + filter.filter(requestContext); + + verify(requestContext).abortWith(argThat(response -> response.getStatus() == HttpStatus.SC_UNAUTHORIZED)); + } + + @Test + void reject_with_401_when_proxy_remote_user_header_is_blank() throws NoSuchMethodException { + Method method = TestResource.class.getMethod("securedEndpoint"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + when(requestContext.getHeaderString(TEST_HEADER)).thenReturn(" "); + + filter.filter(requestContext); + + verify(requestContext).abortWith(argThat(response -> response.getStatus() == HttpStatus.SC_UNAUTHORIZED)); + } + + @Test + void reject_with_403_when_user_lacks_access_grants() throws NoSuchMethodException { + Method method = TestResource.class.getMethod("securedEndpoint"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + when(requestContext.getHeaderString(TEST_HEADER)).thenReturn("alice"); + when(requestContext.getMethod()).thenReturn("GET"); + + UriInfo mockUriInfo = mock(UriInfo.class); + when(mockUriInfo.getPath()).thenReturn("/calm/namespaces/finos/architectures"); + MultivaluedMap pathParams = new MultivaluedHashMap<>(); + pathParams.add("namespace", "finos"); + when(mockUriInfo.getPathParameters()).thenReturn(pathParams); + when(requestContext.getUriInfo()).thenReturn(mockUriInfo); + + when(userAccessValidator.isUserAuthorized(any(UserRequestAttributes.class))).thenReturn(false); + + filter.filter(requestContext); + + verify(requestContext).abortWith(argThat(response -> response.getStatus() == HttpStatus.SC_FORBIDDEN)); + } + + @Test + void allow_request_when_user_has_required_access_grants() throws NoSuchMethodException { + Method method = TestResource.class.getMethod("securedEndpoint"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + when(requestContext.getHeaderString(TEST_HEADER)).thenReturn("alice"); + when(requestContext.getMethod()).thenReturn("GET"); + + UriInfo mockUriInfo = mock(UriInfo.class); + when(mockUriInfo.getPath()).thenReturn("/calm/namespaces/finos/architectures"); + MultivaluedMap pathParams = new MultivaluedHashMap<>(); + pathParams.add("namespace", "finos"); + when(mockUriInfo.getPathParameters()).thenReturn(pathParams); + when(requestContext.getUriInfo()).thenReturn(mockUriInfo); + + when(userAccessValidator.isUserAuthorized(any(UserRequestAttributes.class))).thenReturn(true); + + filter.filter(requestContext); + + verify(requestContext, never()).abortWith(any()); + } + + private static class TestResource { + @SuppressWarnings("unused") + public List unsecuredEndpoint() { + return List.of(); + } + + @PermittedScopes({CalmHubScopes.ARCHITECTURES_READ}) + public void securedEndpoint() { + } + } +} From ff70fd816e712cfa9f41c4de4b33a1c1698debcd Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Mon, 18 May 2026 11:37:57 +0100 Subject: [PATCH 02/26] docs(calm-hub): improve README for proxy mode --- calm-hub/README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/calm-hub/README.md b/calm-hub/README.md index 45b6194b7..77ea9492c 100644 --- a/calm-hub/README.md +++ b/calm-hub/README.md @@ -120,6 +120,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`: 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 `Proxy-Remote-User`. + +#### Proxy profile + +To launch CalmHub with the proxy auth mode enabled: + +```bash +`../mvnw quarkus:dev -Dquarkus.profile=proxy` +``` + +**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` From 94bcef26823b313ea45db80e79eb26af36b28a01 Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Mon, 18 May 2026 12:01:57 +0100 Subject: [PATCH 03/26] feat(calm-hub): update default header to more standard Remote-User --- calm-hub/README.md | 2 +- .../java/org/finos/calm/resources/SearchResource.java | 2 +- .../org/finos/calm/security/ProxyAccessControlFilter.java | 2 +- calm-hub/src/main/resources/application-proxy.properties | 4 ++-- .../calm/resources/TestSearchResourceFilteringShould.java | 8 ++++---- .../calm/security/TestProxyAccessControlFilterShould.java | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/calm-hub/README.md b/calm-hub/README.md index 16266993a..3b7236d43 100644 --- a/calm-hub/README.md +++ b/calm-hub/README.md @@ -127,7 +127,7 @@ 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`: 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 `Proxy-Remote-User`. + - User identity is expected to be passed via a header; by default this is `Remote-User`. #### Proxy profile 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 6ace2076d..4d19f8df1 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 @@ -43,7 +43,7 @@ public class SearchResource { public SearchResource(SearchStore searchStore, Instance userAccessValidatorInstance, Instance jwtInstance, - @ConfigProperty(name = "calm.security.proxy.username-header", defaultValue = "Proxy-Remote-User") String proxyUsernameHeader) { + @ConfigProperty(name = "calm.security.proxy.username-header", defaultValue = "Remote-User") String proxyUsernameHeader) { this.searchStore = searchStore; this.userAccessValidatorInstance = userAccessValidatorInstance; this.jwtInstance = jwtInstance; diff --git a/calm-hub/src/main/java/org/finos/calm/security/ProxyAccessControlFilter.java b/calm-hub/src/main/java/org/finos/calm/security/ProxyAccessControlFilter.java index 85c8c0858..d9aab1652 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/ProxyAccessControlFilter.java +++ b/calm-hub/src/main/java/org/finos/calm/security/ProxyAccessControlFilter.java @@ -27,7 +27,7 @@ public class ProxyAccessControlFilter implements ContainerRequestFilter { public ProxyAccessControlFilter(ResourceInfo resourceInfo, UserAccessValidator userAccessValidator, - @ConfigProperty(name = "calm.security.proxy.username-header", defaultValue = "Proxy-Remote-User") String usernameHeader) { + @ConfigProperty(name = "calm.security.proxy.username-header", defaultValue = "Remote-User") String usernameHeader) { this.resourceInfo = resourceInfo; this.userAccessValidator = userAccessValidator; this.usernameHeader = usernameHeader; diff --git a/calm-hub/src/main/resources/application-proxy.properties b/calm-hub/src/main/resources/application-proxy.properties index cc16d1c5e..f3201b579 100644 --- a/calm-hub/src/main/resources/application-proxy.properties +++ b/calm-hub/src/main/resources/application-proxy.properties @@ -1,4 +1,4 @@ quarkus.oidc.tenant-enabled=false quarkus.oidc.enabled=false -# Header injected by the upstream proxy carrying the authenticated username (default: Proxy-Remote-User) -#calm.security.proxy.username-header=Proxy-Remote-User +# Header injected by the upstream proxy carrying the authenticated username (default: Remote-User) +#calm.security.proxy.username-header=Remote-User 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 761d721d6..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, "Proxy-Remote-User"); + 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, "Proxy-Remote-User"); + 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, "Proxy-Remote-User"); + 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, "Proxy-Remote-User"); + 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/security/TestProxyAccessControlFilterShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestProxyAccessControlFilterShould.java index 645797129..7e4d23888 100644 --- a/calm-hub/src/test/java/org/finos/calm/security/TestProxyAccessControlFilterShould.java +++ b/calm-hub/src/test/java/org/finos/calm/security/TestProxyAccessControlFilterShould.java @@ -31,7 +31,7 @@ public class TestProxyAccessControlFilterShould { private ProxyAccessControlFilter filter; - private static final String TEST_HEADER = "Proxy-Remote-User"; + private static final String TEST_HEADER = "Remote-User"; @BeforeEach void setup() { From 3f4eb83f44b02ac7d921eeb2069569834723b5a1 Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Tue, 19 May 2026 10:44:04 +0100 Subject: [PATCH 04/26] fix(calm-hub): minor pr fixes --- calm-hub/README.md | 2 +- .../main/java/org/finos/calm/security/UserAccessValidator.java | 3 ++- .../calm/security/TestProxyAccessControlFilterShould.java | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/calm-hub/README.md b/calm-hub/README.md index 3b7236d43..edbf0acac 100644 --- a/calm-hub/README.md +++ b/calm-hub/README.md @@ -134,7 +134,7 @@ The two modes are slightly different: To launch CalmHub with the proxy auth mode enabled: ```bash -`../mvnw quarkus:dev -Dquarkus.profile=proxy` +../mvnw quarkus:dev -Dquarkus.profile=proxy ``` **Important notes**: 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 ea25f87ae..d561ad2d8 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 @@ -16,9 +16,10 @@ * Validates whether a user is authorized to access a particular resource based on * their assigned permissions and namespaces. *

- * This validator has no effext unless the 'secure' or 'proxy' profile is enabled. + * This validator has no effect unless the 'secure' or 'proxy' profile is enabled. */ @ApplicationScoped +@IfBuildProfile({"secure", "proxy"}) public class UserAccessValidator { private static final Logger logger = LoggerFactory.getLogger(UserAccessValidator.class); diff --git a/calm-hub/src/test/java/org/finos/calm/security/TestProxyAccessControlFilterShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestProxyAccessControlFilterShould.java index 7e4d23888..5887ab3b6 100644 --- a/calm-hub/src/test/java/org/finos/calm/security/TestProxyAccessControlFilterShould.java +++ b/calm-hub/src/test/java/org/finos/calm/security/TestProxyAccessControlFilterShould.java @@ -19,7 +19,7 @@ import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.*; -@QuarkusTest +@ExtendWith(MockitoExtension.class) public class TestProxyAccessControlFilterShould { @Mock From 9c5df42385430f5311ac0aad7afe36fd004ca0bf Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Wed, 20 May 2026 14:59:32 +0100 Subject: [PATCH 05/26] feat(calm-hub): temp commit of auth writeups --- calm-hub/AUTH_ANALYSIS.md | 307 ++++++++++++++++++++ calm-hub/AUTH_IMPLEMENTATION_CODE.md | 284 ++++++++++++++++++ calm-hub/AUTH_PERMISSION_OPTIONS.md | 420 +++++++++++++++++++++++++++ 3 files changed, 1011 insertions(+) create mode 100644 calm-hub/AUTH_ANALYSIS.md create mode 100644 calm-hub/AUTH_IMPLEMENTATION_CODE.md create mode 100644 calm-hub/AUTH_PERMISSION_OPTIONS.md diff --git a/calm-hub/AUTH_ANALYSIS.md b/calm-hub/AUTH_ANALYSIS.md new file mode 100644 index 000000000..75b1a42b8 --- /dev/null +++ b/calm-hub/AUTH_ANALYSIS.md @@ -0,0 +1,307 @@ +# CalmHub Auth Analysis + +## Summary of overall approach + +In earlier discussions, we had the idea that OAuth 2.0 bearer tokens would be used for authentication, and the scopes granted by the IdP for authorization. +This approach is hard to customize for different IdPs and would almost certainly require an opinionated, per-use case module adding each time. + +In the spirit of getting CalmHub to a usable state as soon as possible, we have decided to move to a simple in-database entitlements system. + +- Authentication can be done by either JWT validation/`sub` principal extraction, or by a trusted proxy setting a `Remote-User` header. +- The database will store user entitlements. + - Keyed by namespace, or by control-domain for controls, which don't belong to a specific namespace. (NB this document focuses on reworking what we have today and doesn't introduce a control-domain entitlements document in the DB) + - Users will have actions on certain types of resource, or just `all` for everything. + - Actions are `read`, `write` and `admin`. + - By default, namespaces will be readable by everyone unless they're marked otherwise. +- Endpoints will be secured with the Quarkus permissions system. A Permission allows us to specify entitlements keyed by namespace and resource type. + - We'll be able to annotate endpoints with `@PermissionsAllowed` and implement some already-existing interfaces to do this. + - This replaces the custom annotations and filters we have today, which will reduce the complexity of the auth system. +- The introduction of `admin` to the database creates a bootstrapping problem where the first time a namespace is created, it won't have any admins or anyone entitled to add more admins. We will solve this problem by adding the creating user as an admin by default when a new namespace is created. +- Synchronisation of entitlements by an external system can be done in two ways: + - Using the UserAccess endpoints to add and remove entitlements via the REST API. + - By directly creating entitlements in the underlying Mongo database. +- Search becomes possible in the secure mode now because we have all entitlements in the database; we can simply filter out all namespaces on which the user does not have `read`. + +This document underlines the current implementation and its issues, briefly outlines why we need to use Quarkus permissions, and then provides a high-level overview of the implementation plan. + +## Current Implementation + +### Two Filters, Two Profiles + +Auth is split across two mutually exclusive filters, activated by Quarkus build profiles: + +| | JWT Mode (`secure`) | Proxy Mode (`proxy`) | +|---|---|---| +| Filter | `AccessControlFilter` | `ProxyAccessControlFilter` | +| Auth source | JWT `preferred_username` claim | `Remote-User` header (configurable) | +| OIDC | Enabled | Disabled | +| Identity assurance | IdP-issued JWT | Upstream proxy | + +Both modes activate `UserAccessValidator` for RBAC enforcement. + +--- + +### Layer 1: Scope Enforcement (`@PermittedScopes` + AccessControlFilter) + +Both filters check the `@PermittedScopes` annotation on the matched endpoint and enforce it — but the enforcement is asymmetric: + +- **JWT mode:** extracts the OAuth `scope` claim from the JWT and verifies at least one required scope is present. This is an OAuth 2.0 concept — scopes are granted by the authorization server at token issuance. +- **Proxy mode:** also checks `@PermittedScopes` but **there is no token**. The filter reads the annotation and proceeds directly to `UserAccessValidator`. The scope check is effectively a dead letter in proxy mode — it validates annotation presence but cannot enforce scope values against anything meaningful. + +The four defined scopes (`architectures:read`, `architectures:all`, `adrs:read`, `adrs:all`, `search:read`, `namespace:admin`) are OAuth constructs. In proxy mode they have no semantic counterpart. + +--- + +### Layer 2: RBAC (`UserAccessValidator`) + +Both modes share the same `UserAccessValidator`. It: + +1. Loads `UserAccess` grants from the database by username. +2. Maps the HTTP method to a permission level: `POST/PUT/PATCH/DELETE → write`, `GET → read`. +3. Iterates grants and checks: namespace match + resource type match + sufficient permission. +4. Returns `true` if any grant satisfies all three conditions. + +The `UserAccess` domain object carries: + +``` +username | permission (read|write) | namespace | resourceType (patterns|flows|adrs|architectures|all) +``` + +Two endpoints bypass namespace-level RBAC: `GET /calm/namespaces` (always allowed) and `GET /calm/search` (results filtered to the user's readable namespaces). + +--- + +### Do We Have Two Levels of Auth? + +**Yes, effectively.** In JWT mode: + +1. The IdP issues a token with granted scopes — coarse-grained capability claims. +2. `AccessControlFilter` checks the token is valid and the endpoint's required scope is present. +3. `UserAccessValidator` checks fine-grained namespace/resource/permission grants in the DB. + +Both must pass. A user with a valid `architectures:all` scope but no DB grant for the namespace is denied. A DB grant without a matching token scope is also denied. + +In **proxy mode**, layer 1 is hollow. The proxy authenticates the user and asserts their identity via header. There are no scopes to validate — the filter reads `@PermittedScopes` but can only verify the annotation exists; it cannot enforce scope values. Only layer 2 (DB RBAC) does real work. + +--- + +### Can We Remove Both Custom Filters? + +**Yes — entirely.** This is the key opportunity. Both `AccessControlFilter` and `ProxyAccessControlFilter` exist only because there was no Quarkus-managed identity to enforce against. The right approach is to feed authentication into Quarkus' security pipeline so that Quarkus' built-in enforcement (`@RolesAllowed`, `@PermissionsAllowed`) can handle the rest natively. + +--- + +## Desired Model: Quarkus-native RBAC + +### Why `@RolesAllowed` Alone Is Not Enough + +`@RolesAllowed` (Jakarta Security) checks whether the `SecurityIdentity` contains a named role string. Roles are global — the annotation is static and cannot reference a request path parameter. An endpoint annotated `@RolesAllowed("architecture-reader")` would grant access to *all* namespaces for anyone holding that role. + +CalmHub's access model is namespace-scoped: a user may read in `foo` but not `bar`. To express that declaratively on the endpoint annotation, we need **`@PermissionsAllowed`** (Quarkus 3.x), which supports parameterised permissions whose check logic is evaluated at runtime with values drawn from the method call. + +### Target Model + +``` +JWT mode Proxy mode +────────────────── ────────────────────────────────── +quarkus-oidc validates ProxyAuthenticationMechanism +token, sets principal reads Remote-User header, sets principal + │ │ + └──────────────┬────────────────────────┘ + │ + CalmHubSecurityIdentityAugmentor (both modes) + loads UserAccess grants from DB + adds CalmHubPermission objects to SecurityIdentity + │ + ▼ + Quarkus enforces @PermissionsAllowed on endpoint + (namespace param matched at call time) +``` + +No custom filters or annotation scanning, and no need for manual 403 responses should a request fail the entitlements checks.. + +### The Permission Model + +`@PermissionsAllowed` on an endpoint declares which named permission is required. The question is how that check is satisfied. There are two Quarkus-native approaches, and they have a meaningful practical difference. + +#### Option A: `SecurityIdentityAugmentor` + +The augmentor runs immediately after authentication. It loads **all** `UserAccess` grants for the current user from the database, converts them into `CalmHubPermission` objects, and attaches them to the `SecurityIdentity`. When an endpoint is reached, Quarkus calls `permission.implies()` on each pre-loaded permission to find a match. + +- The DB is hit once per request, at authentication time, regardless of which endpoint is called +- All grants are loaded even though typically only one namespace is relevant to the request +- Requires a custom `CalmHubPermission` class extending `java.security.Permission` with a correct `implies()` implementation +- The permission objects live on the identity for the lifetime of the request; if multiple secured methods are called in a chain, no additional DB queries occur + +#### Option B: `@PermissionChecker` + +A `@PermissionChecker` method is a CDI bean method annotated with the permission name it satisfies. Quarkus calls it at authorisation time — after authentication, when the endpoint is about to be invoked — passing the method's parameters (including the `namespace` path argument) directly to the checker. The checker queries the DB for that specific user, namespace, and resource type and returns a boolean. + +- The DB is hit at authorisation time, not authentication time — and only for the specific permission being checked on this request +- No `CalmHubPermission` class required; the logic is a plain boolean method +- Write-implies-read is expressed as a simple condition in the checker rather than as an `implies()` contract on a Permission object +- One checker method per distinct permission name is needed, but they all delegate to a shared helper + +#### Comparison + +| | `SecurityIdentityAugmentor` | `@PermissionChecker` | +|---|---|---| +| DB query timing | Authentication (always) | Authorisation (on demand) | +| Data loaded | All grants for user | Only grants relevant to this request | +| Custom class required | `CalmHubPermission` with `implies()` | None | +| Write-implies-read | Encoded in `implies()` | Simple conditional in checker | +| `resourceType=all` expansion | Expand at augmentation time | Query covers `all` and specific type | +| Search result filtering | Separate call still needed | Separate call still needed | + +#### Recommendation + +`@PermissionChecker` is the better fit for CalmHub. The augmentor's pre-loading is only advantageous when the same identity is checked many times per request; for a standard REST or MCP call, one targeted DB query at authorisation time is sufficient and simpler. It also removes the need for a custom `Permission` class entirely — the check logic lives in a readable boolean method rather than an `implies()` implementation. + +The one case that genuinely needs all grants — search result filtering in `SearchResource` — already calls `getUserAccessForUsername()` directly and is unaffected by which approach is used for endpoint enforcement. + +Code examples for all new components are in [AUTH_IMPLEMENTATION_CODE.md](AUTH_IMPLEMENTATION_CODE.md). + +--- + +## Implementation Steps + +### 1. Add `CalmHubPermissionChecker` + +A single CDI bean containing one `@PermissionChecker` method per named permission. This is the only new class required for enforcement — no custom `Permission` subclass is needed. + +- Each method is annotated with the permission name it satisfies, matching the value used in `@PermissionsAllowed` on the endpoints (e.g. `architecture:read`, `adr:write`, `namespace:admin`) +- Each method receives the `SecurityIdentity` (for the username) and the `namespace` argument from the endpoint call, injected automatically by Quarkus +- The method queries the database for a matching `UserAccess` grant: same username, same namespace, matching resource type (or `all`), and sufficient permission level +- Write-implies-read is expressed as a straightforward condition: a checker for a read permission accepts either a `read` or `write` grant +- All checker methods delegate to a single private helper to avoid repetition; the helper encapsulates the grant lookup and matching logic +- Active in both `secure` and `proxy` profiles; has no awareness of how the identity was established + +### 3. Add `ProxyAuthenticationMechanism` (proxy mode only) + +A Quarkus `HttpAuthenticationMechanism` that establishes identity from the `Remote-User` header (or the configured equivalent). This is the proxy-mode counterpart to what `quarkus-oidc` does automatically in JWT mode. + +- Active only in the `proxy` build profile, exactly as `ProxyAccessControlFilter` was +- Reads the configured username header from the incoming request +- If the header is absent or blank, returns no identity — Quarkus will respond with 401 automatically via the challenge mechanism +- If the header is present, creates a Quarkus `SecurityIdentity` with the header value as the principal name, then hands off to the augmentor to add DB-backed permissions +- Retains the same `calm.security.proxy.username-header` configuration property so existing deployments require no changes +- Replaces `ProxyAccessControlFilter` entirely; the JWT mode requires no equivalent change because `quarkus-oidc` already handles authentication correctly + +### 4. Annotate Endpoints with `@PermitAll`, `@Authenticated`, or `@PermissionsAllowed` + +Replace every `@PermittedScopes` annotation across the resource classes. There are three tiers: + +- **`@PermitAll`** — no authentication required; anonymous requests are allowed through. For use on genuinely public endpoints such as health checks or read-only public schema endpoints. Not currently used in CalmHub but available for any endpoint that should be open without a login. +- **`@Authenticated`** — a valid identity must be present (JWT or `Remote-User` header), but no specific permission is checked. Appropriate for `GET /calm/namespaces` and `GET /calm/search`, where any logged-in user may call the endpoint and content is filtered by their grants rather than the request being blocked outright. +- **`@PermissionsAllowed`** — valid identity plus a specific namespace-scoped permission required. Used on all resource endpoints: read operations require `{resource}:read`, write/create/delete operations require `{resource}:write`, and user access management requires `namespace:admin`. The `namespace` path parameter is bound at call time so the check is scoped to the namespace in the URL. + +One configuration note for the `secure` (JWT/OIDC) profile: Quarkus OIDC still attempts token validation on every request even for `@PermitAll` endpoints. For endpoints that should be truly anonymous with no OIDC involvement, the paths must also be listed in `quarkus.http.auth.permission.public.paths` in `application-secure.properties`. + +### 5. Simplify `UserAccessValidator` + +With enforcement delegated to `CalmHubPermissionChecker`, most of `UserAccessValidator` becomes dead code. + +- `isUserAuthorized()` and its helpers (`mapHttpMethodToPermission()`, `hasAccessForActionOnResource()`, `permissionAllows()`) are removed — the checker handles this logic directly +- `getReadableNamespaces()` is retained because `SearchResource` needs it to filter search results to namespaces the user can see — this is a data-shaping concern distinct from access enforcement +- The `UserRequestAttributes` record is deleted as it has no remaining callers +- If in a future iteration `SearchResource` is updated to derive readable namespaces from the DB directly, `UserAccessValidator` can be removed entirely + +### 6. Delete Dead Code + +The following classes and annotations have no remaining purpose once the above steps are complete: + +- `AccessControlFilter` — authentication and enforcement are now handled by OIDC and Quarkus respectively +- `ProxyAccessControlFilter` — replaced by `ProxyAuthenticationMechanism` +- `PermittedScopes` annotation — replaced by `@PermissionsAllowed` +- `CalmHubScopes` constants class — no longer referenced anywhere + +### 7. Update Tests + +The test surface shifts significantly: filter unit tests are deleted and replaced with focused tests on the three new components, plus integration tests that use Quarkus's built-in test security support. + +| Old test | Disposition | +|---|---| +| `TestAccessControlFilterShould` | Delete — the filter no longer exists | +| `TestProxyAccessControlFilterShould` | Delete — the filter no longer exists | +| `TestUserAccessValidatorShould` | Trim to cover `getReadableNamespaces()` only | + +New unit tests: + +- **`TestCalmHubPermissionCheckerShould`** — covers the checker logic in isolation: a read grant allows a read check; a write grant allows both read and write checks; a grant for a different namespace denies the check; a `resourceType=all` grant satisfies checks for specific resource types; a user with no grants is denied; `UserAccessNotFoundException` is handled gracefully +- **`TestProxyAuthenticationMechanismShould`** — verifies that a request with the expected header produces an identity with the correct principal name; verifies that a missing or blank header triggers a 401 challenge + +Integration tests should use Quarkus `@TestSecurity` to inject a pre-built `SecurityIdentity` with known permissions, removing the need to mock filters or intercept request pipelines. + +--- + +## `UserAccessResource` Fit Analysis + +`UserAccessResource` is the API for managing DB grants — the data that the augmentor reads to build the `SecurityIdentity`. It has four issues under the new model. + +### 1. Missing DELETE endpoint + +The resource currently has `POST /{namespace}/user-access` (create) and two GET endpoints (list all, get by id). There is no way to revoke a grant. Without a DELETE, the only way to remove access is directly in the database. This gap exists today but becomes more visible when the DB grants are the sole source of truth for enforcement. + +A `DELETE /{namespace}/user-access/{userAccessId}` endpoint is needed, gated with the same `namespace:admin` permission as the other operations. + +### 2. `namespace:admin` has no representation in the DB model + +The `UserAccess.ResourceType` enum contains `patterns`, `flows`, `adrs`, `architectures`, and `all`. There is no `admin` type. Under the new model, the augmentor needs to know when to add a `namespace:admin` permission to an identity — but nothing in the current DB schema carries that signal. + +There are two options: + +- **Add `admin` as a new `ResourceType`** — a user with `resourceType=admin, permission=write` in a namespace gets the `namespace:admin` permission added by the augmentor. This is the cleanest approach and keeps everything in the existing DB structure. +- **Treat `resourceType=all` + `permission=write` as implying admin** — simpler but conflates "can write resources" with "can manage other users' access", which are meaningfully different capabilities. + +Adding `admin` as an explicit resource type is recommended. + +### 3. Chicken-and-egg bootstrap problem + +Under the old model, the `NAMESPACE_ADMIN` scope is granted by the IdP at token issuance, outside the DB. An operator with the right JWT can call `UserAccessResource` immediately after deployment to seed grants. Under the new model, the `namespace:admin` permission comes from the DB — so there are no grants to begin with, and `UserAccessResource` is protected by the very grants it creates. + +This needs to be resolved before the migration can go live. Options: + +- **Seed grants at namespace creation time** — when a namespace is created via `POST /calm/namespaces`, automatically insert a `namespace:admin` grant for the creating user. This requires passing the authenticated username into `NamespaceResource`. +- **Provide a startup seed mechanism** — a configuration-driven or migration-script approach that inserts bootstrap admin grants during deployment. +- **Hybrid: retain JWT scope as a fallback for bootstrap only** — in `secure` mode, allow the `NAMESPACE_ADMIN` JWT scope to act as a super-admin bypass specifically for `UserAccessResource`. This preserves backwards compatibility but re-introduces some scope logic in one place. + +The seed-at-namespace-creation approach is the most self-contained and does not require retaining any scope logic. + +### 4. No privilege escalation check + +The `createUserAccessForNamespace` method accepts a `UserAccess` object in the request body that specifies both the target `username` and their `permission` level. An admin for namespace `foo` can grant any other user any level of access in `foo`. This is intentional and correct, but it means an admin can also create another admin for the same namespace. There is no check preventing privilege escalation within the namespace. + +This is acceptable behaviour for an admin role by convention, but worth documenting as a deliberate design decision rather than an oversight. + +--- + +## MCP Tools and Security + +The `@Tool`-annotated methods in classes such as `ArchitectureTools` and `SearchTools` are currently **unprotected**. Unlike the REST resource classes, they carry no `@PermittedScopes` annotations and are not reached by either custom filter — they call the store directly. This is a live exposure that exists today regardless of the migration to the new model. + +### Security annotations do work on `@Tool` methods + +The Quarkiverse MCP server documentation explicitly confirms that security annotations such as `@Authenticated` and `@RolesAllowed` are supported on `@Tool` methods via the standard CDI interceptor mechanism. Because `@PermissionsAllowed` uses the same CDI mechanism as `@RolesAllowed`, it should apply equally. The `params = "namespace"` binding works by matching the Java parameter name at invocation time and does not depend on JAX-RS `@PathParam`, so it transfers directly to `@ToolArg` parameters of the same name. + +### Key caveat: error response format + +When a security check fails on a `@Tool` method, the MCP client does not receive an HTTP 4xx status code. Instead it receives an MCP protocol error with code `-32001`. This is a protocol-level difference from the REST endpoints and is inherent to how the MCP server handles errors — it cannot be changed by how the annotation is applied. + +The Quarkiverse documentation notes this limitation and recommends using HTTP-level `quarkus.http.auth.permission` policies (in `application.properties`) for authentication enforcement, reserving method-level annotations for authorisation. In practice for CalmHub, this means the `secure` and `proxy` profile HTTP permission policies already configured continue to handle authentication at the transport level, and `@PermissionsAllowed` on the tool methods handles the namespace-scoped authorisation check — consistent with how the REST endpoints will work after the migration. + +### Recommended action + +Securing the MCP tools should be treated as in-scope for this migration, not a follow-up. Each `@Tool` method that takes a `namespace` argument should receive a `@PermissionsAllowed` annotation with the appropriate permission name, mirroring the annotation applied to the equivalent REST endpoint. + +--- + +## Summary + +| Concern | JWT Mode today | Proxy Mode today | After migration | +|---|---|---|---| +| Identity establishment | quarkus-oidc (JWT) | `ProxyAccessControlFilter` (header) | quarkus-oidc (JWT) / `ProxyAuthenticationMechanism` (header) | +| Permission loading | `UserAccessValidator` in filter | `UserAccessValidator` in filter | `CalmHubPermissionChecker` queries DB at authorisation time | +| Endpoint enforcement | `@PermittedScopes` + custom filter | `@PermittedScopes` + custom filter (scope check no-op) | `@PermissionsAllowed` + Quarkus runtime | +| Namespace scoping | Manual check in filter | Manual check in filter | `CalmHubPermissionChecker` receives namespace from endpoint args | +| Custom filters | 2 | 2 | 0 | +| Custom annotations | 1 (`@PermittedScopes`) | 1 (`@PermittedScopes`) | 0 | diff --git a/calm-hub/AUTH_IMPLEMENTATION_CODE.md b/calm-hub/AUTH_IMPLEMENTATION_CODE.md new file mode 100644 index 000000000..491537905 --- /dev/null +++ b/calm-hub/AUTH_IMPLEMENTATION_CODE.md @@ -0,0 +1,284 @@ +# Auth Implementation — Code Reference + +Supporting code sketches for [AUTH_ANALYSIS.md](AUTH_ANALYSIS.md). These are illustrative; exact method signatures and imports will be confirmed during implementation. + +--- + +## 1. `CalmHubPermissionChecker` + +A single CDI bean satisfying all `@PermissionsAllowed` checks. Each public method handles one named permission; all delegate to a shared private helper. + +```java +@ApplicationScoped +@IfBuildProfile({"secure", "proxy"}) +public class CalmHubPermissionChecker { + + private final UserAccessStore userAccessStore; + + public CalmHubPermissionChecker(UserAccessStore userAccessStore) { + this.userAccessStore = userAccessStore; + } + + @PermissionChecker("architecture:read") + public boolean canReadArchitecture(SecurityIdentity identity, String namespace) { + return hasAccess(identity, namespace, UserAccess.ResourceType.architectures, false); + } + + @PermissionChecker("architecture:write") + public boolean canWriteArchitecture(SecurityIdentity identity, String namespace) { + return hasAccess(identity, namespace, UserAccess.ResourceType.architectures, true); + } + + @PermissionChecker("pattern:read") + public boolean canReadPattern(SecurityIdentity identity, String namespace) { + return hasAccess(identity, namespace, UserAccess.ResourceType.patterns, false); + } + + @PermissionChecker("pattern:write") + public boolean canWritePattern(SecurityIdentity identity, String namespace) { + return hasAccess(identity, namespace, UserAccess.ResourceType.patterns, true); + } + + @PermissionChecker("flow:read") + public boolean canReadFlow(SecurityIdentity identity, String namespace) { + return hasAccess(identity, namespace, UserAccess.ResourceType.flows, false); + } + + @PermissionChecker("flow:write") + public boolean canWriteFlow(SecurityIdentity identity, String namespace) { + return hasAccess(identity, namespace, UserAccess.ResourceType.flows, true); + } + + @PermissionChecker("adr:read") + public boolean canReadAdr(SecurityIdentity identity, String namespace) { + return hasAccess(identity, namespace, UserAccess.ResourceType.adrs, false); + } + + @PermissionChecker("adr:write") + public boolean canWriteAdr(SecurityIdentity identity, String namespace) { + return hasAccess(identity, namespace, UserAccess.ResourceType.adrs, true); + } + + @PermissionChecker("namespace:admin") + public boolean canAdminNamespace(SecurityIdentity identity, String namespace) { + return hasAccess(identity, namespace, UserAccess.ResourceType.admin, true); + } + + private boolean hasAccess(SecurityIdentity identity, String namespace, + UserAccess.ResourceType requiredType, boolean requireWrite) { + String username = identity.getPrincipal().getName(); + try { + return userAccessStore.getUserAccessForUsername(username).stream() + .anyMatch(grant -> namespaceMatches(grant, namespace) + && resourceMatches(grant, requiredType) + && permissionSufficient(grant, requireWrite)); + } catch (UserAccessNotFoundException e) { + return false; + } + } + + private boolean namespaceMatches(UserAccess grant, String namespace) { + return grant.getNamespace().equals(namespace); + } + + private boolean resourceMatches(UserAccess grant, UserAccess.ResourceType required) { + return grant.getResourceType() == UserAccess.ResourceType.all + || grant.getResourceType() == required; + } + + private boolean permissionSufficient(UserAccess grant, boolean requireWrite) { + return !requireWrite || grant.getPermission() == UserAccess.Permission.write; + } +} +``` + +--- + +## 2. `ProxyAuthenticationMechanism` + +Active only in the `proxy` profile. Reads the configured header and creates a `SecurityIdentity` with the header value as the principal name. The `CalmHubPermissionChecker` then handles authorisation for both profiles uniformly. + +```java +@ApplicationScoped +@IfBuildProfile("proxy") +public class ProxyAuthenticationMechanism implements HttpAuthenticationMechanism { + + @ConfigProperty(name = "calm.security.proxy.username-header", defaultValue = "Remote-User") + String usernameHeader; + + @Override + public Uni authenticate(RoutingContext context, + IdentityProviderManager identityManager) { + String username = context.request().getHeader(usernameHeader); + if (username == null || username.isBlank()) { + return Uni.createFrom().optional(Optional.empty()); + } + TrustedAuthRequest request = new TrustedAuthRequest(new QuarkusPrincipal(username)); + return identityManager.authenticate(request); + } + + @Override + public Uni getChallenge(RoutingContext context) { + return Uni.createFrom().item( + new ChallengeData(HttpResponseStatus.UNAUTHORIZED.code(), null, null)); + } + + @Override + public Set> getCredentialTypes() { + return Set.of(TrustedAuthRequest.class); + } +} +``` + +--- + +## 3. Endpoint Annotation Examples + +The `params = "namespace"` binding matches the Java parameter name on the method, not a JAX-RS annotation — so the same pattern applies to both REST endpoints and MCP `@Tool` methods. + +### REST — read endpoint +```java +@GET +@Path("/{namespace}/architectures") +@PermissionsAllowed(value = "architecture:read", params = "namespace") +public Response getArchitectures(@PathParam("namespace") String namespace) { ... } +``` + +### REST — write endpoint +```java +@POST +@Path("/{namespace}/architectures") +@PermissionsAllowed(value = "architecture:write", params = "namespace") +public Response createArchitecture(@PathParam("namespace") String namespace, ...) { ... } +``` + +### REST — namespace admin (UserAccessResource) +```java +@GET +@Path("/{namespace}/user-access") +@PermissionsAllowed(value = "namespace:admin", params = "namespace") +public Response getUserAccess(@PathParam("namespace") String namespace) { ... } +``` + +### REST — no-namespace endpoints +```java +@GET +@Path("/namespaces") +@Authenticated +public Response getNamespaces() { ... } + +@GET +@Path("/search") +@Authenticated +public Response search(@QueryParam("q") String query) { ... } +``` + +### MCP tool — read +```java +@Tool(description = "List all architectures in a CalmHub namespace.") +@PermissionsAllowed(value = "architecture:read", params = "namespace") +public ToolResponse listArchitectures( + @ToolArg(description = "The namespace to list architectures from") String namespace) { ... } +``` + +### MCP tool — write +```java +@Tool(description = "Create a new architecture in a namespace.") +@PermissionsAllowed(value = "architecture:write", params = "namespace") +public ToolResponse createArchitecture( + @ToolArg(description = "The namespace to create the architecture in") String namespace, + ...) { ... } +``` + +--- + +## 4. `UserAccessValidator` — retained method only + +```java +// isUserAuthorized() and all helpers are deleted. +// Only getReadableNamespaces() is kept for SearchResource result filtering. + +public Set getReadableNamespaces(String username) { + try { + return userAccessStore.getUserAccessForUsername(username) + .stream() + .map(UserAccess::getNamespace) + .collect(Collectors.toSet()); + } catch (UserAccessNotFoundException ex) { + logger.debug("No access permissions found for user [{}]", username); + return Set.of(); + } +} +``` + +--- + +## 5. Test Sketches + +### `TestCalmHubPermissionCheckerShould` + +```java +@Test +void read_grant_allows_read_check() { + // given a read grant for architectures in namespace foo + // when canReadArchitecture is called with identity(alice) and namespace foo + // then returns true +} + +@Test +void write_grant_allows_read_check() { + // given a write grant for architectures in namespace foo + // when canReadArchitecture is called + // then returns true (write implies read) +} + +@Test +void read_grant_denies_write_check() { + // given a read grant for architectures in namespace foo + // when canWriteArchitecture is called + // then returns false +} + +@Test +void grant_for_different_namespace_denies_check() { + // given a write grant for architectures in namespace bar + // when canWriteArchitecture is called with namespace foo + // then returns false +} + +@Test +void all_resource_type_satisfies_specific_resource_check() { + // given a write grant with resourceType=all in namespace foo + // when canReadArchitecture, canReadPattern, canReadFlow, canReadAdr are called + // then all return true +} + +@Test +void user_with_no_grants_is_denied() { + // given userAccessStore throws UserAccessNotFoundException + // when any checker method is called + // then returns false +} +``` + +### `TestProxyAuthenticationMechanismShould` + +```java +@Test +void present_header_produces_identity_with_correct_principal() { + // given a request with Remote-User: alice + // then the resulting SecurityIdentity has principal name "alice" +} + +@Test +void missing_header_returns_empty_identity() { + // given a request with no Remote-User header + // then authenticate() returns an empty Optional (triggering 401 challenge) +} + +@Test +void blank_header_is_treated_as_missing() { + // given Remote-User: " " + // then same result as missing header +} +``` diff --git a/calm-hub/AUTH_PERMISSION_OPTIONS.md b/calm-hub/AUTH_PERMISSION_OPTIONS.md new file mode 100644 index 000000000..05acb479e --- /dev/null +++ b/calm-hub/AUTH_PERMISSION_OPTIONS.md @@ -0,0 +1,420 @@ +# Permission Enforcement Options + +Three Quarkus-native approaches for enforcing `@PermissionsAllowed` on CalmHub endpoints. None require a custom `@PermittedScopes`-style annotation or a hand-rolled JAX-RS filter. + +--- + +## Option A — `@PermissionChecker` + +No custom `Permission` class. No augmentor. A CDI bean with one checker method per named permission. Quarkus calls the matching method at authorisation time, passing the `SecurityIdentity` and the endpoint's `namespace` parameter. + +**Checkers needed:** one per distinct `@PermissionsAllowed` value across the API. + +**Resource-type granularity:** only if you encode resource type in the permission name (e.g. `architecture:read`), which causes the combinatorial explosion. Dropping resource type from the name reduces it to three checkers (`calm:read`, `calm:write`, `namespace:admin`) but means any read grant in a namespace allows reading any resource type. + +**DB query timing:** at authorisation time, targeted to the specific namespace being accessed. + +```java +@ApplicationScoped +@IfBuildProfile({"secure", "proxy"}) +public class CalmHubPermissionChecker { + + @Inject UserAccessStore userAccessStore; + + @PermissionChecker("calm:read") + public boolean canRead(SecurityIdentity identity, String namespace) { + return check(identity.getPrincipal().getName(), namespace, false); + } + + @PermissionChecker("calm:write") + public boolean canWrite(SecurityIdentity identity, String namespace) { + return check(identity.getPrincipal().getName(), namespace, true); + } + + @PermissionChecker("namespace:admin") + public boolean canAdmin(SecurityIdentity identity, String namespace) { + return check(identity.getPrincipal().getName(), namespace, + ResourceType.admin, true); + } + + private boolean check(String username, String namespace, boolean requireWrite) { + try { + return userAccessStore.getUserAccessForUsername(username).stream() + .anyMatch(grant -> + grant.getNamespace().equals(namespace) + && grant.getResourceType() != ResourceType.admin + && (!requireWrite || grant.getPermission() == Permission.write)); + } catch (UserAccessNotFoundException e) { + return false; + } + } +} +``` + +Endpoint annotation: +```java +@PermissionsAllowed(value = "calm:read", params = "namespace") +@PermissionsAllowed(value = "calm:write", params = "namespace") +@PermissionsAllowed(value = "namespace:admin", params = "namespace") +``` + +--- + +## Option B — Custom `Permission` class + augmentor + +Two classes. `CalmHubPermission` is the *required* permission — Quarkus instantiates it from the `@PermissionsAllowed` annotation at check time, binding the `namespace` path parameter via the constructor. `CalmHubGrantedPermission` is a *proxy* stored on the identity by the augmentor — its `implies()` method performs the targeted DB query when Quarkus calls it. + +**Checkers needed:** zero. Logic lives in `implies()`. + +**Resource-type granularity:** preserved. The permission name (`architecture:read`, `adr:write`, etc.) is parsed inside `implies()`. New resource types require no new Java code — only a new `@PermissionsAllowed` value on the endpoint. + +**DB query timing:** at authorisation time (inside `implies()`), targeted to the specific namespace, resource type, and action being checked. The augmentor runs at auth time but does not touch the DB. + +### `CalmHubPermission` (required — created by Quarkus) + +> Quarkus requires exactly one constructor. The first parameter is always the permission name (String); additional parameters are bound by name from the secured endpoint method. + +```java +public class CalmHubPermission extends Permission { + private final String namespace; + + public CalmHubPermission(String name, String namespace) { + super(name); // e.g. "architecture:read", "adr:write", "namespace:admin" + this.namespace = namespace; + } + + public String getNamespace() { return namespace; } + + @Override public boolean implies(Permission p) { return false; } // unused + @Override public boolean equals(Object o) { ... } + @Override public int hashCode() { ... } + @Override public String getActions() { return ""; } +} +``` + +### `CalmHubGrantedPermission` (proxy — created by augmentor) + +```java +public class CalmHubGrantedPermission extends Permission { + private final String username; + private final UserAccessStore store; + + public CalmHubGrantedPermission(String username, UserAccessStore store) { + super("calm-hub-granted"); + this.username = username; + this.store = store; + } + + @Override + public boolean implies(Permission p) { + if (!(p instanceof CalmHubPermission required)) return false; + String[] parts = required.getName().split(":"); + String resource = parts[0]; // e.g. "architecture" + String action = parts[1]; // "read" or "write" + try { + return store.getUserAccessForUsername(username).stream() + .anyMatch(grant -> + grant.getNamespace().equals(required.getNamespace()) + && resourceMatches(grant, resource) + && actionSufficient(grant, action)); + } catch (UserAccessNotFoundException e) { + return false; + } + } + + private boolean resourceMatches(UserAccess grant, String resource) { + return grant.getResourceType() == ResourceType.all + || grant.getResourceType().name().equals(resource); + } + + private boolean actionSufficient(UserAccess grant, String action) { + return action.equals("read") || grant.getPermission() == Permission.write; + } + + @Override public boolean equals(Object o) { ... } + @Override public int hashCode() { ... } + @Override public String getActions() { return ""; } +} +``` + +### Augmentor (no DB query at auth time) + +```java +@ApplicationScoped +@IfBuildProfile({"secure", "proxy"}) +public class CalmHubSecurityIdentityAugmentor implements SecurityIdentityAugmentor { + + @Inject UserAccessStore userAccessStore; + + @Override + public Uni augment(SecurityIdentity identity, + AuthenticationRequestContext context) { + return Uni.createFrom().item( + QuarkusSecurityIdentity.builder(identity) + .addPermission(new CalmHubGrantedPermission( + identity.getPrincipal().getName(), userAccessStore)) + .build()); + } +} +``` + +### Endpoint annotation + +```java +@PermissionsAllowed(value = "architecture:read", permission = CalmHubPermission.class, params = "namespace") +@PermissionsAllowed(value = "adr:write", permission = CalmHubPermission.class, params = "namespace") +@PermissionsAllowed(value = "namespace:admin", permission = CalmHubPermission.class, params = "namespace") +``` + +--- + +## Option C — Single `Permission` class with constructor parameter binding + +One class, no `@PermissionChecker` methods. `implies()` is the single checking method. + +The key mechanism: Quarkus binds endpoint method parameters to custom `Permission` constructor parameters **by name**. When a `@PathParam("namespace") String namespace` exists on the endpoint method and the constructor also has a `String namespace` parameter, Quarkus passes the runtime value automatically. This means the same class is used for both the permissions the augmentor stores on the identity (namespace from DB grant) and the permission Quarkus constructs at check time (namespace from the request path). `implies()` then does a pure in-memory comparison between the two — no DB access. + +**Classes needed:** 1 (`CalmHubPermission`) + 1 augmentor. + +**DB query timing:** authentication time — one query per request, grants expanded into permission objects and stored on the identity. + +**Checker methods:** zero — `implies()` is the single checker. + +**Scales with new resource types:** yes. Adding a new endpoint with a new permission value requires no new Java code. + +### `CalmHubPermission` + +The constructor parses `resource` and `action` from the permission name (e.g. `"architecture:read"` → `resource="architecture"`, `action="read"`). The `namespace` parameter is bound automatically by Quarkus from the endpoint method at check time, and supplied explicitly by the augmentor from the DB grant at auth time. + +```java +public class CalmHubPermission extends Permission { + private final String namespace; + private final String resource; + private final String action; // "read" or "write" + + // Quarkus binds namespace from the endpoint's path parameter by name matching. + // The augmentor calls this constructor directly with the grant's namespace. + public CalmHubPermission(String name, String namespace) { + super(name); // e.g. "architecture:read" + String[] parts = name.split(":"); + this.resource = parts[0]; // "architecture" + this.action = parts[1]; // "read" or "write" + this.namespace = namespace; + } + + @Override + public boolean implies(Permission p) { + if (!(p instanceof CalmHubPermission required)) return false; + return this.namespace.equals(required.namespace) + && this.resource.equals(required.resource) + && (this.action.equals("write") || required.action.equals("read")); + } + + @Override public boolean equals(Object o) { ... } + @Override public int hashCode() { ... } + @Override public String getActions() { return ""; } +} +``` + +### Augmentor + +Loads all grants at auth time, expands each into one or two `CalmHubPermission` objects (read + write if the grant is write-level; `resourceType=all` expands to one per resource type), and stores them on the identity. + +```java +@ApplicationScoped +@IfBuildProfile({"secure", "proxy"}) +public class CalmHubSecurityIdentityAugmentor implements SecurityIdentityAugmentor { + + @Inject UserAccessStore userAccessStore; + + @Override + public Uni augment(SecurityIdentity identity, + AuthenticationRequestContext context) { + String username = identity.getPrincipal().getName(); + return context.runBlocking(() -> { + QuarkusSecurityIdentity.Builder builder = + QuarkusSecurityIdentity.builder(identity); + try { + userAccessStore.getUserAccessForUsername(username) + .stream() + .flatMap(CalmHubSecurityIdentityAugmentor::toPermissions) + .forEach(builder::addPermission); + } catch (UserAccessNotFoundException ignored) {} + return builder.build(); + }); + } + + private static Stream toPermissions(UserAccess grant) { + Set resources = grant.getResourceType() == ResourceType.all + ? Set.of("architecture", "pattern", "flow", "adr") + : Set.of(grant.getResourceType().name()); + return resources.stream().flatMap(resource -> { + var perms = Stream.builder() + .add(new CalmHubPermission(resource + ":read", grant.getNamespace())); + if (grant.getPermission() == UserAccess.Permission.write) + perms.add(new CalmHubPermission(resource + ":write", grant.getNamespace())); + return perms.build(); + }); + } +} +``` + +### Endpoint annotation + +The `permission` attribute tells Quarkus which class to instantiate for the check. The `namespace` constructor parameter is bound automatically from the endpoint method's `namespace` parameter — no `params` attribute needed when names match. + +```java +@GET +@PermissionsAllowed(value = "architecture:read", permission = CalmHubPermission.class) +public Response getArchitectures(@PathParam("namespace") String namespace) { ... } + +@POST +@PermissionsAllowed(value = "architecture:write", permission = CalmHubPermission.class) +public Response createArchitecture(@PathParam("namespace") String namespace, ...) { ... } + +@GET +@PermissionsAllowed(value = "adr:read", permission = CalmHubPermission.class) +public Response getAdrs(@PathParam("namespace") String namespace) { ... } + +@POST +@PermissionsAllowed(value = "namespace:admin", permission = CalmHubPermission.class) +public Response createUserAccess(@PathParam("namespace") String namespace, ...) { ... } +``` + +MCP tools work identically — `namespace` is a `@ToolArg` parameter, name-matched the same way: + +```java +@Tool(description = "List all architectures in a namespace.") +@PermissionsAllowed(value = "architecture:read", permission = CalmHubPermission.class) +public ToolResponse listArchitectures( + @ToolArg(description = "The namespace") String namespace) { ... } +``` + +--- + +## Option D — Custom `HttpSecurityPolicy` + +A single CDI bean implementing `HttpSecurityPolicy`. Quarkus calls `checkPermission()` on every matched HTTP request before it reaches the endpoint, passing the raw `RoutingContext` (full HTTP request) and the `SecurityIdentity`. The method extracts namespace from the path, determines the required action from the HTTP method, queries the DB, and returns permit or deny. No annotations on endpoints, no custom `Permission` class, no augmentor. + +**Classes needed:** 1 (the policy). + +**Checker methods:** 1 (`checkPermission`). + +**Scales with new resource types:** yes — the path parser is the only thing that needs updating, and only if resource type enforcement is needed. + +**Limitation:** the `namespace` and resource type come from parsing the HTTP path. This works perfectly for REST endpoints. For MCP tools, the `namespace` is a parameter in the tool call payload (the JSON body), not in the URL — so this policy cannot enforce namespace-scoped authorisation on MCP tool invocations. MCP tools would need a separate approach (Options A, B, or C). + +### Implementation + +```java +@ApplicationScoped +@IfBuildProfile({"secure", "proxy"}) +public class CalmHubHttpSecurityPolicy implements HttpSecurityPolicy { + + @Inject UserAccessStore userAccessStore; + + @Override + public String name() { + return "calm-hub"; + } + + @Override + public Uni checkPermission(RoutingContext event, + Uni identity, + AuthorizationRequestContext context) { + return identity.flatMap(id -> { + String username = id.getPrincipal().getName(); + String path = event.normalizedPath(); + String method = event.request().method().name(); + String namespace = extractNamespace(path); + String resource = extractResource(path); + boolean requireWrite = isWriteMethod(method); + + // Paths with no namespace are accessible to any authenticated user + if (namespace == null) { + return id.isAnonymous() + ? CheckResult.deny() + : CheckResult.permit(); + } + + return context.runBlocking(() -> { + try { + boolean permitted = userAccessStore + .getUserAccessForUsername(username).stream() + .anyMatch(grant -> + grant.getNamespace().equals(namespace) + && resourceMatches(grant, resource) + && (!requireWrite || grant.getPermission() == UserAccess.Permission.write)); + return permitted ? CheckResult.PERMIT : CheckResult.DENY; + } catch (UserAccessNotFoundException e) { + return CheckResult.DENY; + } + }); + }); + } + + private String extractNamespace(String path) { + // /calm/namespaces/{namespace}/... + String[] parts = path.split("/"); + for (int i = 0; i < parts.length - 1; i++) { + if ("namespaces".equals(parts[i])) return parts[i + 1]; + } + return null; + } + + private String extractResource(String path) { + // last meaningful path segment: architectures, patterns, flows, adrs, user-access + String[] parts = path.split("/"); + for (int i = parts.length - 1; i >= 0; i--) { + String segment = parts[i]; + if (segment.matches("architectures|patterns|flows|adrs|user-access")) return segment; + } + return "all"; + } + + private boolean resourceMatches(UserAccess grant, String resource) { + return grant.getResourceType() == UserAccess.ResourceType.all + || grant.getResourceType().name().equals(resource); + } + + private boolean isWriteMethod(String method) { + return Set.of("POST", "PUT", "PATCH", "DELETE").contains(method); + } +} +``` + +### Configuration + +In `application-secure.properties` and `application-proxy.properties`: + +```properties +quarkus.http.auth.permission.calm-hub.paths=/calm/* +quarkus.http.auth.permission.calm-hub.policy=calm-hub +``` + +Alternatively, use `@AuthorizationPolicy("calm-hub")` directly on JAX-RS resource classes instead of the properties configuration — this is more explicit and keeps the security declaration close to the code. + +### What you don't need + +- No `@PermissionsAllowed` on any endpoint method +- No `@PermittedScopes` (removed) +- No augmentor +- No custom `Permission` class + +--- + +## Comparison + +| | Option A (`@PermissionChecker`) | Option B (custom `Permission`, lazy) | Option C (single `Permission` class) | Option D (`HttpSecurityPolicy`) | +|---|---|---|---|---| +| Java classes to write | 1 (checker) | 3 (permission, granted permission, augmentor) | 2 (permission + augmentor) | 1 (policy) | +| Checker / policy methods | One per permission name | Zero | Zero (`implies()` is the checker) | One (`checkPermission`) | +| Scales with new resource types | Only if resource type not in name | Yes — annotation value only | Yes — annotation value only | Yes — path parser only | +| Resource-type granularity | Requires more checker methods | Preserved at no extra cost | Preserved at no extra cost | Preserved via path parsing | +| DB query timing | Authorisation time | Authorisation time (inside `implies()`) | Authentication time (always) | Authorisation time | +| DB query scope | All grants for user, filtered in memory | All grants for user, filtered in memory | All grants for user, expanded into permission objects | All grants for user, filtered in memory | +| Endpoint annotations needed | `@PermissionsAllowed` on every method | `@PermissionsAllowed` on every method | `@PermissionsAllowed` on every method | None (or `@AuthorizationPolicy` on class) | +| MCP tool support | Yes (CDI interceptor) | Yes (CDI interceptor) | Yes (CDI interceptor) | No — namespace not in URL for MCP calls | +| Unusual patterns | None | DB access inside `Permission.implies()` | None | None | + +Option D is the most natural fit for the stated goal — one service, one method, all context passed in — and produces the simplest endpoint code. The MCP limitation is the deciding constraint: if MCP tools also need namespace-scoped enforcement, Option D cannot cover them and a second mechanism would be needed. Options A–C use CDI interceptors and work identically on REST endpoints and MCP tools. From e12436d4f6c3598ef5e2b3bc67dcaa9dd4e838b8 Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Wed, 20 May 2026 17:35:12 +0100 Subject: [PATCH 06/26] feat(calm-hub): auth spike work --- calm-hub/README.md | 4 +- .../org/finos/calm/domain/UserAccess.java | 3 +- .../calm/resources/ArchitectureResource.java | 7 + .../calm/security/AccessControlFilter.java | 125 ------------- .../security/CalmHubPermissionChecker.java | 88 ++++++++++ .../finos/calm/security/CalmHubScopes.java | 1 + .../security/ProxyAccessControlFilter.java | 67 ------- .../ProxyAuthenticationMechanism.java | 44 +++++ .../calm/security/UserAccessValidator.java | 165 +++++++++--------- .../org/finos/calm/security/UserAction.java | 17 ++ .../TestAccessControlFilterShould.java | 122 ------------- .../TestProxyAccessControlFilterShould.java | 126 ------------- .../TestUserAccessValidatorShould.java | 122 ------------- package-lock.json | 41 +++-- 14 files changed, 271 insertions(+), 661 deletions(-) delete mode 100644 calm-hub/src/main/java/org/finos/calm/security/AccessControlFilter.java create mode 100644 calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java delete mode 100644 calm-hub/src/main/java/org/finos/calm/security/ProxyAccessControlFilter.java create mode 100644 calm-hub/src/main/java/org/finos/calm/security/ProxyAuthenticationMechanism.java create mode 100644 calm-hub/src/main/java/org/finos/calm/security/UserAction.java delete mode 100644 calm-hub/src/test/java/org/finos/calm/security/TestAccessControlFilterShould.java delete mode 100644 calm-hub/src/test/java/org/finos/calm/security/TestProxyAccessControlFilterShould.java delete mode 100644 calm-hub/src/test/java/org/finos/calm/security/TestUserAccessValidatorShould.java diff --git a/calm-hub/README.md b/calm-hub/README.md index edbf0acac..063efc18c 100644 --- a/calm-hub/README.md +++ b/calm-hub/README.md @@ -126,7 +126,7 @@ 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`: Assumes CalmHub will be deployed behind an additional proxy component, such as `nginx` or `apache`, that performs OAuth 2 (or other) authentication for you. +- `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 @@ -134,7 +134,7 @@ The two modes are slightly different: To launch CalmHub with the proxy auth mode enabled: ```bash -../mvnw quarkus:dev -Dquarkus.profile=proxy +../mvnw quarkus:dev -Dquarkus.profile=proxy-auth ``` **Important notes**: 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..28f9b6713 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 @@ -15,7 +15,8 @@ public class UserAccess { public enum Permission { read, - write + write, + admin } public enum ResourceType { 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 779085674..11155fadc 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,6 @@ package org.finos.calm.resources; +import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; import jakarta.validation.constraints.Pattern; import jakarta.ws.rs.Consumes; @@ -68,6 +69,7 @@ public ArchitectureResource(ArchitectureStore store) { description = "Architecture stored in a given namespace" ) @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed({CalmHubScopes.ARCHITECTURES_READ}) public Response getArchitecturesForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -88,6 +90,7 @@ public Response getArchitecturesForNamespace( description = "Creates a architecture for a given namespace with an allocated ID and version 1.0.0" ) @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed({CalmHubScopes.ARCHITECTURES_WRITE}) public Response createArchitectureForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @@ -119,6 +122,7 @@ public Response createArchitectureForNamespace( description = "Architecture versions are not opinionated, outside of the first version created" ) @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed({CalmHubScopes.ARCHITECTURES_READ}) public Response getArchitectureVersions( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId @@ -147,6 +151,7 @@ public Response getArchitectureVersions( description = "Retrieve architectures at a specific version" ) @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed({CalmHubScopes.ARCHITECTURES_READ}) public Response getArchitecture( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId, @@ -176,6 +181,7 @@ public Response getArchitecture( @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed({CalmHubScopes.ARCHITECTURES_WRITE}) public Response createVersionedArchitecture( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId, @@ -215,6 +221,7 @@ public Response createVersionedArchitecture( 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.ARCHITECTURES_WRITE}) public Response updateVersionedArchitecture( @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/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..5f32d2e8b --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java @@ -0,0 +1,88 @@ +package org.finos.calm.security; + +import io.netty.util.internal.StringUtil; +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.enterprise.context.ApplicationScoped; +import org.finos.calm.domain.ResourceType; +import org.finos.calm.domain.UserAccess; +import org.finos.calm.domain.exception.UserAccessNotFoundException; +import org.finos.calm.store.UserAccessStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +@ApplicationScoped +public class CalmHubPermissionChecker { + private static final Logger logger = LoggerFactory.getLogger(CalmHubPermissionChecker.class); + + private final UserAccessStore userAccessStore; + + public CalmHubPermissionChecker(UserAccessStore userAccessStore) { + this.userAccessStore = userAccessStore; + } + + // TODO remaining checkers + examine scopes needed. + + @PermissionChecker("architectures:read") + public boolean allowArchitectureRead(SecurityIdentity securityIdentity, String namespace) { + // TODO it seems we either need this or a config prop to turn this on or off. + if (securityIdentity.isAnonymous()) return true; + return isUserEntitled(securityIdentity, namespace, ResourceType.ARCHITECTURE, UserAction.READ); + } + + @PermissionChecker("architectures:write") + public boolean allowArchitectureWrite(SecurityIdentity securityIdentity, String namespace) { + if (securityIdentity.isAnonymous()) return true; + return isUserEntitled(securityIdentity, namespace, ResourceType.ARCHITECTURE, UserAction.WRITE); + } + + // TODO do resource types affect things? Are entitlements different by resource type? + // TODO option to default-allow READ access + public boolean isUserEntitled(SecurityIdentity securityIdentity, + String namespace, + ResourceType resourceType, + UserAction action) { + if (StringUtil.isNullOrEmpty(namespace)) { + logger.error("Missing namespace when checking entitlements."); + throw new IllegalStateException("Permission checker expects 'namespace' String argument on annotated method, potentially misconfigured endpoint."); + } + String username = securityIdentity.getPrincipal().getName(); + logger.debug("Validating whether user [{}] has entitlement [{}] on namespace [{}]", username, action, namespace); + + List userAccesses; + try { + userAccesses = userAccessStore.getUserAccessForUsername(username); + } catch (UserAccessNotFoundException e) { + logger.error("Error while retrieving user entitlements for user {}", username, e); + throw new RuntimeException(e); + } + boolean result = userAccesses.stream().anyMatch(userAccess -> { + boolean namespaceMatches = namespace.equals(userAccess.getNamespace()); + boolean permissionSufficient = permissionAllows(userAccess.getPermission(), action); + return namespaceMatches && permissionSufficient; + }); + if (result) { + logger.info("User {} AUTHORIZED to perform action {} on resource of type {} in namespace {}", username, action, resourceType, namespace); + } else { + logger.warn("User {} DENIED to perform action {} on resource of type {} in namespace {}", username, action, resourceType, namespace); + } + return result; + } + + /** + * 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, UserAction requestedAction) { + return switch(userPermission) { + case read -> requestedAction == UserAction.READ; + case write -> requestedAction == UserAction.READ || requestedAction == 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..f8866af96 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 @@ -13,6 +13,7 @@ private CalmHubScopes() { * Allows read operations on Flows, Patterns, Namespaces, and Architectures resources. */ public static final String ARCHITECTURES_READ = "architectures:read"; + public static final String ARCHITECTURES_WRITE = "architectures:write"; /** * Allows full access (read, write, delete) on Flows, Patterns, Namespaces, and Architectures resources. diff --git a/calm-hub/src/main/java/org/finos/calm/security/ProxyAccessControlFilter.java b/calm-hub/src/main/java/org/finos/calm/security/ProxyAccessControlFilter.java deleted file mode 100644 index d9aab1652..000000000 --- a/calm-hub/src/main/java/org/finos/calm/security/ProxyAccessControlFilter.java +++ /dev/null @@ -1,67 +0,0 @@ -package org.finos.calm.security; - -import io.quarkus.arc.profile.IfBuildProfile; -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.config.inject.ConfigProperty; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Objects; - -@ApplicationScoped -@Provider -@Priority(1) -@IfBuildProfile("proxy") -public class ProxyAccessControlFilter implements ContainerRequestFilter { - - private final ResourceInfo resourceInfo; - private final UserAccessValidator userAccessValidator; - private final String usernameHeader; - private final Logger logger = LoggerFactory.getLogger(ProxyAccessControlFilter.class); - - public ProxyAccessControlFilter(ResourceInfo resourceInfo, - UserAccessValidator userAccessValidator, - @ConfigProperty(name = "calm.security.proxy.username-header", defaultValue = "Remote-User") String usernameHeader) { - this.resourceInfo = resourceInfo; - this.userAccessValidator = userAccessValidator; - this.usernameHeader = usernameHeader; - } - - @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 username = requestContext.getHeaderString(usernameHeader); - if (username == null || username.isBlank()) { - logger.warn("Request rejected: {} header is missing or blank", usernameHeader); - requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED) - .entity("Unauthorized: " + usernameHeader + " header is required") - .build()); - return; - } - - String requestMethod = requestContext.getMethod(); - 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: [{}]", username); - requestContext.abortWith(Response.status(Response.Status.FORBIDDEN) - .entity("Forbidden: user does not have required access grants") - .build()); - } - } -} 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..9f9e4fba9 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/security/ProxyAuthenticationMechanism.java @@ -0,0 +1,44 @@ +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.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 java.util.Optional; + +@ApplicationScoped +@IfBuildProfile("proxy-auth") +public class ProxyAuthenticationMechanism implements HttpAuthenticationMechanism { + @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)) { + return Uni.createFrom().optional(Optional.empty()); + } + return Uni.createFrom() + .item(QuarkusSecurityIdentity.builder() + .setPrincipal(new QuarkusPrincipal(username)) + .build()); + } + + @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/UserAccessValidator.java b/calm-hub/src/main/java/org/finos/calm/security/UserAccessValidator.java index d561ad2d8..b0473c0b8 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,7 @@ package org.finos.calm.security; import io.netty.util.internal.StringUtil; +import io.quarkus.arc.profile.IfBuildProfile; import jakarta.enterprise.context.ApplicationScoped; import org.finos.calm.domain.UserAccess; import org.finos.calm.domain.exception.UserAccessNotFoundException; @@ -19,7 +20,7 @@ * This validator has no effect unless the 'secure' or 'proxy' profile is enabled. */ @ApplicationScoped -@IfBuildProfile({"secure", "proxy"}) +@IfBuildProfile(anyOf = {"secure", "proxy-auth"}) public class UserAccessValidator { private static final Logger logger = LoggerFactory.getLogger(UserAccessValidator.class); @@ -37,50 +38,50 @@ public class UserAccessValidator { 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 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; - }); - } +// /** +// * 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, @@ -101,44 +102,44 @@ public Set getReadableNamespaces(String username) { } } - /** - * 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)); - } +// /** +// * 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; - }; - } +// /** +// * 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); - }; - } +// /** +// * 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/UserAction.java b/calm-hub/src/main/java/org/finos/calm/security/UserAction.java new file mode 100644 index 000000000..ba2875169 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/security/UserAction.java @@ -0,0 +1,17 @@ +package org.finos.calm.security; + +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/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/TestProxyAccessControlFilterShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestProxyAccessControlFilterShould.java deleted file mode 100644 index 5887ab3b6..000000000 --- a/calm-hub/src/test/java/org/finos/calm/security/TestProxyAccessControlFilterShould.java +++ /dev/null @@ -1,126 +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.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.*; - -@ExtendWith(MockitoExtension.class) -public class TestProxyAccessControlFilterShould { - - @Mock - ContainerRequestContext requestContext; - @Mock - ResourceInfo resourceInfo; - @Mock - UserAccessValidator userAccessValidator; - - private ProxyAccessControlFilter filter; - - private static final String TEST_HEADER = "Remote-User"; - - @BeforeEach - void setup() { - MockitoAnnotations.openMocks(this); - filter = new ProxyAccessControlFilter(resourceInfo, userAccessValidator, TEST_HEADER); - } - - @Test - void allow_the_request_when_scopes_not_defined_on_resource() throws NoSuchMethodException { - Method method = TestResource.class.getMethod("unsecuredEndpoint"); - when(resourceInfo.getResourceMethod()).thenReturn(method); - - filter.filter(requestContext); - - verify(requestContext, never()).abortWith(any()); - } - - @Test - void reject_with_401_when_proxy_remote_user_header_is_absent() throws NoSuchMethodException { - Method method = TestResource.class.getMethod("securedEndpoint"); - when(resourceInfo.getResourceMethod()).thenReturn(method); - when(requestContext.getHeaderString(TEST_HEADER)).thenReturn(null); - - filter.filter(requestContext); - - verify(requestContext).abortWith(argThat(response -> response.getStatus() == HttpStatus.SC_UNAUTHORIZED)); - } - - @Test - void reject_with_401_when_proxy_remote_user_header_is_blank() throws NoSuchMethodException { - Method method = TestResource.class.getMethod("securedEndpoint"); - when(resourceInfo.getResourceMethod()).thenReturn(method); - when(requestContext.getHeaderString(TEST_HEADER)).thenReturn(" "); - - filter.filter(requestContext); - - verify(requestContext).abortWith(argThat(response -> response.getStatus() == HttpStatus.SC_UNAUTHORIZED)); - } - - @Test - void reject_with_403_when_user_lacks_access_grants() throws NoSuchMethodException { - Method method = TestResource.class.getMethod("securedEndpoint"); - when(resourceInfo.getResourceMethod()).thenReturn(method); - when(requestContext.getHeaderString(TEST_HEADER)).thenReturn("alice"); - when(requestContext.getMethod()).thenReturn("GET"); - - UriInfo mockUriInfo = mock(UriInfo.class); - when(mockUriInfo.getPath()).thenReturn("/calm/namespaces/finos/architectures"); - MultivaluedMap pathParams = new MultivaluedHashMap<>(); - pathParams.add("namespace", "finos"); - when(mockUriInfo.getPathParameters()).thenReturn(pathParams); - when(requestContext.getUriInfo()).thenReturn(mockUriInfo); - - when(userAccessValidator.isUserAuthorized(any(UserRequestAttributes.class))).thenReturn(false); - - filter.filter(requestContext); - - verify(requestContext).abortWith(argThat(response -> response.getStatus() == HttpStatus.SC_FORBIDDEN)); - } - - @Test - void allow_request_when_user_has_required_access_grants() throws NoSuchMethodException { - Method method = TestResource.class.getMethod("securedEndpoint"); - when(resourceInfo.getResourceMethod()).thenReturn(method); - when(requestContext.getHeaderString(TEST_HEADER)).thenReturn("alice"); - when(requestContext.getMethod()).thenReturn("GET"); - - UriInfo mockUriInfo = mock(UriInfo.class); - when(mockUriInfo.getPath()).thenReturn("/calm/namespaces/finos/architectures"); - MultivaluedMap pathParams = new MultivaluedHashMap<>(); - pathParams.add("namespace", "finos"); - when(mockUriInfo.getPathParameters()).thenReturn(pathParams); - when(requestContext.getUriInfo()).thenReturn(mockUriInfo); - - when(userAccessValidator.isUserAuthorized(any(UserRequestAttributes.class))).thenReturn(true); - - filter.filter(requestContext); - - verify(requestContext, never()).abortWith(any()); - } - - private static class TestResource { - @SuppressWarnings("unused") - public List unsecuredEndpoint() { - return List.of(); - } - - @PermittedScopes({CalmHubScopes.ARCHITECTURES_READ}) - public void securedEndpoint() { - } - } -} 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 deleted file mode 100644 index 98042c0ee..000000000 --- a/calm-hub/src/test/java/org/finos/calm/security/TestUserAccessValidatorShould.java +++ /dev/null @@ -1,122 +0,0 @@ -package org.finos.calm.security; - -import org.finos.calm.domain.UserAccess; -import org.finos.calm.domain.UserAccess.Permission; -import org.finos.calm.domain.UserAccess.ResourceType; -import org.finos.calm.domain.exception.UserAccessNotFoundException; -import org.finos.calm.store.UserAccessStore; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.Set; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class TestUserAccessValidatorShould { - - private UserAccessStore userAccessStore; - private UserAccessValidator validator; - - @BeforeEach - void setUp() { - userAccessStore = mock(UserAccessStore.class); - validator = new UserAccessValidator(userAccessStore); - } - - @Test - void return_true_when_user_has_sufficient_permissions() throws Exception { - UserRequestAttributes requestAttributes = new UserRequestAttributes("GET", "testuser", - "/calm/namespace/finos/patterns", "finos"); - UserAccess userAccess = new UserAccess("testuser", Permission.read, "finos", ResourceType.patterns); - when(userAccessStore.getUserAccessForUsername("testuser")) - .thenReturn(List.of(userAccess)); - - boolean actual = validator.isUserAuthorized(requestAttributes); - assertTrue(actual); - } - - @Test - void return_false_when_user_has_no_matching_permission() throws Exception { - UserRequestAttributes requestAttributes = new UserRequestAttributes("GET", "testuser", - "/calm/namespace/finos/patterns", "finos"); - UserAccess userAccess = new UserAccess("testuser", Permission.read, "workshop", ResourceType.patterns); - when(userAccessStore.getUserAccessForUsername("testuser")) - .thenReturn(List.of(userAccess)); - - boolean actual = validator.isUserAuthorized(requestAttributes); - assertFalse(actual); - } - - @Test - void return_true_when_user_has_write_permission() throws Exception { - UserRequestAttributes requestAttributes = new UserRequestAttributes("GET", "testuser", - "/calm/namespace/finos/patterns", "finos"); - UserAccess userAccess = new UserAccess("testuser", Permission.write, "finos", ResourceType.patterns); - when(userAccessStore.getUserAccessForUsername("testuser")) - .thenReturn(List.of(userAccess)); - - boolean actual = validator.isUserAuthorized(requestAttributes); - assertTrue(actual); - } - - @Test - void return_true_when_user_accessing_default_get_namespaces_endpoint() throws Exception { - UserRequestAttributes requestAttributes = new UserRequestAttributes("GET", "testuser", - "/calm/namespaces", null); - - boolean actual = validator.isUserAuthorized(requestAttributes); - assertTrue(actual); - } - - @Test - void return_false_when_no_permissions_are_mapped_to_user() throws Exception { - UserRequestAttributes requestAttributes = new UserRequestAttributes("GET", "testuser", - "/calm/namespaces/test/finos", "finos"); - when(userAccessStore.getUserAccessForUsername("testuser")) - .thenThrow(new UserAccessNotFoundException()); - - boolean actual = validator.isUserAuthorized(requestAttributes); - assertFalse(actual); - } - - @Test - void return_true_when_user_accessing_search_endpoint() throws Exception { - UserRequestAttributes requestAttributes = new UserRequestAttributes("GET", "testuser", - "/calm/search", null); - - boolean actual = validator.isUserAuthorized(requestAttributes); - assertTrue(actual); - } - - @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)); - - 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 namespaces = validator.getReadableNamespaces("testuser"); - assertEquals(Set.of("finos"), namespaces); - } -} diff --git a/package-lock.json b/package-lock.json index a7d1a5957..168661d82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2327,7 +2327,7 @@ "@calmstudio/calm-core": "file:../calm-core", "@modelcontextprotocol/sdk": "^1.27.1", "elkjs": "^0.11.1", - "elkjs-svg": "*", + "elkjs-svg": "latest", "zod": "^3.24.0" }, "bin": { @@ -2392,7 +2392,7 @@ }, "devDependencies": { "@types/vscode": "^1.99.0", - "@vscode/vsce": "*", + "@vscode/vsce": "latest", "esbuild": "^0.25.0", "typescript": "^5.7.0", "vitest": "^3.0.8" @@ -13083,9 +13083,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -13102,9 +13099,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -13121,9 +13115,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -13140,9 +13131,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -17806,11 +17794,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -17822,11 +17812,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -17838,11 +17830,13 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -17854,11 +17848,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -17870,11 +17866,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -17886,11 +17884,13 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -17902,11 +17902,13 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -17918,11 +17920,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -17934,11 +17938,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -17950,11 +17956,13 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -17966,11 +17974,13 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -17982,11 +17992,13 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -42116,6 +42128,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, From dacfd3330ab8d285f31ee3c7af65d1ef935c0467 Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Thu, 21 May 2026 17:16:43 +0100 Subject: [PATCH 07/26] feat(calm-hub): more experiments --- calm-hub/AUTH_IMPLEMENTATION_CODE.md | 1 + calm-hub/README.md | 10 + .../calm/mcp/tools/ArchitectureTools.java | 7 + .../org/finos/calm/resources/AdrResource.java | 8 + .../calm/resources/ArchitectureResource.java | 21 +- .../finos/calm/resources/FlowResource.java | 8 + .../calm/resources/NamespaceResource.java | 14 +- .../finos/calm/resources/PatternResource.java | 7 + .../finos/calm/resources/SearchResource.java | 2 + .../calm/resources/UserAccessResource.java | 4 + .../security/CalmHubPermissionChecker.java | 214 +++++++++++------- .../finos/calm/security/CalmHubScopes.java | 22 +- .../ProxyAuthenticationMechanism.java | 18 ++ .../calm/security/UserAccessValidator.java | 104 +-------- .../calm/security/UserRequestAttributes.java | 5 - .../application-proxy-auth.properties | 11 + .../resources/application-proxy.properties | 4 - .../src/main/resources/application.properties | 4 +- .../TestCalmHubPermissionCheckerShould.java | 150 ++++++++++++ .../security/TestCalmHubScopesShould.java | 8 +- 20 files changed, 395 insertions(+), 227 deletions(-) delete mode 100644 calm-hub/src/main/java/org/finos/calm/security/UserRequestAttributes.java create mode 100644 calm-hub/src/main/resources/application-proxy-auth.properties delete mode 100644 calm-hub/src/main/resources/application-proxy.properties create mode 100644 calm-hub/src/test/java/org/finos/calm/security/TestCalmHubPermissionCheckerShould.java diff --git a/calm-hub/AUTH_IMPLEMENTATION_CODE.md b/calm-hub/AUTH_IMPLEMENTATION_CODE.md index 491537905..c95dcef9b 100644 --- a/calm-hub/AUTH_IMPLEMENTATION_CODE.md +++ b/calm-hub/AUTH_IMPLEMENTATION_CODE.md @@ -123,6 +123,7 @@ public class ProxyAuthenticationMechanism implements HttpAuthenticationMechanism new ChallengeData(HttpResponseStatus.UNAUTHORIZED.code(), null, null)); } + @Override public Set> getCredentialTypes() { return Set.of(TrustedAuthRequest.class); diff --git a/calm-hub/README.md b/calm-hub/README.md index 063efc18c..6c3c697f0 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: 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 1d4052dc1..0fcee30e1 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,11 +3,13 @@ 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.Architecture; import org.finos.calm.domain.architecture.NamespaceArchitectureSummary; +import org.finos.calm.security.CalmHubScopes; import org.finos.calm.domain.exception.ArchitectureNotFoundException; import org.finos.calm.domain.exception.ArchitectureVersionNotFoundException; import org.finos.calm.domain.exception.NamespaceNotFoundException; @@ -45,6 +47,7 @@ public class ArchitectureTools { ArchitectureStore architectureStore; @Tool(description = "List all architectures in a CalmHub namespace. Returns architecture IDs, names, and descriptions.") + @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_READ}, params = "namespace") public ToolResponse listArchitectures( @ToolArg(description = "The namespace to list architectures from (e.g. 'workshop', 'finos')") String namespace) { Optional err = McpValidationHelper.firstError( @@ -76,6 +79,7 @@ public ToolResponse listArchitectures( } @Tool(description = "List available versions of an architecture in a CalmHub namespace.") + @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_READ}, params = "namespace") public ToolResponse listArchitectureVersions( @ToolArg(description = "The namespace containing the architecture") String namespace, @ToolArg(description = "The architecture ID (positive integer)") int architectureId) { @@ -109,6 +113,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(value = {CalmHubScopes.ARCHITECTURES_READ}, params = "namespace") public ToolResponse getArchitecture( @ToolArg(description = "The namespace containing the architecture") String namespace, @ToolArg(description = "The architecture ID (positive integer)") int architectureId, @@ -139,6 +144,7 @@ public ToolResponse getArchitecture( } } + @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_WRITE}, params = "namespace") @Tool(description = "Publish or overwrite an architecture version against an existing architecture ID. " + "This is an upsert: if the supplied version already exists for the architecture it will be replaced, " + "otherwise it is added as a new version. Provided primarily for legacy/backwards-compatibility flows " + @@ -229,6 +235,7 @@ private NamespaceArchitectureSummary findArchitectureSummary(String namespace, i } @Tool(description = "Create a new architecture in a namespace. Returns the allocated architecture ID and version.") + @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_WRITE}, params = "namespace") 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/resources/AdrResource.java b/calm-hub/src/main/java/org/finos/calm/resources/AdrResource.java index bb93e31d8..37b354dd3 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,5 +1,6 @@ package org.finos.calm.resources; +import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; import jakarta.validation.constraints.Pattern; import jakarta.ws.rs.Consumes; @@ -68,6 +69,7 @@ public AdrResource(AdrStore store) { description = "ADRs stored in a given namespace" ) @PermittedScopes({CalmHubScopes.ADRS_ALL, CalmHubScopes.ADRS_READ}) + @PermissionsAllowed(value = {CalmHubScopes.ADRS_READ}, params = "namespace") public Response getAdrsForNamespace( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -94,6 +96,7 @@ public Response getAdrsForNamespace( description = "Creates an ADR for a given namespace with an allocated ID and revision 1" ) @PermittedScopes({CalmHubScopes.ADRS_ALL}) + @PermissionsAllowed(value = {CalmHubScopes.ADRS_WRITE}, params = "namespace") public Response createAdrForNamespace( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, NewAdrRequest newAdrRequest @@ -134,6 +137,7 @@ public Response createAdrForNamespace( description = "Updates an ADR for a given namespace. Creates a new revision." ) @PermittedScopes({CalmHubScopes.ADRS_ALL}) + @PermissionsAllowed(value = {CalmHubScopes.ADRS_WRITE}, params = "namespace") public Response updateAdrForNamespace( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("adrId") int adrId, @@ -177,6 +181,7 @@ public Response updateAdrForNamespace( ) }) @PermittedScopes({CalmHubScopes.ADRS_ALL, CalmHubScopes.ADRS_READ}) + @PermissionsAllowed(value = {CalmHubScopes.ADRS_READ}, params = "namespace") public Response getAdr( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("adrId") int adrId @@ -208,6 +213,7 @@ public Response getAdr( 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(value = {CalmHubScopes.ADRS_READ}, params = "namespace") public Response getAdrRevisions( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("adrId") int adrId @@ -246,6 +252,7 @@ public Response getAdrRevisions( ) }) @PermittedScopes({CalmHubScopes.ADRS_ALL, CalmHubScopes.ADRS_READ}) + @PermissionsAllowed(value = {CalmHubScopes.ADRS_READ}, params = "namespace") public Response getAdrRevision( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("adrId") int adrId, @@ -280,6 +287,7 @@ public Response getAdrRevision( description = "Updates the status of an ADR for a given namespace. Creates a new revision." ) @PermittedScopes({CalmHubScopes.ADRS_ALL}) + @PermissionsAllowed(value = {CalmHubScopes.ADRS_WRITE}, params = "namespace") 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 11155fadc..5ca41b8e1 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,6 @@ package org.finos.calm.resources; +import io.quarkus.security.Authenticated; import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; import jakarta.validation.constraints.Pattern; @@ -23,7 +24,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.store.ArchitectureStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,6 +41,7 @@ * Resource for managing architectures in a given namespace */ @Path("/calm/namespaces") +@Authenticated public class ArchitectureResource { private final ArchitectureStore store; @@ -68,8 +69,7 @@ public ArchitectureResource(ArchitectureStore store) { summary = "Retrieve architectures in a given namespace", description = "Architecture stored in a given namespace" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) - @PermissionsAllowed({CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(value = {"architectures-read"}, params = "namespace") public Response getArchitecturesForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -89,8 +89,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.ARCHITECTURES_WRITE}) + @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_WRITE}, params = "namespace") public Response createArchitectureForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @@ -121,8 +120,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.ARCHITECTURES_READ}) + @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_READ}, params = "namespace") public Response getArchitectureVersions( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId @@ -150,8 +148,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.ARCHITECTURES_READ}) + @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_READ}, params = "namespace") public Response getArchitecture( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId, @@ -180,8 +177,7 @@ public Response getArchitecture( @Path("{namespace}/architectures/{architectureId}/versions/{version}") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) - @PermissionsAllowed({CalmHubScopes.ARCHITECTURES_WRITE}) + @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_WRITE}, params = "namespace") public Response createVersionedArchitecture( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId, @@ -220,8 +216,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.ARCHITECTURES_WRITE}) + @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_WRITE}, params = "namespace") public Response updateVersionedArchitecture( @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/FlowResource.java b/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java index 66f99bfa5..cd582a26f 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; @@ -55,6 +56,7 @@ public FlowResource(FlowStore store) { description = "Flows stored in a given namespace" ) @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(value = {CalmHubScopes.FLOWS_READ}, params = "namespace") public Response getFlowsForNamespace( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -75,6 +77,7 @@ public Response getFlowsForNamespace( description = "Creates a flow for a given namespace with an allocated ID and version 1.0.0" ) @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(value = {CalmHubScopes.FLOWS_WRITE}, params = "namespace") public Response createFlowForNamespace( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @Valid @NotNull(message = "Request must not be null") CreateFlowRequest flowRequest @@ -99,6 +102,7 @@ public Response createFlowForNamespace( description = "Fetch the latest version of the flow by flowId" ) @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(value = {CalmHubScopes.FLOWS_READ}, params = "namespace") public Response getLatestFlow( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("flowId") int flowId @@ -130,6 +134,7 @@ public Response getLatestFlow( description = "Flow versions are not opinionated, outside of the first version created" ) @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(value = {CalmHubScopes.FLOWS_READ}, params = "namespace") public Response getFlowVersions( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("flowId") int flowId @@ -158,6 +163,7 @@ public Response getFlowVersions( description = "Retrieve flows at a specific version" ) @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(value = {CalmHubScopes.FLOWS_READ}, params = "namespace") public Response getFlow( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("flowId") int flowId, @@ -192,6 +198,7 @@ private Response getFlowInternal(String namespace, int flowId, String version) { @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(value = {CalmHubScopes.FLOWS_WRITE}, params = "namespace") public Response createVersionedFlow( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("flowId") int flowId, @@ -229,6 +236,7 @@ public Response createVersionedFlow( description = "In mutable version stores flow updates are supported by this endpoint, operation unavailable returned in repositories without configuration specified" ) @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(value = {CalmHubScopes.FLOWS_WRITE}, params = "namespace") 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/NamespaceResource.java b/calm-hub/src/main/java/org/finos/calm/resources/NamespaceResource.java index dfd9c9916..b36c753bc 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,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; @@ -37,8 +39,11 @@ 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}) +// @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, +// CalmHubScopes.ARCHITECTURES_READ, CalmHubScopes.ADRS_ALL, CalmHubScopes.ADRS_READ}) +// @PermissionsAllowed({CalmHubScopes.ARCHITECTURES_ALL, +// CalmHubScopes.ARCHITECTURES_READ, CalmHubScopes.ADRS_ALL, CalmHubScopes.ADRS_READ}) + @Authenticated public ValueWrapper namespaces() { return new ValueWrapper<>(namespaceStore.getNamespaces()); } @@ -50,7 +55,10 @@ public ValueWrapper namespaces() { summary = "Create Namespace", description = "Create a new namespace in the Calm Hub" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) +// @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) +// @PermissionsAllowed({CalmHubScopes.ARCHITECTURES_ALL}) + @Authenticated + // TODO need a permission to manage top level namespaces 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 0c32f565b..75e855e8a 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; @@ -53,6 +54,7 @@ public PatternResource(PatternStore store) { description = "Patterns stored in a given namespace" ) @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(value = {CalmHubScopes.PATTERNS_READ}, params = "namespace") public Response getPatternsForNamespace( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -73,6 +75,7 @@ public Response getPatternsForNamespace( description = "Creates a pattern for a given namespace with an allocated ID and version 1.0.0" ) @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(value = {CalmHubScopes.PATTERNS_WRITE}, params = "namespace") 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 @@ -96,6 +99,7 @@ public Response createPatternForNamespace( description = "Pattern versions are not opinionated, outside of the first version created" ) @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(value = {CalmHubScopes.PATTERNS_READ}, params = "namespace") public Response getPatternVersions( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("patternId") int patternId @@ -125,6 +129,7 @@ public Response getPatternVersions( description = "Retrieve patterns at a specific version" ) @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(value = {CalmHubScopes.PATTERNS_READ}, params = "namespace") public Response getPattern( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("patternId") int patternId, @@ -155,6 +160,7 @@ public Response getPattern( @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(value = {CalmHubScopes.PATTERNS_WRITE}, params = "namespace") public Response createVersionedPattern( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("patternId") int patternId, @@ -192,6 +198,7 @@ public Response createVersionedPattern( description = "In mutable version stores pattern updates are supported by this endpoint, operation unavailable returned in repositories without configuration specified" ) @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(value = {CalmHubScopes.PATTERNS_WRITE}, params = "namespace") 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 4d19f8df1..337129658 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,5 +1,6 @@ package org.finos.calm.resources; +import io.quarkus.security.Authenticated; import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; import jakarta.ws.rs.GET; @@ -57,6 +58,7 @@ public SearchResource(SearchStore searchStore, 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) 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..42252f3dc 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,5 +1,6 @@ package org.finos.calm.resources; +import io.quarkus.security.PermissionsAllowed; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.POST; import jakarta.ws.rs.GET; @@ -41,6 +42,7 @@ public UserAccessResource(UserAccessStore userAccessStore) { description = "Creates a user-access for a given namespace on a particular resource type" ) @PermittedScopes({CalmHubScopes.NAMESPACE_ADMIN}) + @PermissionsAllowed(value = {CalmHubScopes.NAMESPACE_ADMIN}, params = "namespace") public Response createUserAccessForNamespace(@PathParam("namespace") String namespace, UserAccess createUserAccessRequest) { @@ -71,6 +73,7 @@ public Response createUserAccessForNamespace(@PathParam("namespace") String name description = "Get user-access details for a given namespace" ) @PermittedScopes({CalmHubScopes.NAMESPACE_ADMIN}) + @PermissionsAllowed(value = {CalmHubScopes.NAMESPACE_ADMIN}, params = "namespace") public Response getUserAccessForNamespace(@PathParam("namespace") String namespace) { try { @@ -95,6 +98,7 @@ public Response getUserAccessForNamespace(@PathParam("namespace") String namespa description = "Get user-access details for a given namespace and Id" ) @PermittedScopes({CalmHubScopes.NAMESPACE_ADMIN}) + @PermissionsAllowed(value = {CalmHubScopes.NAMESPACE_ADMIN}, params = "namespace") public Response getUserAccessForNamespaceAndId(@PathParam("namespace") String namespace, @PathParam("userAccessId") Integer userAccessId) { 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 index 5f32d2e8b..827838b98 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java +++ b/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java @@ -1,88 +1,126 @@ -package org.finos.calm.security; - -import io.netty.util.internal.StringUtil; -import io.quarkus.security.PermissionChecker; -import io.quarkus.security.identity.SecurityIdentity; -import jakarta.enterprise.context.ApplicationScoped; -import org.finos.calm.domain.ResourceType; -import org.finos.calm.domain.UserAccess; -import org.finos.calm.domain.exception.UserAccessNotFoundException; -import org.finos.calm.store.UserAccessStore; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.List; - -@ApplicationScoped -public class CalmHubPermissionChecker { - private static final Logger logger = LoggerFactory.getLogger(CalmHubPermissionChecker.class); - - private final UserAccessStore userAccessStore; - - public CalmHubPermissionChecker(UserAccessStore userAccessStore) { - this.userAccessStore = userAccessStore; - } - - // TODO remaining checkers + examine scopes needed. - - @PermissionChecker("architectures:read") - public boolean allowArchitectureRead(SecurityIdentity securityIdentity, String namespace) { - // TODO it seems we either need this or a config prop to turn this on or off. - if (securityIdentity.isAnonymous()) return true; - return isUserEntitled(securityIdentity, namespace, ResourceType.ARCHITECTURE, UserAction.READ); - } - - @PermissionChecker("architectures:write") - public boolean allowArchitectureWrite(SecurityIdentity securityIdentity, String namespace) { - if (securityIdentity.isAnonymous()) return true; - return isUserEntitled(securityIdentity, namespace, ResourceType.ARCHITECTURE, UserAction.WRITE); - } - - // TODO do resource types affect things? Are entitlements different by resource type? - // TODO option to default-allow READ access - public boolean isUserEntitled(SecurityIdentity securityIdentity, - String namespace, - ResourceType resourceType, - UserAction action) { - if (StringUtil.isNullOrEmpty(namespace)) { - logger.error("Missing namespace when checking entitlements."); - throw new IllegalStateException("Permission checker expects 'namespace' String argument on annotated method, potentially misconfigured endpoint."); - } - String username = securityIdentity.getPrincipal().getName(); - logger.debug("Validating whether user [{}] has entitlement [{}] on namespace [{}]", username, action, namespace); - - List userAccesses; - try { - userAccesses = userAccessStore.getUserAccessForUsername(username); - } catch (UserAccessNotFoundException e) { - logger.error("Error while retrieving user entitlements for user {}", username, e); - throw new RuntimeException(e); - } - boolean result = userAccesses.stream().anyMatch(userAccess -> { - boolean namespaceMatches = namespace.equals(userAccess.getNamespace()); - boolean permissionSufficient = permissionAllows(userAccess.getPermission(), action); - return namespaceMatches && permissionSufficient; - }); - if (result) { - logger.info("User {} AUTHORIZED to perform action {} on resource of type {} in namespace {}", username, action, resourceType, namespace); - } else { - logger.warn("User {} DENIED to perform action {} on resource of type {} in namespace {}", username, action, resourceType, namespace); - } - return result; - } - - /** - * 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, UserAction requestedAction) { - return switch(userPermission) { - case read -> requestedAction == UserAction.READ; - case write -> requestedAction == UserAction.READ || requestedAction == UserAction.WRITE; - case admin -> true; - }; - } -} +package org.finos.calm.security; + +import io.quarkus.security.PermissionChecker; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Singleton; +import org.finos.calm.domain.UserAccess; +import org.finos.calm.domain.exception.UserAccessNotFoundException; +import org.finos.calm.store.UserAccessStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Singleton +public class CalmHubPermissionChecker { + private static final Logger logger = LoggerFactory.getLogger(CalmHubPermissionChecker.class); + + private final UserAccessStore userAccessStore; + + public CalmHubPermissionChecker(UserAccessStore userAccessStore) { + this.userAccessStore = userAccessStore; + } + + @PermissionChecker("architectures-read") + public boolean allowArchitectureRead(SecurityIdentity identity, String namespace) { + logger.warn("checking arch read permissions"); + return true; +// if (identity.isAnonymous()) return true; +// return hasAccess(identity, namespace, UserAccess.ResourceType.architectures, false); + } + + @PermissionChecker("architectures-write") + public boolean allowArchitectureWrite(SecurityIdentity identity, String namespace) { + if (identity.isAnonymous()) return true; + return hasAccess(identity, namespace, UserAccess.ResourceType.architectures, true); + } + + @PermissionChecker("patterns-read") + public boolean allowPatternRead(SecurityIdentity identity, String namespace) { + if (identity.isAnonymous()) return true; + return hasAccess(identity, namespace, UserAccess.ResourceType.patterns, false); + } + + @PermissionChecker("patterns-write") + public boolean allowPatternWrite(SecurityIdentity identity, String namespace) { + if (identity.isAnonymous()) return true; + return hasAccess(identity, namespace, UserAccess.ResourceType.patterns, true); + } + + @PermissionChecker("flows-read") + public boolean allowFlowRead(SecurityIdentity identity, String namespace) { + if (identity.isAnonymous()) return true; + return hasAccess(identity, namespace, UserAccess.ResourceType.flows, false); + } + + @PermissionChecker("flows-write") + public boolean allowFlowWrite(SecurityIdentity identity, String namespace) { + if (identity.isAnonymous()) return true; + return hasAccess(identity, namespace, UserAccess.ResourceType.flows, true); + } + + @PermissionChecker("adrs-read") + public boolean allowAdrRead(SecurityIdentity identity, String namespace) { + if (identity.isAnonymous()) return true; + return hasAccess(identity, namespace, UserAccess.ResourceType.adrs, false); + } + + @PermissionChecker("adrs-write") + public boolean allowAdrWrite(SecurityIdentity identity, String namespace) { + if (identity.isAnonymous()) return true; + return hasAccess(identity, namespace, UserAccess.ResourceType.adrs, true); + } + + @PermissionChecker("namespace-admin") + public boolean allowNamespaceAdmin(SecurityIdentity identity, String namespace) { + if (identity.isAnonymous()) return true; + String username = identity.getPrincipal().getName(); + try { + return userAccessStore.getUserAccessForUsername(username).stream() + .anyMatch(grant -> grant.getNamespace().equals(namespace) + && grant.getPermission() == UserAccess.Permission.admin); + } catch (UserAccessNotFoundException e) { + logger.debug("No access grants found for user [{}]", username); + return false; + } + } + + private boolean hasAccess(SecurityIdentity identity, String namespace, + UserAccess.ResourceType resourceType, boolean requireWrite) { + String username = identity.getPrincipal().getName(); + logger.debug("Checking access for user [{}] on namespace [{}] resource [{}] write=[{}]", + username, namespace, resourceType, requireWrite); + try { + boolean result = userAccessStore.getUserAccessForUsername(username).stream() + .anyMatch(grant -> namespaceMatches(grant, namespace) + && resourceMatches(grant, resourceType) + && permissionSufficient(grant, requireWrite)); + if (result) { + logger.info("User [{}] AUTHORIZED for [{}] on [{}] in namespace [{}]", + username, requireWrite ? "write" : "read", resourceType, namespace); + } else { + logger.warn("User [{}] DENIED for [{}] on [{}] in namespace [{}]", + username, requireWrite ? "write" : "read", resourceType, namespace); + } + return result; + } catch (UserAccessNotFoundException e) { + logger.debug("No access grants found for user [{}]", username); + return false; + } + } + + private boolean namespaceMatches(UserAccess grant, String namespace) { + return grant.getNamespace().equals(namespace); + } + + private boolean resourceMatches(UserAccess grant, UserAccess.ResourceType required) { + return grant.getResourceType() == UserAccess.ResourceType.all + || grant.getResourceType() == required; + } + + private boolean permissionSufficient(UserAccess grant, boolean requireWrite) { + return switch (grant.getPermission()) { + case read -> !requireWrite; + case write, 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 f8866af96..0faab3ea9 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 @@ -12,31 +12,39 @@ private CalmHubScopes() { /** * Allows read operations on Flows, Patterns, Namespaces, and Architectures resources. */ - public static final String ARCHITECTURES_READ = "architectures:read"; - public static final String ARCHITECTURES_WRITE = "architectures:write"; + public static final String ARCHITECTURES_READ = "architectures-read"; + public static final String ARCHITECTURES_WRITE = "architectures-write"; /** * 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 ARCHITECTURES_ALL = "architectures-all"; /** * Allows read operations on Adrs and Namespaces resources. */ - public static final String ADRS_READ = "adrs:read"; + public static final String ADRS_READ = "adrs-read"; /** * Allows full access (read, write, delete) on Adrs and read operation on Namespaces. */ - public static final String ADRS_ALL = "adrs:all"; + 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"; + public static final String SEARCH_READ = "search-read"; + + public static final String PATTERNS_READ = "patterns-read"; + public static final String PATTERNS_WRITE = "patterns-write"; + + public static final String FLOWS_READ = "flows-read"; + public static final String FLOWS_WRITE = "flows-write"; + + public static final String ADRS_WRITE = "adrs-write"; /** * 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 NAMESPACE_ADMIN = "namespace-admin"; } 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 index 9f9e4fba9..35c419431 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/ProxyAuthenticationMechanism.java +++ b/calm-hub/src/main/java/org/finos/calm/security/ProxyAuthenticationMechanism.java @@ -7,6 +7,8 @@ import io.quarkus.runtime.util.StringUtil; import io.quarkus.security.identity.IdentityProviderManager; import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.AuthenticationRequest; +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; @@ -15,12 +17,17 @@ 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; +import java.util.Set; @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; @@ -28,12 +35,18 @@ public class ProxyAuthenticationMechanism implements HttpAuthenticationMechanism 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.debug("Setting user identity to value from proxy authentication header {}: {}", usernameHeader, username); return Uni.createFrom() .item(QuarkusSecurityIdentity.builder() .setPrincipal(new QuarkusPrincipal(username)) + .setAnonymous(false) .build()); +// TrustedAuthenticationRequest request = new TrustedAuthenticationRequest(username); +// return identityProviderManager.authenticate(request); +// return Uni.createFrom().item(QuarkusSecurityIdentity.builder().setPrincipal(new QuarkusPrincipal(username)).build()); } @Override @@ -41,4 +54,9 @@ public Uni getChallenge(RoutingContext context) { return Uni.createFrom().item( new ChallengeData(HttpResponseStatus.UNAUTHORIZED.code(), null, null)); } + +// @Override +// public Set> getCredentialTypes() { +// return Set.of(TrustedAuthenticationRequest.class); +// } } 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 b0473c0b8..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 has no effect unless the 'secure' or 'proxy' profile is enabled. - */ @ApplicationScoped @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/resources/application-proxy-auth.properties b/calm-hub/src/main/resources/application-proxy-auth.properties new file mode 100644 index 000000000..38af21340 --- /dev/null +++ b/calm-hub/src/main/resources/application-proxy-auth.properties @@ -0,0 +1,11 @@ +quarkus.oidc.tenant-enabled=false +quarkus.oidc.enabled=false +# Header injected by the upstream proxy carrying the authenticated username (default: Remote-User) +#calm.security.proxy.username-header=Remote-User +# Force authentication on all CalmHub paths so ProxyAuthenticationMechanism populates +# the SecurityIdentity before @PermissionsAllowed is checked. +quarkus.http.auth.permission.secured.paths=/calm/* +quarkus.http.auth.permission.secured.policy=authenticated +quarkus.http.auth.permission.mcp-tools.paths=/mcp/* +quarkus.http.auth.permission.mcp-tools.policy=authenticated +#quarkus.log.level=DEBUG \ No newline at end of file diff --git a/calm-hub/src/main/resources/application-proxy.properties b/calm-hub/src/main/resources/application-proxy.properties deleted file mode 100644 index f3201b579..000000000 --- a/calm-hub/src/main/resources/application-proxy.properties +++ /dev/null @@ -1,4 +0,0 @@ -quarkus.oidc.tenant-enabled=false -quarkus.oidc.enabled=false -# Header injected by the upstream proxy carrying the authenticated username (default: Remote-User) -#calm.security.proxy.username-header=Remote-User diff --git a/calm-hub/src/main/resources/application.properties b/calm-hub/src/main/resources/application.properties index e411ed64f..e5052739b 100644 --- a/calm-hub/src/main/resources/application.properties +++ b/calm-hub/src/main/resources/application.properties @@ -29,4 +29,6 @@ quarkus.http.filter.security.header."X-Frame-Options"=DENY # environment variable CALM_MCP_ENABLED=false to disable all MCP tools. calm.mcp.enabled=true # 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/security/TestCalmHubPermissionCheckerShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubPermissionCheckerShould.java new file mode 100644 index 000000000..bb4f4b010 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubPermissionCheckerShould.java @@ -0,0 +1,150 @@ +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.isAnonymous()).thenReturn(false); + when(mockIdentity.getPrincipal()).thenReturn(mockPrincipal); + when(mockPrincipal.getName()).thenReturn(username); + } + + @Test + void read_grant_allows_read_check() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.read, "foo", UserAccess.ResourceType.architectures); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertTrue(checker.allowArchitectureRead(mockIdentity, "foo")); + } + + @Test + void write_grant_allows_read_check() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.write, "foo", UserAccess.ResourceType.architectures); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertTrue(checker.allowArchitectureRead(mockIdentity, "foo")); + } + + @Test + void read_grant_denies_write_check() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.read, "foo", UserAccess.ResourceType.architectures); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertFalse(checker.allowArchitectureWrite(mockIdentity, "foo")); + } + + @Test + void grant_for_different_namespace_denies_check() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.write, "bar", UserAccess.ResourceType.architectures); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertFalse(checker.allowArchitectureWrite(mockIdentity, "foo")); + } + + @Test + void all_resource_type_satisfies_any_specific_resource_check() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.write, "foo", UserAccess.ResourceType.all); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertTrue(checker.allowArchitectureRead(mockIdentity, "foo")); + assertTrue(checker.allowPatternRead(mockIdentity, "foo")); + assertTrue(checker.allowFlowRead(mockIdentity, "foo")); + assertTrue(checker.allowAdrRead(mockIdentity, "foo")); + } + + @Test + void user_with_no_grants_is_denied() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenThrow(new UserAccessNotFoundException()); + + assertFalse(checker.allowArchitectureRead(mockIdentity, "foo")); + } + + @Test + void pattern_grant_does_not_satisfy_architecture_check() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.write, "foo", UserAccess.ResourceType.patterns); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertFalse(checker.allowArchitectureRead(mockIdentity, "foo")); + assertTrue(checker.allowPatternRead(mockIdentity, "foo")); + } + + @Test + void anonymous_identity_is_always_allowed() { + when(mockIdentity.isAnonymous()).thenReturn(true); + + assertTrue(checker.allowArchitectureRead(mockIdentity, "foo")); + assertTrue(checker.allowArchitectureWrite(mockIdentity, "foo")); + assertTrue(checker.allowPatternRead(mockIdentity, "foo")); + assertTrue(checker.allowFlowWrite(mockIdentity, "foo")); + assertTrue(checker.allowAdrRead(mockIdentity, "foo")); + assertTrue(checker.allowNamespaceAdmin(mockIdentity, "foo")); + } + + @Test + void namespace_admin_check_requires_admin_permission() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess writeGrant = new UserAccess("alice", UserAccess.Permission.write, "foo", UserAccess.ResourceType.all); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(writeGrant)); + + assertFalse(checker.allowNamespaceAdmin(mockIdentity, "foo")); + } + + @Test + void namespace_admin_check_passes_with_admin_permission() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess adminGrant = new UserAccess("alice", UserAccess.Permission.admin, "foo", UserAccess.ResourceType.architectures); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(adminGrant)); + + assertTrue(checker.allowNamespaceAdmin(mockIdentity, "foo")); + } + + @Test + void admin_permission_allows_write_check() throws UserAccessNotFoundException { + givenAuthenticatedUser("alice"); + UserAccess adminGrant = new UserAccess("alice", UserAccess.Permission.admin, "foo", UserAccess.ResourceType.architectures); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(adminGrant)); + + assertTrue(checker.allowArchitectureWrite(mockIdentity, "foo")); + assertTrue(checker.allowArchitectureRead(mockIdentity, "foo")); + } +} 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..8eed51260 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 @@ -19,21 +19,21 @@ void prevent_instantiation() throws Exception { @Test void match_architectures_read_constant() { - assertEquals("architectures:read", CalmHubScopes.ARCHITECTURES_READ); + assertEquals("architectures-read", CalmHubScopes.ARCHITECTURES_READ); } @Test void match_architectures_all_constant() { - assertEquals("architectures:all", CalmHubScopes.ARCHITECTURES_ALL); + assertEquals("architectures-all", CalmHubScopes.ARCHITECTURES_ALL); } @Test void match_adrs_all_constant() { - assertEquals("adrs:all", CalmHubScopes.ADRS_ALL); + assertEquals("adrs-all", CalmHubScopes.ADRS_ALL); } @Test void match_adrs_read_constant() { - assertEquals("adrs:read", CalmHubScopes.ADRS_READ); + assertEquals("adrs-read", CalmHubScopes.ADRS_READ); } } From a27ec38229ed62729d30ab76745d5b81711d5968 Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Fri, 22 May 2026 15:19:17 +0100 Subject: [PATCH 08/26] fix(calm-hub): fix auth issues --- .../security/CalmHubPermissionChecker.java | 10 +++--- .../ProxyAuthenticationMechanism.java | 18 ++-------- .../calm/security/ProxyIdentityProvider.java | 34 +++++++++++++++++++ .../application-proxy-auth.properties | 9 +---- 4 files changed, 43 insertions(+), 28 deletions(-) create mode 100644 calm-hub/src/main/java/org/finos/calm/security/ProxyIdentityProvider.java 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 index 827838b98..513fca5f2 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java +++ b/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java @@ -3,14 +3,16 @@ import io.quarkus.security.PermissionChecker; import io.quarkus.security.identity.SecurityIdentity; import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Singleton; +import org.finos.calm.domain.ResourceType; import org.finos.calm.domain.UserAccess; import org.finos.calm.domain.exception.UserAccessNotFoundException; import org.finos.calm.store.UserAccessStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@Singleton +import java.util.List; + +@ApplicationScoped public class CalmHubPermissionChecker { private static final Logger logger = LoggerFactory.getLogger(CalmHubPermissionChecker.class); @@ -79,8 +81,8 @@ public boolean allowNamespaceAdmin(SecurityIdentity identity, String namespace) .anyMatch(grant -> grant.getNamespace().equals(namespace) && grant.getPermission() == UserAccess.Permission.admin); } catch (UserAccessNotFoundException e) { - logger.debug("No access grants found for user [{}]", username); - return false; + logger.error("No user access records found for user {}. Rejecting request.", username); + throw new RuntimeException(e); } } 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 index 35c419431..6bb4090fb 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/ProxyAuthenticationMechanism.java +++ b/calm-hub/src/main/java/org/finos/calm/security/ProxyAuthenticationMechanism.java @@ -7,7 +7,6 @@ import io.quarkus.runtime.util.StringUtil; import io.quarkus.security.identity.IdentityProviderManager; import io.quarkus.security.identity.SecurityIdentity; -import io.quarkus.security.identity.request.AuthenticationRequest; import io.quarkus.security.identity.request.TrustedAuthenticationRequest; import io.quarkus.security.runtime.QuarkusPrincipal; import io.quarkus.security.runtime.QuarkusSecurityIdentity; @@ -21,7 +20,6 @@ import org.slf4j.LoggerFactory; import java.util.Optional; -import java.util.Set; @ApplicationScoped @IfBuildProfile("proxy-auth") @@ -38,15 +36,8 @@ public Uni authenticate(RoutingContext context, IdentityProvid logger.error("REJECTING request with missing proxy authentication header {}. Path: {}", usernameHeader, context.request().path()); return Uni.createFrom().optional(Optional.empty()); } - logger.debug("Setting user identity to value from proxy authentication header {}: {}", usernameHeader, username); - return Uni.createFrom() - .item(QuarkusSecurityIdentity.builder() - .setPrincipal(new QuarkusPrincipal(username)) - .setAnonymous(false) - .build()); -// TrustedAuthenticationRequest request = new TrustedAuthenticationRequest(username); -// return identityProviderManager.authenticate(request); -// return Uni.createFrom().item(QuarkusSecurityIdentity.builder().setPrincipal(new QuarkusPrincipal(username)).build()); + logger.info("Authenticating user {} via proxy authentication header {}", username, usernameHeader); + return identityProviderManager.authenticate(new TrustedAuthenticationRequest(username)); } @Override @@ -54,9 +45,4 @@ public Uni getChallenge(RoutingContext context) { return Uni.createFrom().item( new ChallengeData(HttpResponseStatus.UNAUTHORIZED.code(), null, null)); } - -// @Override -// public Set> getCredentialTypes() { -// return Set.of(TrustedAuthenticationRequest.class); -// } } 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..d3acfbce9 --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/security/ProxyIdentityProvider.java @@ -0,0 +1,34 @@ +package org.finos.calm.security; + +import io.quarkus.arc.profile.IfBuildProfile; +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 +@IfBuildProfile("proxy-auth") +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("Receiving identity from ProxyAuthenticationMechanism - validating as no-op as no IDP is configured for proxy mode."); + return Uni.createFrom().item(QuarkusSecurityIdentity.builder() + .setPrincipal(new QuarkusPrincipal(request.getPrincipal())) + .setAnonymous(false) + .build()); + } +} diff --git a/calm-hub/src/main/resources/application-proxy-auth.properties b/calm-hub/src/main/resources/application-proxy-auth.properties index 38af21340..646ae5151 100644 --- a/calm-hub/src/main/resources/application-proxy-auth.properties +++ b/calm-hub/src/main/resources/application-proxy-auth.properties @@ -1,11 +1,4 @@ quarkus.oidc.tenant-enabled=false quarkus.oidc.enabled=false # Header injected by the upstream proxy carrying the authenticated username (default: Remote-User) -#calm.security.proxy.username-header=Remote-User -# Force authentication on all CalmHub paths so ProxyAuthenticationMechanism populates -# the SecurityIdentity before @PermissionsAllowed is checked. -quarkus.http.auth.permission.secured.paths=/calm/* -quarkus.http.auth.permission.secured.policy=authenticated -quarkus.http.auth.permission.mcp-tools.paths=/mcp/* -quarkus.http.auth.permission.mcp-tools.policy=authenticated -#quarkus.log.level=DEBUG \ No newline at end of file +#calm.security.proxy.username-header=Remote-User \ No newline at end of file From cceb9ab2505b1cbe9926907e857129913f8f92c7 Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Fri, 22 May 2026 15:28:31 +0100 Subject: [PATCH 09/26] feat(calm-hub): fix up roles --- .../calm/resources/ArchitectureResource.java | 12 +++++----- .../security/CalmHubPermissionChecker.java | 18 +++++++-------- .../finos/calm/security/CalmHubScopes.java | 22 +++++++++---------- 3 files changed, 26 insertions(+), 26 deletions(-) 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 5ca41b8e1..e9c112ca2 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 @@ -69,7 +69,7 @@ public ArchitectureResource(ArchitectureStore store) { summary = "Retrieve architectures in a given namespace", description = "Architecture stored in a given namespace" ) - @PermissionsAllowed(value = {"architectures-read"}, params = "namespace") + @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_READ}) public Response getArchitecturesForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -89,7 +89,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" ) - @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_WRITE}, params = "namespace") + @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_WRITE}) public Response createArchitectureForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @@ -120,7 +120,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" ) - @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_READ}, params = "namespace") + @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_READ}) public Response getArchitectureVersions( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId @@ -148,7 +148,7 @@ public Response getArchitectureVersions( summary = "Retrieve a specific architecture at a given version", description = "Retrieve architectures at a specific version" ) - @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_READ}, params = "namespace") + @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_READ}) public Response getArchitecture( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId, @@ -177,7 +177,7 @@ public Response getArchitecture( @Path("{namespace}/architectures/{architectureId}/versions/{version}") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) - @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_WRITE}, params = "namespace") + @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_WRITE}) public Response createVersionedArchitecture( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId, @@ -216,7 +216,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" ) - @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_WRITE}, params = "namespace") + @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_WRITE}) public Response updateVersionedArchitecture( @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/security/CalmHubPermissionChecker.java b/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java index 513fca5f2..80466e65f 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java +++ b/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java @@ -22,7 +22,7 @@ public CalmHubPermissionChecker(UserAccessStore userAccessStore) { this.userAccessStore = userAccessStore; } - @PermissionChecker("architectures-read") + @PermissionChecker(CalmHubScopes.ARCHITECTURES_READ) public boolean allowArchitectureRead(SecurityIdentity identity, String namespace) { logger.warn("checking arch read permissions"); return true; @@ -30,49 +30,49 @@ public boolean allowArchitectureRead(SecurityIdentity identity, String namespace // return hasAccess(identity, namespace, UserAccess.ResourceType.architectures, false); } - @PermissionChecker("architectures-write") + @PermissionChecker(CalmHubScopes.ARCHITECTURES_WRITE) public boolean allowArchitectureWrite(SecurityIdentity identity, String namespace) { if (identity.isAnonymous()) return true; return hasAccess(identity, namespace, UserAccess.ResourceType.architectures, true); } - @PermissionChecker("patterns-read") + @PermissionChecker(CalmHubScopes.PATTERNS_READ) public boolean allowPatternRead(SecurityIdentity identity, String namespace) { if (identity.isAnonymous()) return true; return hasAccess(identity, namespace, UserAccess.ResourceType.patterns, false); } - @PermissionChecker("patterns-write") + @PermissionChecker(CalmHubScopes.PATTERNS_WRITE) public boolean allowPatternWrite(SecurityIdentity identity, String namespace) { if (identity.isAnonymous()) return true; return hasAccess(identity, namespace, UserAccess.ResourceType.patterns, true); } - @PermissionChecker("flows-read") + @PermissionChecker(CalmHubScopes.FLOWS_READ) public boolean allowFlowRead(SecurityIdentity identity, String namespace) { if (identity.isAnonymous()) return true; return hasAccess(identity, namespace, UserAccess.ResourceType.flows, false); } - @PermissionChecker("flows-write") + @PermissionChecker(CalmHubScopes.FLOWS_WRITE) public boolean allowFlowWrite(SecurityIdentity identity, String namespace) { if (identity.isAnonymous()) return true; return hasAccess(identity, namespace, UserAccess.ResourceType.flows, true); } - @PermissionChecker("adrs-read") + @PermissionChecker(CalmHubScopes.ADRS_READ) public boolean allowAdrRead(SecurityIdentity identity, String namespace) { if (identity.isAnonymous()) return true; return hasAccess(identity, namespace, UserAccess.ResourceType.adrs, false); } - @PermissionChecker("adrs-write") + @PermissionChecker(CalmHubScopes.ADRS_WRITE) public boolean allowAdrWrite(SecurityIdentity identity, String namespace) { if (identity.isAnonymous()) return true; return hasAccess(identity, namespace, UserAccess.ResourceType.adrs, true); } - @PermissionChecker("namespace-admin") + @PermissionChecker(CalmHubScopes.NAMESPACE_ADMIN) public boolean allowNamespaceAdmin(SecurityIdentity identity, String namespace) { if (identity.isAnonymous()) return true; String username = identity.getPrincipal().getName(); 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 0faab3ea9..5c2976942 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 @@ -12,8 +12,8 @@ private CalmHubScopes() { /** * Allows read operations on Flows, Patterns, Namespaces, and Architectures resources. */ - public static final String ARCHITECTURES_READ = "architectures-read"; - public static final String ARCHITECTURES_WRITE = "architectures-write"; + public static final String ARCHITECTURES_READ = "architectures:read"; + public static final String ARCHITECTURES_WRITE = "architectures:write"; /** * Allows full access (read, write, delete) on Flows, Patterns, Namespaces, and Architectures resources. @@ -23,28 +23,28 @@ private CalmHubScopes() { /** * Allows read operations on Adrs and Namespaces resources. */ - public static final String ADRS_READ = "adrs-read"; + public static final String ADRS_READ = "adrs:read"; /** * Allows full access (read, write, delete) on Adrs and read operation on Namespaces. */ - public static final String ADRS_ALL = "adrs-all"; + 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"; + public static final String SEARCH_READ = "search:read"; - public static final String PATTERNS_READ = "patterns-read"; - public static final String PATTERNS_WRITE = "patterns-write"; + public static final String PATTERNS_READ = "patterns:read"; + public static final String PATTERNS_WRITE = "patterns:write"; - public static final String FLOWS_READ = "flows-read"; - public static final String FLOWS_WRITE = "flows-write"; + public static final String FLOWS_READ = "flows:read"; + public static final String FLOWS_WRITE = "flows:write"; - public static final String ADRS_WRITE = "adrs-write"; + public static final String ADRS_WRITE = "adrs:write"; /** * 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 NAMESPACE_ADMIN = "namespace:admin"; } From eec272e3eb060792301ea379b4f7525e27a8e94c Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Fri, 22 May 2026 17:15:49 +0100 Subject: [PATCH 10/26] feat(calm-hub): do some annotations work --- calm-hub/PERMISSIONS.md | 25 +++++++ calm-hub/mongo/init-mongo.js | 4 +- .../calm/mcp/tools/ArchitectureTools.java | 10 +-- .../org/finos/calm/resources/AdrResource.java | 22 ++---- .../calm/resources/ArchitectureResource.java | 12 ++-- .../finos/calm/resources/ControlResource.java | 22 +++--- .../calm/resources/CoreSchemaResource.java | 10 +-- .../calm/resources/DecoratorResource.java | 12 ++-- .../finos/calm/resources/DomainResource.java | 6 +- .../finos/calm/resources/FlowResource.java | 22 ++---- .../resources/FrontControllerResource.java | 12 ++-- .../calm/resources/InterfaceResource.java | 12 ++-- .../calm/resources/NamespaceResource.java | 8 --- .../finos/calm/resources/PatternResource.java | 19 ++--- .../finos/calm/resources/SearchResource.java | 3 - .../calm/resources/StandardResource.java | 12 ++-- .../calm/resources/UserAccessResource.java | 10 +-- .../security/CalmHubPermissionChecker.java | 69 +++++++++++++++++-- .../finos/calm/security/CalmHubScopes.java | 33 +++++---- .../finos/calm/security/PermittedScopes.java | 15 ---- 20 files changed, 185 insertions(+), 153 deletions(-) create mode 100644 calm-hub/PERMISSIONS.md delete mode 100644 calm-hub/src/main/java/org/finos/calm/security/PermittedScopes.java diff --git a/calm-hub/PERMISSIONS.md b/calm-hub/PERMISSIONS.md new file mode 100644 index 000000000..b9bac1bd4 --- /dev/null +++ b/calm-hub/PERMISSIONS.md @@ -0,0 +1,25 @@ +# 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. +They are separated by resource type. + +The available actions are the following. + +| Action | Description | +|-----------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `RESOURCE_TYPE:read` | Can read any documents of that type in the namespace. | +| `RESOURCE_TYPE: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. +| `namespace:admin` | Can do anything to all resource types, and also grant entitlements to other users in the namespace. | + +For example, `architectures:read` means the user can read all architectures in that NS. + +Please note that each entitlement implies all previous levels - i.e. `write` implies `read`. +`namespace:admin` implies `read` and `write` on all resource types. + +**You can also use `all` as a resource type to apply that permission to all resource types.** +For example, `all:write` means you can read and write on all resources. diff --git a/calm-hub/mongo/init-mongo.js b/calm-hub/mongo/init-mongo.js index 41c1f4ca7..6bb9ba99a 100644 --- a/calm-hub/mongo/init-mongo.js +++ b/calm-hub/mongo/init-mongo.js @@ -2500,14 +2500,14 @@ if (db.userAccess.countDocuments() === 0) { { "userAccessId": NumberInt(1), "username": "demo_admin", - "permission": "write", + "permission": "admin", "namespace": "finos", "resourceType": "all" }, { "userAccessId": NumberInt(2), "username": "demo_admin", - "permission": "write", + "permission": "admin", "namespace": "workshop", "resourceType": "patterns" }, 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 0fcee30e1..426300185 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 @@ -47,7 +47,7 @@ public class ArchitectureTools { ArchitectureStore architectureStore; @Tool(description = "List all architectures in a CalmHub namespace. Returns architecture IDs, names, and descriptions.") - @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_READ}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_READ) public ToolResponse listArchitectures( @ToolArg(description = "The namespace to list architectures from (e.g. 'workshop', 'finos')") String namespace) { Optional err = McpValidationHelper.firstError( @@ -79,7 +79,7 @@ public ToolResponse listArchitectures( } @Tool(description = "List available versions of an architecture in a CalmHub namespace.") - @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_READ}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_READ) public ToolResponse listArchitectureVersions( @ToolArg(description = "The namespace containing the architecture") String namespace, @ToolArg(description = "The architecture ID (positive integer)") int architectureId) { @@ -113,7 +113,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(value = {CalmHubScopes.ARCHITECTURES_READ}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_READ) public ToolResponse getArchitecture( @ToolArg(description = "The namespace containing the architecture") String namespace, @ToolArg(description = "The architecture ID (positive integer)") int architectureId, @@ -144,7 +144,7 @@ public ToolResponse getArchitecture( } } - @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_WRITE}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_WRITE) @Tool(description = "Publish or overwrite an architecture version against an existing architecture ID. " + "This is an upsert: if the supplied version already exists for the architecture it will be replaced, " + "otherwise it is added as a new version. Provided primarily for legacy/backwards-compatibility flows " + @@ -235,7 +235,7 @@ private NamespaceArchitectureSummary findArchitectureSummary(String namespace, i } @Tool(description = "Create a new architecture in a namespace. Returns the allocated architecture ID and version.") - @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_WRITE}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_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/resources/AdrResource.java b/calm-hub/src/main/java/org/finos/calm/resources/AdrResource.java index 37b354dd3..6afdc1afc 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 @@ -27,7 +27,6 @@ import org.finos.calm.domain.exception.AdrRevisionNotFoundException; import org.finos.calm.domain.exception.NamespaceNotFoundException; 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; @@ -68,8 +67,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(value = {CalmHubScopes.ADRS_READ}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.ADRS_READ) public Response getAdrsForNamespace( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -95,8 +93,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(value = {CalmHubScopes.ADRS_WRITE}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.ADRS_WRITE) public Response createAdrForNamespace( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, NewAdrRequest newAdrRequest @@ -136,8 +133,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(value = {CalmHubScopes.ADRS_WRITE}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.ADRS_WRITE) public Response updateAdrForNamespace( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("adrId") int adrId, @@ -180,8 +176,7 @@ public Response updateAdrForNamespace( content = @Content(schema = @Schema(implementation = AdrMeta.class)) ) }) - @PermittedScopes({CalmHubScopes.ADRS_ALL, CalmHubScopes.ADRS_READ}) - @PermissionsAllowed(value = {CalmHubScopes.ADRS_READ}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.ADRS_READ) public Response getAdr( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("adrId") int adrId @@ -212,8 +207,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(value = {CalmHubScopes.ADRS_READ}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.ADRS_READ) public Response getAdrRevisions( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("adrId") int adrId @@ -251,8 +245,7 @@ public Response getAdrRevisions( content = @Content(schema = @Schema(implementation = AdrMeta.class)) ) }) - @PermittedScopes({CalmHubScopes.ADRS_ALL, CalmHubScopes.ADRS_READ}) - @PermissionsAllowed(value = {CalmHubScopes.ADRS_READ}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.ADRS_READ) public Response getAdrRevision( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("adrId") int adrId, @@ -286,8 +279,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(value = {CalmHubScopes.ADRS_WRITE}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.ADRS_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 e9c112ca2..62bc091e7 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 @@ -69,7 +69,7 @@ public ArchitectureResource(ArchitectureStore store) { summary = "Retrieve architectures in a given namespace", description = "Architecture stored in a given namespace" ) - @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_READ) public Response getArchitecturesForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -89,7 +89,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" ) - @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_WRITE}) + @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_WRITE) public Response createArchitectureForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @@ -120,7 +120,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" ) - @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_READ) public Response getArchitectureVersions( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId @@ -148,7 +148,7 @@ public Response getArchitectureVersions( summary = "Retrieve a specific architecture at a given version", description = "Retrieve architectures at a specific version" ) - @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_READ) public Response getArchitecture( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId, @@ -177,7 +177,7 @@ public Response getArchitecture( @Path("{namespace}/architectures/{architectureId}/versions/{version}") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) - @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_WRITE}) + @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_WRITE) public Response createVersionedArchitecture( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId, @@ -216,7 +216,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" ) - @PermissionsAllowed(value = {CalmHubScopes.ARCHITECTURES_WRITE}) + @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_WRITE) public Response updateVersionedArchitecture( @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 953b5b8a4..4e3299040 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; @@ -20,7 +21,6 @@ import org.finos.calm.domain.exception.ControlRequirementVersionNotFoundException; import org.finos.calm.domain.exception.DomainNotFoundException; 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; @@ -57,7 +57,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.ROLE_VIEWER) public Response getControlsForDomain( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -78,7 +78,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.ROLE_CONTRIBUTOR) public Response createControlForDomain( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -100,7 +100,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.ROLE_VIEWER) public Response getRequirementVersions( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -124,7 +124,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.ROLE_VIEWER) public Response getRequirementForVersion( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -155,7 +155,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" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.ROLE_CONTRIBUTOR) public Response createRequirementForVersion( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -190,7 +190,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.ROLE_VIEWER) public Response getConfigurationsForControl( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -215,7 +215,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.ROLE_CONTRIBUTOR) public Response createControlConfiguration( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -241,7 +241,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.ROLE_VIEWER) public Response getConfigurationVersions( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -269,7 +269,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.ROLE_VIEWER) public Response getConfigurationForVersion( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -304,7 +304,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" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.ROLE_CONTRIBUTOR) 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..da7f0a329 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,5 +1,6 @@ package org.finos.calm.resources; +import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.GET; @@ -12,7 +13,6 @@ 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; @@ -37,7 +37,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}) + @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) public ValueWrapper schemaVersions() { return new ValueWrapper<>(coreSchemaStore.getVersions()); } @@ -48,7 +48,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}) + @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) public Response schemasForVersion(@PathParam("version") String version) { Map schemas = coreSchemaStore.getSchemasForVersion(version); if (schemas == null) { @@ -65,7 +65,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}) + @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) public Response getSchema(@PathParam("version") String version, @PathParam("schemaName") String schemaName) { Map schemas = coreSchemaStore.getSchemasForVersion(version); @@ -89,7 +89,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.ROLE_CONTRIBUTOR) 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..75fde8f9b 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,5 +1,6 @@ package org.finos.calm.resources; +import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Pattern; @@ -21,7 +22,6 @@ 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; @@ -64,7 +64,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.ROLE_VIEWER) 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 +93,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.ROLE_VIEWER) 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 +121,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.ROLE_VIEWER) 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 +151,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.ROLE_CONTRIBUTOR) public Response createDecoratorForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, String decoratorJson @@ -185,7 +185,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.ROLE_CONTRIBUTOR) 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..72a3214d3 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,6 @@ package org.finos.calm.resources; +import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; @@ -11,7 +12,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 +45,7 @@ public DomainResource(DomainStore store) { summary = "Available Domains", description = "The available domains in this Calm Hub" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) public Response getDomains() { return Response.ok(new ValueWrapper<>(store.getDomains())).build(); } @@ -62,7 +62,7 @@ public Response getDomains() { summary = "Create Domain", description = "Create a new domain in the Calm Hub" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.ROLE_CONTRIBUTOR) 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/FlowResource.java b/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java index cd582a26f..f9cd77c42 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 @@ -18,7 +18,6 @@ import org.finos.calm.domain.exception.FlowVersionNotFoundException; 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; @@ -55,8 +54,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(value = {CalmHubScopes.FLOWS_READ}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.FLOWS_READ) public Response getFlowsForNamespace( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -76,8 +74,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(value = {CalmHubScopes.FLOWS_WRITE}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.FLOWS_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 @@ -101,8 +98,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(value = {CalmHubScopes.FLOWS_READ}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.FLOWS_READ) public Response getLatestFlow( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("flowId") int flowId @@ -133,8 +129,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(value = {CalmHubScopes.FLOWS_READ}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.FLOWS_READ) public Response getFlowVersions( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("flowId") int flowId @@ -162,8 +157,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(value = {CalmHubScopes.FLOWS_READ}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.FLOWS_READ) public Response getFlow( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("flowId") int flowId, @@ -197,8 +191,7 @@ private Response getFlowInternal(String namespace, int flowId, String version) { @Path("{namespace}/flows/{flowId}/versions/{version}") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) - @PermissionsAllowed(value = {CalmHubScopes.FLOWS_WRITE}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.FLOWS_WRITE) public Response createVersionedFlow( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("flowId") int flowId, @@ -235,8 +228,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" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) - @PermissionsAllowed(value = {CalmHubScopes.FLOWS_WRITE}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.FLOWS_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..c81063ae8 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,6 +4,7 @@ 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; @@ -24,7 +25,6 @@ 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 +83,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.ROLE_CONTRIBUTOR) 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 +107,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.ROLE_VIEWER) 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 +142,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.ROLE_VIEWER) 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 +175,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.ROLE_VIEWER) 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 +207,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.ROLE_VIEWER) 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..f61485a59 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; @@ -45,7 +45,7 @@ public InterfaceResource(InterfaceStore interfaceStore) { @GET @Path("{namespace}/interfaces") @Produces(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.INTERFACES_READ) public Response getInterfacesForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -61,7 +61,7 @@ public Response getInterfacesForNamespace( @Path("{namespace}/interfaces") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.INTERFACES_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 +81,7 @@ public Response createInterfaceForNamespace( @GET @Path("{namespace}/interfaces/{interfaceId}/versions") @Produces(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.INTERFACES_READ) public Response getInterfaceVersions( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("interfaceId") Integer interfaceId @@ -100,7 +100,7 @@ public Response getInterfaceVersions( @GET @Path("{namespace}/interfaces/{interfaceId}/versions/{version}") @Produces(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.INTERFACES_READ) public Response getInterfaceForVersion( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("interfaceId") Integer interfaceId, @@ -124,7 +124,7 @@ public Response getInterfaceForVersion( @Path("{namespace}/interfaces/{interfaceId}/versions/{version}") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) + @PermissionsAllowed(CalmHubScopes.INTERFACES_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 b36c753bc..2bdf929a6 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 @@ -17,8 +17,6 @@ import org.finos.calm.domain.ValueWrapper; 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; @@ -39,10 +37,6 @@ 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}) -// @PermissionsAllowed({CalmHubScopes.ARCHITECTURES_ALL, -// CalmHubScopes.ARCHITECTURES_READ, CalmHubScopes.ADRS_ALL, CalmHubScopes.ADRS_READ}) @Authenticated public ValueWrapper namespaces() { return new ValueWrapper<>(namespaceStore.getNamespaces()); @@ -55,8 +49,6 @@ public ValueWrapper namespaces() { summary = "Create Namespace", description = "Create a new namespace in the Calm Hub" ) -// @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) -// @PermissionsAllowed({CalmHubScopes.ARCHITECTURES_ALL}) @Authenticated // TODO need a permission to manage top level namespaces public Response createNamespace(@Valid @NotNull(message = "Request must not be null") NamespaceRequest request) throws URISyntaxException { 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 75e855e8a..7215782db 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 @@ -17,7 +17,6 @@ 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; @@ -53,8 +52,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(value = {CalmHubScopes.PATTERNS_READ}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.PATTERNS_READ) public Response getPatternsForNamespace( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -74,8 +72,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(value = {CalmHubScopes.PATTERNS_WRITE}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.PATTERNS_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 @@ -98,8 +95,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(value = {CalmHubScopes.PATTERNS_READ}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.PATTERNS_READ) public Response getPatternVersions( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("patternId") int patternId @@ -128,8 +124,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(value = {CalmHubScopes.PATTERNS_READ}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.PATTERNS_READ) public Response getPattern( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("patternId") int patternId, @@ -159,8 +154,7 @@ public Response getPattern( @Path("{namespace}/patterns/{patternId}/versions/{version}") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) - @PermissionsAllowed(value = {CalmHubScopes.PATTERNS_WRITE}, params = "namespace") + @PermissionsAllowed(CalmHubScopes.PATTERNS_WRITE) public Response createVersionedPattern( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("patternId") int patternId, @@ -197,8 +191,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" ) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL}) - @PermissionsAllowed(value = {CalmHubScopes.PATTERNS_WRITE}, params = "namespace") + @PermissionsAllowed({CalmHubScopes.PATTERNS_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 337129658..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 @@ -15,8 +15,6 @@ 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; @@ -57,7 +55,6 @@ 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()) { 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..192ed2247 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; @@ -39,7 +39,7 @@ public StandardResource(StandardStore standardStore) { @GET @Path("{namespace}/standards") @Produces(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.STANDARDS_READ) public Response getStandardsForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -55,7 +55,7 @@ public Response getStandardsForNamespace( @Path("{namespace}/standards") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.STANDARDS_WRITE) public Response createStandardForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, CreateStandardRequest standard @@ -72,7 +72,7 @@ public Response createStandardForNamespace( @GET @Path("{namespace}/standards/{standardId}/versions") @Produces(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.STANDARDS_READ) public Response getStandardVersions( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("standardId") Integer standardId @@ -91,7 +91,7 @@ public Response getStandardVersions( @GET @Path("{namespace}/standards/{standardId}/versions/{version}") @Produces(MediaType.APPLICATION_JSON) - @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @PermissionsAllowed(CalmHubScopes.STANDARDS_READ) public Response getStandardForVersion( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("standardId") Integer standardId, @@ -115,7 +115,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.STANDARDS_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/UserAccessResource.java b/calm-hub/src/main/java/org/finos/calm/resources/UserAccessResource.java index 42252f3dc..c5cffdaff 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 @@ -14,7 +14,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; @@ -41,8 +40,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(value = {CalmHubScopes.NAMESPACE_ADMIN}, params = "namespace") + @PermissionsAllowed({CalmHubScopes.NAMESPACE_ADMIN}) public Response createUserAccessForNamespace(@PathParam("namespace") String namespace, UserAccess createUserAccessRequest) { @@ -72,8 +70,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(value = {CalmHubScopes.NAMESPACE_ADMIN}, params = "namespace") + @PermissionsAllowed({CalmHubScopes.NAMESPACE_ADMIN}) public Response getUserAccessForNamespace(@PathParam("namespace") String namespace) { try { @@ -97,8 +94,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(value = {CalmHubScopes.NAMESPACE_ADMIN}, params = "namespace") + @PermissionsAllowed({CalmHubScopes.NAMESPACE_ADMIN}) public Response getUserAccessForNamespaceAndId(@PathParam("namespace") String namespace, @PathParam("userAccessId") Integer userAccessId) { 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 index 80466e65f..c35b414a4 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java +++ b/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java @@ -24,57 +24,92 @@ public CalmHubPermissionChecker(UserAccessStore userAccessStore) { @PermissionChecker(CalmHubScopes.ARCHITECTURES_READ) public boolean allowArchitectureRead(SecurityIdentity identity, String namespace) { - logger.warn("checking arch read permissions"); - return true; -// if (identity.isAnonymous()) return true; -// return hasAccess(identity, namespace, UserAccess.ResourceType.architectures, false); + if (identity.isAnonymous()) return true; + if (hasReadRole(identity)) return true; + return hasAccess(identity, namespace, UserAccess.ResourceType.architectures, false); } @PermissionChecker(CalmHubScopes.ARCHITECTURES_WRITE) public boolean allowArchitectureWrite(SecurityIdentity identity, String namespace) { if (identity.isAnonymous()) return true; + if (hasWriteRole(identity)) return true; return hasAccess(identity, namespace, UserAccess.ResourceType.architectures, true); } @PermissionChecker(CalmHubScopes.PATTERNS_READ) public boolean allowPatternRead(SecurityIdentity identity, String namespace) { if (identity.isAnonymous()) return true; + if (hasReadRole(identity)) return true; return hasAccess(identity, namespace, UserAccess.ResourceType.patterns, false); } @PermissionChecker(CalmHubScopes.PATTERNS_WRITE) public boolean allowPatternWrite(SecurityIdentity identity, String namespace) { if (identity.isAnonymous()) return true; + if (hasWriteRole(identity)) return true; return hasAccess(identity, namespace, UserAccess.ResourceType.patterns, true); } @PermissionChecker(CalmHubScopes.FLOWS_READ) public boolean allowFlowRead(SecurityIdentity identity, String namespace) { if (identity.isAnonymous()) return true; + if (hasReadRole(identity)) return true; return hasAccess(identity, namespace, UserAccess.ResourceType.flows, false); } @PermissionChecker(CalmHubScopes.FLOWS_WRITE) public boolean allowFlowWrite(SecurityIdentity identity, String namespace) { if (identity.isAnonymous()) return true; + if (hasWriteRole(identity)) return true; return hasAccess(identity, namespace, UserAccess.ResourceType.flows, true); } @PermissionChecker(CalmHubScopes.ADRS_READ) public boolean allowAdrRead(SecurityIdentity identity, String namespace) { if (identity.isAnonymous()) return true; + if (hasReadRole(identity)) return true; return hasAccess(identity, namespace, UserAccess.ResourceType.adrs, false); } @PermissionChecker(CalmHubScopes.ADRS_WRITE) public boolean allowAdrWrite(SecurityIdentity identity, String namespace) { if (identity.isAnonymous()) return true; + if (hasWriteRole(identity)) return true; return hasAccess(identity, namespace, UserAccess.ResourceType.adrs, true); } + @PermissionChecker(CalmHubScopes.INTERFACES_READ) + public boolean allowInterfaceRead(SecurityIdentity identity, String namespace) { + if (identity.isAnonymous()) return true; + if (hasReadRole(identity)) return true; + return hasAccess(identity, namespace, UserAccess.ResourceType.all, false); + } + + @PermissionChecker(CalmHubScopes.INTERFACES_WRITE) + public boolean allowInterfaceWrite(SecurityIdentity identity, String namespace) { + if (identity.isAnonymous()) return true; + if (hasWriteRole(identity)) return true; + return hasAccess(identity, namespace, UserAccess.ResourceType.all, true); + } + + @PermissionChecker(CalmHubScopes.STANDARDS_READ) + public boolean allowStandardRead(SecurityIdentity identity, String namespace) { + if (identity.isAnonymous()) return true; + if (hasReadRole(identity)) return true; + return hasAccess(identity, namespace, UserAccess.ResourceType.all, false); + } + + @PermissionChecker(CalmHubScopes.STANDARDS_WRITE) + public boolean allowStandardWrite(SecurityIdentity identity, String namespace) { + if (identity.isAnonymous()) return true; + if (hasWriteRole(identity)) return true; + return hasAccess(identity, namespace, UserAccess.ResourceType.all, true); + } + @PermissionChecker(CalmHubScopes.NAMESPACE_ADMIN) public boolean allowNamespaceAdmin(SecurityIdentity identity, String namespace) { if (identity.isAnonymous()) return true; + if (identity.hasRole(CalmHubScopes.ROLE_ADMIN)) return true; String username = identity.getPrincipal().getName(); try { return userAccessStore.getUserAccessForUsername(username).stream() @@ -86,6 +121,32 @@ public boolean allowNamespaceAdmin(SecurityIdentity identity, String namespace) } } + @PermissionChecker(CalmHubScopes.ROLE_VIEWER) + public boolean checkViewerRole(SecurityIdentity identity) { + return identity.isAnonymous() || hasReadRole(identity); + } + + @PermissionChecker(CalmHubScopes.ROLE_CONTRIBUTOR) + public boolean checkContributorRole(SecurityIdentity identity) { + return identity.isAnonymous() || hasWriteRole(identity); + } + + @PermissionChecker(CalmHubScopes.ROLE_ADMIN) + public boolean checkAdminRole(SecurityIdentity identity) { + return identity.isAnonymous() || identity.hasRole(CalmHubScopes.ROLE_ADMIN); + } + + private boolean hasReadRole(SecurityIdentity identity) { + return identity.hasRole(CalmHubScopes.ROLE_VIEWER) + || identity.hasRole(CalmHubScopes.ROLE_CONTRIBUTOR) + || identity.hasRole(CalmHubScopes.ROLE_ADMIN); + } + + private boolean hasWriteRole(SecurityIdentity identity) { + return identity.hasRole(CalmHubScopes.ROLE_CONTRIBUTOR) + || identity.hasRole(CalmHubScopes.ROLE_ADMIN); + } + private boolean hasAccess(SecurityIdentity identity, String namespace, UserAccess.ResourceType resourceType, boolean requireWrite) { String username = identity.getPrincipal().getName(); 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 5c2976942..46238188b 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,26 +9,11 @@ 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"; public static final String ARCHITECTURES_WRITE = "architectures:write"; - /** - * Allows full access (read, write, delete) on Flows, Patterns, Namespaces, and Architectures resources. - */ - public static final String ARCHITECTURES_ALL = "architectures-all"; - - /** - * Allows read operations on Adrs and Namespaces resources. - */ public static final String ADRS_READ = "adrs:read"; - - /** - * Allows full access (read, write, delete) on Adrs and read operation on Namespaces. - */ - public static final String ADRS_ALL = "adrs:all"; + public static final String ADRS_WRITE = "adrs:write"; /** * Allows read operations on the Search endpoint. Results are filtered based on user access grants. @@ -41,10 +26,24 @@ private CalmHubScopes() { public static final String FLOWS_READ = "flows:read"; public static final String FLOWS_WRITE = "flows:write"; - public static final String ADRS_WRITE = "adrs:write"; + public static final String INTERFACES_READ = "interfaces:read"; + public static final String INTERFACES_WRITE = "interfaces:write"; + + public static final String STANDARDS_READ = "standards:read"; + public static final String STANDARDS_WRITE = "standards:write"; /** * Allow to grant access to users on namespace associated resources and for the admin operations. */ public static final String NAMESPACE_ADMIN = "namespace:admin"; + + /** + * Platform-level roles reflecting the PERMISSIONS.md hierarchy. + * ROLE_VIEWER implies all resource-type :read permissions. + * ROLE_CONTRIBUTOR implies all resource-type :write permissions (and therefore :read). + * ROLE_ADMIN implies namespace:admin across all namespaces (and therefore all write/read). + */ + public static final String ROLE_VIEWER = "calm-hub-viewer"; + public static final String ROLE_CONTRIBUTOR = "calm-hub-contributor"; + public static final String ROLE_ADMIN = "calm-hub-admin"; } 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 From f7b4d37aa2f22dcb713c917dd003e6b0119542c2 Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Tue, 26 May 2026 17:31:59 +0100 Subject: [PATCH 11/26] feat(calm-hub): rework entitlements to be across all resource types --- .../PermittedScopesIntegration.java | 9 +- .../UserAccessGrantsIntegration.java | 4 +- .../calm/mcp/tools/ArchitectureTools.java | 12 +- .../org/finos/calm/resources/AdrResource.java | 31 ++- .../calm/resources/ArchitectureResource.java | 25 +-- .../finos/calm/resources/ControlResource.java | 37 ++-- .../calm/resources/CoreSchemaResource.java | 16 +- .../calm/resources/DecoratorResource.java | 25 +-- .../finos/calm/resources/DomainResource.java | 4 +- .../finos/calm/resources/FlowResource.java | 25 ++- .../resources/FrontControllerResource.java | 15 +- .../calm/resources/InterfaceResource.java | 16 +- .../calm/resources/NamespaceResource.java | 13 +- .../finos/calm/resources/PatternResource.java | 21 +-- .../calm/resources/StandardResource.java | 15 +- .../calm/resources/UserAccessResource.java | 13 +- .../security/CalmHubPermissionChecker.java | 176 +++++------------- .../finos/calm/security/CalmHubScopes.java | 40 +--- .../security/TestCalmHubScopesShould.java | 16 +- 19 files changed, 160 insertions(+), 353 deletions(-) diff --git a/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java b/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java index c4d2aad3e..27ed7864e 100644 --- a/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java +++ b/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java @@ -71,7 +71,7 @@ void setupPatterns() { void end_to_end_get_patterns_with_valid_scopes() { String authServerUrl = ConfigProvider.getConfig().getValue("quarkus.oidc.auth-server-url", String.class); logger.info("authServerUrl {}", authServerUrl); - String accessToken = generateAccessTokenWithPasswordGrantType(authServerUrl, CalmHubScopes.ARCHITECTURES_READ); + String accessToken = generateAccessTokenWithPasswordGrantType(authServerUrl, CalmHubScopes.READ); given() .auth().oauth2(accessToken) .when().get("/calm/namespaces/finos/patterns") @@ -123,7 +123,7 @@ private String generateAccessTokenWithPasswordGrantType(String authServerUrl, St void end_to_end_forbidden_create_pattern_when_matching_scopes_notfound() { String authServerUrl = ConfigProvider.getConfig().getValue("quarkus.oidc.auth-server-url", String.class); logger.info("authServerUrl {}", authServerUrl); - String accessToken = generateAccessTokenWithClientCredentialGrant(authServerUrl, CalmHubScopes.ARCHITECTURES_READ); + String accessToken = generateAccessTokenWithClientCredentialGrant(authServerUrl, CalmHubScopes.READ); given() .auth().oauth2(accessToken) @@ -147,8 +147,7 @@ void end_to_end_unauthorize_create_pattern_request_with_no_access_token() { } @ParameterizedTest - @ValueSource(strings = {CalmHubScopes.ADRS_READ, CalmHubScopes.ADRS_ALL, - CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ}) + @ValueSource(strings = {CalmHubScopes.READ, CalmHubScopes.WRITE}) @Order(4) void end_to_end_get_namespaces_with_valid_access_token(String scope) { String authServerUrl = ConfigProvider.getConfig().getValue("quarkus.oidc.auth-server-url", String.class); @@ -181,7 +180,7 @@ void end_to_end_forbidden_get_namespaces_when_matching_scopes_notfound() { 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); + String accessToken = generateAccessTokenWithPasswordGrantType(authServerUrl, CalmHubScopes.WRITE); logger.info("accessToken: {}", accessToken); given() .auth().oauth2(accessToken) diff --git a/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java b/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java index b02f20df0..477d7ca31 100644 --- a/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java +++ b/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java @@ -105,7 +105,7 @@ private String generateAccessTokenWithPasswordGrantType(String authServerUrl, St void end_to_end_create_user_access_with_namespace_admin_scope_and_with_admin_user_grants() { String authServerUrl = ConfigProvider.getConfig().getValue("quarkus.oidc.auth-server-url", String.class); logger.info("authServerUrl {}", authServerUrl); - String accessToken = generateAccessTokenWithPasswordGrantType(authServerUrl, CalmHubScopes.NAMESPACE_ADMIN); + String accessToken = generateAccessTokenWithPasswordGrantType(authServerUrl, CalmHubScopes.ADMIN); given() .auth().oauth2(accessToken) .body(CREATE_USER_ACCESS_REQUEST) @@ -120,7 +120,7 @@ void end_to_end_create_user_access_with_namespace_admin_scope_and_with_admin_use void end_to_end_forbidden_create_user_access_when_admin_has_no_access_on_namespace() { String authServerUrl = ConfigProvider.getConfig().getValue("quarkus.oidc.auth-server-url", String.class); - String accessToken = generateAccessTokenWithPasswordGrantType(authServerUrl, CalmHubScopes.NAMESPACE_ADMIN); + String accessToken = generateAccessTokenWithPasswordGrantType(authServerUrl, CalmHubScopes.ADMIN); given() .auth().oauth2(accessToken) .body(CREATE_USER_ACCESS_REQUEST_2) 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 426300185..350073837 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 @@ -9,10 +9,10 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; import org.finos.calm.domain.Architecture; import org.finos.calm.domain.architecture.NamespaceArchitectureSummary; -import org.finos.calm.security.CalmHubScopes; import org.finos.calm.domain.exception.ArchitectureNotFoundException; 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; @@ -47,7 +47,7 @@ public class ArchitectureTools { ArchitectureStore architectureStore; @Tool(description = "List all architectures in a CalmHub namespace. Returns architecture IDs, names, and descriptions.") - @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_READ) + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse listArchitectures( @ToolArg(description = "The namespace to list architectures from (e.g. 'workshop', 'finos')") String namespace) { Optional err = McpValidationHelper.firstError( @@ -79,7 +79,7 @@ public ToolResponse listArchitectures( } @Tool(description = "List available versions of an architecture in a CalmHub namespace.") - @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_READ) + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse listArchitectureVersions( @ToolArg(description = "The namespace containing the architecture") String namespace, @ToolArg(description = "The architecture ID (positive integer)") int architectureId) { @@ -113,7 +113,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.ARCHITECTURES_READ) + @PermissionsAllowed(CalmHubScopes.READ) public ToolResponse getArchitecture( @ToolArg(description = "The namespace containing the architecture") String namespace, @ToolArg(description = "The architecture ID (positive integer)") int architectureId, @@ -144,7 +144,7 @@ public ToolResponse getArchitecture( } } - @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_WRITE) + @PermissionsAllowed(CalmHubScopes.WRITE) @Tool(description = "Publish or overwrite an architecture version against an existing architecture ID. " + "This is an upsert: if the supplied version already exists for the architecture it will be replaced, " + "otherwise it is added as a new version. Provided primarily for legacy/backwards-compatibility flows " + @@ -235,7 +235,7 @@ private NamespaceArchitectureSummary findArchitectureSummary(String namespace, i } @Tool(description = "Create a new architecture in a namespace. Returns the allocated architecture ID and version.") - @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_WRITE) + @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/resources/AdrResource.java b/calm-hub/src/main/java/org/finos/calm/resources/AdrResource.java index 6afdc1afc..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 @@ -3,12 +3,7 @@ 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; @@ -16,16 +11,12 @@ 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.store.AdrStore; import org.slf4j.Logger; @@ -67,7 +58,7 @@ public AdrResource(AdrStore store) { summary = "Retrieve ADRs in a given namespace", description = "ADRs stored in a given namespace" ) - @PermissionsAllowed(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" ) - @PermissionsAllowed(CalmHubScopes.ADRS_WRITE) + @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." ) - @PermissionsAllowed(CalmHubScopes.ADRS_WRITE) + @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)) ) }) - @PermissionsAllowed(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." ) - @PermissionsAllowed(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)) ) }) - @PermissionsAllowed(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." ) - @PermissionsAllowed(CalmHubScopes.ADRS_WRITE) + @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 62bc091e7..95742e891 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 @@ -4,13 +4,7 @@ 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.PUT; -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.bson.json.JsonParseException; @@ -31,10 +25,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.*; /** @@ -69,7 +60,7 @@ public ArchitectureResource(ArchitectureStore store) { summary = "Retrieve architectures in a given namespace", description = "Architecture stored in a given namespace" ) - @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_READ) + @PermissionsAllowed(CalmHubScopes.READ) public Response getArchitecturesForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -89,7 +80,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" ) - @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_WRITE) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response createArchitectureForNamespace( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @@ -120,7 +111,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" ) - @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_READ) + @PermissionsAllowed(CalmHubScopes.READ) public Response getArchitectureVersions( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId @@ -148,7 +139,7 @@ public Response getArchitectureVersions( summary = "Retrieve a specific architecture at a given version", description = "Retrieve architectures at a specific version" ) - @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_READ) + @PermissionsAllowed(CalmHubScopes.READ) public Response getArchitecture( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId, @@ -177,7 +168,7 @@ public Response getArchitecture( @Path("{namespace}/architectures/{architectureId}/versions/{version}") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) - @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_WRITE) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response createVersionedArchitecture( @PathParam("namespace") @Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("architectureId") int architectureId, @@ -216,7 +207,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" ) - @PermissionsAllowed(CalmHubScopes.ARCHITECTURES_WRITE) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response updateVersionedArchitecture( @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 4e3299040..ad0142266 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 @@ -8,32 +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.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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) + @PermissionsAllowed(CalmHubScopes.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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_CONTRIBUTOR) + @PermissionsAllowed(CalmHubScopes.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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) + @PermissionsAllowed(CalmHubScopes.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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) + @PermissionsAllowed(CalmHubScopes.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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_CONTRIBUTOR) + @PermissionsAllowed(CalmHubScopes.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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) + @PermissionsAllowed(CalmHubScopes.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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_CONTRIBUTOR) + @PermissionsAllowed(CalmHubScopes.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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) + @PermissionsAllowed(CalmHubScopes.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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) + @PermissionsAllowed(CalmHubScopes.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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_CONTRIBUTOR) + @PermissionsAllowed(CalmHubScopes.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 da7f0a329..fa8216c6e 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 @@ -2,12 +2,7 @@ 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; @@ -15,6 +10,7 @@ import org.finos.calm.security.CalmHubScopes; 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 +33,7 @@ public CoreSchemaResource(CoreSchemaStore coreSchemaStore) { summary = "Published CALM Schema Versions", description = "Retrieve the CALM Schema versions published by this CALM Hub" ) - @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) + @PermissionsAllowed(CalmHubScopes.READ) public ValueWrapper schemaVersions() { return new ValueWrapper<>(coreSchemaStore.getVersions()); } @@ -48,7 +44,7 @@ public ValueWrapper schemaVersions() { summary = "Published CALM Schemas for Version", description = "Retrieve the names of CALM Schemas in a given version" ) - @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) + @PermissionsAllowed(CalmHubScopes.READ) public Response schemasForVersion(@PathParam("version") String version) { Map schemas = coreSchemaStore.getSchemasForVersion(version); if (schemas == null) { @@ -65,7 +61,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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) + @PermissionsAllowed(CalmHubScopes.READ) public Response getSchema(@PathParam("version") String version, @PathParam("schemaName") String schemaName) { Map schemas = coreSchemaStore.getSchemasForVersion(version); @@ -89,7 +85,7 @@ public Response getSchema(@PathParam("version") String version, summary = "Create Schema Version", description = "Create a new schema version with associated schemas" ) - @PermissionsAllowed(CalmHubScopes.ROLE_CONTRIBUTOR) + @PermissionsAllowed(CalmHubScopes.WRITE) 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 75fde8f9b..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 @@ -5,19 +5,11 @@ 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; @@ -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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) + @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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) + @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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) + @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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_CONTRIBUTOR) + @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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_CONTRIBUTOR) + @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 72a3214d3..c62442b33 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 @@ -45,7 +45,7 @@ public DomainResource(DomainStore store) { summary = "Available Domains", description = "The available domains in this Calm Hub" ) - @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) + @PermissionsAllowed(CalmHubScopes.READ) public Response getDomains() { return Response.ok(new ValueWrapper<>(store.getDomains())).build(); } @@ -62,7 +62,7 @@ public Response getDomains() { summary = "Create Domain", description = "Create a new domain in the Calm Hub" ) - @PermissionsAllowed(CalmHubScopes.ROLE_CONTRIBUTOR) + @PermissionsAllowed(CalmHubScopes.WRITE) 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/FlowResource.java b/calm-hub/src/main/java/org/finos/calm/resources/FlowResource.java index f9cd77c42..71c05186b 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 @@ -11,11 +11,12 @@ 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.store.FlowStore; @@ -26,11 +27,7 @@ import java.net.URISyntaxException; import java.util.List; -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 FlowResource { @@ -54,7 +51,7 @@ public FlowResource(FlowStore store) { summary = "Retrieve flows in a given namespace", description = "Flows stored in a given namespace" ) - @PermissionsAllowed(CalmHubScopes.FLOWS_READ) + @PermissionsAllowed(CalmHubScopes.READ) public Response getFlowsForNamespace( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -74,7 +71,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" ) - @PermissionsAllowed(CalmHubScopes.FLOWS_WRITE) + @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 +95,7 @@ public Response createFlowForNamespace( summary = "Retrieve the latest flow version", description = "Fetch the latest version of the flow by flowId" ) - @PermissionsAllowed(CalmHubScopes.FLOWS_READ) + @PermissionsAllowed(CalmHubScopes.READ) public Response getLatestFlow( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("flowId") int flowId @@ -129,7 +126,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" ) - @PermissionsAllowed(CalmHubScopes.FLOWS_READ) + @PermissionsAllowed(CalmHubScopes.READ) public Response getFlowVersions( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("flowId") int flowId @@ -157,7 +154,7 @@ public Response getFlowVersions( summary = "Retrieve a specific flow at a given version", description = "Retrieve flows at a specific version" ) - @PermissionsAllowed(CalmHubScopes.FLOWS_READ) + @PermissionsAllowed(CalmHubScopes.READ) public Response getFlow( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("flowId") int flowId, @@ -191,7 +188,7 @@ 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.FLOWS_WRITE) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response createVersionedFlow( @PathParam("namespace") @Pattern(regexp= NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("flowId") int flowId, @@ -228,7 +225,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" ) - @PermissionsAllowed(CalmHubScopes.FLOWS_WRITE) + @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 c81063ae8..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 @@ -6,19 +6,14 @@ 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; @@ -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." ) - @PermissionsAllowed(CalmHubScopes.ROLE_CONTRIBUTOR) + @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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) + @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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) + @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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) + @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" ) - @PermissionsAllowed(CalmHubScopes.ROLE_VIEWER) + @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 f61485a59..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 @@ -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) - @PermissionsAllowed(CalmHubScopes.INTERFACES_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) - @PermissionsAllowed(CalmHubScopes.INTERFACES_WRITE) + @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) - @PermissionsAllowed(CalmHubScopes.INTERFACES_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) - @PermissionsAllowed(CalmHubScopes.INTERFACES_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) - @PermissionsAllowed(CalmHubScopes.INTERFACES_WRITE) + @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 2bdf929a6..c161885e0 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,15 +1,10 @@ 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; @@ -17,6 +12,7 @@ import org.finos.calm.domain.ValueWrapper; 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 java.net.URI; @@ -37,7 +33,7 @@ public NamespaceResource(NamespaceStore store) { summary = "Available Namespaces", description = "The available namespaces available in this Calm Hub" ) - @Authenticated + @PermissionsAllowed(CalmHubScopes.READ) public ValueWrapper namespaces() { return new ValueWrapper<>(namespaceStore.getNamespaces()); } @@ -49,8 +45,7 @@ public ValueWrapper namespaces() { summary = "Create Namespace", description = "Create a new namespace in the Calm Hub" ) - @Authenticated - // TODO need a permission to manage top level namespaces + @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 7215782db..4f374a344 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 @@ -10,7 +10,8 @@ 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; @@ -24,11 +25,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 PatternResource { @@ -52,7 +49,7 @@ public PatternResource(PatternStore store) { summary = "Retrieve patterns in a given namespace", description = "Patterns stored in a given namespace" ) - @PermissionsAllowed(CalmHubScopes.PATTERNS_READ) + @PermissionsAllowed(CalmHubScopes.READ) public Response getPatternsForNamespace( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace ) { @@ -72,7 +69,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" ) - @PermissionsAllowed(CalmHubScopes.PATTERNS_WRITE) + @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 +92,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" ) - @PermissionsAllowed(CalmHubScopes.PATTERNS_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 +121,7 @@ public Response getPatternVersions( summary = "Retrieve a specific pattern at a given version", description = "Retrieve patterns at a specific version" ) - @PermissionsAllowed(CalmHubScopes.PATTERNS_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, @@ -154,7 +151,7 @@ public Response getPattern( @Path("{namespace}/patterns/{patternId}/versions/{version}") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) - @PermissionsAllowed(CalmHubScopes.PATTERNS_WRITE) + @PermissionsAllowed(CalmHubScopes.WRITE) public Response createVersionedPattern( @PathParam("namespace") @jakarta.validation.constraints.Pattern(regexp = NAMESPACE_REGEX, message = NAMESPACE_MESSAGE) String namespace, @PathParam("patternId") int patternId, @@ -191,7 +188,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" ) - @PermissionsAllowed({CalmHubScopes.PATTERNS_WRITE}) + @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/StandardResource.java b/calm-hub/src/main/java/org/finos/calm/resources/StandardResource.java index 192ed2247..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 @@ -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) - @PermissionsAllowed(CalmHubScopes.STANDARDS_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) - @PermissionsAllowed(CalmHubScopes.STANDARDS_WRITE) + @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) - @PermissionsAllowed(CalmHubScopes.STANDARDS_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) - @PermissionsAllowed(CalmHubScopes.STANDARDS_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) - @PermissionsAllowed(CalmHubScopes.STANDARDS_WRITE) + @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/UserAccessResource.java b/calm-hub/src/main/java/org/finos/calm/resources/UserAccessResource.java index c5cffdaff..b29f76b8b 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,12 +1,7 @@ package org.finos.calm.resources; import io.quarkus.security.PermissionsAllowed; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.openapi.annotations.Operation; @@ -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" ) - @PermissionsAllowed({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" ) - @PermissionsAllowed({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" ) - @PermissionsAllowed({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/CalmHubPermissionChecker.java b/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java index c35b414a4..e2214637c 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java +++ b/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java @@ -3,14 +3,12 @@ import io.quarkus.security.PermissionChecker; import io.quarkus.security.identity.SecurityIdentity; import jakarta.enterprise.context.ApplicationScoped; -import org.finos.calm.domain.ResourceType; import org.finos.calm.domain.UserAccess; import org.finos.calm.domain.exception.UserAccessNotFoundException; import org.finos.calm.store.UserAccessStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.List; @ApplicationScoped public class CalmHubPermissionChecker { @@ -22,147 +20,65 @@ public CalmHubPermissionChecker(UserAccessStore userAccessStore) { this.userAccessStore = userAccessStore; } - @PermissionChecker(CalmHubScopes.ARCHITECTURES_READ) - public boolean allowArchitectureRead(SecurityIdentity identity, String namespace) { - if (identity.isAnonymous()) return true; - if (hasReadRole(identity)) return true; - return hasAccess(identity, namespace, UserAccess.ResourceType.architectures, false); - } - - @PermissionChecker(CalmHubScopes.ARCHITECTURES_WRITE) - public boolean allowArchitectureWrite(SecurityIdentity identity, String namespace) { - if (identity.isAnonymous()) return true; - if (hasWriteRole(identity)) return true; - return hasAccess(identity, namespace, UserAccess.ResourceType.architectures, true); - } - - @PermissionChecker(CalmHubScopes.PATTERNS_READ) - public boolean allowPatternRead(SecurityIdentity identity, String namespace) { - if (identity.isAnonymous()) return true; - if (hasReadRole(identity)) return true; - return hasAccess(identity, namespace, UserAccess.ResourceType.patterns, false); - } - - @PermissionChecker(CalmHubScopes.PATTERNS_WRITE) - public boolean allowPatternWrite(SecurityIdentity identity, String namespace) { - if (identity.isAnonymous()) return true; - if (hasWriteRole(identity)) return true; - return hasAccess(identity, namespace, UserAccess.ResourceType.patterns, true); - } - - @PermissionChecker(CalmHubScopes.FLOWS_READ) - public boolean allowFlowRead(SecurityIdentity identity, String namespace) { - if (identity.isAnonymous()) return true; - if (hasReadRole(identity)) return true; - return hasAccess(identity, namespace, UserAccess.ResourceType.flows, false); - } - - @PermissionChecker(CalmHubScopes.FLOWS_WRITE) - public boolean allowFlowWrite(SecurityIdentity identity, String namespace) { - if (identity.isAnonymous()) return true; - if (hasWriteRole(identity)) return true; - return hasAccess(identity, namespace, UserAccess.ResourceType.flows, true); - } - - @PermissionChecker(CalmHubScopes.ADRS_READ) - public boolean allowAdrRead(SecurityIdentity identity, String namespace) { - if (identity.isAnonymous()) return true; - if (hasReadRole(identity)) return true; - return hasAccess(identity, namespace, UserAccess.ResourceType.adrs, false); - } - - @PermissionChecker(CalmHubScopes.ADRS_WRITE) - public boolean allowAdrWrite(SecurityIdentity identity, String namespace) { - if (identity.isAnonymous()) return true; - if (hasWriteRole(identity)) return true; - return hasAccess(identity, namespace, UserAccess.ResourceType.adrs, true); - } - - @PermissionChecker(CalmHubScopes.INTERFACES_READ) - public boolean allowInterfaceRead(SecurityIdentity identity, String namespace) { - if (identity.isAnonymous()) return true; - if (hasReadRole(identity)) return true; - return hasAccess(identity, namespace, UserAccess.ResourceType.all, false); - } - - @PermissionChecker(CalmHubScopes.INTERFACES_WRITE) - public boolean allowInterfaceWrite(SecurityIdentity identity, String namespace) { + @PermissionChecker(CalmHubScopes.ADMIN) + public boolean allowNamespaceAdmin(SecurityIdentity identity, String namespace) { if (identity.isAnonymous()) return true; - if (hasWriteRole(identity)) return true; - return hasAccess(identity, namespace, UserAccess.ResourceType.all, true); + return hasAccess(identity, namespace, UserAction.ADMIN); } - @PermissionChecker(CalmHubScopes.STANDARDS_READ) - public boolean allowStandardRead(SecurityIdentity identity, String namespace) { - if (identity.isAnonymous()) return true; - if (hasReadRole(identity)) return true; - return hasAccess(identity, namespace, UserAccess.ResourceType.all, false); + @PermissionChecker(CalmHubScopes.READ) + public boolean canRead(SecurityIdentity identity, String namespace) { + return identity.isAnonymous() + || hasAccess(identity, namespace, UserAction.READ); } - @PermissionChecker(CalmHubScopes.STANDARDS_WRITE) - public boolean allowStandardWrite(SecurityIdentity identity, String namespace) { - if (identity.isAnonymous()) return true; - if (hasWriteRole(identity)) return true; - return hasAccess(identity, namespace, UserAccess.ResourceType.all, true); + @PermissionChecker(CalmHubScopes.WRITE) + public boolean canWrite(SecurityIdentity identity, String namespace) { + return identity.isAnonymous() + || hasAccess(identity, namespace, UserAction.WRITE); } - @PermissionChecker(CalmHubScopes.NAMESPACE_ADMIN) - public boolean allowNamespaceAdmin(SecurityIdentity identity, String namespace) { - if (identity.isAnonymous()) return true; - if (identity.hasRole(CalmHubScopes.ROLE_ADMIN)) return true; + @PermissionChecker(CalmHubScopes.GLOBAL_ADMIN) + public boolean hasGlobalAdmin(SecurityIdentity identity) { + if (identity.isAnonymous()) { + 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 { - return userAccessStore.getUserAccessForUsername(username).stream() - .anyMatch(grant -> grant.getNamespace().equals(namespace) - && grant.getPermission() == UserAccess.Permission.admin); + boolean granted = + userAccessStore.getUserAccessForUsername(username) + .stream() + .anyMatch(grant -> namespaceMatches(grant, "GLOBAL") + && 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.error("No user access records found for user {}. Rejecting request.", username); - throw new RuntimeException(e); + logger.debug("No access grants found for user [{}]", username); + return false; } } - @PermissionChecker(CalmHubScopes.ROLE_VIEWER) - public boolean checkViewerRole(SecurityIdentity identity) { - return identity.isAnonymous() || hasReadRole(identity); - } - - @PermissionChecker(CalmHubScopes.ROLE_CONTRIBUTOR) - public boolean checkContributorRole(SecurityIdentity identity) { - return identity.isAnonymous() || hasWriteRole(identity); - } - - @PermissionChecker(CalmHubScopes.ROLE_ADMIN) - public boolean checkAdminRole(SecurityIdentity identity) { - return identity.isAnonymous() || identity.hasRole(CalmHubScopes.ROLE_ADMIN); - } - - private boolean hasReadRole(SecurityIdentity identity) { - return identity.hasRole(CalmHubScopes.ROLE_VIEWER) - || identity.hasRole(CalmHubScopes.ROLE_CONTRIBUTOR) - || identity.hasRole(CalmHubScopes.ROLE_ADMIN); - } - - private boolean hasWriteRole(SecurityIdentity identity) { - return identity.hasRole(CalmHubScopes.ROLE_CONTRIBUTOR) - || identity.hasRole(CalmHubScopes.ROLE_ADMIN); - } - - private boolean hasAccess(SecurityIdentity identity, String namespace, - UserAccess.ResourceType resourceType, boolean requireWrite) { + private boolean hasAccess(SecurityIdentity identity, String namespace, UserAction action) { String username = identity.getPrincipal().getName(); - logger.debug("Checking access for user [{}] on namespace [{}] resource [{}] write=[{}]", - username, namespace, resourceType, requireWrite); + logger.debug("Checking access for user [{}] on namespace [{}] action=[{}]", + username, namespace, action); try { boolean result = userAccessStore.getUserAccessForUsername(username).stream() .anyMatch(grant -> namespaceMatches(grant, namespace) - && resourceMatches(grant, resourceType) - && permissionSufficient(grant, requireWrite)); + && permissionSufficient(grant, action)); if (result) { - logger.info("User [{}] AUTHORIZED for [{}] on [{}] in namespace [{}]", - username, requireWrite ? "write" : "read", resourceType, namespace); + logger.info("User [{}] AUTHORIZED for [{}] in namespace [{}]", + username, action, namespace); } else { - logger.warn("User [{}] DENIED for [{}] on [{}] in namespace [{}]", - username, requireWrite ? "write" : "read", resourceType, namespace); + logger.warn("User [{}] DENIED for [{}] in namespace [{}]", + username, action, namespace); } return result; } catch (UserAccessNotFoundException e) { @@ -175,15 +91,11 @@ private boolean namespaceMatches(UserAccess grant, String namespace) { return grant.getNamespace().equals(namespace); } - private boolean resourceMatches(UserAccess grant, UserAccess.ResourceType required) { - return grant.getResourceType() == UserAccess.ResourceType.all - || grant.getResourceType() == required; - } - - private boolean permissionSufficient(UserAccess grant, boolean requireWrite) { + private boolean permissionSufficient(UserAccess grant, UserAction action) { return switch (grant.getPermission()) { - case read -> !requireWrite; - case write, admin -> true; + 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 46238188b..d7fdbef2b 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,41 +9,9 @@ private CalmHubScopes() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); } - public static final String ARCHITECTURES_READ = "architectures:read"; - public static final String ARCHITECTURES_WRITE = "architectures:write"; + public static final String READ = "read"; + public static final String WRITE = "write"; + public static final String ADMIN = "admin"; - public static final String ADRS_READ = "adrs:read"; - public static final String ADRS_WRITE = "adrs:write"; - - /** - * Allows read operations on the Search endpoint. Results are filtered based on user access grants. - */ - public static final String SEARCH_READ = "search:read"; - - public static final String PATTERNS_READ = "patterns:read"; - public static final String PATTERNS_WRITE = "patterns:write"; - - public static final String FLOWS_READ = "flows:read"; - public static final String FLOWS_WRITE = "flows:write"; - - public static final String INTERFACES_READ = "interfaces:read"; - public static final String INTERFACES_WRITE = "interfaces:write"; - - public static final String STANDARDS_READ = "standards:read"; - public static final String STANDARDS_WRITE = "standards:write"; - - /** - * Allow to grant access to users on namespace associated resources and for the admin operations. - */ - public static final String NAMESPACE_ADMIN = "namespace:admin"; - - /** - * Platform-level roles reflecting the PERMISSIONS.md hierarchy. - * ROLE_VIEWER implies all resource-type :read permissions. - * ROLE_CONTRIBUTOR implies all resource-type :write permissions (and therefore :read). - * ROLE_ADMIN implies namespace:admin across all namespaces (and therefore all write/read). - */ - public static final String ROLE_VIEWER = "calm-hub-viewer"; - public static final String ROLE_CONTRIBUTOR = "calm-hub-contributor"; - public static final String ROLE_ADMIN = "calm-hub-admin"; + public static final String GLOBAL_ADMIN = "global_admin"; } 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 8eed51260..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); } } From 7d85c21a892dc8ffa6b73e3d707a6893678cc822 Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Thu, 28 May 2026 11:20:49 +0100 Subject: [PATCH 12/26] feat(calm-hub): add annotations to mcp tools --- .../main/java/org/finos/calm/mcp/tools/ControlTools.java | 7 +++++++ .../main/java/org/finos/calm/mcp/tools/DecoratorTools.java | 6 ++++++ .../main/java/org/finos/calm/mcp/tools/DomainTools.java | 4 ++++ .../main/java/org/finos/calm/mcp/tools/NamespaceTools.java | 4 ++++ .../main/java/org/finos/calm/mcp/tools/SearchTools.java | 3 +++ 5 files changed, 24 insertions(+) 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 df127515d..f325347ea 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; @@ -35,6 +37,7 @@ public class ControlTools { @Inject ControlStore controlStore; + @PermissionsAllowed(CalmHubScopes.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) { @@ -66,6 +69,7 @@ public ToolResponse listControls( } } + @PermissionsAllowed(CalmHubScopes.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, @@ -92,6 +96,7 @@ public ToolResponse getControl( } } + @PermissionsAllowed(CalmHubScopes.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, @@ -121,6 +126,7 @@ public ToolResponse listControlVersions( } } + @PermissionsAllowed(CalmHubScopes.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, @@ -150,6 +156,7 @@ public ToolResponse createControlRequirement( } } + @PermissionsAllowed(CalmHubScopes.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 85bf69aed..a6909133a 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; @@ -33,6 +35,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, @@ -69,6 +72,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, @@ -98,6 +102,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, @@ -119,6 +124,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 30193ad36..4f275aec1 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,13 @@ 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.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; @@ -30,6 +32,7 @@ public class DomainTools { @Inject DomainStore domainStore; + @PermissionsAllowed(CalmHubScopes.READ) @Tool(description = "List all control domains available in CalmHub (e.g. 'security'). Domains group related control requirements.") public ToolResponse listDomains() { String error = McpValidationHelper.checkEnabled(mcpEnabled); @@ -47,6 +50,7 @@ public ToolResponse listDomains() { return ToolResponse.success(sb.toString()); } + @PermissionsAllowed(CalmHubScopes.WRITE) @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/NamespaceTools.java b/calm-hub/src/main/java/org/finos/calm/mcp/tools/NamespaceTools.java index d022eee75..1fa3f5f25 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,13 @@ 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.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 +33,7 @@ public class NamespaceTools { @Inject NamespaceStore namespaceStore; + @PermissionsAllowed(CalmHubScopes.READ) @Tool(description = "List all namespaces available in CalmHub. Returns namespace names and descriptions.") public ToolResponse listNamespaces() { Optional err = McpValidationHelper.firstError( @@ -51,6 +54,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/SearchTools.java b/calm-hub/src/main/java/org/finos/calm/mcp/tools/SearchTools.java index 286cb737d..32ce86ec0 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,11 +3,13 @@ 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.search.GroupedSearchResults; import org.finos.calm.domain.search.SearchResult; +import org.finos.calm.security.CalmHubScopes; import org.finos.calm.store.SearchStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,6 +38,7 @@ public class SearchTools { @Inject SearchStore searchStore; + @PermissionsAllowed(CalmHubScopes.READ) @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) { From 586820a535ec4c91d21ab3068ed9fa63a2dc50ae Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Thu, 28 May 2026 13:33:24 +0100 Subject: [PATCH 13/26] feat(calm-hub): public read setting to make read default --- .../finos/calm/security/CalmHubPermissionChecker.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 index e2214637c..281a1c4f7 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java +++ b/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java @@ -3,6 +3,8 @@ 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.exception.UserAccessNotFoundException; import org.finos.calm.store.UserAccessStore; @@ -16,6 +18,10 @@ public class CalmHubPermissionChecker { private final UserAccessStore userAccessStore; + @Inject + @ConfigProperty(name = "calm.hub.allow.public.read", defaultValue = "false") + boolean allowPublicRead; + public CalmHubPermissionChecker(UserAccessStore userAccessStore) { this.userAccessStore = userAccessStore; } @@ -28,8 +34,9 @@ public boolean allowNamespaceAdmin(SecurityIdentity identity, String namespace) @PermissionChecker(CalmHubScopes.READ) public boolean canRead(SecurityIdentity identity, String namespace) { - return identity.isAnonymous() - || hasAccess(identity, namespace, UserAction.READ); + if (identity.isAnonymous()) return true; + if (allowPublicRead) return true; + return hasAccess(identity, namespace, UserAction.READ); } @PermissionChecker(CalmHubScopes.WRITE) From 95c9b4b9e5ad76c2e331b7071dd8714ec6138244 Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Thu, 28 May 2026 13:34:36 +0100 Subject: [PATCH 14/26] feat(calm-hub): remove resource type from user access db model --- calm-hub/mongo/init-mongo.js | 18 +-- .../PermittedScopesIntegration.java | 1 - .../UserAccessGrantsIntegration.java | 7 +- .../org/finos/calm/domain/UserAccess.java | 113 ++++++-------- .../store/mongo/MongoUserAccessStore.java | 9 +- .../store/nitrite/NitriteUserAccessStore.java | 8 +- .../calm/domain/TestUserAccessShould.java | 11 +- .../TestUserAccessResourceShould.java | 7 +- .../TestCalmHubPermissionCheckerShould.java | 142 ++++++++++++------ .../mongo/TestMongoUserAccessStoreShould.java | 13 +- .../TestNitriteUserAccessStoreShould.java | 5 - 11 files changed, 162 insertions(+), 172 deletions(-) diff --git a/calm-hub/mongo/init-mongo.js b/calm-hub/mongo/init-mongo.js index 6bb9ba99a..acc8d1857 100644 --- a/calm-hub/mongo/init-mongo.js +++ b/calm-hub/mongo/init-mongo.js @@ -2501,43 +2501,37 @@ if (db.userAccess.countDocuments() === 0) { "userAccessId": NumberInt(1), "username": "demo_admin", "permission": "admin", - "namespace": "finos", - "resourceType": "all" + "namespace": "finos" }, { "userAccessId": NumberInt(2), "username": "demo_admin", "permission": "admin", - "namespace": "workshop", - "resourceType": "patterns" + "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/src/integration-test/java/integration/PermittedScopesIntegration.java b/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java index 27ed7864e..c12ad03b7 100644 --- a/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java +++ b/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java @@ -56,7 +56,6 @@ void setupPatterns() { 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); diff --git a/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java b/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java index 477d7ca31..76d3921c3 100644 --- a/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java +++ b/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java @@ -29,8 +29,7 @@ public class UserAccessGrantsIntegration { { "username": "testuser1", "permission": "read", - "namespace": "finos", - "resourceType": "all" + "namespace": "finos" } """; @@ -38,8 +37,7 @@ public class UserAccessGrantsIntegration { { "username": "testuser1", "permission": "read", - "namespace": "workshop", - "resourceType": "all" + "namespace": "workshop" } """; @@ -69,7 +67,6 @@ void setupPatterns() { 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); 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 28f9b6713..e547f79ce 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 @@ -19,18 +19,9 @@ public enum Permission { admin } - public enum ResourceType { - patterns, - flows, - adrs, - architectures, - all - } - private String username; private Permission permission; private String namespace; - private ResourceType resourceType; private int userAccessId; @JsonDeserialize(using = LocalDateTimeDeserializer.class) @@ -41,61 +32,33 @@ 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 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; - } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; - public UserAccess build(){ - return new UserAccess(username, permission, namespace, resourceType, userAccessId); - } + 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; + return Objects.equals(namespace, that.namespace); } public String getUsername() { @@ -110,10 +73,6 @@ public String getNamespace() { return namespace; } - public ResourceType getResourceType() { - return resourceType; - } - public int getUserAccessId() { return userAccessId; } @@ -135,21 +94,40 @@ public void setUpdateDateTime(LocalDateTime updateDateTime) { } @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - UserAccess that = (UserAccess) o; - if (userAccessId != that.userAccessId) return false; - if (!Objects.equals(username, that.username)) return false; - if (!Objects.equals(permission, that.permission)) return false; - if (!Objects.equals(namespace, that.namespace)) return false; - return Objects.equals(resourceType, that.resourceType); + public int hashCode() { + return Objects.hash(username, permission, namespace, userAccessId); } - @Override - public int hashCode() { - return Objects.hash(username, permission, namespace, resourceType, userAccessId); + public static class UserAccessBuilder { + + private String username; + private Permission permission; + private String namespace; + 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 setUserAccessId(int userAccessId) { + this.userAccessId = userAccessId; + return this; + } + + public UserAccess build(){ + return new UserAccess(username, permission, namespace, userAccessId); + } } @Override @@ -158,7 +136,6 @@ public String toString() { "username='" + username + '\'' + ", permission='" + permission + '\'' + ", namespace='" + namespace + '\'' + - ", resourceType='" + resourceType + '\'' + ", userAccessId=" + userAccessId + '}'; } @@ -175,10 +152,6 @@ public void setNamespace(String namespace) { this.namespace = namespace; } - public void setResourceType(ResourceType resourceType) { - this.resourceType = resourceType; - } - public void setUserAccessId(int userAccessId) { this.userAccessId = userAccessId; } diff --git a/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoUserAccessStore.java b/calm-hub/src/main/java/org/finos/calm/store/mongo/MongoUserAccessStore.java index c3602e839..6de29766e 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 @@ -44,18 +44,16 @@ 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() .setUserAccessId(userAccessId) - .setResourceType(userAccess.getResourceType()) .setNamespace(userAccess.getNamespace()) .setPermission(userAccess.getPermission()) .setUsername(userAccess.getUsername()) @@ -75,7 +73,6 @@ public List getUserAccessForUsername(String username) .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); @@ -100,7 +97,6 @@ public List getUserAccessForNamespace(String namespace) .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); @@ -131,7 +127,6 @@ public UserAccess getUserAccessForNamespaceAndId(String namespace, Integer userA .setUsername(document.getString("username")) .setPermission(UserAccess.Permission.valueOf(document.getString("permission"))) .setNamespace(namespace) - .setResourceType(UserAccess.ResourceType.valueOf(document.getString("resourceType"))) .setUserAccessId(document.getInteger("userAccessId")) .build(); } diff --git a/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteUserAccessStore.java b/calm-hub/src/main/java/org/finos/calm/store/nitrite/NitriteUserAccessStore.java index ee4ef441a..0d7e016bd 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 @@ -35,7 +35,6 @@ public class NitriteUserAccessStore implements UserAccessStore { private static final String USERNAME_FIELD = "username"; private static final String NAMESPACE_FIELD = "namespace"; 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"; @@ -71,19 +70,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()) @@ -154,7 +151,6 @@ private UserAccess buildUserAccessFromDocument(Document doc) { .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))) .setUserAccessId(doc.get(USER_ACCESS_ID_FIELD, Integer.class)) .build(); } diff --git a/calm-hub/src/test/java/org/finos/calm/domain/TestUserAccessShould.java b/calm-hub/src/test/java/org/finos/calm/domain/TestUserAccessShould.java 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/TestUserAccessResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestUserAccessResourceShould.java index 3709d1599..1eaa0bd70 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 @@ -8,10 +8,11 @@ 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.*; @@ -29,7 +30,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 +58,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 +82,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 +106,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/TestCalmHubPermissionCheckerShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubPermissionCheckerShould.java index bb4f4b010..14a7803eb 100644 --- a/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubPermissionCheckerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubPermissionCheckerShould.java @@ -34,6 +34,7 @@ class TestCalmHubPermissionCheckerShould { @BeforeEach void setUp() { checker = new CalmHubPermissionChecker(mockUserAccessStore); + // allowPublicRead defaults to false } private void givenAuthenticatedUser(String username) throws UserAccessNotFoundException { @@ -42,109 +43,164 @@ private void givenAuthenticatedUser(String username) throws UserAccessNotFoundEx when(mockPrincipal.getName()).thenReturn(username); } + // --- READ checks --- + @Test - void read_grant_allows_read_check() throws UserAccessNotFoundException { + void read_grant_allows_read() throws UserAccessNotFoundException { givenAuthenticatedUser("alice"); - UserAccess grant = new UserAccess("alice", UserAccess.Permission.read, "foo", UserAccess.ResourceType.architectures); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.read, "foo"); when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); - assertTrue(checker.allowArchitectureRead(mockIdentity, "foo")); + assertTrue(checker.canRead(mockIdentity, "foo")); } @Test - void write_grant_allows_read_check() throws UserAccessNotFoundException { + void write_grant_allows_read() throws UserAccessNotFoundException { givenAuthenticatedUser("alice"); - UserAccess grant = new UserAccess("alice", UserAccess.Permission.write, "foo", UserAccess.ResourceType.architectures); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.write, "foo"); when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); - assertTrue(checker.allowArchitectureRead(mockIdentity, "foo")); + assertTrue(checker.canRead(mockIdentity, "foo")); } @Test - void read_grant_denies_write_check() throws UserAccessNotFoundException { + void grant_for_different_namespace_denies_read() throws UserAccessNotFoundException { givenAuthenticatedUser("alice"); - UserAccess grant = new UserAccess("alice", UserAccess.Permission.read, "foo", UserAccess.ResourceType.architectures); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.write, "bar"); when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); - assertFalse(checker.allowArchitectureWrite(mockIdentity, "foo")); + 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 grant_for_different_namespace_denies_check() throws UserAccessNotFoundException { + void any_namespace_grant_satisfies_read_regardless_of_resource_type() throws UserAccessNotFoundException { givenAuthenticatedUser("alice"); - UserAccess grant = new UserAccess("alice", UserAccess.Permission.write, "bar", UserAccess.ResourceType.architectures); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.read, "foo"); when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); - assertFalse(checker.allowArchitectureWrite(mockIdentity, "foo")); + assertTrue(checker.canRead(mockIdentity, "foo")); } + // --- WRITE checks --- + @Test - void all_resource_type_satisfies_any_specific_resource_check() throws UserAccessNotFoundException { + void read_grant_denies_write() throws UserAccessNotFoundException { givenAuthenticatedUser("alice"); - UserAccess grant = new UserAccess("alice", UserAccess.Permission.write, "foo", UserAccess.ResourceType.all); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.read, "foo"); when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); - assertTrue(checker.allowArchitectureRead(mockIdentity, "foo")); - assertTrue(checker.allowPatternRead(mockIdentity, "foo")); - assertTrue(checker.allowFlowRead(mockIdentity, "foo")); - assertTrue(checker.allowAdrRead(mockIdentity, "foo")); + assertFalse(checker.canWrite(mockIdentity, "foo")); } @Test - void user_with_no_grants_is_denied() throws UserAccessNotFoundException { + void write_grant_allows_write() throws UserAccessNotFoundException { givenAuthenticatedUser("alice"); - when(mockUserAccessStore.getUserAccessForUsername("alice")).thenThrow(new UserAccessNotFoundException()); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.write, "foo"); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); + + assertTrue(checker.canWrite(mockIdentity, "foo")); + } - assertFalse(checker.allowArchitectureRead(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 pattern_grant_does_not_satisfy_architecture_check() throws UserAccessNotFoundException { + void admin_grant_allows_namespace_admin() throws UserAccessNotFoundException { givenAuthenticatedUser("alice"); - UserAccess grant = new UserAccess("alice", UserAccess.Permission.write, "foo", UserAccess.ResourceType.patterns); + UserAccess grant = new UserAccess("alice", UserAccess.Permission.admin, "foo"); when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(grant)); - assertFalse(checker.allowArchitectureRead(mockIdentity, "foo")); - assertTrue(checker.allowPatternRead(mockIdentity, "foo")); + assertTrue(checker.allowNamespaceAdmin(mockIdentity, "foo")); } + // --- Anonymous --- + @Test void anonymous_identity_is_always_allowed() { when(mockIdentity.isAnonymous()).thenReturn(true); - assertTrue(checker.allowArchitectureRead(mockIdentity, "foo")); - assertTrue(checker.allowArchitectureWrite(mockIdentity, "foo")); - assertTrue(checker.allowPatternRead(mockIdentity, "foo")); - assertTrue(checker.allowFlowWrite(mockIdentity, "foo")); - assertTrue(checker.allowAdrRead(mockIdentity, "foo")); + assertTrue(checker.canRead(mockIdentity, "foo")); + assertTrue(checker.canWrite(mockIdentity, "foo")); assertTrue(checker.allowNamespaceAdmin(mockIdentity, "foo")); } + // --- allowPublicRead --- + @Test - void namespace_admin_check_requires_admin_permission() throws UserAccessNotFoundException { + void public_read_disabled_denies_user_without_grants() throws UserAccessNotFoundException { givenAuthenticatedUser("alice"); - UserAccess writeGrant = new UserAccess("alice", UserAccess.Permission.write, "foo", UserAccess.ResourceType.all); - when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(writeGrant)); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenThrow(new UserAccessNotFoundException()); - assertFalse(checker.allowNamespaceAdmin(mockIdentity, "foo")); + assertFalse(checker.canRead(mockIdentity, "foo")); + } + + @Test + void public_read_enabled_allows_any_authenticated_user_to_read() { + checker.allowPublicRead = true; + when(mockIdentity.isAnonymous()).thenReturn(false); + + assertTrue(checker.canRead(mockIdentity, "foo")); } @Test - void namespace_admin_check_passes_with_admin_permission() throws UserAccessNotFoundException { + void public_read_enabled_does_not_grant_write_access() throws UserAccessNotFoundException { + checker.allowPublicRead = true; givenAuthenticatedUser("alice"); - UserAccess adminGrant = new UserAccess("alice", UserAccess.Permission.admin, "foo", UserAccess.ResourceType.architectures); - when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(adminGrant)); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenThrow(new UserAccessNotFoundException()); - assertTrue(checker.allowNamespaceAdmin(mockIdentity, "foo")); + assertFalse(checker.canWrite(mockIdentity, "foo")); } @Test - void admin_permission_allows_write_check() throws UserAccessNotFoundException { + void public_read_enabled_does_not_grant_namespace_admin_access() throws UserAccessNotFoundException { + checker.allowPublicRead = true; givenAuthenticatedUser("alice"); - UserAccess adminGrant = new UserAccess("alice", UserAccess.Permission.admin, "foo", UserAccess.ResourceType.architectures); - when(mockUserAccessStore.getUserAccessForUsername("alice")).thenReturn(List.of(adminGrant)); + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenThrow(new UserAccessNotFoundException()); + + assertFalse(checker.allowNamespaceAdmin(mockIdentity, "foo")); + } + + @Test + void public_read_enabled_still_allows_anonymous_users() { + checker.allowPublicRead = true; + when(mockIdentity.isAnonymous()).thenReturn(true); + + assertTrue(checker.canRead(mockIdentity, "foo")); + } + + @Test + void public_read_enabled_allows_read_without_store_lookup() { + checker.allowPublicRead = true; + when(mockIdentity.isAnonymous()).thenReturn(false); + // No stubbing of the store — if the checker hits it, Mockito strict mode will error, + // confirming the store is bypassed when allowPublicRead is true - assertTrue(checker.allowArchitectureWrite(mockIdentity, "foo")); - assertTrue(checker.allowArchitectureRead(mockIdentity, "foo")); + assertTrue(checker.canRead(mockIdentity, "foo")); } } 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..2f6aa0c43 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,7 +197,6 @@ 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)); } 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..06ec9fe6b 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); @@ -102,7 +100,6 @@ public void testGetUserAccessForUsername() throws UserAccessNotFoundException { when(mockDoc.get("username", String.class)).thenReturn("testuser"); when(mockDoc.get("namespace", String.class)).thenReturn("finos"); 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 @@ -135,7 +132,6 @@ 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("permission", String.class)).thenReturn("read"); - when(mockDoc.get("resourceType", String.class)).thenReturn("patterns"); when(mockDoc.get("userAccessId", Integer.class)).thenReturn(1); // Act @@ -166,7 +162,6 @@ public void testGetUserAccessForNamespaceAndId() throws NamespaceNotFoundExcepti when(mockDoc.get("username", String.class)).thenReturn("testuser"); when(mockDoc.get("namespace", String.class)).thenReturn("finos"); 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 From abadffb88109c82b26138e926fe45def73a5aca3 Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Thu, 28 May 2026 14:15:21 +0100 Subject: [PATCH 15/26] feat(calm-hub): delete proposal documents and rewrite PERMISSIONS.md --- calm-hub/AUTH_ANALYSIS.md | 307 -------------------- calm-hub/AUTH_IMPLEMENTATION_CODE.md | 285 ------------------ calm-hub/AUTH_PERMISSION_OPTIONS.md | 420 --------------------------- calm-hub/PERMISSIONS.md | 31 +- 4 files changed, 20 insertions(+), 1023 deletions(-) delete mode 100644 calm-hub/AUTH_ANALYSIS.md delete mode 100644 calm-hub/AUTH_IMPLEMENTATION_CODE.md delete mode 100644 calm-hub/AUTH_PERMISSION_OPTIONS.md diff --git a/calm-hub/AUTH_ANALYSIS.md b/calm-hub/AUTH_ANALYSIS.md deleted file mode 100644 index 75b1a42b8..000000000 --- a/calm-hub/AUTH_ANALYSIS.md +++ /dev/null @@ -1,307 +0,0 @@ -# CalmHub Auth Analysis - -## Summary of overall approach - -In earlier discussions, we had the idea that OAuth 2.0 bearer tokens would be used for authentication, and the scopes granted by the IdP for authorization. -This approach is hard to customize for different IdPs and would almost certainly require an opinionated, per-use case module adding each time. - -In the spirit of getting CalmHub to a usable state as soon as possible, we have decided to move to a simple in-database entitlements system. - -- Authentication can be done by either JWT validation/`sub` principal extraction, or by a trusted proxy setting a `Remote-User` header. -- The database will store user entitlements. - - Keyed by namespace, or by control-domain for controls, which don't belong to a specific namespace. (NB this document focuses on reworking what we have today and doesn't introduce a control-domain entitlements document in the DB) - - Users will have actions on certain types of resource, or just `all` for everything. - - Actions are `read`, `write` and `admin`. - - By default, namespaces will be readable by everyone unless they're marked otherwise. -- Endpoints will be secured with the Quarkus permissions system. A Permission allows us to specify entitlements keyed by namespace and resource type. - - We'll be able to annotate endpoints with `@PermissionsAllowed` and implement some already-existing interfaces to do this. - - This replaces the custom annotations and filters we have today, which will reduce the complexity of the auth system. -- The introduction of `admin` to the database creates a bootstrapping problem where the first time a namespace is created, it won't have any admins or anyone entitled to add more admins. We will solve this problem by adding the creating user as an admin by default when a new namespace is created. -- Synchronisation of entitlements by an external system can be done in two ways: - - Using the UserAccess endpoints to add and remove entitlements via the REST API. - - By directly creating entitlements in the underlying Mongo database. -- Search becomes possible in the secure mode now because we have all entitlements in the database; we can simply filter out all namespaces on which the user does not have `read`. - -This document underlines the current implementation and its issues, briefly outlines why we need to use Quarkus permissions, and then provides a high-level overview of the implementation plan. - -## Current Implementation - -### Two Filters, Two Profiles - -Auth is split across two mutually exclusive filters, activated by Quarkus build profiles: - -| | JWT Mode (`secure`) | Proxy Mode (`proxy`) | -|---|---|---| -| Filter | `AccessControlFilter` | `ProxyAccessControlFilter` | -| Auth source | JWT `preferred_username` claim | `Remote-User` header (configurable) | -| OIDC | Enabled | Disabled | -| Identity assurance | IdP-issued JWT | Upstream proxy | - -Both modes activate `UserAccessValidator` for RBAC enforcement. - ---- - -### Layer 1: Scope Enforcement (`@PermittedScopes` + AccessControlFilter) - -Both filters check the `@PermittedScopes` annotation on the matched endpoint and enforce it — but the enforcement is asymmetric: - -- **JWT mode:** extracts the OAuth `scope` claim from the JWT and verifies at least one required scope is present. This is an OAuth 2.0 concept — scopes are granted by the authorization server at token issuance. -- **Proxy mode:** also checks `@PermittedScopes` but **there is no token**. The filter reads the annotation and proceeds directly to `UserAccessValidator`. The scope check is effectively a dead letter in proxy mode — it validates annotation presence but cannot enforce scope values against anything meaningful. - -The four defined scopes (`architectures:read`, `architectures:all`, `adrs:read`, `adrs:all`, `search:read`, `namespace:admin`) are OAuth constructs. In proxy mode they have no semantic counterpart. - ---- - -### Layer 2: RBAC (`UserAccessValidator`) - -Both modes share the same `UserAccessValidator`. It: - -1. Loads `UserAccess` grants from the database by username. -2. Maps the HTTP method to a permission level: `POST/PUT/PATCH/DELETE → write`, `GET → read`. -3. Iterates grants and checks: namespace match + resource type match + sufficient permission. -4. Returns `true` if any grant satisfies all three conditions. - -The `UserAccess` domain object carries: - -``` -username | permission (read|write) | namespace | resourceType (patterns|flows|adrs|architectures|all) -``` - -Two endpoints bypass namespace-level RBAC: `GET /calm/namespaces` (always allowed) and `GET /calm/search` (results filtered to the user's readable namespaces). - ---- - -### Do We Have Two Levels of Auth? - -**Yes, effectively.** In JWT mode: - -1. The IdP issues a token with granted scopes — coarse-grained capability claims. -2. `AccessControlFilter` checks the token is valid and the endpoint's required scope is present. -3. `UserAccessValidator` checks fine-grained namespace/resource/permission grants in the DB. - -Both must pass. A user with a valid `architectures:all` scope but no DB grant for the namespace is denied. A DB grant without a matching token scope is also denied. - -In **proxy mode**, layer 1 is hollow. The proxy authenticates the user and asserts their identity via header. There are no scopes to validate — the filter reads `@PermittedScopes` but can only verify the annotation exists; it cannot enforce scope values. Only layer 2 (DB RBAC) does real work. - ---- - -### Can We Remove Both Custom Filters? - -**Yes — entirely.** This is the key opportunity. Both `AccessControlFilter` and `ProxyAccessControlFilter` exist only because there was no Quarkus-managed identity to enforce against. The right approach is to feed authentication into Quarkus' security pipeline so that Quarkus' built-in enforcement (`@RolesAllowed`, `@PermissionsAllowed`) can handle the rest natively. - ---- - -## Desired Model: Quarkus-native RBAC - -### Why `@RolesAllowed` Alone Is Not Enough - -`@RolesAllowed` (Jakarta Security) checks whether the `SecurityIdentity` contains a named role string. Roles are global — the annotation is static and cannot reference a request path parameter. An endpoint annotated `@RolesAllowed("architecture-reader")` would grant access to *all* namespaces for anyone holding that role. - -CalmHub's access model is namespace-scoped: a user may read in `foo` but not `bar`. To express that declaratively on the endpoint annotation, we need **`@PermissionsAllowed`** (Quarkus 3.x), which supports parameterised permissions whose check logic is evaluated at runtime with values drawn from the method call. - -### Target Model - -``` -JWT mode Proxy mode -────────────────── ────────────────────────────────── -quarkus-oidc validates ProxyAuthenticationMechanism -token, sets principal reads Remote-User header, sets principal - │ │ - └──────────────┬────────────────────────┘ - │ - CalmHubSecurityIdentityAugmentor (both modes) - loads UserAccess grants from DB - adds CalmHubPermission objects to SecurityIdentity - │ - ▼ - Quarkus enforces @PermissionsAllowed on endpoint - (namespace param matched at call time) -``` - -No custom filters or annotation scanning, and no need for manual 403 responses should a request fail the entitlements checks.. - -### The Permission Model - -`@PermissionsAllowed` on an endpoint declares which named permission is required. The question is how that check is satisfied. There are two Quarkus-native approaches, and they have a meaningful practical difference. - -#### Option A: `SecurityIdentityAugmentor` - -The augmentor runs immediately after authentication. It loads **all** `UserAccess` grants for the current user from the database, converts them into `CalmHubPermission` objects, and attaches them to the `SecurityIdentity`. When an endpoint is reached, Quarkus calls `permission.implies()` on each pre-loaded permission to find a match. - -- The DB is hit once per request, at authentication time, regardless of which endpoint is called -- All grants are loaded even though typically only one namespace is relevant to the request -- Requires a custom `CalmHubPermission` class extending `java.security.Permission` with a correct `implies()` implementation -- The permission objects live on the identity for the lifetime of the request; if multiple secured methods are called in a chain, no additional DB queries occur - -#### Option B: `@PermissionChecker` - -A `@PermissionChecker` method is a CDI bean method annotated with the permission name it satisfies. Quarkus calls it at authorisation time — after authentication, when the endpoint is about to be invoked — passing the method's parameters (including the `namespace` path argument) directly to the checker. The checker queries the DB for that specific user, namespace, and resource type and returns a boolean. - -- The DB is hit at authorisation time, not authentication time — and only for the specific permission being checked on this request -- No `CalmHubPermission` class required; the logic is a plain boolean method -- Write-implies-read is expressed as a simple condition in the checker rather than as an `implies()` contract on a Permission object -- One checker method per distinct permission name is needed, but they all delegate to a shared helper - -#### Comparison - -| | `SecurityIdentityAugmentor` | `@PermissionChecker` | -|---|---|---| -| DB query timing | Authentication (always) | Authorisation (on demand) | -| Data loaded | All grants for user | Only grants relevant to this request | -| Custom class required | `CalmHubPermission` with `implies()` | None | -| Write-implies-read | Encoded in `implies()` | Simple conditional in checker | -| `resourceType=all` expansion | Expand at augmentation time | Query covers `all` and specific type | -| Search result filtering | Separate call still needed | Separate call still needed | - -#### Recommendation - -`@PermissionChecker` is the better fit for CalmHub. The augmentor's pre-loading is only advantageous when the same identity is checked many times per request; for a standard REST or MCP call, one targeted DB query at authorisation time is sufficient and simpler. It also removes the need for a custom `Permission` class entirely — the check logic lives in a readable boolean method rather than an `implies()` implementation. - -The one case that genuinely needs all grants — search result filtering in `SearchResource` — already calls `getUserAccessForUsername()` directly and is unaffected by which approach is used for endpoint enforcement. - -Code examples for all new components are in [AUTH_IMPLEMENTATION_CODE.md](AUTH_IMPLEMENTATION_CODE.md). - ---- - -## Implementation Steps - -### 1. Add `CalmHubPermissionChecker` - -A single CDI bean containing one `@PermissionChecker` method per named permission. This is the only new class required for enforcement — no custom `Permission` subclass is needed. - -- Each method is annotated with the permission name it satisfies, matching the value used in `@PermissionsAllowed` on the endpoints (e.g. `architecture:read`, `adr:write`, `namespace:admin`) -- Each method receives the `SecurityIdentity` (for the username) and the `namespace` argument from the endpoint call, injected automatically by Quarkus -- The method queries the database for a matching `UserAccess` grant: same username, same namespace, matching resource type (or `all`), and sufficient permission level -- Write-implies-read is expressed as a straightforward condition: a checker for a read permission accepts either a `read` or `write` grant -- All checker methods delegate to a single private helper to avoid repetition; the helper encapsulates the grant lookup and matching logic -- Active in both `secure` and `proxy` profiles; has no awareness of how the identity was established - -### 3. Add `ProxyAuthenticationMechanism` (proxy mode only) - -A Quarkus `HttpAuthenticationMechanism` that establishes identity from the `Remote-User` header (or the configured equivalent). This is the proxy-mode counterpart to what `quarkus-oidc` does automatically in JWT mode. - -- Active only in the `proxy` build profile, exactly as `ProxyAccessControlFilter` was -- Reads the configured username header from the incoming request -- If the header is absent or blank, returns no identity — Quarkus will respond with 401 automatically via the challenge mechanism -- If the header is present, creates a Quarkus `SecurityIdentity` with the header value as the principal name, then hands off to the augmentor to add DB-backed permissions -- Retains the same `calm.security.proxy.username-header` configuration property so existing deployments require no changes -- Replaces `ProxyAccessControlFilter` entirely; the JWT mode requires no equivalent change because `quarkus-oidc` already handles authentication correctly - -### 4. Annotate Endpoints with `@PermitAll`, `@Authenticated`, or `@PermissionsAllowed` - -Replace every `@PermittedScopes` annotation across the resource classes. There are three tiers: - -- **`@PermitAll`** — no authentication required; anonymous requests are allowed through. For use on genuinely public endpoints such as health checks or read-only public schema endpoints. Not currently used in CalmHub but available for any endpoint that should be open without a login. -- **`@Authenticated`** — a valid identity must be present (JWT or `Remote-User` header), but no specific permission is checked. Appropriate for `GET /calm/namespaces` and `GET /calm/search`, where any logged-in user may call the endpoint and content is filtered by their grants rather than the request being blocked outright. -- **`@PermissionsAllowed`** — valid identity plus a specific namespace-scoped permission required. Used on all resource endpoints: read operations require `{resource}:read`, write/create/delete operations require `{resource}:write`, and user access management requires `namespace:admin`. The `namespace` path parameter is bound at call time so the check is scoped to the namespace in the URL. - -One configuration note for the `secure` (JWT/OIDC) profile: Quarkus OIDC still attempts token validation on every request even for `@PermitAll` endpoints. For endpoints that should be truly anonymous with no OIDC involvement, the paths must also be listed in `quarkus.http.auth.permission.public.paths` in `application-secure.properties`. - -### 5. Simplify `UserAccessValidator` - -With enforcement delegated to `CalmHubPermissionChecker`, most of `UserAccessValidator` becomes dead code. - -- `isUserAuthorized()` and its helpers (`mapHttpMethodToPermission()`, `hasAccessForActionOnResource()`, `permissionAllows()`) are removed — the checker handles this logic directly -- `getReadableNamespaces()` is retained because `SearchResource` needs it to filter search results to namespaces the user can see — this is a data-shaping concern distinct from access enforcement -- The `UserRequestAttributes` record is deleted as it has no remaining callers -- If in a future iteration `SearchResource` is updated to derive readable namespaces from the DB directly, `UserAccessValidator` can be removed entirely - -### 6. Delete Dead Code - -The following classes and annotations have no remaining purpose once the above steps are complete: - -- `AccessControlFilter` — authentication and enforcement are now handled by OIDC and Quarkus respectively -- `ProxyAccessControlFilter` — replaced by `ProxyAuthenticationMechanism` -- `PermittedScopes` annotation — replaced by `@PermissionsAllowed` -- `CalmHubScopes` constants class — no longer referenced anywhere - -### 7. Update Tests - -The test surface shifts significantly: filter unit tests are deleted and replaced with focused tests on the three new components, plus integration tests that use Quarkus's built-in test security support. - -| Old test | Disposition | -|---|---| -| `TestAccessControlFilterShould` | Delete — the filter no longer exists | -| `TestProxyAccessControlFilterShould` | Delete — the filter no longer exists | -| `TestUserAccessValidatorShould` | Trim to cover `getReadableNamespaces()` only | - -New unit tests: - -- **`TestCalmHubPermissionCheckerShould`** — covers the checker logic in isolation: a read grant allows a read check; a write grant allows both read and write checks; a grant for a different namespace denies the check; a `resourceType=all` grant satisfies checks for specific resource types; a user with no grants is denied; `UserAccessNotFoundException` is handled gracefully -- **`TestProxyAuthenticationMechanismShould`** — verifies that a request with the expected header produces an identity with the correct principal name; verifies that a missing or blank header triggers a 401 challenge - -Integration tests should use Quarkus `@TestSecurity` to inject a pre-built `SecurityIdentity` with known permissions, removing the need to mock filters or intercept request pipelines. - ---- - -## `UserAccessResource` Fit Analysis - -`UserAccessResource` is the API for managing DB grants — the data that the augmentor reads to build the `SecurityIdentity`. It has four issues under the new model. - -### 1. Missing DELETE endpoint - -The resource currently has `POST /{namespace}/user-access` (create) and two GET endpoints (list all, get by id). There is no way to revoke a grant. Without a DELETE, the only way to remove access is directly in the database. This gap exists today but becomes more visible when the DB grants are the sole source of truth for enforcement. - -A `DELETE /{namespace}/user-access/{userAccessId}` endpoint is needed, gated with the same `namespace:admin` permission as the other operations. - -### 2. `namespace:admin` has no representation in the DB model - -The `UserAccess.ResourceType` enum contains `patterns`, `flows`, `adrs`, `architectures`, and `all`. There is no `admin` type. Under the new model, the augmentor needs to know when to add a `namespace:admin` permission to an identity — but nothing in the current DB schema carries that signal. - -There are two options: - -- **Add `admin` as a new `ResourceType`** — a user with `resourceType=admin, permission=write` in a namespace gets the `namespace:admin` permission added by the augmentor. This is the cleanest approach and keeps everything in the existing DB structure. -- **Treat `resourceType=all` + `permission=write` as implying admin** — simpler but conflates "can write resources" with "can manage other users' access", which are meaningfully different capabilities. - -Adding `admin` as an explicit resource type is recommended. - -### 3. Chicken-and-egg bootstrap problem - -Under the old model, the `NAMESPACE_ADMIN` scope is granted by the IdP at token issuance, outside the DB. An operator with the right JWT can call `UserAccessResource` immediately after deployment to seed grants. Under the new model, the `namespace:admin` permission comes from the DB — so there are no grants to begin with, and `UserAccessResource` is protected by the very grants it creates. - -This needs to be resolved before the migration can go live. Options: - -- **Seed grants at namespace creation time** — when a namespace is created via `POST /calm/namespaces`, automatically insert a `namespace:admin` grant for the creating user. This requires passing the authenticated username into `NamespaceResource`. -- **Provide a startup seed mechanism** — a configuration-driven or migration-script approach that inserts bootstrap admin grants during deployment. -- **Hybrid: retain JWT scope as a fallback for bootstrap only** — in `secure` mode, allow the `NAMESPACE_ADMIN` JWT scope to act as a super-admin bypass specifically for `UserAccessResource`. This preserves backwards compatibility but re-introduces some scope logic in one place. - -The seed-at-namespace-creation approach is the most self-contained and does not require retaining any scope logic. - -### 4. No privilege escalation check - -The `createUserAccessForNamespace` method accepts a `UserAccess` object in the request body that specifies both the target `username` and their `permission` level. An admin for namespace `foo` can grant any other user any level of access in `foo`. This is intentional and correct, but it means an admin can also create another admin for the same namespace. There is no check preventing privilege escalation within the namespace. - -This is acceptable behaviour for an admin role by convention, but worth documenting as a deliberate design decision rather than an oversight. - ---- - -## MCP Tools and Security - -The `@Tool`-annotated methods in classes such as `ArchitectureTools` and `SearchTools` are currently **unprotected**. Unlike the REST resource classes, they carry no `@PermittedScopes` annotations and are not reached by either custom filter — they call the store directly. This is a live exposure that exists today regardless of the migration to the new model. - -### Security annotations do work on `@Tool` methods - -The Quarkiverse MCP server documentation explicitly confirms that security annotations such as `@Authenticated` and `@RolesAllowed` are supported on `@Tool` methods via the standard CDI interceptor mechanism. Because `@PermissionsAllowed` uses the same CDI mechanism as `@RolesAllowed`, it should apply equally. The `params = "namespace"` binding works by matching the Java parameter name at invocation time and does not depend on JAX-RS `@PathParam`, so it transfers directly to `@ToolArg` parameters of the same name. - -### Key caveat: error response format - -When a security check fails on a `@Tool` method, the MCP client does not receive an HTTP 4xx status code. Instead it receives an MCP protocol error with code `-32001`. This is a protocol-level difference from the REST endpoints and is inherent to how the MCP server handles errors — it cannot be changed by how the annotation is applied. - -The Quarkiverse documentation notes this limitation and recommends using HTTP-level `quarkus.http.auth.permission` policies (in `application.properties`) for authentication enforcement, reserving method-level annotations for authorisation. In practice for CalmHub, this means the `secure` and `proxy` profile HTTP permission policies already configured continue to handle authentication at the transport level, and `@PermissionsAllowed` on the tool methods handles the namespace-scoped authorisation check — consistent with how the REST endpoints will work after the migration. - -### Recommended action - -Securing the MCP tools should be treated as in-scope for this migration, not a follow-up. Each `@Tool` method that takes a `namespace` argument should receive a `@PermissionsAllowed` annotation with the appropriate permission name, mirroring the annotation applied to the equivalent REST endpoint. - ---- - -## Summary - -| Concern | JWT Mode today | Proxy Mode today | After migration | -|---|---|---|---| -| Identity establishment | quarkus-oidc (JWT) | `ProxyAccessControlFilter` (header) | quarkus-oidc (JWT) / `ProxyAuthenticationMechanism` (header) | -| Permission loading | `UserAccessValidator` in filter | `UserAccessValidator` in filter | `CalmHubPermissionChecker` queries DB at authorisation time | -| Endpoint enforcement | `@PermittedScopes` + custom filter | `@PermittedScopes` + custom filter (scope check no-op) | `@PermissionsAllowed` + Quarkus runtime | -| Namespace scoping | Manual check in filter | Manual check in filter | `CalmHubPermissionChecker` receives namespace from endpoint args | -| Custom filters | 2 | 2 | 0 | -| Custom annotations | 1 (`@PermittedScopes`) | 1 (`@PermittedScopes`) | 0 | diff --git a/calm-hub/AUTH_IMPLEMENTATION_CODE.md b/calm-hub/AUTH_IMPLEMENTATION_CODE.md deleted file mode 100644 index c95dcef9b..000000000 --- a/calm-hub/AUTH_IMPLEMENTATION_CODE.md +++ /dev/null @@ -1,285 +0,0 @@ -# Auth Implementation — Code Reference - -Supporting code sketches for [AUTH_ANALYSIS.md](AUTH_ANALYSIS.md). These are illustrative; exact method signatures and imports will be confirmed during implementation. - ---- - -## 1. `CalmHubPermissionChecker` - -A single CDI bean satisfying all `@PermissionsAllowed` checks. Each public method handles one named permission; all delegate to a shared private helper. - -```java -@ApplicationScoped -@IfBuildProfile({"secure", "proxy"}) -public class CalmHubPermissionChecker { - - private final UserAccessStore userAccessStore; - - public CalmHubPermissionChecker(UserAccessStore userAccessStore) { - this.userAccessStore = userAccessStore; - } - - @PermissionChecker("architecture:read") - public boolean canReadArchitecture(SecurityIdentity identity, String namespace) { - return hasAccess(identity, namespace, UserAccess.ResourceType.architectures, false); - } - - @PermissionChecker("architecture:write") - public boolean canWriteArchitecture(SecurityIdentity identity, String namespace) { - return hasAccess(identity, namespace, UserAccess.ResourceType.architectures, true); - } - - @PermissionChecker("pattern:read") - public boolean canReadPattern(SecurityIdentity identity, String namespace) { - return hasAccess(identity, namespace, UserAccess.ResourceType.patterns, false); - } - - @PermissionChecker("pattern:write") - public boolean canWritePattern(SecurityIdentity identity, String namespace) { - return hasAccess(identity, namespace, UserAccess.ResourceType.patterns, true); - } - - @PermissionChecker("flow:read") - public boolean canReadFlow(SecurityIdentity identity, String namespace) { - return hasAccess(identity, namespace, UserAccess.ResourceType.flows, false); - } - - @PermissionChecker("flow:write") - public boolean canWriteFlow(SecurityIdentity identity, String namespace) { - return hasAccess(identity, namespace, UserAccess.ResourceType.flows, true); - } - - @PermissionChecker("adr:read") - public boolean canReadAdr(SecurityIdentity identity, String namespace) { - return hasAccess(identity, namespace, UserAccess.ResourceType.adrs, false); - } - - @PermissionChecker("adr:write") - public boolean canWriteAdr(SecurityIdentity identity, String namespace) { - return hasAccess(identity, namespace, UserAccess.ResourceType.adrs, true); - } - - @PermissionChecker("namespace:admin") - public boolean canAdminNamespace(SecurityIdentity identity, String namespace) { - return hasAccess(identity, namespace, UserAccess.ResourceType.admin, true); - } - - private boolean hasAccess(SecurityIdentity identity, String namespace, - UserAccess.ResourceType requiredType, boolean requireWrite) { - String username = identity.getPrincipal().getName(); - try { - return userAccessStore.getUserAccessForUsername(username).stream() - .anyMatch(grant -> namespaceMatches(grant, namespace) - && resourceMatches(grant, requiredType) - && permissionSufficient(grant, requireWrite)); - } catch (UserAccessNotFoundException e) { - return false; - } - } - - private boolean namespaceMatches(UserAccess grant, String namespace) { - return grant.getNamespace().equals(namespace); - } - - private boolean resourceMatches(UserAccess grant, UserAccess.ResourceType required) { - return grant.getResourceType() == UserAccess.ResourceType.all - || grant.getResourceType() == required; - } - - private boolean permissionSufficient(UserAccess grant, boolean requireWrite) { - return !requireWrite || grant.getPermission() == UserAccess.Permission.write; - } -} -``` - ---- - -## 2. `ProxyAuthenticationMechanism` - -Active only in the `proxy` profile. Reads the configured header and creates a `SecurityIdentity` with the header value as the principal name. The `CalmHubPermissionChecker` then handles authorisation for both profiles uniformly. - -```java -@ApplicationScoped -@IfBuildProfile("proxy") -public class ProxyAuthenticationMechanism implements HttpAuthenticationMechanism { - - @ConfigProperty(name = "calm.security.proxy.username-header", defaultValue = "Remote-User") - String usernameHeader; - - @Override - public Uni authenticate(RoutingContext context, - IdentityProviderManager identityManager) { - String username = context.request().getHeader(usernameHeader); - if (username == null || username.isBlank()) { - return Uni.createFrom().optional(Optional.empty()); - } - TrustedAuthRequest request = new TrustedAuthRequest(new QuarkusPrincipal(username)); - return identityManager.authenticate(request); - } - - @Override - public Uni getChallenge(RoutingContext context) { - return Uni.createFrom().item( - new ChallengeData(HttpResponseStatus.UNAUTHORIZED.code(), null, null)); - } - - - @Override - public Set> getCredentialTypes() { - return Set.of(TrustedAuthRequest.class); - } -} -``` - ---- - -## 3. Endpoint Annotation Examples - -The `params = "namespace"` binding matches the Java parameter name on the method, not a JAX-RS annotation — so the same pattern applies to both REST endpoints and MCP `@Tool` methods. - -### REST — read endpoint -```java -@GET -@Path("/{namespace}/architectures") -@PermissionsAllowed(value = "architecture:read", params = "namespace") -public Response getArchitectures(@PathParam("namespace") String namespace) { ... } -``` - -### REST — write endpoint -```java -@POST -@Path("/{namespace}/architectures") -@PermissionsAllowed(value = "architecture:write", params = "namespace") -public Response createArchitecture(@PathParam("namespace") String namespace, ...) { ... } -``` - -### REST — namespace admin (UserAccessResource) -```java -@GET -@Path("/{namespace}/user-access") -@PermissionsAllowed(value = "namespace:admin", params = "namespace") -public Response getUserAccess(@PathParam("namespace") String namespace) { ... } -``` - -### REST — no-namespace endpoints -```java -@GET -@Path("/namespaces") -@Authenticated -public Response getNamespaces() { ... } - -@GET -@Path("/search") -@Authenticated -public Response search(@QueryParam("q") String query) { ... } -``` - -### MCP tool — read -```java -@Tool(description = "List all architectures in a CalmHub namespace.") -@PermissionsAllowed(value = "architecture:read", params = "namespace") -public ToolResponse listArchitectures( - @ToolArg(description = "The namespace to list architectures from") String namespace) { ... } -``` - -### MCP tool — write -```java -@Tool(description = "Create a new architecture in a namespace.") -@PermissionsAllowed(value = "architecture:write", params = "namespace") -public ToolResponse createArchitecture( - @ToolArg(description = "The namespace to create the architecture in") String namespace, - ...) { ... } -``` - ---- - -## 4. `UserAccessValidator` — retained method only - -```java -// isUserAuthorized() and all helpers are deleted. -// Only getReadableNamespaces() is kept for SearchResource result filtering. - -public Set getReadableNamespaces(String username) { - try { - return userAccessStore.getUserAccessForUsername(username) - .stream() - .map(UserAccess::getNamespace) - .collect(Collectors.toSet()); - } catch (UserAccessNotFoundException ex) { - logger.debug("No access permissions found for user [{}]", username); - return Set.of(); - } -} -``` - ---- - -## 5. Test Sketches - -### `TestCalmHubPermissionCheckerShould` - -```java -@Test -void read_grant_allows_read_check() { - // given a read grant for architectures in namespace foo - // when canReadArchitecture is called with identity(alice) and namespace foo - // then returns true -} - -@Test -void write_grant_allows_read_check() { - // given a write grant for architectures in namespace foo - // when canReadArchitecture is called - // then returns true (write implies read) -} - -@Test -void read_grant_denies_write_check() { - // given a read grant for architectures in namespace foo - // when canWriteArchitecture is called - // then returns false -} - -@Test -void grant_for_different_namespace_denies_check() { - // given a write grant for architectures in namespace bar - // when canWriteArchitecture is called with namespace foo - // then returns false -} - -@Test -void all_resource_type_satisfies_specific_resource_check() { - // given a write grant with resourceType=all in namespace foo - // when canReadArchitecture, canReadPattern, canReadFlow, canReadAdr are called - // then all return true -} - -@Test -void user_with_no_grants_is_denied() { - // given userAccessStore throws UserAccessNotFoundException - // when any checker method is called - // then returns false -} -``` - -### `TestProxyAuthenticationMechanismShould` - -```java -@Test -void present_header_produces_identity_with_correct_principal() { - // given a request with Remote-User: alice - // then the resulting SecurityIdentity has principal name "alice" -} - -@Test -void missing_header_returns_empty_identity() { - // given a request with no Remote-User header - // then authenticate() returns an empty Optional (triggering 401 challenge) -} - -@Test -void blank_header_is_treated_as_missing() { - // given Remote-User: " " - // then same result as missing header -} -``` diff --git a/calm-hub/AUTH_PERMISSION_OPTIONS.md b/calm-hub/AUTH_PERMISSION_OPTIONS.md deleted file mode 100644 index 05acb479e..000000000 --- a/calm-hub/AUTH_PERMISSION_OPTIONS.md +++ /dev/null @@ -1,420 +0,0 @@ -# Permission Enforcement Options - -Three Quarkus-native approaches for enforcing `@PermissionsAllowed` on CalmHub endpoints. None require a custom `@PermittedScopes`-style annotation or a hand-rolled JAX-RS filter. - ---- - -## Option A — `@PermissionChecker` - -No custom `Permission` class. No augmentor. A CDI bean with one checker method per named permission. Quarkus calls the matching method at authorisation time, passing the `SecurityIdentity` and the endpoint's `namespace` parameter. - -**Checkers needed:** one per distinct `@PermissionsAllowed` value across the API. - -**Resource-type granularity:** only if you encode resource type in the permission name (e.g. `architecture:read`), which causes the combinatorial explosion. Dropping resource type from the name reduces it to three checkers (`calm:read`, `calm:write`, `namespace:admin`) but means any read grant in a namespace allows reading any resource type. - -**DB query timing:** at authorisation time, targeted to the specific namespace being accessed. - -```java -@ApplicationScoped -@IfBuildProfile({"secure", "proxy"}) -public class CalmHubPermissionChecker { - - @Inject UserAccessStore userAccessStore; - - @PermissionChecker("calm:read") - public boolean canRead(SecurityIdentity identity, String namespace) { - return check(identity.getPrincipal().getName(), namespace, false); - } - - @PermissionChecker("calm:write") - public boolean canWrite(SecurityIdentity identity, String namespace) { - return check(identity.getPrincipal().getName(), namespace, true); - } - - @PermissionChecker("namespace:admin") - public boolean canAdmin(SecurityIdentity identity, String namespace) { - return check(identity.getPrincipal().getName(), namespace, - ResourceType.admin, true); - } - - private boolean check(String username, String namespace, boolean requireWrite) { - try { - return userAccessStore.getUserAccessForUsername(username).stream() - .anyMatch(grant -> - grant.getNamespace().equals(namespace) - && grant.getResourceType() != ResourceType.admin - && (!requireWrite || grant.getPermission() == Permission.write)); - } catch (UserAccessNotFoundException e) { - return false; - } - } -} -``` - -Endpoint annotation: -```java -@PermissionsAllowed(value = "calm:read", params = "namespace") -@PermissionsAllowed(value = "calm:write", params = "namespace") -@PermissionsAllowed(value = "namespace:admin", params = "namespace") -``` - ---- - -## Option B — Custom `Permission` class + augmentor - -Two classes. `CalmHubPermission` is the *required* permission — Quarkus instantiates it from the `@PermissionsAllowed` annotation at check time, binding the `namespace` path parameter via the constructor. `CalmHubGrantedPermission` is a *proxy* stored on the identity by the augmentor — its `implies()` method performs the targeted DB query when Quarkus calls it. - -**Checkers needed:** zero. Logic lives in `implies()`. - -**Resource-type granularity:** preserved. The permission name (`architecture:read`, `adr:write`, etc.) is parsed inside `implies()`. New resource types require no new Java code — only a new `@PermissionsAllowed` value on the endpoint. - -**DB query timing:** at authorisation time (inside `implies()`), targeted to the specific namespace, resource type, and action being checked. The augmentor runs at auth time but does not touch the DB. - -### `CalmHubPermission` (required — created by Quarkus) - -> Quarkus requires exactly one constructor. The first parameter is always the permission name (String); additional parameters are bound by name from the secured endpoint method. - -```java -public class CalmHubPermission extends Permission { - private final String namespace; - - public CalmHubPermission(String name, String namespace) { - super(name); // e.g. "architecture:read", "adr:write", "namespace:admin" - this.namespace = namespace; - } - - public String getNamespace() { return namespace; } - - @Override public boolean implies(Permission p) { return false; } // unused - @Override public boolean equals(Object o) { ... } - @Override public int hashCode() { ... } - @Override public String getActions() { return ""; } -} -``` - -### `CalmHubGrantedPermission` (proxy — created by augmentor) - -```java -public class CalmHubGrantedPermission extends Permission { - private final String username; - private final UserAccessStore store; - - public CalmHubGrantedPermission(String username, UserAccessStore store) { - super("calm-hub-granted"); - this.username = username; - this.store = store; - } - - @Override - public boolean implies(Permission p) { - if (!(p instanceof CalmHubPermission required)) return false; - String[] parts = required.getName().split(":"); - String resource = parts[0]; // e.g. "architecture" - String action = parts[1]; // "read" or "write" - try { - return store.getUserAccessForUsername(username).stream() - .anyMatch(grant -> - grant.getNamespace().equals(required.getNamespace()) - && resourceMatches(grant, resource) - && actionSufficient(grant, action)); - } catch (UserAccessNotFoundException e) { - return false; - } - } - - private boolean resourceMatches(UserAccess grant, String resource) { - return grant.getResourceType() == ResourceType.all - || grant.getResourceType().name().equals(resource); - } - - private boolean actionSufficient(UserAccess grant, String action) { - return action.equals("read") || grant.getPermission() == Permission.write; - } - - @Override public boolean equals(Object o) { ... } - @Override public int hashCode() { ... } - @Override public String getActions() { return ""; } -} -``` - -### Augmentor (no DB query at auth time) - -```java -@ApplicationScoped -@IfBuildProfile({"secure", "proxy"}) -public class CalmHubSecurityIdentityAugmentor implements SecurityIdentityAugmentor { - - @Inject UserAccessStore userAccessStore; - - @Override - public Uni augment(SecurityIdentity identity, - AuthenticationRequestContext context) { - return Uni.createFrom().item( - QuarkusSecurityIdentity.builder(identity) - .addPermission(new CalmHubGrantedPermission( - identity.getPrincipal().getName(), userAccessStore)) - .build()); - } -} -``` - -### Endpoint annotation - -```java -@PermissionsAllowed(value = "architecture:read", permission = CalmHubPermission.class, params = "namespace") -@PermissionsAllowed(value = "adr:write", permission = CalmHubPermission.class, params = "namespace") -@PermissionsAllowed(value = "namespace:admin", permission = CalmHubPermission.class, params = "namespace") -``` - ---- - -## Option C — Single `Permission` class with constructor parameter binding - -One class, no `@PermissionChecker` methods. `implies()` is the single checking method. - -The key mechanism: Quarkus binds endpoint method parameters to custom `Permission` constructor parameters **by name**. When a `@PathParam("namespace") String namespace` exists on the endpoint method and the constructor also has a `String namespace` parameter, Quarkus passes the runtime value automatically. This means the same class is used for both the permissions the augmentor stores on the identity (namespace from DB grant) and the permission Quarkus constructs at check time (namespace from the request path). `implies()` then does a pure in-memory comparison between the two — no DB access. - -**Classes needed:** 1 (`CalmHubPermission`) + 1 augmentor. - -**DB query timing:** authentication time — one query per request, grants expanded into permission objects and stored on the identity. - -**Checker methods:** zero — `implies()` is the single checker. - -**Scales with new resource types:** yes. Adding a new endpoint with a new permission value requires no new Java code. - -### `CalmHubPermission` - -The constructor parses `resource` and `action` from the permission name (e.g. `"architecture:read"` → `resource="architecture"`, `action="read"`). The `namespace` parameter is bound automatically by Quarkus from the endpoint method at check time, and supplied explicitly by the augmentor from the DB grant at auth time. - -```java -public class CalmHubPermission extends Permission { - private final String namespace; - private final String resource; - private final String action; // "read" or "write" - - // Quarkus binds namespace from the endpoint's path parameter by name matching. - // The augmentor calls this constructor directly with the grant's namespace. - public CalmHubPermission(String name, String namespace) { - super(name); // e.g. "architecture:read" - String[] parts = name.split(":"); - this.resource = parts[0]; // "architecture" - this.action = parts[1]; // "read" or "write" - this.namespace = namespace; - } - - @Override - public boolean implies(Permission p) { - if (!(p instanceof CalmHubPermission required)) return false; - return this.namespace.equals(required.namespace) - && this.resource.equals(required.resource) - && (this.action.equals("write") || required.action.equals("read")); - } - - @Override public boolean equals(Object o) { ... } - @Override public int hashCode() { ... } - @Override public String getActions() { return ""; } -} -``` - -### Augmentor - -Loads all grants at auth time, expands each into one or two `CalmHubPermission` objects (read + write if the grant is write-level; `resourceType=all` expands to one per resource type), and stores them on the identity. - -```java -@ApplicationScoped -@IfBuildProfile({"secure", "proxy"}) -public class CalmHubSecurityIdentityAugmentor implements SecurityIdentityAugmentor { - - @Inject UserAccessStore userAccessStore; - - @Override - public Uni augment(SecurityIdentity identity, - AuthenticationRequestContext context) { - String username = identity.getPrincipal().getName(); - return context.runBlocking(() -> { - QuarkusSecurityIdentity.Builder builder = - QuarkusSecurityIdentity.builder(identity); - try { - userAccessStore.getUserAccessForUsername(username) - .stream() - .flatMap(CalmHubSecurityIdentityAugmentor::toPermissions) - .forEach(builder::addPermission); - } catch (UserAccessNotFoundException ignored) {} - return builder.build(); - }); - } - - private static Stream toPermissions(UserAccess grant) { - Set resources = grant.getResourceType() == ResourceType.all - ? Set.of("architecture", "pattern", "flow", "adr") - : Set.of(grant.getResourceType().name()); - return resources.stream().flatMap(resource -> { - var perms = Stream.builder() - .add(new CalmHubPermission(resource + ":read", grant.getNamespace())); - if (grant.getPermission() == UserAccess.Permission.write) - perms.add(new CalmHubPermission(resource + ":write", grant.getNamespace())); - return perms.build(); - }); - } -} -``` - -### Endpoint annotation - -The `permission` attribute tells Quarkus which class to instantiate for the check. The `namespace` constructor parameter is bound automatically from the endpoint method's `namespace` parameter — no `params` attribute needed when names match. - -```java -@GET -@PermissionsAllowed(value = "architecture:read", permission = CalmHubPermission.class) -public Response getArchitectures(@PathParam("namespace") String namespace) { ... } - -@POST -@PermissionsAllowed(value = "architecture:write", permission = CalmHubPermission.class) -public Response createArchitecture(@PathParam("namespace") String namespace, ...) { ... } - -@GET -@PermissionsAllowed(value = "adr:read", permission = CalmHubPermission.class) -public Response getAdrs(@PathParam("namespace") String namespace) { ... } - -@POST -@PermissionsAllowed(value = "namespace:admin", permission = CalmHubPermission.class) -public Response createUserAccess(@PathParam("namespace") String namespace, ...) { ... } -``` - -MCP tools work identically — `namespace` is a `@ToolArg` parameter, name-matched the same way: - -```java -@Tool(description = "List all architectures in a namespace.") -@PermissionsAllowed(value = "architecture:read", permission = CalmHubPermission.class) -public ToolResponse listArchitectures( - @ToolArg(description = "The namespace") String namespace) { ... } -``` - ---- - -## Option D — Custom `HttpSecurityPolicy` - -A single CDI bean implementing `HttpSecurityPolicy`. Quarkus calls `checkPermission()` on every matched HTTP request before it reaches the endpoint, passing the raw `RoutingContext` (full HTTP request) and the `SecurityIdentity`. The method extracts namespace from the path, determines the required action from the HTTP method, queries the DB, and returns permit or deny. No annotations on endpoints, no custom `Permission` class, no augmentor. - -**Classes needed:** 1 (the policy). - -**Checker methods:** 1 (`checkPermission`). - -**Scales with new resource types:** yes — the path parser is the only thing that needs updating, and only if resource type enforcement is needed. - -**Limitation:** the `namespace` and resource type come from parsing the HTTP path. This works perfectly for REST endpoints. For MCP tools, the `namespace` is a parameter in the tool call payload (the JSON body), not in the URL — so this policy cannot enforce namespace-scoped authorisation on MCP tool invocations. MCP tools would need a separate approach (Options A, B, or C). - -### Implementation - -```java -@ApplicationScoped -@IfBuildProfile({"secure", "proxy"}) -public class CalmHubHttpSecurityPolicy implements HttpSecurityPolicy { - - @Inject UserAccessStore userAccessStore; - - @Override - public String name() { - return "calm-hub"; - } - - @Override - public Uni checkPermission(RoutingContext event, - Uni identity, - AuthorizationRequestContext context) { - return identity.flatMap(id -> { - String username = id.getPrincipal().getName(); - String path = event.normalizedPath(); - String method = event.request().method().name(); - String namespace = extractNamespace(path); - String resource = extractResource(path); - boolean requireWrite = isWriteMethod(method); - - // Paths with no namespace are accessible to any authenticated user - if (namespace == null) { - return id.isAnonymous() - ? CheckResult.deny() - : CheckResult.permit(); - } - - return context.runBlocking(() -> { - try { - boolean permitted = userAccessStore - .getUserAccessForUsername(username).stream() - .anyMatch(grant -> - grant.getNamespace().equals(namespace) - && resourceMatches(grant, resource) - && (!requireWrite || grant.getPermission() == UserAccess.Permission.write)); - return permitted ? CheckResult.PERMIT : CheckResult.DENY; - } catch (UserAccessNotFoundException e) { - return CheckResult.DENY; - } - }); - }); - } - - private String extractNamespace(String path) { - // /calm/namespaces/{namespace}/... - String[] parts = path.split("/"); - for (int i = 0; i < parts.length - 1; i++) { - if ("namespaces".equals(parts[i])) return parts[i + 1]; - } - return null; - } - - private String extractResource(String path) { - // last meaningful path segment: architectures, patterns, flows, adrs, user-access - String[] parts = path.split("/"); - for (int i = parts.length - 1; i >= 0; i--) { - String segment = parts[i]; - if (segment.matches("architectures|patterns|flows|adrs|user-access")) return segment; - } - return "all"; - } - - private boolean resourceMatches(UserAccess grant, String resource) { - return grant.getResourceType() == UserAccess.ResourceType.all - || grant.getResourceType().name().equals(resource); - } - - private boolean isWriteMethod(String method) { - return Set.of("POST", "PUT", "PATCH", "DELETE").contains(method); - } -} -``` - -### Configuration - -In `application-secure.properties` and `application-proxy.properties`: - -```properties -quarkus.http.auth.permission.calm-hub.paths=/calm/* -quarkus.http.auth.permission.calm-hub.policy=calm-hub -``` - -Alternatively, use `@AuthorizationPolicy("calm-hub")` directly on JAX-RS resource classes instead of the properties configuration — this is more explicit and keeps the security declaration close to the code. - -### What you don't need - -- No `@PermissionsAllowed` on any endpoint method -- No `@PermittedScopes` (removed) -- No augmentor -- No custom `Permission` class - ---- - -## Comparison - -| | Option A (`@PermissionChecker`) | Option B (custom `Permission`, lazy) | Option C (single `Permission` class) | Option D (`HttpSecurityPolicy`) | -|---|---|---|---|---| -| Java classes to write | 1 (checker) | 3 (permission, granted permission, augmentor) | 2 (permission + augmentor) | 1 (policy) | -| Checker / policy methods | One per permission name | Zero | Zero (`implies()` is the checker) | One (`checkPermission`) | -| Scales with new resource types | Only if resource type not in name | Yes — annotation value only | Yes — annotation value only | Yes — path parser only | -| Resource-type granularity | Requires more checker methods | Preserved at no extra cost | Preserved at no extra cost | Preserved via path parsing | -| DB query timing | Authorisation time | Authorisation time (inside `implies()`) | Authentication time (always) | Authorisation time | -| DB query scope | All grants for user, filtered in memory | All grants for user, filtered in memory | All grants for user, expanded into permission objects | All grants for user, filtered in memory | -| Endpoint annotations needed | `@PermissionsAllowed` on every method | `@PermissionsAllowed` on every method | `@PermissionsAllowed` on every method | None (or `@AuthorizationPolicy` on class) | -| MCP tool support | Yes (CDI interceptor) | Yes (CDI interceptor) | Yes (CDI interceptor) | No — namespace not in URL for MCP calls | -| Unusual patterns | None | DB access inside `Permission.implies()` | None | None | - -Option D is the most natural fit for the stated goal — one service, one method, all context passed in — and produces the simplest endpoint code. The MCP limitation is the deciding constraint: if MCP tools also need namespace-scoped enforcement, Option D cannot cover them and a second mechanism would be needed. Options A–C use CDI interceptors and work identically on REST endpoints and MCP tools. diff --git a/calm-hub/PERMISSIONS.md b/calm-hub/PERMISSIONS.md index b9bac1bd4..898230316 100644 --- a/calm-hub/PERMISSIONS.md +++ b/calm-hub/PERMISSIONS.md @@ -6,20 +6,29 @@ 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. -They are separated by resource type. The available actions are the following. -| Action | Description | -|-----------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `RESOURCE_TYPE:read` | Can read any documents of that type in the namespace. | -| `RESOURCE_TYPE: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. -| `namespace:admin` | Can do anything to all resource types, and also grant entitlements to other users in the namespace. | +| 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, `architectures:read` means the user can read all architectures in that NS. +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`. -`namespace:admin` implies `read` and `write` on all resource types. +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`. -**You can also use `all` as a resource type to apply that permission to all resource types.** -For example, `all:write` means you can read and write on all resources. From 321cbbbd6a2cacbebeaaff1841e17d1ba5f0da02 Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Thu, 28 May 2026 15:16:34 +0100 Subject: [PATCH 16/26] feat(calm-hub): expand entitilements model to work with domains for controls --- .../org/finos/calm/domain/UserAccess.java | 101 ++++++++------- .../finos/calm/mcp/tools/ControlTools.java | 10 +- .../org/finos/calm/mcp/tools/DomainTools.java | 5 +- .../finos/calm/mcp/tools/NamespaceTools.java | 3 +- .../org/finos/calm/mcp/tools/SearchTools.java | 5 +- .../finos/calm/resources/ControlResource.java | 20 +-- .../calm/resources/CoreSchemaResource.java | 8 +- .../finos/calm/resources/DomainResource.java | 5 +- .../resources/DomainUserAccessResource.java | 94 ++++++++++++++ .../calm/resources/NamespaceResource.java | 3 +- .../security/CalmHubPermissionChecker.java | 53 +++++--- .../finos/calm/security/CalmHubScopes.java | 3 + .../org/finos/calm/store/UserAccessStore.java | 26 ++++ .../store/mongo/MongoUserAccessStore.java | 97 +++++++++++---- .../store/nitrite/NitriteUserAccessStore.java | 61 +++++++++ .../TestDomainUserAccessResourceShould.java | 117 ++++++++++++++++++ .../TestCalmHubPermissionCheckerShould.java | 87 +++++++++++++ .../mongo/TestMongoUserAccessStoreShould.java | 86 +++++++++++++ .../TestNitriteUserAccessStoreShould.java | 77 ++++++++++++ 19 files changed, 747 insertions(+), 114 deletions(-) create mode 100644 calm-hub/src/main/java/org/finos/calm/resources/DomainUserAccessResource.java create mode 100644 calm-hub/src/test/java/org/finos/calm/resources/TestDomainUserAccessResourceShould.java 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 e547f79ce..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,7 +9,7 @@ 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 { @@ -22,6 +22,7 @@ public enum Permission { private String username; private Permission permission; private String namespace; + private String domain; private int userAccessId; @JsonDeserialize(using = LocalDateTimeDeserializer.class) @@ -45,20 +46,11 @@ public UserAccess(String username, Permission permission, String namespace) { this.namespace = namespace; } - public UserAccess(){ - + public UserAccess() { } - @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; - return Objects.equals(namespace, that.namespace); + public String getDomain() { + return domain; } public String getUsername() { @@ -73,6 +65,10 @@ public String getNamespace() { return namespace; } + public void setDomain(String domain) { + this.domain = domain; + } + public int getUserAccessId() { return userAccessId; } @@ -85,6 +81,34 @@ public LocalDateTime getUpdateDateTime() { return updateDateTime; } + public void setUsername(String username) { + this.username = username; + } + + public void setPermission(Permission permission) { + this.permission = permission; + } + + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + public void 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(domain, that.domain); + } + public void setCreationDateTime(LocalDateTime creationDateTime) { this.creationDateTime = creationDateTime; } @@ -95,7 +119,18 @@ public void setUpdateDateTime(LocalDateTime updateDateTime) { @Override public int hashCode() { - return Objects.hash(username, permission, namespace, userAccessId); + return Objects.hash(username, permission, namespace, domain, userAccessId); + } + + @Override + public String toString() { + return "UserAccess{" + + "username='" + username + '\'' + + ", permission='" + permission + '\'' + + ", namespace='" + namespace + '\'' + + ", domain='" + domain + '\'' + + ", userAccessId=" + userAccessId + + '}'; } public static class UserAccessBuilder { @@ -103,6 +138,7 @@ public static class UserAccessBuilder { private String username; private Permission permission; private String namespace; + private String domain; private int userAccessId; public UserAccessBuilder setUsername(String username) { @@ -120,39 +156,20 @@ public UserAccessBuilder setNamespace(String 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(){ - return new UserAccess(username, permission, namespace, userAccessId); + public UserAccess build() { + UserAccess ua = new UserAccess(username, permission, namespace, userAccessId); + ua.domain = this.domain; + return ua; } } - - @Override - public String toString() { - return "UserAccess{" + - "username='" + username + '\'' + - ", permission='" + permission + '\'' + - ", namespace='" + namespace + '\'' + - ", userAccessId=" + userAccessId + - '}'; - } - - public void setUsername(String username) { - this.username = username; - } - - public void setPermission(Permission permission) { - this.permission = permission; - } - - public void setNamespace(String namespace) { - this.namespace = namespace; - } - - public void setUserAccessId(int userAccessId) { - this.userAccessId = userAccessId; - } } 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 f325347ea..e6352bd55 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 @@ -37,7 +37,7 @@ public class ControlTools { @Inject ControlStore controlStore; - @PermissionsAllowed(CalmHubScopes.READ) + @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) { @@ -69,7 +69,7 @@ public ToolResponse listControls( } } - @PermissionsAllowed(CalmHubScopes.READ) + @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, @@ -96,7 +96,7 @@ public ToolResponse getControl( } } - @PermissionsAllowed(CalmHubScopes.READ) + @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, @@ -126,7 +126,7 @@ public ToolResponse listControlVersions( } } - @PermissionsAllowed(CalmHubScopes.WRITE) + @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, @@ -156,7 +156,7 @@ public ToolResponse createControlRequirement( } } - @PermissionsAllowed(CalmHubScopes.WRITE) + @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/DomainTools.java b/calm-hub/src/main/java/org/finos/calm/mcp/tools/DomainTools.java index 4f275aec1..1bfcff061 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,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 io.quarkus.security.PermissionsAllowed; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -32,7 +33,7 @@ public class DomainTools { @Inject DomainStore domainStore; - @PermissionsAllowed(CalmHubScopes.READ) + @Authenticated @Tool(description = "List all control domains available in CalmHub (e.g. 'security'). Domains group related control requirements.") public ToolResponse listDomains() { String error = McpValidationHelper.checkEnabled(mcpEnabled); @@ -50,7 +51,7 @@ public ToolResponse listDomains() { return ToolResponse.success(sb.toString()); } - @PermissionsAllowed(CalmHubScopes.WRITE) + @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/NamespaceTools.java b/calm-hub/src/main/java/org/finos/calm/mcp/tools/NamespaceTools.java index 1fa3f5f25..01c54df17 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,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 io.quarkus.security.PermissionsAllowed; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -33,7 +34,7 @@ public class NamespaceTools { @Inject NamespaceStore namespaceStore; - @PermissionsAllowed(CalmHubScopes.READ) + @Authenticated @Tool(description = "List all namespaces available in CalmHub. Returns namespace names and descriptions.") public ToolResponse listNamespaces() { Optional err = McpValidationHelper.firstError( 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 32ce86ec0..4e8be4ea9 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,13 +3,12 @@ import io.quarkiverse.mcp.server.Tool; import io.quarkiverse.mcp.server.ToolArg; import io.quarkiverse.mcp.server.ToolResponse; -import io.quarkus.security.PermissionsAllowed; +import io.quarkus.security.Authenticated; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.finos.calm.domain.search.GroupedSearchResults; import org.finos.calm.domain.search.SearchResult; -import org.finos.calm.security.CalmHubScopes; import org.finos.calm.store.SearchStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,7 +37,7 @@ public class SearchTools { @Inject SearchStore searchStore; - @PermissionsAllowed(CalmHubScopes.READ) + @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/resources/ControlResource.java b/calm-hub/src/main/java/org/finos/calm/resources/ControlResource.java index ad0142266..27bc1cef9 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 @@ -46,7 +46,7 @@ public ControlResource(ControlStore store) { summary = "Retrieve controls for a given domain", description = "Controls stored in a given domain" ) - @PermissionsAllowed(CalmHubScopes.READ) + @PermissionsAllowed(CalmHubScopes.DOMAIN_READ) public Response getControlsForDomain( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -67,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" ) - @PermissionsAllowed(CalmHubScopes.WRITE) + @PermissionsAllowed(CalmHubScopes.DOMAIN_WRITE) public Response createControlForDomain( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -89,7 +89,7 @@ public Response createControlForDomain( summary = "Retrieve requirement versions for a control", description = "Returns the list of versions for a control requirement" ) - @PermissionsAllowed(CalmHubScopes.READ) + @PermissionsAllowed(CalmHubScopes.DOMAIN_READ) public Response getRequirementVersions( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -113,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" ) - @PermissionsAllowed(CalmHubScopes.READ) + @PermissionsAllowed(CalmHubScopes.DOMAIN_READ) public Response getRequirementForVersion( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -144,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" ) - @PermissionsAllowed(CalmHubScopes.WRITE) + @PermissionsAllowed(CalmHubScopes.DOMAIN_WRITE) public Response createRequirementForVersion( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -179,7 +179,7 @@ public Response createRequirementForVersion( summary = "Retrieve configurations for a control", description = "Returns the list of configuration IDs for a given control" ) - @PermissionsAllowed(CalmHubScopes.READ) + @PermissionsAllowed(CalmHubScopes.DOMAIN_READ) public Response getConfigurationsForControl( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -204,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" ) - @PermissionsAllowed(CalmHubScopes.WRITE) + @PermissionsAllowed(CalmHubScopes.DOMAIN_WRITE) public Response createControlConfiguration( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -230,7 +230,7 @@ public Response createControlConfiguration( summary = "Retrieve versions for a control configuration", description = "Returns the list of versions for a specific control configuration" ) - @PermissionsAllowed(CalmHubScopes.READ) + @PermissionsAllowed(CalmHubScopes.DOMAIN_READ) public Response getConfigurationVersions( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -258,7 +258,7 @@ public Response getConfigurationVersions( summary = "Retrieve a specific configuration version", description = "Returns the configuration JSON at a specific version" ) - @PermissionsAllowed(CalmHubScopes.READ) + @PermissionsAllowed(CalmHubScopes.DOMAIN_READ) public Response getConfigurationForVersion( @PathParam("domain") @Pattern(regexp = DOMAIN_NAME_REGEX, message = DOMAIN_NAME_MESSAGE) @@ -293,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" ) - @PermissionsAllowed(CalmHubScopes.WRITE) + @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 fa8216c6e..98ff7a765 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 @@ -33,7 +33,7 @@ public CoreSchemaResource(CoreSchemaStore coreSchemaStore) { summary = "Published CALM Schema Versions", description = "Retrieve the CALM Schema versions published by this CALM Hub" ) - @PermissionsAllowed(CalmHubScopes.READ) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) public ValueWrapper schemaVersions() { return new ValueWrapper<>(coreSchemaStore.getVersions()); } @@ -44,7 +44,7 @@ public ValueWrapper schemaVersions() { summary = "Published CALM Schemas for Version", description = "Retrieve the names of CALM Schemas in a given version" ) - @PermissionsAllowed(CalmHubScopes.READ) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) public Response schemasForVersion(@PathParam("version") String version) { Map schemas = coreSchemaStore.getSchemasForVersion(version); if (schemas == null) { @@ -61,7 +61,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" ) - @PermissionsAllowed(CalmHubScopes.READ) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) public Response getSchema(@PathParam("version") String version, @PathParam("schemaName") String schemaName) { Map schemas = coreSchemaStore.getSchemasForVersion(version); @@ -85,7 +85,7 @@ public Response getSchema(@PathParam("version") String version, summary = "Create Schema Version", description = "Create a new schema version with associated schemas" ) - @PermissionsAllowed(CalmHubScopes.WRITE) + @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/DomainResource.java b/calm-hub/src/main/java/org/finos/calm/resources/DomainResource.java index c62442b33..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,6 @@ package org.finos.calm.resources; +import io.quarkus.security.Authenticated; import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; import jakarta.validation.Valid; @@ -45,7 +46,7 @@ public DomainResource(DomainStore store) { summary = "Available Domains", description = "The available domains in this Calm Hub" ) - @PermissionsAllowed(CalmHubScopes.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" ) - @PermissionsAllowed(CalmHubScopes.WRITE) + @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..1ecc3239f --- /dev/null +++ b/calm-hub/src/main/java/org/finos/calm/resources/DomainUserAccessResource.java @@ -0,0 +1,94 @@ +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) + public Response createUserAccessForDomain(@PathParam("domain") String domain, + UserAccess createUserAccessRequest) { + + createUserAccessRequest.setDomain(domain); + createUserAccessRequest.setCreationDateTime(LocalDateTime.now()); + createUserAccessRequest.setUpdateDateTime(LocalDateTime.now()); + + try { + UserAccess created = store.createUserAccessForDomain(createUserAccessRequest); + return Response.created(new URI( + String.format("/calm/domains/%s/user-access/%s", domain, created.getUserAccessId()))) + .build(); + } 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(); + } + } +} 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 c161885e0..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,5 +1,6 @@ package org.finos.calm.resources; +import io.quarkus.security.Authenticated; import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; import jakarta.validation.Valid; @@ -33,7 +34,7 @@ public NamespaceResource(NamespaceStore store) { summary = "Available Namespaces", description = "The available namespaces available in this Calm Hub" ) - @PermissionsAllowed(CalmHubScopes.READ) + @Authenticated public ValueWrapper namespaces() { return new ValueWrapper<>(namespaceStore.getNamespaces()); } 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 index 281a1c4f7..56f3847ec 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java +++ b/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java @@ -11,6 +11,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.function.Predicate; + @ApplicationScoped public class CalmHubPermissionChecker { @@ -29,20 +31,33 @@ public CalmHubPermissionChecker(UserAccessStore userAccessStore) { @PermissionChecker(CalmHubScopes.ADMIN) public boolean allowNamespaceAdmin(SecurityIdentity identity, String namespace) { if (identity.isAnonymous()) return true; - return hasAccess(identity, namespace, UserAction.ADMIN); + return hasNamespaceAccess(identity, namespace, UserAction.ADMIN); } @PermissionChecker(CalmHubScopes.READ) public boolean canRead(SecurityIdentity identity, String namespace) { if (identity.isAnonymous()) return true; if (allowPublicRead) return true; - return hasAccess(identity, namespace, UserAction.READ); + return hasNamespaceAccess(identity, namespace, UserAction.READ); + } + + @PermissionChecker(CalmHubScopes.DOMAIN_READ) + public boolean canReadByDomain(SecurityIdentity identity, String domain) { + if (identity.isAnonymous()) return true; + if (allowPublicRead) return true; + return hasDomainAccess(identity, domain, UserAction.READ); } @PermissionChecker(CalmHubScopes.WRITE) public boolean canWrite(SecurityIdentity identity, String namespace) { return identity.isAnonymous() - || hasAccess(identity, namespace, UserAction.WRITE); + || hasNamespaceAccess(identity, namespace, UserAction.WRITE); + } + + @PermissionChecker(CalmHubScopes.DOMAIN_WRITE) + public boolean canWriteByDomain(SecurityIdentity identity, String domain) { + return identity.isAnonymous() + || hasDomainAccess(identity, domain, UserAction.WRITE); } @PermissionChecker(CalmHubScopes.GLOBAL_ADMIN) @@ -57,7 +72,7 @@ public boolean hasGlobalAdmin(SecurityIdentity identity) { boolean granted = userAccessStore.getUserAccessForUsername(username) .stream() - .anyMatch(grant -> namespaceMatches(grant, "GLOBAL") + .anyMatch(grant -> "GLOBAL".equals(grant.getNamespace()) && permissionSufficient(grant, UserAction.ADMIN)); if (granted) { @@ -72,20 +87,28 @@ public boolean hasGlobalAdmin(SecurityIdentity identity) { } } - private boolean hasAccess(SecurityIdentity identity, String namespace, UserAction action) { + 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 namespace [{}] action=[{}]", - username, namespace, action); + logger.debug("Checking {} access for user [{}] on {} [{}] action=[{}]", + scopeType, username, scopeType, scopeValue, action); try { boolean result = userAccessStore.getUserAccessForUsername(username).stream() - .anyMatch(grant -> namespaceMatches(grant, namespace) - && permissionSufficient(grant, action)); + .anyMatch(grant -> grantMatcher.test(grant) && permissionSufficient(grant, action)); if (result) { - logger.info("User [{}] AUTHORIZED for [{}] in namespace [{}]", - username, action, namespace); + logger.info("User [{}] AUTHORIZED for [{}] in {} [{}]", username, action, scopeType, scopeValue); } else { - logger.warn("User [{}] DENIED for [{}] in namespace [{}]", - username, action, namespace); + logger.warn("User [{}] DENIED for [{}] in {} [{}]", username, action, scopeType, scopeValue); } return result; } catch (UserAccessNotFoundException e) { @@ -94,10 +117,6 @@ private boolean hasAccess(SecurityIdentity identity, String namespace, UserActio } } - private boolean namespaceMatches(UserAccess grant, String namespace) { - return grant.getNamespace().equals(namespace); - } - private boolean permissionSufficient(UserAccess grant, UserAction action) { return switch (grant.getPermission()) { case read -> action == UserAction.READ; 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 d7fdbef2b..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 @@ -13,5 +13,8 @@ private CalmHubScopes() { public static final String WRITE = "write"; public static final String ADMIN = "admin"; + public static final String DOMAIN_READ = "domain_read"; + public static final String DOMAIN_WRITE = "domain_write"; + public static final String GLOBAL_ADMIN = "global_admin"; } 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 6de29766e..539e61fc9 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 @@ -52,13 +52,35 @@ public UserAccess createUserAccessForNamespace(UserAccess userAccess) 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) .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 @@ -67,15 +89,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) - .setUserAccessId(doc.getInteger("userAccessId")) - .build(); - userAccessList.add(userAccess); + userAccessList.add(buildFromDocument(doc)); } if (userAccessList.isEmpty()) { @@ -93,13 +107,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) - .setUserAccessId(doc.getInteger("userAccessId")) - .build(); - userAccessList.add(userAccess); + userAccessList.add(buildFromDocument(doc)); } if (userAccessList.isEmpty()) { @@ -116,19 +124,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) - .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 0d7e016bd..b8dfd5117 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 @@ -34,6 +34,7 @@ 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 USER_ACCESS_ID_FIELD = "userAccessId"; private static final String CREATED_AT_FIELD = "createdAt"; @@ -146,11 +147,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)) + .setDomain(doc.get(DOMAIN_FIELD, String.class)) .setUserAccessId(doc.get(USER_ACCESS_ID_FIELD, Integer.class)) .build(); } 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..0bd3962c6 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestDomainUserAccessResourceShould.java @@ -0,0 +1,117 @@ +package org.finos.calm.resources; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import org.finos.calm.domain.UserAccess; +import org.finos.calm.domain.exception.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.*; + +@QuarkusTest +public class TestDomainUserAccessResourceShould { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + @InjectMock + UserAccessStore mockUserAccessStore; + + @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_200_with_user_access_list_for_domain() throws Exception { + UserAccess ua = new UserAccess(); + ua.setUserAccessId(201); + ua.setUsername("test_user"); + ua.setDomain("payments"); + when(mockUserAccessStore.getUserAccessForDomain("payments")).thenReturn(List.of(ua)); + + given() + .when() + .get("/calm/domains/payments/user-access") + .then() + .statusCode(200) + .body(containsString("test_user")); + + 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_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/security/TestCalmHubPermissionCheckerShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubPermissionCheckerShould.java index 14a7803eb..992ca9330 100644 --- a/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubPermissionCheckerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubPermissionCheckerShould.java @@ -203,4 +203,91 @@ void public_read_enabled_allows_read_without_store_lookup() { 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")); + } + + // --- 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")); + } + + @Test + void anonymous_identity_is_allowed_domain_read_and_write() { + when(mockIdentity.isAnonymous()).thenReturn(true); + + assertTrue(checker.canReadByDomain(mockIdentity, "payments")); + assertTrue(checker.canWriteByDomain(mockIdentity, "payments")); + } + + @Test + void public_read_enabled_allows_domain_read_without_store_lookup() { + checker.allowPublicRead = true; + when(mockIdentity.isAnonymous()).thenReturn(false); + + assertTrue(checker.canReadByDomain(mockIdentity, "payments")); + } } 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 2f6aa0c43..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 @@ -201,6 +201,92 @@ void return_user_access_for_namespace_and_user_access_id() throws Exception { 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 06ec9fe6b..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 @@ -99,6 +99,7 @@ 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("userAccessId", Integer.class)).thenReturn(1); @@ -131,6 +132,7 @@ 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("userAccessId", Integer.class)).thenReturn(1); @@ -161,6 +163,7 @@ 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("userAccessId", Integer.class)).thenReturn(1); @@ -183,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)); + } } From c8646d6a9b989f1e16c37ee8dd92b107bfa904f3 Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Thu, 28 May 2026 16:02:37 +0100 Subject: [PATCH 17/26] feat(calm-hub): fix tests --- calm-hub/pom.xml | 5 +++ .../calm/resources/TestAdrResourceShould.java | 18 +++------- ...tArchitectureResourcePutEnabledShould.java | 8 ++--- .../TestArchitectureResourceShould.java | 8 ++--- .../resources/TestControlResourceShould.java | 19 +++-------- .../TestCoreSchemaResourceShould.java | 4 ++- .../TestDecoratorResourceShould.java | 12 +++---- .../resources/TestDomainResourceShould.java | 2 ++ .../TestDomainUserAccessResourceShould.java | 2 ++ .../TestFlowResourcePutEnabledShould.java | 2 ++ .../resources/TestFlowResourceShould.java | 4 ++- .../TestFrontControllerResourceShould.java | 2 ++ ...ntControllerResourceWithBaseUrlShould.java | 8 ++--- .../TestInterfaceResourceShould.java | 34 ++++++++----------- .../TestNamespaceResourceShould.java | 4 ++- .../TestPatternResourcePutEnabledShould.java | 21 ++++++------ .../resources/TestPatternResourceShould.java | 4 ++- .../resources/TestSearchResourceShould.java | 10 +++--- .../resources/TestStandardResourceShould.java | 34 ++++++++----------- .../TestUserAccessResourceShould.java | 2 ++ .../TestSecurityResponseHeadersShould.java | 2 ++ 21 files changed, 98 insertions(+), 107 deletions(-) diff --git a/calm-hub/pom.xml b/calm-hub/pom.xml index 205bf7ea9..b1e0a0ef0 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/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 42dae5a90..998871dcc 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; @@ -20,7 +21,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; @@ -30,11 +30,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 605cbcb63..e56466d6a 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 index 0bd3962c6..2333ec74b 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestDomainUserAccessResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestDomainUserAccessResourceShould.java @@ -3,6 +3,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.finos.calm.domain.UserAccess; import org.finos.calm.domain.exception.UserAccessNotFoundException; import org.finos.calm.store.UserAccessStore; @@ -15,6 +16,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +@TestSecurity(authorizationEnabled = false) @QuarkusTest public class TestDomainUserAccessResourceShould { 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 cdb952b64..3659d2f3c 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 4c9a8fb82..e269506b5 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 e8c9506cb..bbfba91c1 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 9bae53de7..5b069d5fe 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/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/TestUserAccessResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestUserAccessResourceShould.java index 1eaa0bd70..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,6 +3,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.finos.calm.domain.UserAccess; import org.finos.calm.domain.exception.NamespaceNotFoundException; import org.finos.calm.domain.exception.UserAccessNotFoundException; @@ -16,6 +17,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +@TestSecurity(authorizationEnabled = false) @QuarkusTest public class TestUserAccessResourceShould { 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 { From d544e958511700ddf189a3b32c54d16ad7db40cc Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Thu, 28 May 2026 16:41:19 +0100 Subject: [PATCH 18/26] feat(calm-hub): fix fix annotations from conflicts --- .../org/finos/calm/mcp/tools/AdrTools.java | 15 +++++++++----- .../finos/calm/mcp/tools/InterfaceTools.java | 7 +++++++ .../finos/calm/mcp/tools/PatternTools.java | 8 ++++++++ .../finos/calm/mcp/tools/StandardTools.java | 7 +++++++ .../finos/calm/mcp/tools/TimelineTools.java | 8 ++++++++ .../calm/resources/CoreSchemaResource.java | 7 ++++--- .../resources/DomainUserAccessResource.java | 2 +- .../calm/resources/TimelineResource.java | 20 ++++++++----------- .../calm/resources/UserAccessResource.java | 6 +++--- 9 files changed, 56 insertions(+), 24 deletions(-) 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/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/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/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/CoreSchemaResource.java b/calm-hub/src/main/java/org/finos/calm/resources/CoreSchemaResource.java index 98ff7a765..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,5 +1,6 @@ package org.finos.calm.resources; +import io.quarkus.security.Authenticated; import io.quarkus.security.PermissionsAllowed; import jakarta.inject.Inject; import jakarta.ws.rs.*; @@ -33,7 +34,7 @@ public CoreSchemaResource(CoreSchemaStore coreSchemaStore) { summary = "Published CALM Schema Versions", description = "Retrieve the CALM Schema versions published by this CALM Hub" ) - @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) + @Authenticated public ValueWrapper schemaVersions() { return new ValueWrapper<>(coreSchemaStore.getVersions()); } @@ -44,7 +45,7 @@ public ValueWrapper schemaVersions() { summary = "Published CALM Schemas for Version", description = "Retrieve the names of CALM Schemas in a given version" ) - @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) + @Authenticated public Response schemasForVersion(@PathParam("version") String version) { Map schemas = coreSchemaStore.getSchemasForVersion(version); if (schemas == null) { @@ -61,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" ) - @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) + @Authenticated public Response getSchema(@PathParam("version") String version, @PathParam("schemaName") String schemaName) { Map schemas = coreSchemaStore.getSchemasForVersion(version); 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 index 1ecc3239f..2e794e916 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/DomainUserAccessResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/DomainUserAccessResource.java @@ -34,7 +34,7 @@ public DomainUserAccessResource(UserAccessStore userAccessStore) { summary = "Create user access for domain", description = "Creates a user-access grant for a given domain" ) - @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) + @PermissionsAllowed(CalmHubScopes.GLOBAL_ADMIN) // TODO domain level admin scope needed public Response createUserAccessForDomain(@PathParam("domain") String domain, UserAccess createUserAccessRequest) { 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 b29f76b8b..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 @@ -35,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" ) - @PermissionsAllowed({CalmHubScopes.ADMIN}) + @PermissionsAllowed(CalmHubScopes.ADMIN) public Response createUserAccessForNamespace(@PathParam("namespace") String namespace, UserAccess createUserAccessRequest) { @@ -65,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" ) - @PermissionsAllowed({CalmHubScopes.ADMIN}) + @PermissionsAllowed(CalmHubScopes.ADMIN) public Response getUserAccessForNamespace(@PathParam("namespace") String namespace) { try { @@ -89,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" ) - @PermissionsAllowed({CalmHubScopes.ADMIN}) + @PermissionsAllowed(CalmHubScopes.ADMIN) public Response getUserAccessForNamespaceAndId(@PathParam("namespace") String namespace, @PathParam("userAccessId") Integer userAccessId) { From f79023559cfea92e2300faf13265a50322cbeb34 Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Thu, 28 May 2026 16:47:52 +0100 Subject: [PATCH 19/26] feat(calm-hub): fix tests --- .../calm/resources/TestTimelineResourcePutEnabledShould.java | 2 ++ .../org/finos/calm/resources/TestTimelineResourceShould.java | 2 ++ 2 files changed, 4 insertions(+) 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 { From 2b5e6be0fa3e35a38ab14e3fa8a4f473e5e4ac7d Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Thu, 28 May 2026 17:27:28 +0100 Subject: [PATCH 20/26] feat(calm-hub): tweaks to integration tests (needs more work) --- .../java/integration/PermittedScopesIntegration.java | 8 ++------ .../java/integration/UserAccessGrantsIntegration.java | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java b/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java index c12ad03b7..b2ab4c0d7 100644 --- a/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java +++ b/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java @@ -163,15 +163,11 @@ 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 end_to_end_unauthorized_get_namespaces_with_no_access_token() { given() - .auth().oauth2(accessToken) .when().get("/calm/namespaces") .then() - .statusCode(403); + .statusCode(401); } @Test diff --git a/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java b/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java index 76d3921c3..e114bf752 100644 --- a/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java +++ b/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java @@ -66,7 +66,7 @@ void setupPatterns() { } Document document1 = new Document("username", "test-user") .append("namespace", "finos") - .append("permission", UserAccess.Permission.write.name()) + .append("permission", UserAccess.Permission.admin.name()) .append("userAccessId", 101); database.getCollection("userAccess").insertOne(document1); From f5e56ace2a496846cdfb307d4b6ebb5b79f6330e Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Fri, 29 May 2026 15:20:16 +0100 Subject: [PATCH 21/26] feat(calm-hub): add no-auth mechanism so there's a user in no-auth mode --- .../security/CalmHubPermissionChecker.java | 16 +++-- .../NoAuthAuthenticationMechanism.java | 48 +++++++++++++ .../calm/security/ProxyIdentityProvider.java | 4 +- .../application-proxy-auth.properties | 1 + .../resources/application-secure.properties | 3 +- .../src/main/resources/application.properties | 4 ++ .../TestCalmHubPermissionCheckerShould.java | 69 ++++++++++--------- 7 files changed, 103 insertions(+), 42 deletions(-) create mode 100644 calm-hub/src/main/java/org/finos/calm/security/NoAuthAuthenticationMechanism.java 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 index 56f3847ec..0fc363123 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java +++ b/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java @@ -20,6 +20,10 @@ public class CalmHubPermissionChecker { 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; @@ -30,39 +34,39 @@ public CalmHubPermissionChecker(UserAccessStore userAccessStore) { @PermissionChecker(CalmHubScopes.ADMIN) public boolean allowNamespaceAdmin(SecurityIdentity identity, String namespace) { - if (identity.isAnonymous()) return true; + if (noAuthEnabled) return true; return hasNamespaceAccess(identity, namespace, UserAction.ADMIN); } @PermissionChecker(CalmHubScopes.READ) public boolean canRead(SecurityIdentity identity, String namespace) { - if (identity.isAnonymous()) return true; + 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 (identity.isAnonymous()) return true; + 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 identity.isAnonymous() + return noAuthEnabled || hasNamespaceAccess(identity, namespace, UserAction.WRITE); } @PermissionChecker(CalmHubScopes.DOMAIN_WRITE) public boolean canWriteByDomain(SecurityIdentity identity, String domain) { - return identity.isAnonymous() + return noAuthEnabled || hasDomainAccess(identity, domain, UserAction.WRITE); } @PermissionChecker(CalmHubScopes.GLOBAL_ADMIN) public boolean hasGlobalAdmin(SecurityIdentity identity) { - if (identity.isAnonymous()) { + if (noAuthEnabled) { logger.warn("CalmHub is running with no authentication. Granting user access unconditionally."); return true; } 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/ProxyIdentityProvider.java b/calm-hub/src/main/java/org/finos/calm/security/ProxyIdentityProvider.java index d3acfbce9..8baa71f3d 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/ProxyIdentityProvider.java +++ b/calm-hub/src/main/java/org/finos/calm/security/ProxyIdentityProvider.java @@ -1,6 +1,5 @@ package org.finos.calm.security; -import io.quarkus.arc.profile.IfBuildProfile; import io.quarkus.security.identity.AuthenticationRequestContext; import io.quarkus.security.identity.IdentityProvider; import io.quarkus.security.identity.SecurityIdentity; @@ -13,7 +12,6 @@ import org.slf4j.LoggerFactory; @ApplicationScoped -@IfBuildProfile("proxy-auth") public class ProxyIdentityProvider implements IdentityProvider { Logger logger = LoggerFactory.getLogger(ProxyIdentityProvider.class); @@ -25,7 +23,7 @@ public Class getRequestType() { @Override public Uni authenticate(TrustedAuthenticationRequest request, AuthenticationRequestContext context) { - logger.debug("Receiving identity from ProxyAuthenticationMechanism - validating as no-op as no IDP is configured for proxy mode."); + 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) diff --git a/calm-hub/src/main/resources/application-proxy-auth.properties b/calm-hub/src/main/resources/application-proxy-auth.properties index 646ae5151..2779207ea 100644 --- a/calm-hub/src/main/resources/application-proxy-auth.properties +++ b/calm-hub/src/main/resources/application-proxy-auth.properties @@ -1,4 +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 b911c5ace..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 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 index 992ca9330..1fdf94e43 100644 --- a/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubPermissionCheckerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubPermissionCheckerShould.java @@ -34,11 +34,9 @@ class TestCalmHubPermissionCheckerShould { @BeforeEach void setUp() { checker = new CalmHubPermissionChecker(mockUserAccessStore); - // allowPublicRead defaults to false } private void givenAuthenticatedUser(String username) throws UserAccessNotFoundException { - when(mockIdentity.isAnonymous()).thenReturn(false); when(mockIdentity.getPrincipal()).thenReturn(mockPrincipal); when(mockPrincipal.getName()).thenReturn(username); } @@ -139,17 +137,6 @@ void admin_grant_allows_namespace_admin() throws UserAccessNotFoundException { assertTrue(checker.allowNamespaceAdmin(mockIdentity, "foo")); } - // --- Anonymous --- - - @Test - void anonymous_identity_is_always_allowed() { - when(mockIdentity.isAnonymous()).thenReturn(true); - - assertTrue(checker.canRead(mockIdentity, "foo")); - assertTrue(checker.canWrite(mockIdentity, "foo")); - assertTrue(checker.allowNamespaceAdmin(mockIdentity, "foo")); - } - // --- allowPublicRead --- @Test @@ -163,7 +150,6 @@ void public_read_disabled_denies_user_without_grants() throws UserAccessNotFound @Test void public_read_enabled_allows_any_authenticated_user_to_read() { checker.allowPublicRead = true; - when(mockIdentity.isAnonymous()).thenReturn(false); assertTrue(checker.canRead(mockIdentity, "foo")); } @@ -186,20 +172,10 @@ void public_read_enabled_does_not_grant_namespace_admin_access() throws UserAcce assertFalse(checker.allowNamespaceAdmin(mockIdentity, "foo")); } - @Test - void public_read_enabled_still_allows_anonymous_users() { - checker.allowPublicRead = true; - when(mockIdentity.isAnonymous()).thenReturn(true); - - assertTrue(checker.canRead(mockIdentity, "foo")); - } - @Test void public_read_enabled_allows_read_without_store_lookup() { checker.allowPublicRead = true; - when(mockIdentity.isAnonymous()).thenReturn(false); - // No stubbing of the store — if the checker hits it, Mockito strict mode will error, - // confirming the store is bypassed when allowPublicRead is true + // No isAnonymous() stub and no store stub — confirms both are bypassed when allowPublicRead is true assertTrue(checker.canRead(mockIdentity, "foo")); } @@ -253,6 +229,13 @@ void user_with_no_grants_is_denied_domain_read() throws UserAccessNotFoundExcept 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 @@ -275,19 +258,41 @@ void write_grant_for_domain_allows_domain_write() throws UserAccessNotFoundExcep assertTrue(checker.canWriteByDomain(mockIdentity, "payments")); } + // --- noAuthEnabled --- + @Test - void anonymous_identity_is_allowed_domain_read_and_write() { - when(mockIdentity.isAnonymous()).thenReturn(true); + void no_auth_mode_grants_read_without_store_lookup() { + checker.noAuthEnabled = true; - assertTrue(checker.canReadByDomain(mockIdentity, "payments")); - assertTrue(checker.canWriteByDomain(mockIdentity, "payments")); + assertTrue(checker.canRead(mockIdentity, "foo")); } @Test - void public_read_enabled_allows_domain_read_without_store_lookup() { - checker.allowPublicRead = true; - when(mockIdentity.isAnonymous()).thenReturn(false); + 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")); } } From 5085cebe7cfaf861ea124e99636e4383630dc920 Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Fri, 29 May 2026 15:22:11 +0100 Subject: [PATCH 22/26] feat(calm-hub): fix integration tests and rework secure profile to no longer use scopes --- .../integration/IntegrationTestProfile.java | 3 +- .../PermittedScopesIntegration.java | 128 ++++------- .../UserAccessGrantsIntegration.java | 90 ++++---- .../test/resources/secure-profile/realm.json | 210 +----------------- 4 files changed, 102 insertions(+), 329 deletions(-) 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/PermittedScopesIntegration.java b/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java index b2ab4c0d7..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,79 +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("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.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.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") @@ -135,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") @@ -145,15 +124,14 @@ void end_to_end_unauthorize_create_pattern_request_with_no_access_token() { .statusCode(401); } - @ParameterizedTest - @ValueSource(strings = {CalmHubScopes.READ, CalmHubScopes.WRITE}) + @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) @@ -163,26 +141,10 @@ void end_to_end_get_namespaces_with_valid_access_token(String scope) { @Test @Order(5) - void end_to_end_unauthorized_get_namespaces_with_no_access_token() { + void unauthenticated_request_for_namespaces_is_rejected() { given() .when().get("/calm/namespaces") .then() .statusCode(401); } - - @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.WRITE); - logger.info("accessToken: {}", accessToken); - given() - .auth().oauth2(accessToken) - .body(PATTERN) - .header("Content-Type", "application/json") - .when().post("/calm/namespaces/finos/patterns") - .then() - .statusCode(403); - } -} \ No newline at end of file +} diff --git a/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java b/calm-hub/src/integration-test/java/integration/UserAccessGrantsIntegration.java index e114bf752..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,41 +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" - } + + 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" - } + 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( @@ -64,48 +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.admin.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.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() @@ -114,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.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/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 +} From 91ce54afaf3e620a312bf45f35029942047ec1a6 Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Fri, 29 May 2026 16:01:09 +0100 Subject: [PATCH 23/26] feat(cli): improve testing and move useraction to domain --- .../calm/{security => domain}/UserAction.java | 34 +++---- .../security/CalmHubPermissionChecker.java | 1 + .../TestCalmHubPermissionCheckerShould.java | 39 ++++++++ ...stNoAuthAuthenticationMechanismShould.java | 77 +++++++++++++++ ...estProxyAuthenticationMechanismShould.java | 95 +++++++++++++++++++ .../TestUserAccessValidatorShould.java | 51 ++++++++++ 6 files changed, 280 insertions(+), 17 deletions(-) rename calm-hub/src/main/java/org/finos/calm/{security => domain}/UserAction.java (83%) create mode 100644 calm-hub/src/test/java/org/finos/calm/security/TestNoAuthAuthenticationMechanismShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/security/TestProxyAuthenticationMechanismShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/security/TestUserAccessValidatorShould.java diff --git a/calm-hub/src/main/java/org/finos/calm/security/UserAction.java b/calm-hub/src/main/java/org/finos/calm/domain/UserAction.java similarity index 83% rename from calm-hub/src/main/java/org/finos/calm/security/UserAction.java rename to calm-hub/src/main/java/org/finos/calm/domain/UserAction.java index ba2875169..3e88e1826 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/UserAction.java +++ b/calm-hub/src/main/java/org/finos/calm/domain/UserAction.java @@ -1,17 +1,17 @@ -package org.finos.calm.security; - -public enum UserAction { - READ("read"), - WRITE("write"), - ADMIN("admin"); - - private final String value; - - UserAction(String value) { - this.value = value; - } - - public String getValue() { - return value; - } -} +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/security/CalmHubPermissionChecker.java b/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java index 0fc363123..2a34ff698 100644 --- a/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java +++ b/calm-hub/src/main/java/org/finos/calm/security/CalmHubPermissionChecker.java @@ -6,6 +6,7 @@ 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; 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 index 1fdf94e43..03077e7e1 100644 --- a/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubPermissionCheckerShould.java +++ b/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubPermissionCheckerShould.java @@ -295,4 +295,43 @@ void no_auth_mode_grants_domain_read_and_write_without_store_lookup() { 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/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/TestUserAccessValidatorShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestUserAccessValidatorShould.java new file mode 100644 index 000000000..ed31445f0 --- /dev/null +++ b/calm-hub/src/test/java/org/finos/calm/security/TestUserAccessValidatorShould.java @@ -0,0 +1,51 @@ +package org.finos.calm.security; + +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.util.List; +import java.util.Set; + +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 { + + @Mock + UserAccessStore mockUserAccessStore; + + UserAccessValidator validator; + + @BeforeEach + void setUp() { + validator = new UserAccessValidator(mockUserAccessStore); + } + + @Test + 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)); + + Set result = validator.getReadableNamespaces("alice"); + + assertEquals(Set.of("finos", "workshop"), result); + } + + @Test + void return_empty_set_when_user_has_no_access_grants() throws UserAccessNotFoundException { + when(mockUserAccessStore.getUserAccessForUsername("alice")).thenThrow(new UserAccessNotFoundException()); + + Set result = validator.getReadableNamespaces("alice"); + + assertTrue(result.isEmpty()); + } +} From dfd2c96c37a1e8ad87262abd34c6e4b361eb66e0 Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Fri, 29 May 2026 16:52:35 +0100 Subject: [PATCH 24/26] fix(calm-hub): testing --- .../resources/DomainUserAccessResource.java | 17 ++- .../TestDomainUserAccessResourceShould.java | 102 ++++++++++++++++-- 2 files changed, 107 insertions(+), 12 deletions(-) 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 index 2e794e916..c12328c27 100644 --- a/calm-hub/src/main/java/org/finos/calm/resources/DomainUserAccessResource.java +++ b/calm-hub/src/main/java/org/finos/calm/resources/DomainUserAccessResource.java @@ -38,15 +38,16 @@ public DomainUserAccessResource(UserAccessStore userAccessStore) { public Response createUserAccessForDomain(@PathParam("domain") String domain, UserAccess createUserAccessRequest) { - createUserAccessRequest.setDomain(domain); 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 { - UserAccess created = store.createUserAccessForDomain(createUserAccessRequest); - return Response.created(new URI( - String.format("/calm/domains/%s/user-access/%s", domain, created.getUserAccessId()))) - .build(); + 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) @@ -91,4 +92,10 @@ public Response getUserAccessForDomainAndId(@PathParam("domain") String domain, .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/test/java/org/finos/calm/resources/TestDomainUserAccessResourceShould.java b/calm-hub/src/test/java/org/finos/calm/resources/TestDomainUserAccessResourceShould.java index 2333ec74b..d3946f92f 100644 --- a/calm-hub/src/test/java/org/finos/calm/resources/TestDomainUserAccessResourceShould.java +++ b/calm-hub/src/test/java/org/finos/calm/resources/TestDomainUserAccessResourceShould.java @@ -20,10 +20,11 @@ @QuarkusTest public class TestDomainUserAccessResourceShould { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @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(); @@ -51,20 +52,93 @@ void return_201_created_with_location_header_when_domain_user_access_is_created( 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 ua = new UserAccess(); - ua.setUserAccessId(201); - ua.setUsername("test_user"); - ua.setDomain("payments"); - when(mockUserAccessStore.getUserAccessForDomain("payments")).thenReturn(List.of(ua)); + 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_user")); + .body(containsString("test_user1")) + .body(containsString("test_user2")); verify(mockUserAccessStore, times(1)).getUserAccessForDomain("payments"); } @@ -84,6 +158,20 @@ void return_404_when_no_user_access_found_for_domain() throws Exception { 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(); From 1c561827f59a085f444816b020a3cb73ecb6b265 Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Fri, 29 May 2026 17:34:46 +0100 Subject: [PATCH 25/26] feat(cli): fix mcp tests --- .../integration-test/java/integration/MongoMcpIntegration.java | 2 ++ .../java/integration/NitriteMcpIntegration.java | 2 ++ 2 files changed, 4 insertions(+) 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); From 8631fb4115c0257430c05365bebeeb32cd26c17c Mon Sep 17 00:00:00 2001 From: Will Osborne Date: Fri, 29 May 2026 18:14:44 +0100 Subject: [PATCH 26/26] feat(calm-hub): test for proxy auth profile --- .../IntegrationTestProxyAuthProfile.java | 26 ++ .../integration/ProxyAuthIntegration.java | 305 ++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 calm-hub/src/integration-test/java/integration/IntegrationTestProxyAuthProfile.java create mode 100644 calm-hub/src/integration-test/java/integration/ProxyAuthIntegration.java 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/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); + } +}