Repository: finos/architecture-as-code
Pull request #978: finos#715 enable https profile and support coarse-grained access control using keycloak
Author: login: challamani-ms
Labels: name: calm-hub
description: Affects `calm-hub`
name: config
name: calm-hub-ui
description: Affects `calm-hub-ui`
Created: 2025-03-06T14:44:37Z | Merged: 2025-03-14T09:37:22Z

Submitting this PR based on the discussion in #715

We can find the following changes in this PR
- Enable HTTPS profile in calm-hub using self-signed certificates.
- Docker compose script to launch KeyCloak Container with a sample realm.json
- JWT & Scope validation logic implemented in calm-hub.
- Used the oidc-client in CalmUI to enable Authorization Code flow (only when backend profile is https)
- Created a shell script to test the device-code flow.

Note: we will create two new issues to implement following
- Enable fine-grained access control (RBAC, user entitlement checks after a successful bearer token validation)
- Provide onboarding guide to integrate with different IdPs.


Diff:
diff --git a/calm-hub-ui/package.json b/calm-hub-ui/package.json
index 0fcd8f997..de1a782c7 100644
--- a/calm-hub-ui/package.json
+++ b/calm-hub-ui/package.json
@@ -25,6 +25,7 @@
         "cytoscape-dagre": "^2.5.0",
         "cytoscape-expand-collapse": "^4.1.1",
         "cytoscape-node-edge-html-label": "^1.0.6",
+        "oidc-client": "^1.11.5",
         "react": "^18.2.0",
         "react-dom": "^18.2.0",
         "react-icons": "^5.3.0",
diff --git a/calm-hub-ui/src/ProtectedRoute.tsx b/calm-hub-ui/src/ProtectedRoute.tsx
new file mode 100644
index 000000000..c47ae425e
--- /dev/null
+++ b/calm-hub-ui/src/ProtectedRoute.tsx
@@ -0,0 +1,39 @@
+import React, { ReactNode, useEffect, useState } from "react";
+import { User } from "oidc-client";
+import { authService } from "./authService";
+
+interface ProtectedRouteProps {
+  children: ReactNode;
+}
+
+const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
+  const [user, setUser] = useState<User | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    const authenticate = async () => {
+      const currentUser = await authService.getUser();
+      if (currentUser && !currentUser.expired) {
+        setUser(currentUser);
+      } else if (window.location.search.includes("code=")) {
+        const loggedInUser = await authService.processRedirect();
+        setUser(loggedInUser);
+      } else {
+        await authService.login();
+      }
+      setLoading(false);
+    };
+
+    authenticate();
+  }, []);
+
+  if (loading) {
+    return <div>Loading...</div>;
+  }
+
+  if (!user) {
+    return <div>Redirecting to login...</div>;
+  }
+  return <>{children}</>;
+};
+export default ProtectedRoute;
\ No newline at end of file
diff --git a/calm-hub-ui/src/authService.tsx b/calm-hub-ui/src/authService.tsx
new file mode 100644
index 000000000..581ff1c13
--- /dev/null
+++ b/calm-hub-ui/src/authService.tsx
@@ -0,0 +1,96 @@
+import { UserManager, Log, User } from "oidc-client";
+
+const config = {
+  authority: 'https://localhost:9443/realms/calm-hub-realm',
+  client_id: 'calm-hub-authz-code',
+  redirect_uri: window.location.origin,
+  response_type: 'code',
+  scope: 'openid profile architectures:read adrs:all',
+  post_logout_redirect_uri: window.location.origin,
+  automaticSilentRenew: true,
+  filterProtocolClaims: true,
+  loadUserInfo: true,
+};
+
+let userManager = null;
+const isHttps = window.location.protocol === 'https:';
+if (isHttps) {
+  userManager = new UserManager(config);
+  Log.logger = console;
+  Log.level = Log.INFO;
+}
+
+export const getUser = async (): Promise<User | null> => {
+  return await userManager.getUser();
+};
+
+export const login = async (): Promise<void> => {
+  await userManager.signinRedirect();
+};
+
+export const processRedirect = async (): Promise<User | null> => {
+  try {
+    await userManager.signinRedirectCallback();
+    return await getUser();
+  } catch (error) {
+    console.error("Redirect Processing Error:", error);
+    return null;
+  }
+};
+
+export const logout = async (): Promise<void> => {
+  try {
+    await userManager.signoutRedirect();
+  } catch (error) {
+    console.error("Logout Error:", error);
+  }
+};
+
+export const clearSession = async (): Promise<void> => {
+  try {
+    await userManager.removeUser();
+    console.log("Session cleared successfully.");
+  } catch (error) {
+    console.error("Error clearing session:", error);
+  }
+};
+
+export const getToken = async (): Promise<string> => {
+    if (!isHttps) {
+        return "";
+    }
+    const user = await userManager.getUser();
+    if (user && !user.expired) {
+        return user.access_token;
+    }
+
+    if (user && user.expired) {
+       try {
+         const refreshedUser = await userManager.signinSilent();
+         return refreshedUser.access_token;
+       } catch (error) {
+          console.error('Error refreshing token:', error);
+          return "";
+       }
+    }
+    return "";
+};
+
+export const checkAuthorityService = async (): Promise<boolean> => {
+  try {
+    const response = await fetch(config.authority, { method: 'HEAD' });
+    return response.ok;
+  } catch (error) {
+    console.error("Authority Service Check Error:", error);
+    return false;
+  }
+};
+
+export const authService = {
+  getUser,
+  login,
+  processRedirect,
+  logout,
+  clearSession,
+  getToken,
+};
diff --git a/calm-hub-ui/src/index.tsx b/calm-hub-ui/src/index.tsx
index 94abc1ae9..667ee9cd2 100644
--- a/calm-hub-ui/src/index.tsx
+++ b/calm-hub-ui/src/index.tsx
@@ -1,12 +1,32 @@
-import React from 'react';
-import ReactDOM from 'react-dom/client';
+import React from "react";
+import ReactDOM from "react-dom/client";
+import ProtectedRoute from "./ProtectedRoute";
 import './index.css';
 import '@patternfly/patternfly/patternfly.css';
-import App from './App.js';
+import { authService } from "./authService";
+import App from "./App";
+
+const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement);
+
+const LogoutButton: React.FC = () => {
+  const handleLogout = async () => {
+    await authService.logout();
+  };
+
+  return <button onClick={handleLogout} style={{ position: 'absolute', top: 10, right: 10 }}>Logout</button>;
+};
+
+const isHttps = window.location.protocol === 'https:';
 
-const root = ReactDOM.createRoot(document.getElementById('root')!);
 root.render(
-    <React.StrictMode>
+  <React.StrictMode>
+    {isHttps ? (
+      <ProtectedRoute>
+        <App />
+        <LogoutButton />
+      </ProtectedRoute>
+    ) : (
         <App />
-    </React.StrictMode>
-);
+    )}
+  </React.StrictMode>
+);
\ No newline at end of file
diff --git a/calm-hub-ui/src/service/calm-service.tsx b/calm-hub-ui/src/service/calm-service.tsx
index 13b6be445..0d11cba25 100644
--- a/calm-hub-ui/src/service/calm-service.tsx
+++ b/calm-hub-ui/src/service/calm-service.tsx
@@ -8,13 +8,18 @@ import {
     Architecture,
     ArchitectureID,
 } from '../model/calm.js';
+import { getToken } from "../authService";
 
 /**
  * Fetch namespaces and set them using the provided setter function.
  */
 export async function fetchNamespaces(setNamespaces: (namespaces: Namespace[]) => void) {
     try {
-        const res = await fetch('/calm/namespaces');
+        const accessToken = await getToken();
+        const res = await fetch('/calm/namespaces',{
+                        method: 'GET',
+                        headers: {'Authorization': `Bearer ${accessToken}`,},
+                        });
         const data = await res.json();
         setNamespaces(data.values);
     } catch (error) {
@@ -30,7 +35,11 @@ export async function fetchPatternIDs(
     setPatternIDs: (patternIDs: PatternID[]) => void
 ) {
     try {
-        const res = await fetch(`/calm/namespaces/${namespace}/patterns`);
+        const accessToken = await getToken();
+        const res = await fetch(`/calm/namespaces/${namespace}/patterns`,{
+                            method: 'GET',
+                            headers: {'Authorization': `Bearer ${accessToken}`,},
+                            });
         const data = await res.json();
         setPatternIDs(data.values.map((num: number) => num.toString()));
     } catch (error) {
@@ -43,7 +52,11 @@ export async function fetchPatternIDs(
  */
 export async function fetchFlowIDs(namespace: string, setFlowIDs: (flowIDs: FlowID[]) => void) {
     try {
-        const res = await fetch(`/calm/namespaces/${namespace}/flows`);
+        const accessToken = await getToken();
+        const res = await fetch(`/calm/namespaces/${namespace}/flows`,{
+                            method: 'GET',
+                            headers: {'Authorization': `Bearer ${accessToken}`,},
+                            });
         const data = await res.json();
         setFlowIDs(data.values.map((id: number) => id.toString()));
     } catch (error) {
@@ -60,7 +73,11 @@ export async function fetchPatternVersions(
     setVersions: (versions: Version[]) => void
 ) {
     try {
-        const res = await fetch(`/calm/namespaces/${namespace}/patterns/${patternID}/versions`);
+        const accessToken = await getToken();
+        const res = await fetch(`/calm/namespaces/${namespace}/patterns/${patternID}/versions`,{
+                            method: 'GET',
+                            headers: {'Authorization': `Bearer ${accessToken}`,},
+                            });
         const data = await res.json();
         setVersions(data.values);
     } catch (error) {
@@ -77,7 +94,11 @@ export async function fetchFlowVersions(
     setFlowVersions: (flowVersions: Version[]) => void
 ) {
     try {
-        const res = await fetch(`/calm/namespaces/${namespace}/flows/${flowID}/versions`);
+        const accessToken = await getToken();
+        const res = await fetch(`/calm/namespaces/${namespace}/flows/${flowID}/versions`,{
+                            method: 'GET',
+                            headers: {'Authorization': `Bearer ${accessToken}`,},
+                            });
         const data = await res.json();
         setFlowVersions(data.values);
     } catch (error) {
@@ -95,9 +116,12 @@ export async function fetchPattern(
     setPattern: (pattern: Pattern) => void
 ) {
     try {
+        const accessToken = await getToken();
         const res = await fetch(
-            `/calm/namespaces/${namespace}/patterns/${patternID}/versions/${version}`
-        );
+            `/calm/namespaces/${namespace}/patterns/${patternID}/versions/${version}`,{
+                method: 'GET',
+                headers: {'Authorization': `Bearer ${accessToken}`,},
+            });
         const data = await res.json();
         setPattern(data);
     } catch (error) {
@@ -118,9 +142,12 @@ export async function fetchFlow(
     setFlow: (flow: Flow) => void
 ) {
     try {
+        const accessToken = await getToken();
         const res = await fetch(
-            `/calm/namespaces/${namespace}/flows/${flowID}/versions/${version}`
-        );
+            `/calm/namespaces/${namespace}/flows/${flowID}/versions/${version}`,{
+                 method: 'GET',
+                 headers: {'Authorization': `Bearer ${accessToken}`,},
+             });
         const data = await res.json();
         setFlow(data);
     } catch (error) {
@@ -139,7 +166,11 @@ export async function fetchArchitectureIDs(
     setArchitectureIDs: (architectureIDs: ArchitectureID[]) => void
 ) {
     try {
-        const res = await fetch(`/calm/namespaces/${namespace}/architectures`);
+        const accessToken = await getToken();
+        const res = await fetch(`/calm/namespaces/${namespace}/architectures`,{
+                            method: 'GET',
+                            headers: {'Authorization': `Bearer ${accessToken}`,},
+                          });
         const data = await res.json();
         setArchitectureIDs(data.values.map((id: number) => id.toString()));
     } catch (error) {
@@ -156,9 +187,12 @@ export async function fetchArchitectureVersions(
     setVersions: (versions: Version[]) => void
 ) {
     try {
-        const res = await fetch(
-            `/calm/namespaces/${namespace}/architectures/${architectureID}/versions`
-        );
+
+        const accessToken = await getToken();
+        const res = await fetch(`/calm/namespaces/${namespace}/architectures/${architectureID}/versions`,{
+                            method: 'GET',
+                            headers: {'Authorization': `Bearer ${accessToken}`,},
+                          });
         const data = await res.json();
         setVersions(data.values);
     } catch (error) {
@@ -176,9 +210,12 @@ export async function fetchArchitecture(
     setArchitecture: (architecture: Architecture) => void
 ) {
     try {
+        const accessToken = await getToken();
         const res = await fetch(
-            `/calm/namespaces/${namespace}/architectures/${architectureID}/versions/${version}`
-        );
+            `/calm/namespaces/${namespace}/architectures/${architectureID}/versions/${version}`,{
+                method: 'GET',
+                headers: {'Authorization': `Bearer ${accessToken}`,},
+            });
         const data = await res.json();
         setArchitecture(data);
     } catch (error) {
diff --git a/calm-hub/.gitignore b/calm-hub/.gitignore
index 049281bf5..050e1b639 100644
--- a/calm-hub/.gitignore
+++ b/calm-hub/.gitignore
@@ -315,5 +315,7 @@ psd
 thumb
 sketch
 
+# Ignore .pem files in the resources directory
+**/*.pem
 
 # End of https://www.toptal.com/developers/gitignore/api/intellij,react,node,java,maven
diff --git a/calm-hub/README.md b/calm-hub/README.md
index 9588cf905..ee5318093 100644
--- a/calm-hub/README.md
+++ b/calm-hub/README.md
@@ -54,6 +54,47 @@ From the `calm-hub` directory
 1. `../mvnw package`
 2. `../mvnw quarkus:dev`
 
+
+### Secure profile
+
+#### Launch keycloak
+
+From the `keycloak-dev` directory in `calm-hub`
+
+Create certs for KeyCloak:
+
+```shell
+  mkdir ./certs &&
+  openssl req -x509 -newkey rsa:2048 \
+    -keyout ./certs/key.pem \
+    -out ./certs/cert.pem -days 90 -nodes \
+    -subj "/C=GB/ST=England/L=Manchester/O=finos/OU=Technology/CN=idp.finos.org"
+```
+
+Launch KeyCloak:
+```shell
+export KC_BOOTSTRAP_ADMIN_PASSWORD=<set me>
+docker-compose up
+```
+- Open KeyCloak UI: https://localhost:9443, login with admin user.
+- Switch realm from `master` to `calm-hub-realm`.
+- You can find a `demo` user with a temporary credentials under `calm-hub-realm` realm.
+- During the local development, the `demo` user you can use to authenticate with `keycloak-dev` when you integrate the `calm-ui` with `authorization-code` flow type.
+
+#### Server Side with secure profile
+
+From the `calm-hub` directory
+1. Create a server side certificates
+    ```shell
+    openssl req -x509 -newkey rsa:2048 \
+      -keyout ./src/main/resources/key.pem \
+      -out ./src/main/resources/cert.pem -days 90 -nodes \
+      -subj "/C=GB/ST=England/L=Manchester/O=finos/OU=Technology/CN=calm-hub.finos.org"
+    ```
+2. `../mvnw package`
+3. `../mvnw quarkus:dev -Dquarkus.profile=secure`
+4. Open Calm UI: https://localhost:8443
+
 ### UI with hot reload (from src/main/webapp)
 
 The first time, you may need to run `npm install`.
diff --git a/calm-hub/keycloak-dev/device-code-flow.sh b/calm-hub/keycloak-dev/device-code-flow.sh
new file mode 100755
index 000000000..9a614f4c3
--- /dev/null
+++ b/calm-hub/keycloak-dev/device-code-flow.sh
@@ -0,0 +1,64 @@
+#!/bin/bash
+
+CLIENT_ID="calm-hub-device-flow"
+SCOPE="architectures:all architectures:read"
+DEVICE_AUTH_ENDPOINT="https://localhost:9443/realms/calm-hub-realm/protocol/openid-connect/auth/device"
+DEVICE_AUTH_RESPONSE=$(curl --insecure -X POST \
+  -H "Content-Type: application/x-www-form-urlencoded" \
+  -d "client_id=$CLIENT_ID" -d "scope=$SCOPE" \
+  $DEVICE_AUTH_ENDPOINT)
+
+# Extract values from the device auth response.
+DEVICE_CODE=$(echo "$DEVICE_AUTH_RESPONSE" | jq -r '.device_code')
+USER_CODE=$(echo "$DEVICE_AUTH_RESPONSE" | jq -r '.user_code')
+VERIFICATION_URI=$(echo "$DEVICE_AUTH_RESPONSE" | jq -r '.verification_uri')
+VERIFICATION_URI_COMPLETE=$(echo "$DEVICE_AUTH_RESPONSE" | jq -r '.verification_uri_complete')
+EXPIRES_IN=$(echo "$DEVICE_AUTH_RESPONSE" | jq -r '.expires_in')
+INTERVAL=$(echo "$DEVICE_AUTH_RESPONSE" | jq -r '.interval')
+
+echo -e "\nOpen the link in a browser \033[3m[$VERIFICATION_URI]\033[0m, and authenticate with the UserCode:[$USER_CODE] \n the associated device code for this request will expires in $EXPIRES_IN seconds.\n"
+
+# Poll the token endpoint
+TOKEN_URL="https://localhost:9443/realms/calm-hub-realm/protocol/openid-connect/token"
+ACCESS_TOKEN=""
+POLL_INTERVAL=15 #Seconds
+
+poll_token() {
+  while true; do
+    RESPONSE=$(curl --insecure -s -X POST "$TOKEN_URL" \
+      -H "Content-Type: application/x-www-form-urlencoded" \
+      -d "client_id=$CLIENT_ID" \
+      -d "device_code=$DEVICE_CODE" \
+      -d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
+      -d "scope=$SCOPE")
+
+    ERROR=$(echo "$RESPONSE" | jq -r '.error')
+    if [[ "$ERROR" == "authorization_pending" ]]; then
+      echo "Waiting for user authorization..."
+      sleep $POLL_INTERVAL
+    elif [[ "$ERROR" == "expired_token" ]]; then
+      echo "Device code expired. Restart the flow."
+      exit 1
+    elif [[ "$ERROR" == "slow_down" ]]; then
+      echo "Server requested slower polling. "
+      POLL_INTERVAL=$((POLL_INTERVAL + 10))
+      sleep $POLL_INTERVAL
+    else
+      ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r '.access_token')
+      echo "Access Token: $ACCESS_TOKEN"
+      break;
+    fi
+  done
+}
+
+#Start token polling
+poll_token
+
+echo -e "\nPress enter to get patterns"
+read
+if [[ -n $ACCESS_TOKEN ]]; then
+  curl --insecure -v "https://localhost:8443/calm/namespaces/finos/patterns" \
+    -H "Content-Type: application/json" \
+    -H "Authorization: Bearer $ACCESS_TOKEN"
+fi
+#Reference: https://github.com/keycloak/keycloak-community/blob/main/design/oauth2-device-authorization-grant.md
\ No newline at end of file
diff --git a/calm-hub/keycloak-dev/docker-compose.yml b/calm-hub/keycloak-dev/docker-compose.yml
new file mode 100644
index 000000000..781a6abba
--- /dev/null
+++ b/calm-hub/keycloak-dev/docker-compose.yml
@@ -0,0 +1,17 @@
+services:
+  keycloak:
+    image: quay.io/keycloak/keycloak:26.1
+    container_name: keycloak
+    ports:
+      - "9443:8443"
+    environment:
+      KC_BOOTSTRAP_ADMIN_USERNAME: admin
+      KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD}
+      KC_HTTPS_PORT: 8443
+      KC_HTTPS_CERTIFICATE_FILE: /opt/keycloak/certs/cert.pem
+      KC_HTTPS_CERTIFICATE_KEY_FILE: /opt/keycloak/certs/key.pem
+    volumes:
+      - ./imports:/opt/keycloak/data/import
+      - ./certs:/opt/keycloak/certs
+    command: start-dev --import-realm
+    restart: unless-stopped
\ No newline at end of file
diff --git a/calm-hub/keycloak-dev/imports/realm.json b/calm-hub/keycloak-dev/imports/realm.json
new file mode 100644
index 000000000..753613bc5
--- /dev/null
+++ b/calm-hub/keycloak-dev/imports/realm.json
@@ -0,0 +1,630 @@
+{
+  "realm": "calm-hub-realm",
+  "enabled": true,
+  "clients": [
+    {
+      "clientId": "calm-hub-producer-app",
+      "name": "calm-hub-producer-app",
+      "description": "Calm-hub resource server, clientId using in backend application",
+      "surrogateAuthRequired": false,
+      "enabled": true,
+      "alwaysDisplayInConsole": false,
+      "clientAuthenticatorType": "client-secret",
+      "bearerOnly": false,
+      "consentRequired": false,
+      "standardFlowEnabled": true,
+      "implicitFlowEnabled": false,
+      "directAccessGrantsEnabled": false,
+      "serviceAccountsEnabled": true,
+      "publicClient": false,
+      "frontchannelLogout": true,
+      "protocol": "openid-connect",
+      "attributes": {
+        "realm_client": "false",
+        "oidc.ciba.grant.enabled": "false",
+        "client.secret.creation.time": "1736115543",
+        "backchannel.logout.session.required": "true",
+        "post.logout.redirect.uris": "+",
+        "display.on.consent.screen": "false",
+        "oauth2.device.authorization.grant.enabled": "false",
+        "backchannel.logout.revoke.offline.tokens": "false"
+      },
+      "authenticationFlowBindingOverrides": {},
+      "fullScopeAllowed": true,
+      "protocolMappers": [
+        {
+          "name": "Client Host",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usersessionmodel-note-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.session.note": "clientHost",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "clientHost",
+            "jsonType.label": "String",
+            "userinfo.token.claim": "true"
+          }
+        },
+        {
+          "name": "Client IP Address",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usersessionmodel-note-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.session.note": "clientAddress",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "clientAddress",
+            "jsonType.label": "String",
+            "userinfo.token.claim": "true"
+          }
+        },
+        {
+          "name": "Client ID",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usersessionmodel-note-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.session.note": "clientId",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "clientId",
+            "jsonType.label": "String",
+            "userinfo.token.claim": "true"
+          }
+        }
+      ],
+      "defaultClientScopes": [
+        "service_account",
+        "basic"
+      ],
+      "optionalClientScopes": [
+        "web-origins",
+        "profile"
+      ]
+    },
+    {
+      "clientId": "calm-hub-authz-code",
+      "name": "calm-hub-authz-code",
+      "enabled": true,
+      "protocol": "openid-connect",
+      "publicClient": true,
+      "standardFlowEnabled": true,
+      "directAccessGrantsEnabled": false,
+      "serviceAccountsEnabled": false,
+      "clientAuthenticatorType": "client-secret",
+      "redirectUris": [
+        "https://localhost:8443"
+      ],
+      "webOrigins": [
+        "https://localhost:8443",
+        "*"
+      ],
+      "notBefore": 0,
+      "bearerOnly": false,
+      "consentRequired": false,
+      "implicitFlowEnabled": false,
+      "frontchannelLogout": true,
+      "attributes": {
+        "access.token.lifespan": "3600",
+        "login_theme": "keycloak",
+        "post.logout.redirect.uris": "https://localhost:8443",
+        "oauth2.device.authorization.grant.enabled": "false",
+        "backchannel.logout.revoke.offline.tokens": "false",
+        "use.refresh.tokens": "true",
+        "tls-client-certificate-bound-access-tokens": "false",
+        "realm_client": "false",
+        "oidc.ciba.grant.enabled": "false",
+        "backchannel.logout.session.required": "true",
+        "client_credentials.use_refresh_token": "false",
+        "acr.loa.map": "{}",
+        "require.pushed.authorization.requests": "false",
+        "display.on.consent.screen": "false",
+        "pkce.code.challenge.method": "S256",
+        "token.response.type.bearer.lower-case": "false"
+      },
+      "authenticationFlowBindingOverrides": {},
+      "fullScopeAllowed": true,
+      "optionalClientScopes": [
+        "adrs:read",
+        "adrs:all",
+        "architectures:read",
+        "email",
+        "web-origins",
+        "profile"
+      ]
+    },
+    {
+      "clientId": "calm-hub-device-flow",
+      "name": "calm-hub-device-flow",
+      "description": "Client for Device Flow",
+      "surrogateAuthRequired": false,
+      "enabled": true,
+      "alwaysDisplayInConsole": true,
+      "clientAuthenticatorType": "client-secret",
+      "redirectUris": [],
+      "webOrigins": [],
+      "notBefore": 0,
+      "bearerOnly": false,
+      "consentRequired": false,
+      "standardFlowEnabled": false,
+      "implicitFlowEnabled": false,
+      "directAccessGrantsEnabled": false,
+      "serviceAccountsEnabled": false,
+      "publicClient": true,
+      "frontchannelLogout": true,
+      "protocol": "openid-connect",
+      "attributes": {
+        "realm_client": "false",
+        "oidc.ciba.grant.enabled": "false",
+        "backchannel.logout.session.required": "true",
+        "post.logout.redirect.uris": "+",
+        "display.on.consent.screen": "false",
+        "oauth2.device.authorization.grant.enabled": "true",
+        "backchannel.logout.revoke.offline.tokens": "false"
+      },
+      "authenticationFlowBindingOverrides": {},
+      "fullScopeAllowed": true,
+      "nodeReRegistrationTimeout": -1,
+      "defaultClientScopes": [
+        "profile"
+      ],
+      "optionalClientScopes": [
+        "adrs:read",
+        "architectures:all"
+      ]
+    }
+  ],
+  "clientScopes": [
+    {
+      "name": "service_account",
+      "description": "Specific scope for a client enabled for service accounts",
+      "protocol": "openid-connect",
+      "attributes": {
+        "include.in.token.scope": "false",
+        "display.on.consent.screen": "false"
+      },
+      "protocolMappers": [
+        {
+          "name": "Client IP Address",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usersessionmodel-note-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.session.note": "clientAddress",
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "clientAddress",
+            "jsonType.label": "String"
+          }
+        },
+        {
+          "name": "Client ID",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usersessionmodel-note-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.session.note": "client_id",
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "client_id",
+            "jsonType.label": "String"
+          }
+        },
+        {
+          "name": "Client Host",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usersessionmodel-note-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.session.note": "clientHost",
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "clientHost",
+            "jsonType.label": "String"
+          }
+        }
+      ]
+    },
+    {
+      "name": "basic",
+      "description": "OpenID Connect scope for add all basic claims to the token",
+      "protocol": "openid-connect",
+      "attributes": {
+        "include.in.token.scope": "false",
+        "display.on.consent.screen": "false"
+      },
+      "protocolMappers": [
+        {
+          "name": "auth_time",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usersessionmodel-note-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.session.note": "AUTH_TIME",
+            "introspection.token.claim": "true",
+            "userinfo.token.claim": "true",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "auth_time",
+            "jsonType.label": "long"
+          }
+        },
+        {
+          "name": "sub",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-sub-mapper",
+          "consentRequired": false,
+          "config": {
+            "introspection.token.claim": "true",
+            "access.token.claim": "true"
+          }
+        }
+      ]
+    },
+    {
+      "name": "web-origins",
+      "description": "OpenID Connect scope for add allowed web origins to the access token",
+      "protocol": "openid-connect",
+      "attributes": {
+        "include.in.token.scope": "false",
+        "consent.screen.text": "",
+        "display.on.consent.screen": "false"
+      },
+      "protocolMappers": [
+        {
+          "name": "allowed web origins",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-allowed-origins-mapper",
+          "consentRequired": false,
+          "config": {}
+        }
+      ]
+    },
+    {
+      "name": "profile",
+      "description": "OpenID Connect built-in scope: profile",
+      "protocol": "openid-connect",
+      "attributes": {
+        "include.in.token.scope": "true",
+        "consent.screen.text": "${profileScopeConsentText}",
+        "display.on.consent.screen": "true"
+      },
+      "protocolMappers": [
+        {
+          "name": "given name",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-property-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.attribute": "firstName",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "given_name",
+            "jsonType.label": "String",
+            "userinfo.token.claim": "true"
+          }
+        },
+        {
+          "name": "profile",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.attribute": "profile",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "profile",
+            "jsonType.label": "String",
+            "userinfo.token.claim": "true"
+          }
+        },
+        {
+          "name": "family name",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-property-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.attribute": "lastName",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "family_name",
+            "jsonType.label": "String",
+            "userinfo.token.claim": "true"
+          }
+        },
+        {
+          "name": "middle name",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.attribute": "middleName",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "middle_name",
+            "jsonType.label": "String",
+            "userinfo.token.claim": "true"
+          }
+        },
+        {
+          "name": "website",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.attribute": "website",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "website",
+            "jsonType.label": "String",
+            "userinfo.token.claim": "true"
+          }
+        },
+        {
+          "name": "zoneinfo",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.attribute": "zoneinfo",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "zoneinfo",
+            "jsonType.label": "String",
+            "userinfo.token.claim": "true"
+          }
+        },
+        {
+          "name": "full name",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-full-name-mapper",
+          "consentRequired": false,
+          "config": {
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "userinfo.token.claim": "true"
+          }
+        },
+        {
+          "name": "birthdate",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.attribute": "birthdate",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "birthdate",
+            "jsonType.label": "String",
+            "userinfo.token.claim": "true"
+          }
+        },
+        {
+          "name": "updated at",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.attribute": "updatedAt",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "updated_at",
+            "jsonType.label": "long",
+            "userinfo.token.claim": "true"
+          }
+        },
+        {
+          "name": "locale",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.attribute": "locale",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "locale",
+            "jsonType.label": "String",
+            "userinfo.token.claim": "true"
+          }
+        },
+        {
+          "name": "username",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-property-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.attribute": "username",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "preferred_username",
+            "jsonType.label": "String",
+            "userinfo.token.claim": "true"
+          }
+        },
+        {
+          "name": "gender",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.attribute": "gender",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "gender",
+            "jsonType.label": "String",
+            "userinfo.token.claim": "true"
+          }
+        },
+        {
+          "name": "nickname",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.attribute": "nickname",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "nickname",
+            "jsonType.label": "String",
+            "userinfo.token.claim": "true"
+          }
+        },
+        {
+          "name": "picture",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-usermodel-attribute-mapper",
+          "consentRequired": false,
+          "config": {
+            "user.attribute": "picture",
+            "id.token.claim": "true",
+            "access.token.claim": "true",
+            "claim.name": "picture",
+            "jsonType.label": "String",
+            "userinfo.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": "adrs:all",
+      "protocol": "openid-connect",
+      "protocolMappers": [
+        {
+          "name": "audience",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-audience-mapper",
+          "config": {
+            "included.client.audience": "calm-hub-producer-app",
+            "id.token.claim": "true",
+            "access.token.claim": "true"
+          }
+        }
+      ]
+    },
+    {
+      "name": "architectures:read",
+      "protocol": "openid-connect",
+      "protocolMappers": [
+        {
+          "name": "audience",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-audience-mapper",
+          "config": {
+            "included.client.audience": "calm-hub-producer-app",
+            "id.token.claim": "true",
+            "access.token.claim": "true"
+          }
+        }
+      ]
+    },
+    {
+      "name": "architectures:all",
+      "protocol": "openid-connect",
+      "protocolMappers": [
+        {
+          "name": "audience",
+          "protocol": "openid-connect",
+          "protocolMapper": "oidc-audience-mapper",
+          "config": {
+            "included.client.audience": "calm-hub-producer-app",
+            "id.token.claim": "true",
+            "access.token.claim": "true"
+          }
+        }
+      ]
+    }
+  ],
+  "defaultOptionalClientScopes": [
+    "adrs:read",
+    "adrs:all",
+    "architectures:read",
+    "architectures:all",
+    "offline_access",
+    "service_account"
+  ],
+  "browserSecurityHeaders": {
+    "contentSecurityPolicyReportOnly": "",
+    "xContentTypeOptions": "nosniff",
+    "referrerPolicy": "no-referrer",
+    "xRobotsTag": "none",
+    "xFrameOptions": "SAMEORIGIN",
+    "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';",
+    "xXSSProtection": "1; mode=block",
+    "strictTransportSecurity": "max-age=31536000; includeSubDomains"
+  },
+  "scopeMappings": [
+    {
+      "clientScope": "adrs:read",
+      "roles": [
+        "calm-hub-readonly"
+      ]
+    },
+    {
+      "clientScope": "architectures:read",
+      "roles": [
+        "calm-hub-readonly"
+      ]
+    }
+  ],
+  "roles": {
+    "realm": [
+      {
+        "name": "calm-hub-readonly",
+        "clientRole": false,
+        "description": "Readonly role with access to read scopes",
+        "composite": false,
+        "attributes": {}
+      }
+    ]
+  },
+  "users": [
+    {
+      "username": "demo",
+      "enabled": true,
+      "email": "demo@calm-hub.finos.com",
+      "firstName": "CalmHub",
+      "lastName": "Readonly",
+      "attributes": {
+        "department": "Technology",
+        "location": "HQ"
+      },
+      "credentials": [
+        {
+          "type": "password",
+          "value": "changeme",
+          "temporary": true
+        }
+      ],
+      "realmRoles": [
+        "calm-hub-readonly"
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/calm-hub/pom.xml b/calm-hub/pom.xml
index a691a5756..4beb71933 100644
--- a/calm-hub/pom.xml
+++ b/calm-hub/pom.xml
@@ -20,11 +20,11 @@
   </properties>
 
   <dependencies>
-  <dependency>
-  <groupId>org.apache.commons</groupId>
-  <artifactId>commons-text</artifactId>
-  <version>1.13.0</version>
-  </dependency>
+    <dependency>
+      <groupId>org.apache.commons</groupId>
+      <artifactId>commons-text</artifactId>
+      <version>1.13.0</version>
+    </dependency>
     <dependency>
       <groupId>io.quarkus</groupId>
       <artifactId>quarkus-jsonb</artifactId>
@@ -58,6 +58,14 @@
       <artifactId>owasp-java-html-sanitizer</artifactId>
       <version>20240325.1</version>
     </dependency>
+    <dependency>
+      <groupId>io.quarkus</groupId>
+      <artifactId>quarkus-oidc</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>io.quarkus</groupId>
+      <artifactId>quarkus-security</artifactId>
+    </dependency>
 
     <!-- Testing -->
     <dependency>
@@ -258,6 +266,12 @@
           <version>1.20.6</version>
           <scope>test</scope>
         </dependency>
+        <dependency>
+          <groupId>com.github.dasniko</groupId>
+          <artifactId>testcontainers-keycloak</artifactId>
+          <version>3.6.0</version>
+          <scope>test</scope>
+        </dependency>
       </dependencies>
       <build>
         <plugins>
diff --git a/calm-hub/src/integration-test/java/integration/IntegrationTestSecureProfile.java b/calm-hub/src/integration-test/java/integration/IntegrationTestSecureProfile.java
new file mode 100644
index 000000000..4486bb342
--- /dev/null
+++ b/calm-hub/src/integration-test/java/integration/IntegrationTestSecureProfile.java
@@ -0,0 +1,40 @@
+package integration;
+
+import io.quarkus.test.common.QuarkusTestResource;
+import io.quarkus.test.junit.QuarkusTestProfile;
+
+import java.util.Map;
+import java.util.Set;
+
+@QuarkusTestResource.List({
+        @QuarkusTestResource(EndToEndResource.class),
+        @QuarkusTestResource(KeycloakTestResource.class)
+})
+public class IntegrationTestSecureProfile implements QuarkusTestProfile {
+
+    @Override
+    public Set<Class<?>> getEnabledAlternatives() {
+        // Optionally, return specific classes you want enabled only for this profile
+        return Set.of();
+    }
+
+    @Override
+    public String getConfigProfile() {
+        // Optional: specify a custom profile name if needed
+        return "secure";
+    }
+
+    @Override
+    public Map<String, String> getConfigOverrides() {
+        // override the following secure profile's properties to start the application container with http
+        return Map.of(
+                "quarkus.profile", "secure",
+                "quarkus.http.ssl-port", "0",
+                "quarkus.http.insecure-requests", "enabled",
+                "quarkus.http.port", "8080",
+                "quarkus.http.ssl.certificate.key-files","",
+                "quarkus.http.ssl.certificate.key-store-file-type","",
+                "quarkus.http.ssl.certificate.files",""
+        );
+    }
+}
\ No newline at end of file
diff --git a/calm-hub/src/integration-test/java/integration/KeycloakTestResource.java b/calm-hub/src/integration-test/java/integration/KeycloakTestResource.java
new file mode 100644
index 000000000..e77f336ce
--- /dev/null
+++ b/calm-hub/src/integration-test/java/integration/KeycloakTestResource.java
@@ -0,0 +1,39 @@
+package integration;
+
+import dasniko.testcontainers.keycloak.KeycloakContainer;
+import io.quarkus.test.common.QuarkusTestResourceLifecycleManager;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Map;
+import java.util.Objects;
+
+public class KeycloakTestResource implements QuarkusTestResourceLifecycleManager {
+    private KeycloakContainer keycloakContainer;
+    private static final Logger logger = LoggerFactory.getLogger(KeycloakTestResource.class);
+
+    @Override
+    public Map<String, String> start() {
+        if (Objects.isNull(keycloakContainer)) {
+            keycloakContainer = new KeycloakContainer("quay.io/keycloak/keycloak:26.1")
+                    .withBootstrapAdminDisabled()
+                    .withAdminUsername("admin")
+                    .withAdminPassword("admin")
+                    .withRealmImportFile("/secure-profile/realm.json")
+                    .withContextPath("/auth");
+        }
+
+        logger.info("Starting keycloakContainer container");
+        keycloakContainer.start();
+        String authServerUrl = keycloakContainer.getAuthServerUrl() + "/realms/calm-hub-realm";
+        logger.info("quarkus.oidc.auth-server-url: {}", authServerUrl);
+
+        System.setProperty("quarkus.oidc.auth-server-url", authServerUrl);
+        return Map.of("quarkus.oidc.auth-server-url", authServerUrl);
+    }
+
+    @Override
+    public void stop() {
+        keycloakContainer.stop();
+    }
+}
\ No newline at end of file
diff --git a/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java b/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java
new file mode 100644
index 000000000..d65503a0b
--- /dev/null
+++ b/calm-hub/src/integration-test/java/integration/PermittedScopesIntegration.java
@@ -0,0 +1,142 @@
+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.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;
+
+import java.util.ArrayList;
+
+import static integration.MongoSetup.counterSetup;
+import static integration.MongoSetup.namespaceSetup;
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.hasItem;
+
+@QuarkusTest
+@TestProfile(IntegrationTestSecureProfile.class)
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class PermittedScopesIntegration {
+
+    private static final Logger logger = LoggerFactory.getLogger(PermittedScopesIntegration.class);
+    private static final String PATTERN = "{\"name\": \"demo-pattern\"}";
+
+    @BeforeEach
+    void setupPatterns() {
+        String mongoUri = ConfigProvider.getConfig().getValue("quarkus.mongodb.connection-string", String.class);
+
+        // Safeguard: Fail fast if URI is not set
+        if (mongoUri == null || mongoUri.isBlank()) {
+            logger.error("MongoDB URI is not set. Check the EndToEndResource configuration.");
+            throw new IllegalStateException("MongoDB URI is not set. Check the EndToEndResource configuration.");
+        }
+
+        try (MongoClient mongoClient = MongoClients.create(mongoUri)) {
+            MongoDatabase database = mongoClient.getDatabase("calmSchemas");
+            if (!database.listCollectionNames().into(new ArrayList<>()).contains("patterns")) {
+                database.createCollection("patterns");
+                database.getCollection("patterns").insertOne(
+                        new Document("namespace", "finos").append("patterns", new ArrayList<>())
+                );
+            }
+            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 = getAccessToken(authServerUrl, CalmHubScopes.ARCHITECTURES_READ);
+        given()
+                .auth().oauth2(accessToken)
+                .when().get("/calm/namespaces/finos/patterns")
+                .then()
+                .statusCode(200)
+                .body("values", empty());
+    }
+
+    private String getAccessToken(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)
+                .when()
+                .post(authServerUrl.concat("/protocol/openid-connect/token"))
+                .then()
+                .statusCode(200)
+                .extract()
+                .path("access_token");
+        return accessToken;
+    }
+
+    @Test
+    @Order(2)
+    void end_to_end_forbidden_create_pattern_when_matching_scopes_notfound() {
+        String authServerUrl = ConfigProvider.getConfig().getValue("quarkus.oidc.auth-server-url", String.class);
+        logger.info("authServerUrl {}", authServerUrl);
+        String accessToken = getAccessToken(authServerUrl, CalmHubScopes.ARCHITECTURES_READ);
+
+        given()
+                .auth().oauth2(accessToken)
+                .body(PATTERN)
+                .header("Content-Type", "application/json")
+                .when().post("/calm/namespaces/finos/patterns")
+                .then()
+                .statusCode(403);
+    }
+
+    @Test
+    @Order(3)
+    void end_to_end_unauthorize_create_pattern_request_with_no_access_token() {
+
+        given()
+                .body(PATTERN)
+                .header("Content-Type", "application/json")
+                .when().post("/calm/namespaces/finos/patterns")
+                .then()
+                .statusCode(401);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {CalmHubScopes.ADRS_READ, CalmHubScopes.ADRS_ALL,
+            CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ})
+    @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);
+        logger.info("authServerUrl {}", authServerUrl);
+        String accessToken = getAccessToken(authServerUrl, scope);
+        given()
+                .auth().oauth2(accessToken)
+                .when().get("/calm/namespaces")
+                .then()
+                .statusCode(200)
+                .body("values", hasItem("finos"));
+    }
+
+    @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 = getAccessToken(authServerUrl, "deny:all");
+        given()
+                .auth().oauth2(accessToken)
+                .when().get("/calm/namespaces")
+                .then()
+                .statusCode(403);
+    }
+}
\ No newline at end of file
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 a21fc4d7d..c18b6c8d3 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
@@ -23,6 +23,8 @@
 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.security.CalmHubScopes;
+import org.finos.calm.security.PermittedScopes;
 import org.finos.calm.store.AdrStore;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -59,6 +61,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})
     public Response getAdrsForNamespace(@PathParam("namespace") String namespace) {
         try {
             return Response.ok(new ValueWrapper<>(store.getAdrsForNamespace(namespace))).build();
@@ -82,6 +85,7 @@ public Response getAdrsForNamespace(@PathParam("namespace") String namespace) {
             summary = "Create ADR for namespace",
             description = "Creates an ADR for a given namespace with an allocated ID and revision 1"
     )
+    @PermittedScopes({CalmHubScopes.ADRS_ALL})
     public Response createAdrForNamespace(@PathParam("namespace") String namespace, NewAdrRequest newAdrRequest) {
         Adr adr = new Adr.AdrBuilder(newAdrRequest)
                 .setStatus(Status.draft)
@@ -118,6 +122,7 @@ public Response createAdrForNamespace(@PathParam("namespace") String namespace,
             summary = "Update ADR for namespace",
             description = "Updates an ADR for a given namespace. Creates a new revision."
     )
+    @PermittedScopes({CalmHubScopes.ADRS_ALL})
     public Response updateAdrForNamespace(@PathParam("namespace") String namespace, @PathParam("adrId") int adrId, NewAdrRequest newAdrRequest) {
         Adr adr = new Adr.AdrBuilder(newAdrRequest)
                 .setUpdateDateTime(LocalDateTime.now())
@@ -156,6 +161,7 @@ public Response updateAdrForNamespace(@PathParam("namespace") String namespace,
                     content = @Content(schema = @Schema(implementation = AdrMeta.class))
             )
     })
+    @PermittedScopes({CalmHubScopes.ADRS_ALL, CalmHubScopes.ADRS_READ})
     public Response getAdr(@PathParam("namespace") String namespace, @PathParam("adrId") int adrId) {
         AdrMeta adrMeta = new AdrMeta.AdrMetaBuilder()
                 .setNamespace(namespace)
@@ -183,6 +189,7 @@ public Response getAdr(@PathParam("namespace") String namespace, @PathParam("adr
             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})
     public Response getAdrRevisions(@PathParam("namespace") String namespace, @PathParam("adrId") int adrId) {
         AdrMeta adrMeta = new AdrMeta.AdrMetaBuilder()
                 .setNamespace(namespace)
@@ -217,6 +224,7 @@ public Response getAdrRevisions(@PathParam("namespace") String namespace, @PathP
                     content = @Content(schema = @Schema(implementation = AdrMeta.class))
             )
     })
+    @PermittedScopes({CalmHubScopes.ADRS_ALL, CalmHubScopes.ADRS_READ})
     public Response getAdrRevision(@PathParam("namespace") String namespace, @PathParam("adrId") int adrId, @PathParam("revision") int revision) {
         AdrMeta adrMeta = new AdrMeta.AdrMetaBuilder()
                 .setNamespace(namespace)
@@ -247,6 +255,7 @@ public Response getAdrRevision(@PathParam("namespace") String namespace, @PathPa
             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})
     public Response updateAdrStatusForNamespace(@PathParam("namespace") String namespace, @PathParam("adrId") int adrId, @PathParam("status") Status status) throws URISyntaxException {
 
         AdrMeta adrMeta = new AdrMeta.AdrMetaBuilder()
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 b23279e4c..c1b7cbab6 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
@@ -11,6 +11,8 @@
 import org.finos.calm.domain.exception.ArchitectureVersionExistsException;
 import org.finos.calm.domain.exception.ArchitectureVersionNotFoundException;
 import org.finos.calm.domain.exception.NamespaceNotFoundException;
+import org.finos.calm.security.CalmHubScopes;
+import org.finos.calm.security.PermittedScopes;
 import org.finos.calm.store.ArchitectureStore;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -47,6 +49,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})
     public Response getArchitecturesForNamespace(@PathParam("namespace") String namespace) {
         try {
             return Response.ok(new ValueWrapper<>(store.getArchitecturesForNamespace(namespace))).build();
@@ -64,6 +67,7 @@ public Response getArchitecturesForNamespace(@PathParam("namespace") String name
             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})
     public Response createArchitectureForNamespace(@PathParam("namespace") String namespace, String architectureJson) throws URISyntaxException {
         Architecture architecture = new Architecture.ArchitectureBuilder()
                 .setNamespace(namespace)
@@ -88,6 +92,7 @@ public Response createArchitectureForNamespace(@PathParam("namespace") String na
             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})
     public Response getArchitectureVersions(@PathParam("namespace") String namespace, @PathParam("architectureId") int architectureId) {
         Architecture architecture = new Architecture.ArchitectureBuilder()
                 .setNamespace(namespace)
@@ -112,6 +117,7 @@ public Response getArchitectureVersions(@PathParam("namespace") String namespace
             summary = "Retrieve a specific architecture at a given version",
             description = "Retrieve architectures at a specific version"
     )
+    @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ})
     public Response getArchitecture(@PathParam("namespace") String namespace, @PathParam("architectureId") int architectureId, @PathParam("version") String version) {
         Architecture architecture = new Architecture.ArchitectureBuilder()
                 .setNamespace(namespace)
@@ -137,6 +143,7 @@ public Response getArchitecture(@PathParam("namespace") String namespace, @PathP
     @Path("{namespace}/architectures/{architectureId}/versions/{version}")
     @Consumes(MediaType.APPLICATION_JSON)
     @Produces(MediaType.APPLICATION_JSON)
+    @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL})
     public Response createVersionedArchitecture(@PathParam("namespace") String namespace, @PathParam("architectureId") int architectureId, @PathParam("version") String version, String architectureJson) throws URISyntaxException {
         Architecture architecture = new Architecture.ArchitectureBuilder()
                 .setNamespace(namespace)
@@ -168,6 +175,7 @@ public Response createVersionedArchitecture(@PathParam("namespace") String names
             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})
     public Response updateVersionedArchitecture(@PathParam("namespace") String namespace, @PathParam("architectureId") int architectureId, @PathParam("version") String version, String architectureJson) throws URISyntaxException {
         Architecture architecture = new Architecture.ArchitectureBuilder()
                 .setNamespace(namespace)
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 d0e1ef4ff..e911509c9 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
@@ -6,6 +6,8 @@
 import jakarta.ws.rs.core.Response;
 import org.eclipse.microprofile.openapi.annotations.Operation;
 import org.finos.calm.domain.ValueWrapper;
+import org.finos.calm.security.CalmHubScopes;
+import org.finos.calm.security.PermittedScopes;
 import org.finos.calm.store.CoreSchemaStore;
 import org.apache.commons.text.StringEscapeUtils;
 import java.util.ArrayList;
@@ -25,6 +27,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})
     public ValueWrapper<String> schemaVersions() {
         return new ValueWrapper<>(coreSchemaStore.getVersions());
     }
@@ -35,6 +38,7 @@ public ValueWrapper<String> 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})
     public Response schemasForVersion(@PathParam("version") String version) {
         Map<String, Object> schemas = coreSchemaStore.getSchemasForVersion(version);
         if (schemas == null) {
@@ -51,6 +55,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})
     public Response getSchema(@PathParam("version") String version,
                               @PathParam("schemaName") String schemaName) {
         Map<String, Object> schemas = coreSchemaStore.getSchemasForVersion(version);
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 b8f1f79a1..2b744b0bf 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,6 +11,8 @@
 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.security.CalmHubScopes;
+import org.finos.calm.security.PermittedScopes;
 import org.finos.calm.store.FlowStore;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -40,6 +42,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})
     public Response getFlowsForNamespace(@PathParam("namespace") String namespace) {
         try {
             return Response.ok(new ValueWrapper<>(store.getFlowsForNamespace(namespace))).build();
@@ -57,6 +60,7 @@ public Response getFlowsForNamespace(@PathParam("namespace") String namespace) {
             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})
     public Response createFlowForNamespace(@PathParam("namespace") String namespace, String flowJson) throws URISyntaxException {
         Flow flow = new Flow.FlowBuilder()
                 .setNamespace(namespace)
@@ -83,6 +87,7 @@ public Response createFlowForNamespace(@PathParam("namespace") String namespace,
             summary = "Retrieve the latest flow version",
             description = "Fetch the latest version of the flow by flowId"
     )
+    @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ})
     public Response getLatestFlow(@PathParam("namespace") String namespace, @PathParam("flowId") int flowId) {
         Flow flow = new Flow.FlowBuilder()
                 .setNamespace(namespace)
@@ -111,6 +116,7 @@ public Response getLatestFlow(@PathParam("namespace") String namespace, @PathPar
             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})
     public Response getFlowVersions(@PathParam("namespace") String namespace, @PathParam("flowId") int flowId) {
         Flow flow = new Flow.FlowBuilder()
                 .setNamespace(namespace)
@@ -135,6 +141,7 @@ public Response getFlowVersions(@PathParam("namespace") String namespace, @PathP
             summary = "Retrieve a specific flow at a given version",
             description = "Retrieve flows at a specific version"
     )
+    @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ})
     public Response getFlow(@PathParam("namespace") String namespace, @PathParam("flowId") int flowId, @PathParam("version") String version) {
         return getFlowInternal(namespace, flowId, version);
     }
@@ -164,7 +171,9 @@ private Response getFlowInternal(String namespace, int flowId, String version) {
     @Path("{namespace}/flows/{flowId}/versions/{version}")
     @Consumes(MediaType.APPLICATION_JSON)
     @Produces(MediaType.APPLICATION_JSON)
-    public Response createVersionedFlow(@PathParam("namespace") String namespace, @PathParam("flowId") int flowId, @PathParam("version") String version, String flowJson) throws URISyntaxException {
+    @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL})
+    public Response createVersionedFlow(@PathParam("namespace") String namespace, @PathParam("flowId") int flowId,
+                                        @PathParam("version") String version, String flowJson) throws URISyntaxException {
         Flow flow = new Flow.FlowBuilder()
                 .setNamespace(namespace)
                 .setId(flowId)
@@ -195,7 +204,9 @@ public Response createVersionedFlow(@PathParam("namespace") String namespace, @P
             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"
     )
-    public Response updateVersionedFlow(@PathParam("namespace") String namespace, @PathParam("flowId") int flowId, @PathParam("version") String version, String flowJson) throws URISyntaxException {
+    @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL})
+    public Response updateVersionedFlow(@PathParam("namespace") String namespace, @PathParam("flowId") int flowId,
+                                        @PathParam("version") String version, String flowJson) throws URISyntaxException {
         Flow flow = new Flow.FlowBuilder()
                 .setNamespace(namespace)
                 .setId(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 6e209e693..ae6ce4a21 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
@@ -4,6 +4,8 @@
 import jakarta.ws.rs.Path;
 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.NamespaceStore;
 
 @Path("/calm/namespaces")
@@ -20,6 +22,8 @@ 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})
     public ValueWrapper<String> namespaces() {
         return new ValueWrapper<>(namespaceStore.getNamespaces());
     }
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 02e8caf67..79106f89c 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
@@ -11,6 +11,8 @@
 import org.finos.calm.domain.exception.PatternNotFoundException;
 import org.finos.calm.domain.exception.PatternVersionExistsException;
 import org.finos.calm.domain.exception.PatternVersionNotFoundException;
+import org.finos.calm.security.CalmHubScopes;
+import org.finos.calm.security.PermittedScopes;
 import org.finos.calm.store.PatternStore;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -39,6 +41,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})
     public Response getPatternsForNamespace(@PathParam("namespace") String namespace) {
         try {
             return Response.ok(new ValueWrapper<>(store.getPatternsForNamespace(namespace))).build();
@@ -56,6 +59,7 @@ public Response getPatternsForNamespace(@PathParam("namespace") String namespace
             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})
     public Response createPatternForNamespace(@PathParam("namespace") String namespace, String patternJson) throws URISyntaxException {
         Pattern pattern = new Pattern.PatternBuilder()
                 .setNamespace(namespace)
@@ -80,6 +84,7 @@ public Response createPatternForNamespace(@PathParam("namespace") String namespa
             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})
     public Response getPatternVersions(@PathParam("namespace") String namespace, @PathParam("patternId") int patternId) {
         Pattern pattern = new Pattern.PatternBuilder()
                 .setNamespace(namespace)
@@ -104,7 +109,9 @@ public Response getPatternVersions(@PathParam("namespace") String namespace, @Pa
             summary = "Retrieve a specific pattern at a given version",
             description = "Retrieve patterns at a specific version"
     )
-    public Response getPattern(@PathParam("namespace") String namespace, @PathParam("patternId") int patternId, @PathParam("version") String version) {
+    @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL, CalmHubScopes.ARCHITECTURES_READ})
+    public Response getPattern(@PathParam("namespace") String namespace, @PathParam("patternId") int patternId,
+                               @PathParam("version") String version) {
         Pattern pattern = new Pattern.PatternBuilder()
                 .setNamespace(namespace)
                 .setId(patternId)
@@ -129,7 +136,9 @@ public Response getPattern(@PathParam("namespace") String namespace, @PathParam(
     @Path("{namespace}/patterns/{patternId}/versions/{version}")
     @Consumes(MediaType.APPLICATION_JSON)
     @Produces(MediaType.APPLICATION_JSON)
-    public Response createVersionedPattern(@PathParam("namespace") String namespace, @PathParam("patternId") int patternId, @PathParam("version") String version, String patternJson) throws URISyntaxException {
+    @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL})
+    public Response createVersionedPattern(@PathParam("namespace") String namespace, @PathParam("patternId") int patternId,
+                                           @PathParam("version") String version, String patternJson) throws URISyntaxException {
         Pattern pattern = new Pattern.PatternBuilder()
                 .setNamespace(namespace)
                 .setId(patternId)
@@ -160,7 +169,9 @@ public Response createVersionedPattern(@PathParam("namespace") String namespace,
             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"
     )
-    public Response updateVersionedPattern(@PathParam("namespace") String namespace, @PathParam("patternId") int patternId, @PathParam("version") String version, String patternJson) throws URISyntaxException {
+    @PermittedScopes({CalmHubScopes.ARCHITECTURES_ALL})
+    public Response updateVersionedPattern(@PathParam("namespace") String namespace, @PathParam("patternId") int patternId,
+                                           @PathParam("version") String version, String patternJson) throws URISyntaxException {
         Pattern pattern = new Pattern.PatternBuilder()
                 .setNamespace(namespace)
                 .setId(patternId)
@@ -168,7 +179,7 @@ public Response updateVersionedPattern(@PathParam("namespace") String namespace,
                 .setPattern(patternJson)
                 .build();
 
-        if(!allowPutOperations) {
+        if (!allowPutOperations) {
             return Response.status(Response.Status.FORBIDDEN).entity("This Calm Hub does not support PUT operations").build();
         }
 
diff --git a/calm-hub/src/main/java/org/finos/calm/security/AccessControlFilter.java b/calm-hub/src/main/java/org/finos/calm/security/AccessControlFilter.java
new file mode 100644
index 000000000..0eb938b91
--- /dev/null
+++ b/calm-hub/src/main/java/org/finos/calm/security/AccessControlFilter.java
@@ -0,0 +1,92 @@
+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 Logger logger = LoggerFactory.getLogger(AccessControlFilter.class);
+
+    public AccessControlFilter(JsonWebToken jwt, ResourceInfo resourceInfo) {
+        this.jwt = jwt;
+        this.resourceInfo = resourceInfo;
+    }
+
+    @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());
+        }
+    }
+
+    /**
+     * 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<String> requiredScopesList = List.of(requiredScopes);
+        boolean hasMatch = requiredScopesList.stream()
+                .anyMatch(tokenScopes::contains);
+
+        if (hasMatch) {
+            logger.info("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/CalmHubScopes.java b/calm-hub/src/main/java/org/finos/calm/security/CalmHubScopes.java
new file mode 100644
index 000000000..912f96b4b
--- /dev/null
+++ b/calm-hub/src/main/java/org/finos/calm/security/CalmHubScopes.java
@@ -0,0 +1,31 @@
+package org.finos.calm.security;
+
+public class CalmHubScopes {
+
+    /**
+     * prevent instantiation
+     */
+    private CalmHubScopes() {
+        throw new UnsupportedOperationException("This is a utility class and cannot be instantiated");
+    }
+
+    /**
+     * Allows read operations on Flows, Patterns, Namespaces, and Architectures resources.
+     */
+    public static final String ARCHITECTURES_READ = "architectures:read";
+
+    /**
+     * Allows full access (read, write, delete) on Flows, Patterns, Namespaces, and Architectures resources.
+     */
+    public static final String ARCHITECTURES_ALL = "architectures:all";
+
+    /**
+     * 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";
+}
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
new file mode 100644
index 000000000..b30afe83b
--- /dev/null
+++ b/calm-hub/src/main/java/org/finos/calm/security/PermittedScopes.java
@@ -0,0 +1,15 @@
+package org.finos.calm.security;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Custom annotation to define required scopes for a REST endpoint.
+ */
+@Target({ ElementType.METHOD, ElementType.TYPE })
+@Retention(RetentionPolicy.RUNTIME)
+public @interface PermittedScopes {
+    String[] value();
+}
\ No newline at end of file
diff --git a/calm-hub/src/main/resources/application-secure.properties b/calm-hub/src/main/resources/application-secure.properties
new file mode 100644
index 000000000..3762a3de8
--- /dev/null
+++ b/calm-hub/src/main/resources/application-secure.properties
@@ -0,0 +1,14 @@
+quarkus.http.ssl-port=8443
+quarkus.http.insecure-requests=disabled
+quarkus.http.ssl.certificate.key-files=key.pem
+quarkus.http.ssl.certificate.key-store-file-type=PEM
+quarkus.http.ssl.certificate.files=cert.pem
+
+quarkus.http.auth.permission.secured.paths=/calm/*
+quarkus.http.auth.permission.secured.policy=authenticated
+
+#calm-hub has to configure with truststore to validate the IdP's server certs.
+quarkus.oidc.tls.verification=none
+quarkus.oidc.auth-server-url=https://localhost:9443/realms/calm-hub-realm
+quarkus.oidc.client-id=calm-hub-producer-app
+quarkus.oidc.token.audience=calm-hub-producer-app
\ 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 e3584e573..2e0a9a765 100644
--- a/calm-hub/src/main/resources/application.properties
+++ b/calm-hub/src/main/resources/application.properties
@@ -3,4 +3,7 @@ quarkus.smallrye-openapi.info-title=Calm Hub
 #If using in quarkus dev mode use this with local-dev/docker-compose.yml
 quarkus.mongodb.connection-string = mongodb://localhost:27017
 #quarkus.mongodb.connection-string = mongodb://mongodb:27017
-quarkus.mongodb.database = calmSchemas
\ No newline at end of file
+quarkus.mongodb.database = calmSchemas
+
+#Disable creating the keycloak container for default profile.
+quarkus.keycloak.devservices.enabled=false
\ No newline at end of file
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
new file mode 100644
index 000000000..c355e999c
--- /dev/null
+++ b/calm-hub/src/test/java/org/finos/calm/security/TestAccessControlFilterShould.java
@@ -0,0 +1,79 @@
+package org.finos.calm.security;
+
+import io.quarkus.test.junit.QuarkusTest;
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.container.ResourceInfo;
+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.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.never;
+
+@QuarkusTest
+public class TestAccessControlFilterShould {
+
+    @Mock
+    JsonWebToken jwt;
+    @Mock
+    ContainerRequestContext requestContext;
+    @Mock
+    ResourceInfo resourceInfo;
+
+    private AccessControlFilter accessControlFilter;
+
+    @BeforeEach
+    void setup() {
+        MockitoAnnotations.openMocks(this);
+        accessControlFilter = new AccessControlFilter(jwt, resourceInfo);
+    }
+
+    @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() throws NoSuchMethodException {
+        Method method = TestNamespaceResource.class.getMethod("createNamespace");
+        when(resourceInfo.getResourceMethod()).thenReturn(method);
+        when(jwt.getClaim("scope")).thenReturn("openid architectures:all");
+
+        accessControlFilter.filter(requestContext);
+        verify(requestContext, never()).abortWith(any());
+    }
+
+    @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");
+
+        accessControlFilter.filter(requestContext);
+        verify(requestContext)
+                .abortWith(argThat(response -> response.getStatus() == HttpStatus.SC_FORBIDDEN));
+    }
+
+    private static class TestNamespaceResource {
+        public List<String> 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/TestCalmHubScopesShould.java b/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubScopesShould.java
new file mode 100644
index 000000000..523569c72
--- /dev/null
+++ b/calm-hub/src/test/java/org/finos/calm/security/TestCalmHubScopesShould.java
@@ -0,0 +1,39 @@
+package org.finos.calm.security;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class TestCalmHubScopesShould {
+
+    @Test
+    void prevent_instantiation() throws Exception {
+        Constructor<CalmHubScopes> constructor = CalmHubScopes.class.getDeclaredConstructor();
+        constructor.setAccessible(true);
+        Exception actual = assertThrows(InvocationTargetException.class, constructor::newInstance);
+        assertTrue(actual.getCause() instanceof UnsupportedOperationException);
+    }
+
+    @Test
+    void match_architectures_read_constant() {
+        assertEquals("architectures:read", CalmHubScopes.ARCHITECTURES_READ);
+    }
+
+    @Test
+    void match_architectures_all_constant() {
+        assertEquals("architectures:all", CalmHubScopes.ARCHITECTURES_ALL);
+    }
+
+    @Test
+    void match_adrs_all_constant() {
+        assertEquals("adrs:all", CalmHubScopes.ADRS_ALL);
+    }
+
+    @Test
+    void match_adrs_read_constant() {
+        assertEquals("adrs:read", CalmHubScopes.ADRS_READ);
+    }
+}
diff --git a/calm-hub/src/test/resources/secure-profile/realm.json b/calm-hub/src/test/resources/secure-profile/realm.json
new file mode 100644
index 000000000..ecffb648a
--- /dev/null
+++ b/calm-hub/src/test/resources/secure-profile/realm.json
@@ -0,0 +1,201 @@
+{
+  "realm": "calm-hub-realm",
+  "enabled": true,
+  "clients": [
+    {
+      "clientId": "calm-hub-client-app",
+      "enabled": true,
+      "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",
+        "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",
+          "config": {
+            "included.client.audience": "calm-hub-producer-app",
+            "id.token.claim": "true",
+            "access.token.claim": "true"
+          }
+        }
+      ]
+    }
+  ],
+  "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"
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 7b8fa11f1..b6b2d67b1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -37,6 +37,7 @@
         "cytoscape-dagre": "^2.5.0",
         "cytoscape-expand-collapse": "^4.1.1",
         "cytoscape-node-edge-html-label": "^1.0.6",
+        "oidc-client": "^1.11.5",
         "react": "^18.2.0",
         "react-dom": "^18.2.0",
         "react-icons": "^5.3.0",
@@ -10127,6 +10128,25 @@
       "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
       "license": "MIT"
     },
+    "node_modules/base64-js": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
     "node_modules/batch": {
       "version": "0.6.1",
       "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz",
@@ -11553,6 +11573,11 @@
         "node": ">= 8"
       }
     },
+    "node_modules/crypto-js": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+      "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
+    },
     "node_modules/crypto-random-string": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz",
@@ -21508,6 +21533,37 @@
       "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
       "license": "MIT"
     },
+    "node_modules/oidc-client": {
+      "version": "1.11.5",
+      "resolved": "https://registry.npmjs.org/oidc-client/-/oidc-client-1.11.5.tgz",
+      "integrity": "sha512-LcKrKC8Av0m/KD/4EFmo9Sg8fSQ+WFJWBrmtWd+tZkNn3WT/sQG3REmPANE9tzzhbjW6VkTNy4xhAXCfPApAOg==",
+      "dependencies": {
+        "acorn": "^7.4.1",
+        "base64-js": "^1.5.1",
+        "core-js": "^3.8.3",
+        "crypto-js": "^4.0.0",
+        "serialize-javascript": "^4.0.0"
+      }
+    },
+    "node_modules/oidc-client/node_modules/acorn": {
+      "version": "7.4.1",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
+      "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/oidc-client/node_modules/serialize-javascript": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+      "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
+      "dependencies": {
+        "randombytes": "^2.1.0"
+      }
+    },
     "node_modules/on-finished": {
       "version": "2.4.1",
       "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",


Reviews:
user: login: jpgough-ms
body: Build is failing 
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-06T15:20:09Z
user: login: jpgough-ms
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-06T15:33:25Z
user: login: challamani-ms
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T16:28:47Z
user: login: challamani-ms
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T16:33:27Z
user: login: challamani-ms
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T16:39:31Z
user: login: jpgough-ms
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-06T17:46:12Z
user: login: jpgough-ms
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-06T17:47:53Z
user: login: challamani-ms
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T18:10:20Z
user: login: rocketstack-matt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-06T19:04:32Z
user: login: challamani-ms
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T19:32:37Z
user: login: challamani-ms
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T20:13:05Z
user: login: challamani-ms
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T18:22:04Z
user: login: jpgough-ms
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-14T08:48:42Z
user: login: aidanm3341
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T09:31:09Z
user: login: jpgough-ms
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T09:37:14Z

Review comments:
diff_hunk: @@ -0,0 +1,109 @@
+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.junit.jupiter.api.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+
+import static integration.MongoSetup.counterSetup;
+import static integration.MongoSetup.namespaceSetup;
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.empty;
+
+@QuarkusTest
+@TestProfile(IntegrationTestSecureProfile.class)
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class ScopesAllowedIntegration {
+
+    private static final Logger logger = LoggerFactory.getLogger(ScopesAllowedIntegration.class);
+    public static final String PATTERN = "{\"name\": \"demo-pattern\"}";
path: calm-hub/src/integration-test/java/integration/ScopesAllowedIntegration.java
user: login: jpgough-ms
body: Can be private
created_at: 2025-03-06T15:23:25Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,109 @@
+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.junit.jupiter.api.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+
+import static integration.MongoSetup.counterSetup;
+import static integration.MongoSetup.namespaceSetup;
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.empty;
+
+@QuarkusTest
+@TestProfile(IntegrationTestSecureProfile.class)
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class ScopesAllowedIntegration {
+
+    private static final Logger logger = LoggerFactory.getLogger(ScopesAllowedIntegration.class);
+    public static final String PATTERN = "{\"name\": \"demo-pattern\"}";
+
+    @BeforeEach
+    public void setupPatterns() {
path: calm-hub/src/integration-test/java/integration/ScopesAllowedIntegration.java
user: login: jpgough-ms
body: I think that this can be: 

`void setupPatterns()`
created_at: 2025-03-06T15:23:53Z
author_association: MEMBER
diff_hunk: @@ -47,6 +48,7 @@ public ArchitectureResource(ArchitectureStore store) {
             summary = "Retrieve architectures in a given namespace",
             description = "Architecture stored in a given namespace"
     )
+    @ScopesAllowed({"architectures:all", "architectures:read"})
path: calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java
user: login: jpgough-ms
body: A comment for all these annotations in all files

It looks like this would be really easy to mess up. Should we create a `CalmHubScopes` class statically defining and fully documenting the scopes we want to support?
created_at: 2025-03-06T15:25:42Z
author_association: MEMBER
diff_hunk: @@ -20,6 +21,7 @@ public NamespaceResource(NamespaceStore store) {
             summary = "Available Namespaces",
             description = "The available namespaces available in this Calm Hub"
     )
+    @ScopesAllowed({"architectures:all", "architectures:read"})
path: calm-hub/src/main/java/org/finos/calm/resources/NamespaceResource.java
user: login: jpgough-ms
body: I think this one needs checking, `/calm/namespaces` is used by both patterns and architectures. This might not be quite doing what you expect. I think a test around this would be good. 
created_at: 2025-03-06T15:27:09Z
author_association: MEMBER
diff_hunk: @@ -39,6 +40,7 @@ public PatternResource(PatternStore store) {
             summary = "Retrieve patterns in a given namespace",
             description = "Patterns stored in a given namespace"
     )
+    @ScopesAllowed({"architectures:all", "architectures:read"})
path: calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java
user: login: jpgough-ms
body: Perhaps I am misunderstanding are patterns considered architectures? If so, that's probably not the right naming. 
created_at: 2025-03-06T15:28:02Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,82 @@
+package org.finos.calm.security;
+
+import io.quarkus.test.junit.QuarkusTest;
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.container.ResourceInfo;
+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.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.never;
+
+@QuarkusTest
+public class TestScopesAllowedFilterShould {
+
+    @Mock
+    JsonWebToken jwt;
+    @Mock
+    ContainerRequestContext requestContext;
+    @Mock
+    ResourceInfo resourceInfo;
+
+    private ScopesAllowedFilter scopesAllowedFilter;
+
+    @BeforeEach
+    public void setup() {
+        MockitoAnnotations.openMocks(this);
+        scopesAllowedFilter = new ScopesAllowedFilter(jwt, resourceInfo);
+    }
+
+    @Test
+    public void allow_the_request_when_scopes_not_defined_on_resource() throws NoSuchMethodException {
path: calm-hub/src/test/java/org/finos/calm/security/TestScopesAllowedFilterShould.java
user: login: jpgough-ms
body: As we're using JUnit 5, I don't think this needs to be public
created_at: 2025-03-06T15:31:56Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,82 @@
+package org.finos.calm.security;
+
+import io.quarkus.test.junit.QuarkusTest;
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.container.ResourceInfo;
+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.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.never;
+
+@QuarkusTest
+public class TestScopesAllowedFilterShould {
+
+    @Mock
+    JsonWebToken jwt;
+    @Mock
+    ContainerRequestContext requestContext;
+    @Mock
+    ResourceInfo resourceInfo;
+
+    private ScopesAllowedFilter scopesAllowedFilter;
+
+    @BeforeEach
+    public void setup() {
+        MockitoAnnotations.openMocks(this);
+        scopesAllowedFilter = new ScopesAllowedFilter(jwt, resourceInfo);
+    }
+
+    @Test
+    public void allow_the_request_when_scopes_not_defined_on_resource() throws NoSuchMethodException {
+        Method method = TestNamespaceResource.class.getMethod("getNamespacesUnsecured");
+        when(resourceInfo.getResourceMethod()).thenReturn(method);
+
+        scopesAllowedFilter.filter(requestContext);
+        //verify(logger).warn("Unsecured endpoint accessed: "+ method);
path: calm-hub/src/test/java/org/finos/calm/security/TestScopesAllowedFilterShould.java
user: login: jpgough-ms
body: Remove commented out code
created_at: 2025-03-06T15:32:26Z
author_association: MEMBER
diff_hunk: @@ -47,6 +48,7 @@ public ArchitectureResource(ArchitectureStore store) {
             summary = "Retrieve architectures in a given namespace",
             description = "Architecture stored in a given namespace"
     )
+    @ScopesAllowed({"architectures:all", "architectures:read"})
path: calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java
user: login: challamani-ms
body: Make sense, I'll update accordingly, instead of hardcoding the scopes values, will use the static values.
created_at: 2025-03-06T16:28:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,6 +21,7 @@ public NamespaceResource(NamespaceStore store) {
             summary = "Available Namespaces",
             description = "The available namespaces available in this Calm Hub"
     )
+    @ScopesAllowed({"architectures:all", "architectures:read"})
path: calm-hub/src/main/java/org/finos/calm/resources/NamespaceResource.java
user: login: challamani-ms
body: I got it, both the `adrs` & `architectures` can be associated with the same namespace, we have to add additional scopes on this, I'll update it accordingly, I'll add corresponding test cases as well. 
created_at: 2025-03-06T16:33:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,6 +40,7 @@ public PatternResource(PatternStore store) {
             summary = "Retrieve patterns in a given namespace",
             description = "Patterns stored in a given namespace"
     )
+    @ScopesAllowed({"architectures:all", "architectures:read"})
path: calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java
user: login: challamani-ms
body: Yes @jpgough-ms , we have created these scopes by considering `patterns` are part of `architectures`. 

For the coarse-grained access control we have defined two top-level scopes.
1. `architectures` scope for accessing the flows, patterns and architectures.
2. `adrs` scope for accessing the ADRs.   

created_at: 2025-03-06T16:39:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,6 +40,7 @@ public PatternResource(PatternStore store) {
             summary = "Retrieve patterns in a given namespace",
             description = "Patterns stored in a given namespace"
     )
+    @ScopesAllowed({"architectures:all", "architectures:read"})
path: calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java
user: login: jpgough-ms
body: @rocketstack-matt interested in our opinion here, `patterns` and `architectures` end up getting grouped together under an `architectures` scope in this model. 
created_at: 2025-03-06T17:46:12Z
author_association: MEMBER
diff_hunk: @@ -47,6 +48,7 @@ public ArchitectureResource(ArchitectureStore store) {
             summary = "Retrieve architectures in a given namespace",
             description = "Architecture stored in a given namespace"
     )
+    @ScopesAllowed({"architectures:all", "architectures:read"})
path: calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java
user: login: jpgough-ms
body: @challamani-ms the static values could perhaps go in `@ScopesAllowed` - though thinking about it, perhaps it would read better as `PermittedScopes` i.e.

`@PermittedScopes({PermittedScopes.ALL, PermittedScopes.READ})

Another question is if you have ALL does READ need to be defined? 
created_at: 2025-03-06T17:47:53Z
author_association: MEMBER
diff_hunk: @@ -47,6 +48,7 @@ public ArchitectureResource(ArchitectureStore store) {
             summary = "Retrieve architectures in a given namespace",
             description = "Architecture stored in a given namespace"
     )
+    @ScopesAllowed({"architectures:all", "architectures:read"})
path: calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java
user: login: challamani-ms
body: Yeah, I think we need `READ` scope to allow only `GET` operations on resources. 
Example: CalmHub can reject any `POST/PUT` requests that contain only READ scopes, by having READ scope we can limit the client application access, in our case **CalmUI** is only allowed to request tokens with READ scopes, as it is not allowed to perform POST & PUT operations on any patterns.
created_at: 2025-03-06T18:10:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,6 +40,7 @@ public PatternResource(PatternStore store) {
             summary = "Retrieve patterns in a given namespace",
             description = "Patterns stored in a given namespace"
     )
+    @ScopesAllowed({"architectures:all", "architectures:read"})
path: calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java
user: login: rocketstack-matt
body: My initial thought is that they're different, because I would think someone who can author a pattern isn't necessarily the same person using a pattern to generate an architecture or creating an architecture from scratch.

However, as I was typing that, isn't the separation really around the 'state' of a pattern or architecture. Is there any reason we would restrict anyone from being able to create a pattern or an architecture, but we perhaps need to have a different role that allows folks to approve architectures or patterns.

At this point, I think that role is actually split, in so far as someone who can approve an architecture isn't necessarily authorised to approve an entire pattern. 
created_at: 2025-03-06T19:04:32Z
author_association: MEMBER
diff_hunk: @@ -39,6 +40,7 @@ public PatternResource(PatternStore store) {
             summary = "Retrieve patterns in a given namespace",
             description = "Patterns stored in a given namespace"
     )
+    @ScopesAllowed({"architectures:all", "architectures:read"})
path: calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java
user: login: challamani-ms
body: Just an additional information @jpgough-ms @rocketstack-matt 
These scopes we have defined to control what a client application can do,  
example: 
-  Restrict the `CalmUI` only to read patterns, namespaces, and flow, by not granting the `architectures:all` scope to CalmUI client application in IdP.

Note: Once JWT validation is completed, CalmHub server validates the end-user level RBAC checks. 
We will create a separate PR, for the fine-grained user level access control. We are thinking of maintaining a table in CalmHub backend to achieve that, we can find more details in #715. 
created_at: 2025-03-06T19:32:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,6 +40,7 @@ public PatternResource(PatternStore store) {
             summary = "Retrieve patterns in a given namespace",
             description = "Patterns stored in a given namespace"
     )
+    @ScopesAllowed({"architectures:all", "architectures:read"})
path: calm-hub/src/main/java/org/finos/calm/resources/PatternResource.java
user: login: challamani-ms
body: Probably we can introduce more scopes to control the client applications' access. 

Scope & associated actions:
- A client application with `architectures:all` scope, is allowed to perform all operations on flows, architectures, and patterns.
- A client application with `architectures:read` scope, is allowed to access only GET operations on flows, namespaces and patterns.
- A client application with `adrs:read` scope, is allowed to access only GET operations on adrs and namespaces. 
- A client application with `adrs:all` scope, is allowed to perform all operations on adrs.

Note: Additional user level RBAC checks in the Calm server are required, as OAuth2 cannot map the application scopes to User. 
created_at: 2025-03-06T20:13:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -47,6 +48,7 @@ public ArchitectureResource(ArchitectureStore store) {
             summary = "Retrieve architectures in a given namespace",
             description = "Architecture stored in a given namespace"
     )
+    @ScopesAllowed({"architectures:all", "architectures:read"})
path: calm-hub/src/main/java/org/finos/calm/resources/ArchitectureResource.java
user: login: challamani-ms
body: - As readonly clients will be configured with `READ` scopes, I think we need to add both `ALL` & `READ` scopes in all GET endpoints, to accept calls from both **CalmUI** & **CalmCLI**.
- Post, Put, and Delete endpoints are configured with only `ALL` scope, e.g:  to allow only **CalmCLI** calls, as it will be configured with write scopes.
created_at: 2025-03-07T18:22:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,39 @@
+import React, { ReactNode, useEffect, useState } from "react";
+import { User } from "oidc-client";
+import { authService } from "./authService";
+
+interface ProtectedRouteProps {
+  children: ReactNode;
+}
+
+const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
+  const [user, setUser] = useState<User | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    const authenticate = async () => {
+      const currentUser = await authService.getUser();
+      if (currentUser && !currentUser.expired) {
+        setUser(currentUser);
+      } else if (window.location.search.includes("code=")) {
+        const loggedInUser = await authService.processRedirect();
+        setUser(loggedInUser);
+      } else {
+        await authService.login();
+      }
+      setLoading(false);
+    };
+
+    authenticate();
+  }, []);
+
+  if (loading) {
+    return <div>Loading...</div>;
+  }
+
+  if (!user) {
+    return <div>Redirecting to login...</div>;
+  }
+  return <>{children}</>;
path: calm-hub-ui/src/ProtectedRoute.tsx
user: login: aidanm3341
body: One small thing is that the formatting is slightly inconsistent with the rest of the UI project. E.g. the indentation everywhere else is 4 spaces, this has 2. Also ideally we use the `function() {}` syntax for defining functions rather than the `const x = () => {}` syntax.

However these aren't really blockers for the PR I would say. The content of the PR looks great
created_at: 2025-03-14T09:31:08Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,39 @@
+import React, { ReactNode, useEffect, useState } from "react";
+import { User } from "oidc-client";
+import { authService } from "./authService";
+
+interface ProtectedRouteProps {
+  children: ReactNode;
+}
+
+const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
+  const [user, setUser] = useState<User | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    const authenticate = async () => {
+      const currentUser = await authService.getUser();
+      if (currentUser && !currentUser.expired) {
+        setUser(currentUser);
+      } else if (window.location.search.includes("code=")) {
+        const loggedInUser = await authService.processRedirect();
+        setUser(loggedInUser);
+      } else {
+        await authService.login();
+      }
+      setLoading(false);
+    };
+
+    authenticate();
+  }, []);
+
+  if (loading) {
+    return <div>Loading...</div>;
+  }
+
+  if (!user) {
+    return <div>Redirecting to login...</div>;
+  }
+  return <>{children}</>;
path: calm-hub-ui/src/ProtectedRoute.tsx
user: login: jpgough-ms
body: I've created an issue to capture this @aidanm3341 and we can take this on separately 
created_at: 2025-03-14T09:37:14Z
author_association: MEMBER

PR comments:
user: login: linux-foundation-easycla[bot]
created_at: 2025-03-06T14:44:42Z
body: <a href="https://easycla.lfx.linuxfoundation.org/#/?version=2"><img src="https://s3.amazonaws.com/cla-project-logo-prod/cla-signed.svg" alt="CLA Signed" align="left" height="28" width="328" ></a><br/><br />The committers listed above are authorized under a signed CLA.<ul><li>:white_check_mark: login: jpgough-ms  (115c7dee18ebd55e1d4f762f983a1736842a1805, 348fb46df932232c8407ff8a791c89367e8fb202)</li><li>:white_check_mark: login: challamani-ms / name: Manikanta Challa  (896bc9a19a19a562817dce5056c5e4fa468dde2f, 400ab6ec8717e6dbe92c653b2ce7125e4b0c6a0d, a295734bd7ab26010deb038093ab7f13fda97ba9, 7c6bae8d83404678c667b5fc85969b8417d7d36b, 38adec5b71c8dd3d49fe5b5b7952b340314a2c8f, def09bda77621126657f3722db5d40bbf3deb98f, cebd3927ce3b77a89155892ee026b552cb71fdcd)</li></ul><!-- Date Modified: 2025-03-14 09:05:05.491849 -->
author_association: NONE
user: login: jpgough-ms
created_at: 2025-03-06T15:18:40Z
body: Waiting to confirm things build and then will review
author_association: MEMBER
user: login: jpgough-ms
created_at: 2025-03-12T07:43:16Z
body: @challamani-ms this branch will need a rebase, there are significant changes in the UI - which might need a bit of rework. 
author_association: MEMBER