Repository: ls1intum/Helios
Pull request #590: feat: auto approve deployments
Author: login: gbanu
Labels: name: documentation
description: Improvements or additions to documentation
name: size:XL
name: ready for review
name: application-server
name: keycloakify
name: migration-script
Created: 2025-03-25T12:15:16Z | Merged: 2025-03-30T15:06:22Z

Basic workflow for authorized to approve user:
The logic for the approve button is still in the frontend for the case something goes wrong.

https://github.com/user-attachments/assets/ffeaff32-9e6e-4db2-a94f-93e63b95de06

Workflow for unauthorized user, then the authorized user (Stephan) can approve see the deployments that need approval and approve

https://github.com/user-attachments/assets/585ad3fc-5f89-43ee-b141-5e1fc1766d49

Will tackle all the UX issues in the following PR 🙏 wanted to make sure we have the functionality merged first and tested on staging

For testing make sure that the github app that is defined in your developer Keycloak, is installed in the repository you want to test it with. I think all of us are using my dev GH app for authentication, so the testing should be in the ege-test-org, with the repo where the GH app is installed

(In the deployment history for the helios-user it says the gbanu deployed it, because I'm using my gh token in the backend for the deployments)



Diff:
diff --git a/docs/development/local/keycloak_token_exchange.rst b/docs/development/local/keycloak_token_exchange.rst
new file mode 100644
index 000000000..ab4928ecf
--- /dev/null
+++ b/docs/development/local/keycloak_token_exchange.rst
@@ -0,0 +1,147 @@
+=====================
+Token Exchange Setup
+=====================
+
+This document describes how to set up token exchange functionality in Keycloak 26.1.3, which allows a client to obtain identity provider tokens of users.
+
+Prerequisites
+-------------
+
+* Keycloak server with admin access
+* A confidential client (public clients are not allowed for token exchange)
+* Admin permissions to configure client permissions and policies
+* Token exchange feature enabled (currently in preview)
+
+Feature Enablement
+--------------------
+
+At the moment of development, token exchange is only available as a feature preview. To enable the feature, start Keycloak with the following feature flags:
+
+.. code-block:: bash
+
+   --features=token-exchange,admin-fine-grained-authz
+
+Setup Steps
+-----------
+
+1. Create Token Exchange Client
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+#. Log in to the Keycloak Admin Console
+#. Navigate to the target realm (Helios)
+#. Go to Clients and create a new client (e.g., helios-token-exchange)
+#. Set the client as confidential (public clients are not allowed for token exchange)
+    .. figure:: ../../images/token_exchange/confidential-client.png
+       :height: 512px
+       :alt: Repository selection screen
+#. Generate and save the client secret (this client ID and secret will be used to access users' GitHub tokens)
+    .. figure:: ../../images/token_exchange/client-credentials.png
+       :height: 512px
+       :alt: Repository selection screen
+
+
+2. Configure User Impersonation Permission
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Follow the steps listed under "Direct Naked Impersonation" in the Keycloak documentation. 
+https://www.keycloak.org/securing-apps/token-exchange#_direct_naked_impersonation
+
+The section below duplicates the steps in the documentation in case the link is broken or changes.
+
+#. Go to the Users tab
+#. In the Permissions tab, click on the "impersonate" link
+#. In the permission setup page:
+   * Click on "Client details" in the breadcrumbs
+   * Define a policy for this permission
+#. Go to the Policies tab and create a client policy:
+   * Enter the client ID that will perform the token exchange
+   * Save the policy
+#. Return to the users' "impersonation" permission
+#. Add the client policy you just defined
+
+3. Configure Identity Provider Token Exchange
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+#. Go to Identity Providers
+#. Select the external identity provider (e.g., GitHub)
+#. Go to the Permissions tab
+#. Enable Permissions
+#. Click on the "impersonate" link
+#. Select the policy created earlier for the helios-token-exchange client
+
+4. Making Token Exchange Requests
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+To make a token exchange request, use the following endpoint:
+
+.. code-block:: bash
+
+   curl -X POST \
+       -d "client_id=<your-client-id>" \
+       -d "client_secret=<your-client-secret>" \
+       --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
+       -d "requested_subject=<target-username>" \
+       --data-urlencode "requested_token_type=urn:ietf:params:oauth:token-type:access_token" \
+       -d "requested_issuer=<identity-provider>" \
+       http://localhost:8081/realms/<your-realm>/protocol/openid-connect/token
+
+Parameters:
+* ``client_id``: The ID of your token exchange client
+* ``client_secret``: The client's secret
+* ``grant_type``: Must be "urn:ietf:params:oauth:grant-type:token-exchange"
+* ``requested_subject``: The username or user ID to impersonate
+* ``requested_token_type``: The type of token to request (e.g., access_token)
+* ``requested_issuer``: The identity provider (e.g., github)
+
+Example Response
+-----------------
+
+A successful response will include an access token:
+
+.. code-block:: json
+
+   {
+      "access_token": "<provider-specific-token>",
+      "expires_in": 0,
+      "refresh_expires_in": 0,
+      "not-before-policy": 0,
+      "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
+      "account-link-url": "<account-link-url>"
+   }
+
+NOTE:
+--------------
+
+GitHub access tokens are valid for 8 hours. Identity provider tokens are not refreshed automatically in Keycloak.
+In order to make sure you always have a valid token, limit the session to 8 hours.
+
+Security Considerations
+-----------------------
+
+1. Only use confidential clients for token exchange
+2. Keep client credentials secure and never expose them
+3. Limit the number of clients that have token exchange permissions
+4. Regularly audit which clients have token exchange permissions
+5. Monitor token exchange usage for suspicious patterns
+
+Error Handling
+--------------
+
+Common error responses:
+
+* 403 Forbidden: Client lacks required permissions
+* 400 Bad Request: Invalid parameters or unsupported grant type
+* 401 Unauthorized: Invalid client credentials
+
+Troubleshooting
+---------------
+
+If you encounter issues:
+
+1. Verify client permissions are properly configured
+2. Ensure the client is confidential (not public)
+3. Check that the target user exists and is active
+4. Verify all required parameters are provided
+5. Check Keycloak server logs for detailed error messages
+
+For additional information, refer to the `Keycloak documentation <https://www.keycloak.org/securing-apps/token-exchange>`_.
diff --git a/docs/images/token_exchange/client-credentials.png b/docs/images/token_exchange/client-credentials.png
new file mode 100644
index 000000000..2819ebdc2
Binary files /dev/null and b/docs/images/token_exchange/client-credentials.png differ
diff --git a/docs/images/token_exchange/confidential-client.png b/docs/images/token_exchange/confidential-client.png
new file mode 100644
index 000000000..421afa95a
Binary files /dev/null and b/docs/images/token_exchange/confidential-client.png differ
diff --git a/helios-example-realm.json b/helios-example-realm.json
index 6335224c8..24eb80cd9 100644
--- a/helios-example-realm.json
+++ b/helios-example-realm.json
@@ -7,8 +7,8 @@
   "refreshTokenMaxReuse": 0,
   "accessTokenLifespan": 300,
   "accessTokenLifespanForImplicitFlow": 900,
-  "ssoSessionIdleTimeout": 1800,
-  "ssoSessionMaxLifespan": 36000,
+  "ssoSessionIdleTimeout": 28800,
+  "ssoSessionMaxLifespan": 28800,
   "ssoSessionIdleTimeoutRememberMe": 0,
   "ssoSessionMaxLifespanRememberMe": 0,
   "offlineSessionIdleTimeout": 2592000,
@@ -27,9 +27,9 @@
   "oauth2DevicePollingInterval": 5,
   "enabled": true,
   "sslRequired": "external",
-  "registrationAllowed": true,
+  "registrationAllowed": false,
   "registrationEmailAsUsername": false,
-  "rememberMe": false,
+  "rememberMe": true,
   "verifyEmail": false,
   "loginWithEmailAllowed": true,
   "duplicateEmailsAllowed": false,
@@ -67,9 +67,6 @@
             "uma_authorization"
           ],
           "client": {
-            "broker": [
-              "read-token"
-            ],
             "account": [
               "view-profile",
               "manage-account"
@@ -132,11 +129,11 @@
                 "manage-users",
                 "manage-identity-providers",
                 "view-events",
-                "view-identity-providers",
                 "view-authorization",
+                "view-identity-providers",
                 "view-users",
-                "manage-realm",
                 "impersonation",
+                "manage-realm",
                 "query-users",
                 "manage-clients",
                 "query-realms",
@@ -307,6 +304,7 @@
       "admin-cli": [],
       "account-console": [],
       "helios-app-example": [],
+      "helios-token-exchange": [],
       "broker": [
         {
           "id": "6063e61f-fde9-47ff-8ef7-47e57ffd5ba0",
@@ -354,27 +352,27 @@
           "attributes": {}
         },
         {
-          "id": "96de9837-9c14-4646-a31e-308161162f95",
-          "name": "delete-account",
-          "description": "${role_delete-account}",
+          "id": "569ea973-3f43-4034-b878-3dd388b1c734",
+          "name": "view-consent",
+          "description": "${role_view-consent}",
           "composite": false,
           "clientRole": true,
           "containerId": "36e24c10-5a73-4fb2-8aba-a6eaa6b44a4d",
           "attributes": {}
         },
         {
-          "id": "05c29cd8-3f76-42c1-a0f8-e150c55fb698",
-          "name": "view-profile",
-          "description": "${role_view-profile}",
+          "id": "96de9837-9c14-4646-a31e-308161162f95",
+          "name": "delete-account",
+          "description": "${role_delete-account}",
           "composite": false,
           "clientRole": true,
           "containerId": "36e24c10-5a73-4fb2-8aba-a6eaa6b44a4d",
           "attributes": {}
         },
         {
-          "id": "569ea973-3f43-4034-b878-2b952bda91bc",
-          "name": "view-consent",
-          "description": "${role_view-consent}",
+          "id": "05c29cd8-3f76-42c1-a0f8-e150c55fb698",
+          "name": "view-profile",
+          "description": "${role_view-profile}",
           "composite": false,
           "clientRole": true,
           "containerId": "36e24c10-5a73-4fb2-8aba-a6eaa6b44a4d",
@@ -523,8 +521,8 @@
       "optionalClientScopes": [
         "address",
         "phone",
-        "offline_access",
         "organization",
+        "offline_access",
         "microprofile-jwt"
       ]
     },
@@ -581,8 +579,8 @@
       "optionalClientScopes": [
         "address",
         "phone",
-        "offline_access",
         "organization",
+        "offline_access",
         "microprofile-jwt"
       ]
     },
@@ -625,8 +623,8 @@
       "optionalClientScopes": [
         "address",
         "phone",
-        "offline_access",
         "organization",
+        "offline_access",
         "microprofile-jwt"
       ]
     },
@@ -668,8 +666,8 @@
       "optionalClientScopes": [
         "address",
         "phone",
-        "offline_access",
         "organization",
+        "offline_access",
         "microprofile-jwt"
       ]
     },
@@ -706,6 +704,7 @@
         "oidc.ciba.grant.enabled": "false",
         "backchannel.logout.session.required": "true",
         "post.logout.redirect.uris": "http://localhost:4200/*",
+        "display.on.consent.screen": "false",
         "oauth2.device.authorization.grant.enabled": "false",
         "backchannel.logout.revoke.offline.tokens": "false"
       },
@@ -742,8 +741,64 @@
       "optionalClientScopes": [
         "address",
         "phone",
+        "organization",
         "offline_access",
+        "microprofile-jwt"
+      ]
+    },
+    {
+      "id": "5e66a231-a0a8-4483-ba5b-ee65109a9f85",
+      "clientId": "helios-token-exchange",
+      "name": "",
+      "description": "",
+      "rootUrl": "",
+      "adminUrl": "",
+      "baseUrl": "",
+      "surrogateAuthRequired": false,
+      "enabled": true,
+      "alwaysDisplayInConsole": false,
+      "clientAuthenticatorType": "client-secret",
+      "secret": "wHBfstYLRLDG8xmJtHNsrGjo40UULNmy",
+      "redirectUris": [
+        "/*"
+      ],
+      "webOrigins": [
+        "/*"
+      ],
+      "notBefore": 0,
+      "bearerOnly": false,
+      "consentRequired": false,
+      "standardFlowEnabled": true,
+      "implicitFlowEnabled": false,
+      "directAccessGrantsEnabled": true,
+      "serviceAccountsEnabled": false,
+      "publicClient": false,
+      "frontchannelLogout": true,
+      "protocol": "openid-connect",
+      "attributes": {
+        "realm_client": "false",
+        "oidc.ciba.grant.enabled": "false",
+        "client.secret.creation.time": "1742093471",
+        "backchannel.logout.session.required": "true",
+        "oauth2.device.authorization.grant.enabled": "false",
+        "backchannel.logout.revoke.offline.tokens": "false"
+      },
+      "authenticationFlowBindingOverrides": {},
+      "fullScopeAllowed": true,
+      "nodeReRegistrationTimeout": -1,
+      "defaultClientScopes": [
+        "web-origins",
+        "acr",
+        "roles",
+        "profile",
+        "basic",
+        "email"
+      ],
+      "optionalClientScopes": [
+        "address",
+        "phone",
         "organization",
+        "offline_access",
         "microprofile-jwt"
       ]
     },
@@ -764,6 +819,7 @@
       "implicitFlowEnabled": false,
       "directAccessGrantsEnabled": false,
       "serviceAccountsEnabled": false,
+      "authorizationServicesEnabled": true,
       "publicClient": false,
       "frontchannelLogout": false,
       "protocol": "openid-connect",
@@ -785,10 +841,164 @@
       "optionalClientScopes": [
         "address",
         "phone",
-        "offline_access",
         "organization",
+        "offline_access",
         "microprofile-jwt"
-      ]
+      ],
+      "authorizationSettings": {
+        "allowRemoteResourceManagement": false,
+        "policyEnforcementMode": "ENFORCING",
+        "resources": [
+          {
+            "name": "Users",
+            "ownerManagedAccess": false,
+            "attributes": {},
+            "uris": [],
+            "scopes": [
+              {
+                "name": "user-impersonated"
+              },
+              {
+                "name": "manage-group-membership"
+              },
+              {
+                "name": "view"
+              },
+              {
+                "name": "impersonate"
+              },
+              {
+                "name": "map-roles"
+              },
+              {
+                "name": "manage"
+              }
+            ]
+          },
+          {
+            "name": "idp.resource.8aef9828-e67a-470b-975a-413a723e2870",
+            "type": "IdentityProvider",
+            "ownerManagedAccess": false,
+            "attributes": {},
+            "uris": [],
+            "scopes": [
+              {
+                "name": "token-exchange"
+              }
+            ]
+          }
+        ],
+        "policies": [
+          {
+            "name": "client-impersonators",
+            "description": "",
+            "type": "client",
+            "logic": "POSITIVE",
+            "decisionStrategy": "UNANIMOUS",
+            "config": {
+              "clients": "[\"helios-token-exchange\"]"
+            }
+          },
+          {
+            "name": "manage.permission.users",
+            "type": "scope",
+            "logic": "POSITIVE",
+            "decisionStrategy": "UNANIMOUS",
+            "config": {
+              "resources": "[\"Users\"]",
+              "scopes": "[\"manage\"]"
+            }
+          },
+          {
+            "name": "view.permission.users",
+            "type": "scope",
+            "logic": "POSITIVE",
+            "decisionStrategy": "UNANIMOUS",
+            "config": {
+              "resources": "[\"Users\"]",
+              "scopes": "[\"view\"]"
+            }
+          },
+          {
+            "name": "map-roles.permission.users",
+            "type": "scope",
+            "logic": "POSITIVE",
+            "decisionStrategy": "UNANIMOUS",
+            "config": {
+              "resources": "[\"Users\"]",
+              "scopes": "[\"map-roles\"]"
+            }
+          },
+          {
+            "name": "manage-group-membership.permission.users",
+            "type": "scope",
+            "logic": "POSITIVE",
+            "decisionStrategy": "UNANIMOUS",
+            "config": {
+              "resources": "[\"Users\"]",
+              "scopes": "[\"manage-group-membership\"]"
+            }
+          },
+          {
+            "name": "admin-impersonating.permission.users",
+            "description": "",
+            "type": "scope",
+            "logic": "POSITIVE",
+            "decisionStrategy": "UNANIMOUS",
+            "config": {
+              "resources": "[\"Users\"]",
+              "scopes": "[\"impersonate\"]",
+              "applyPolicies": "[\"client-impersonators\"]"
+            }
+          },
+          {
+            "name": "user-impersonated.permission.users",
+            "type": "scope",
+            "logic": "POSITIVE",
+            "decisionStrategy": "UNANIMOUS",
+            "config": {
+              "resources": "[\"Users\"]",
+              "scopes": "[\"user-impersonated\"]"
+            }
+          },
+          {
+            "name": "token-exchange.permission.idp.8aef9828-e67a-470b-975a-413a723e2870",
+            "description": "",
+            "type": "scope",
+            "logic": "POSITIVE",
+            "decisionStrategy": "UNANIMOUS",
+            "config": {
+              "resources": "[\"idp.resource.8aef9828-e67a-470b-975a-413a723e2870\"]",
+              "scopes": "[\"token-exchange\"]",
+              "applyPolicies": "[\"client-impersonators\"]"
+            }
+          }
+        ],
+        "scopes": [
+          {
+            "name": "manage"
+          },
+          {
+            "name": "view"
+          },
+          {
+            "name": "map-roles"
+          },
+          {
+            "name": "impersonate"
+          },
+          {
+            "name": "user-impersonated"
+          },
+          {
+            "name": "manage-group-membership"
+          },
+          {
+            "name": "token-exchange"
+          }
+        ],
+        "decisionStrategy": "UNANIMOUS"
+      }
     },
     {
       "id": "ff1dd14e-aa8d-4b51-87c6-1d3dce438fec",
@@ -854,8 +1064,8 @@
       "optionalClientScopes": [
         "address",
         "phone",
-        "offline_access",
         "organization",
+        "offline_access",
         "microprofile-jwt"
       ]
     }
@@ -1309,16 +1519,6 @@
         }
       ]
     },
-    {
-      "id": "51639520-aae6-46c8-8282-eb58f4066a4b",
-      "name": "offline_access",
-      "description": "OpenID Connect built-in scope: offline_access",
-      "protocol": "openid-connect",
-      "attributes": {
-        "consent.screen.text": "${offlineAccessScopeConsentText}",
-        "display.on.consent.screen": "true"
-      }
-    },
     {
       "id": "2c9245ae-0737-41e5-a9ee-f39a9c44f469",
       "name": "microprofile-jwt",
@@ -1447,6 +1647,76 @@
         }
       ]
     },
+    {
+      "id": "34afcff0-019f-4b28-bc92-38de8585cf0f",
+      "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": [
+        {
+          "id": "3389de95-761a-4618-957f-c5f83d61ebd5",
+          "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"
+          }
+        },
+        {
+          "id": "51639520-aae6-46c8-8282-eb58f4066a4b",
+          "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"
+          }
+        },
+        {
+          "id": "8da67ea6-9cc5-49ac-ad2e-6d7ccbb68f8b",
+          "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"
+          }
+        }
+      ]
+    },
+    {
+      "id": "160ca8d0-f083-40fb-b462-6016c07124be",
+      "name": "offline_access",
+      "description": "OpenID Connect built-in scope: offline_access",
+      "protocol": "openid-connect",
+      "attributes": {
+        "consent.screen.text": "${offlineAccessScopeConsentText}",
+        "display.on.consent.screen": "true"
+      }
+    },
     {
       "id": "c7450a01-944b-42f5-ac1d-a5081ab9e51b",
       "name": "basic",
@@ -1545,7 +1815,7 @@
         "clientId": "Iv23lixQlqcu7YydQArM",
         "disableUserInfo": "false",
         "filteredByClaim": "false",
-        "syncMode": "FORCE",
+        "syncMode": "LEGACY",
         "clientSecret": "d4465624691ff36b973e8b53bbc0f1896cb62067",
         "caseSensitiveOriginalUsername": "false",
         "defaultScope": "read:user workflow"
@@ -1607,14 +1877,14 @@
         "subComponents": {},
         "config": {
           "allowed-protocol-mapper-types": [
-            "oidc-usermodel-attribute-mapper",
-            "oidc-address-mapper",
-            "oidc-full-name-mapper",
             "oidc-usermodel-property-mapper",
+            "oidc-address-mapper",
+            "oidc-sha256-pairwise-sub-mapper",
             "saml-user-property-mapper",
             "saml-role-list-mapper",
-            "oidc-sha256-pairwise-sub-mapper",
-            "saml-user-attribute-mapper"
+            "saml-user-attribute-mapper",
+            "oidc-full-name-mapper",
+            "oidc-usermodel-attribute-mapper"
           ]
         }
       },
@@ -1622,18 +1892,18 @@
         "id": "45aebce1-5a77-4f93-a2f6-1e3653a92cb4",
         "name": "Allowed Protocol Mapper Types",
         "providerId": "allowed-protocol-mappers",
-        "subType": "authenticated",
+        "subType": "anonymous",
         "subComponents": {},
         "config": {
           "allowed-protocol-mapper-types": [
-            "oidc-sha256-pairwise-sub-mapper",
-            "oidc-usermodel-attribute-mapper",
-            "saml-user-attribute-mapper",
             "saml-user-property-mapper",
-            "oidc-usermodel-property-mapper",
             "saml-role-list-mapper",
+            "oidc-usermodel-attribute-mapper",
+            "oidc-address-mapper",
+            "saml-user-attribute-mapper",
             "oidc-full-name-mapper",
-            "oidc-address-mapper"
+            "oidc-usermodel-property-mapper",
+            "oidc-sha256-pairwise-sub-mapper"
           ]
         }
       },
@@ -1680,15 +1950,6 @@
         "providerId": "rsa-generated",
         "subComponents": {},
         "config": {
-          "privateKey": [
-            "MIIEpAIBAAKCAQEAzTB7ZFwLMB/MF9rn4QJmpsDGsJWFaDfDcIjDgw0DubJ4h41Nrr/sqoDF30bFF16rm+H3ljEs7ayExXabDvpFEYEoraTMvmDHLOJYP/YFmzUiHjFrK0ax7pTuDObibDIZhH2Sd6+HMnpP1dDTOrmIjXl9VY6l5/N7XrBeTvCEYLiI5L87YPesQEgZ+2K4sTKki04YJMQ9TsuKnGk9R7AtLOIdjfxgiPpBrZNpnT9WpdIaeWXranotSaMMCn7QtnheGDcfhlsUot/LQATSCh/hX2ioPXm0dOWdEyMb6thyrBSJpc87rzVjhbzshuoibOzBiX2LZHy4SJsDC67bRAJwawIDAQABAoIBAARg6Pkqx6hWvV2y86jHCta7HWJORoMDUZ3YpUdqIy62wsCtwL8eZFf9GHvbwD/v6ryQVV60nO9Tc5/dGJD4wTmO5oXSEvZrDUUFbXmr79GwkBJR3qYVQ53COtQ2pxVE+mtr3JDGQ53No19+Lcu+gs32DjkhqqOo+/R40gJgOorvy1o55vQ45bLjNkTXdJ6n+HzXDmvN6VMqjAoLrQ4xInk2b+5g0H+b8bxSHWGvoYg1R4BzpCQZA3EmQACkI+FKun5VCPuXwYWZXn3tzoOcNV7AkpxNxN7ODxJTraFeRvsAOEV0OripJn6Dtqs6/1t0to0WOuBd6wEZc+lpudDKeN0CgYEA9ThjgXQzpYTzOfhRMGJUONVtrBjFYRMKFiuQkXhN80E/tEwjzXvYQJGmIddHXwH2D1U/V27raonRX2WsJ85mU3I1eyfObPlsfZgdFwe1C4BDUy91hXlEQYVOVnDfFhTjth5vd3ua9OYLRj4EvZ2rkUZyDUehGuXHIceLYB3sRfUCgYEA1jWaBd6Dyf9aJ6rjIrFtLQyixt3lk0XBatBE7bvDH2faN1FE331yTTXypLWVQv72jD41qbjl4wdEJXqaXfVp2txGx08hFxOMuyig5jmonpjFtkdVhNJF8fQFSdDCG3bYXJqYNMyoxgBmSiCvd/J6weracOpIAYNT1Qay8/X7gN8CgYA8oHthhyQ5uPwVigIwry7ZelMKKHmsPXPR9WVhwder26bvYZ+8bvERTbFATqRTpf163v2xFThY98fvCQkeMlZ0LRlx4G6V3Ljo+FHA3uPw+v4qM+KfZFLuzBGJJ5DQOcvuu+mdJc8y2ylQbYbohnHjkgIirGH1b3fjLx2TXvKKfQKBgQCI0uIX36KkcuOeNMwyS8xlmw2aIwBSCptZ48AzbdxbghVjghiQ/Gr3IH9lb8rwaTUILXBPgZftZKXFnsNdQrWjo1HeWw/S0pzwqynjBG9NJl4baQMZ/MnKm0iJdruVmVroA6M3Doa7OmQb8zbBUfPRS3bN6welV8JDbqz9oWBNVwKBgQCEZNmvZCEr0f9P6TSKOVKAc6evjoOdXMBZBS5eBgTmqNDk3vndqq76dSXQ3tpArIe52Uxygbq72ZZcO7R+O4vIQnNvViPNv/L5BVgBnhKl+fW3ILH830cqX8Vx6EeZ3GyOM+AhztYnE/is+uYwkmAC4FGapjgA5eFN///lnU6esg=="
-          ],
-          "keyUse": [
-            "SIG"
-          ],
-          "certificate": [
-            "MIICqzCCAZMCBgGTrgGNsjANBgkqhkiG9w0BAQsFADAZMRcwFQYDVQQDDA5oZWxpb3MtZXhhbXBsZTAeFw0yNDEyMTAwMDM3MzdaFw0zNDEyMTAwMDM5MTdaMBkxFzAVBgNVBAMMDmhlbGlvcy1leGFtcGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzTB7ZFwLMB/MF9rn4QJmpsDGsJWFaDfDcIjDgw0DubJ4h41Nrr/sqoDF30bFF16rm+H3ljEs7ayExXabDvpFEYEoraTMvmDHLOJYP/YFmzUiHjFrK0ax7pTuDObibDIZhH2Sd6+HMnpP1dDTOrmIjXl9VY6l5/N7XrBeTvCEYLiI5L87YPesQEgZ+2K4sTKki04YJMQ9TsuKnGk9R7AtLOIdjfxgiPpBrZNpnT9WpdIaeWXranotSaMMCn7QtnheGDcfhlsUot/LQATSCh/hX2ioPXm0dOWdEyMb6thyrBSJpc87rzVjhbzshuoibOzBiX2LZHy4SJsDC67bRAJwawIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQBOozc2Rb1zn1FEzcHLE+LSMbqSzi1yVcoUHkB7xOc2JjdfVYL0WcI+OkdpDHZDlL5jhZrhcXFbidyPpCInq4BCg9cHQph6WJ3MPUb95hNjFMz1jSMuEcodTjnAhcqkHsnqJeOPiMMEht1ZLRaW0DBfT2sFm5dqWhUUpwOjI5cox8wb5IQ78Fi0XAK9K/stTN8aPYvU1vSDc3OaoKS0UvJgb0Qj2NHKomXS7giHXUVIllrG18qJuT0XGMGpjbpfPouA+txxc2wWjN5UeQZQlcdbvKjM+cvWHGcx1EIotna8oJrD5DxdHHtuly+DqLfG+LKJsa4fHjDr9x8QSTPJA5K9"
-          ],
           "priority": [
             "100"
           ]
@@ -1700,12 +1961,6 @@
         "providerId": "aes-generated",
         "subComponents": {},
         "config": {
-          "kid": [
-            "0c3e3c0d-0de9-40ca-bddc-5165dffdd658"
-          ],
-          "secret": [
-            "6Sqp9zLrpv8dX2_p_7l0tQ"
-          ],
           "priority": [
             "100"
           ]
@@ -1717,12 +1972,6 @@
         "providerId": "hmac-generated",
         "subComponents": {},
         "config": {
-          "kid": [
-            "0704cfee-7bf7-41a5-9c97-cd1fa14f3b55"
-          ],
-          "secret": [
-            "K3qn6aSohQfg3Ik4pN2-koofd7MEBI6kB5qXgsIRU_Ittnn4N-9wFb7kXkviQ5FLwqvJJV4W2aIE_UoPMDmS_TV-W7mVFBQgMGu19OYe0Yj-kWTMr321KdazFbQ_GcbVliBfWDMI9G1RTDeZnKjJ5XgksnfmjsZJsm76wHqeSVU"
-          ],
           "priority": [
             "100"
           ],
@@ -1737,15 +1986,6 @@
         "providerId": "rsa-enc-generated",
         "subComponents": {},
         "config": {
-          "privateKey": [
-            "MIIEpAIBAAKCAQEAt5GsKBRPQynTPqx1sUmIBXcwuNgC+CgKN1KX+k0uf54rk937sgw3YAPWzseTCsit5PcawMczFjX+ddOZupMwgP1xJjA+fO348LNTd9RQO1iolMxKzHdiy05BCzkUBOKVTAnHcaWRu5n1pVU+jE4XpmQF3iKxsh22Ohdtf0rO0nchgU2f7ByMQS66gmGBH2/e//a/7AhmyJPbd3czyn76JMHIahYZXwvExAqwrkQqNq+bFg83TKVYPbWgq2CcQchVwTtmAIAS0AiotrkKRQHpRPYkFpEoOwtwpiVIszFEyLpVI8i47HgzXWAGraXV7LudkxU9aGAwGvNqjGMxH6LxEQIDAQABAoIBABpJNU497kWvUQ0J9AZxgZVKxoLJh89SKHhYjuOsNqrWkuXnJRoPTsSkeBd/E4q5RtFhIwcU3zdgfvIPXVWOqyTlZdTylpgcGYgaHassj1qYLPe1s0ZJGDAFoiHByCCU840CX3liHJaxCrBknwLHUfKlIwP2ktxvDb+OyEKwiyIu53s4PPvnx1zRWJlsn1M5x4ycVPi0kwhdkEmeFOdcK6ecBBygz8tT7VuDre+z3ipsCAFhJU5cVouFlmbwDThdSHD/ub8y+dIPqln0Jr/EtzSA2bIAgN5VaG5B1CdvDeOupg+SGuWkyA5AMluenRToJ5rDLs5zP1gP9QX9M4L32XkCgYEA8fYB0V9DVulp8XX7CiiCiRrio8OnObzgoNc7LuxNEKC4C5L9dHrXA/c1oDqKqKtmYBeMr5Wp+IKtbzbkitFOeQcFxpXP1sE7qwZzPC+VfwRUylawx610rWkq6J94VDOZYYrD54AGrbRHYlLNudWch0V8LreReGPH6IIsvyhISTUCgYEAwjhVqaNDwL/qEHwdjgF+ry6cxpGbb/11NLqMc1z8YG5Yt9EjTHZxYC3uU3PaTIvy8ulrSIlQGuxO1ZQdrasqsK1+LZ/V5IuyuUGBkVzeQ8h29ByloqQWKYC0a7DUjQxut6jkBBLvpChYlMGa494hDQfPb6123wQhJzq/4/lq3+0CgYEAsXg0KyKJXMUFPOLBR9kgrXl3xJAsJrA/vhk/wpH1kH2hoHOmzXmQHcuDnMtzinV1OIPtX5gRvEH0/b6wcJVGzoR1GwwlDqpann2zpfBs4WYG3wYo0byK+OpLW6Yj9vaFyMKkIDeKLnwtY5PCa0yfa6RuikGARGJDbWmVcnwUUQUCgYEAjaWybB8jic8t8Unj8C6M60zOkE+fkqTCt2sahgSaF+ZClAVWfiyf8WpuN7T1z//9d0Xfta5Xmtsff8sffgqo/uP5c2LwB1gPR+dLjO2zOLJadxzxfFW5yBkS/LuhuSEJGq91ZLc+yXWXbR5qTagyaKN6mVNkr6/+UfMaAjVkyd0CgYAudqu8L4z22y/M6zB2e5lzWle+AmjnUx8D/L2QhifJZF8hf9I6ULXCuYz0nioyK+Sdphrcp58exQbxtPaSsLrKjjqATExXN0GWu1zPwuLvOSjl2hf9nDnLcxCllFfwUqB2red5X/Fe//IqXbhfos/bsgXU6RV3Ki9Tc9Co6cTgQg=="
-          ],
-          "keyUse": [
-            "ENC"
-          ],
-          "certificate": [
-            "MIICqzCCAZMCBgGTrgGPBTANBgkqhkiG9w0BAQsFADAZMRcwFQYDVQQDDA5oZWxpb3MtZXhhbXBsZTAeFw0yNDEyMTAwMDM3MzhaFw0zNDEyMTAwMDM5MThaMBkxFzAVBgNVBAMMDmhlbGlvcy1leGFtcGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt5GsKBRPQynTPqx1sUmIBXcwuNgC+CgKN1KX+k0uf54rk937sgw3YAPWzseTCsit5PcawMczFjX+ddOZupMwgP1xJjA+fO348LNTd9RQO1iolMxKzHdiy05BCzkUBOKVTAnHcaWRu5n1pVU+jE4XpmQF3iKxsh22Ohdtf0rO0nchgU2f7ByMQS66gmGBH2/e//a/7AhmyJPbd3czyn76JMHIahYZXwvExAqwrkQqNq+bFg83TKVYPbWgq2CcQchVwTtmAIAS0AiotrkKRQHpRPYkFpEoOwtwpiVIszFEyLpVI8i47HgzXWAGraXV7LudkxU9aGAwGvNqjGMxH6LxEQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQBT/p8SRbX1I/n6Xwn3a0d6f9Mlbq5gsa36hVtMsCv3y2joFnRHRWLxOQAI/FROzbCc/G+CADRyemHbfyui0gAseNtD0dd9m2PuWlxcWL35feI4jq4Hbe0C77BMhPkXM4OpuWQeg7m2RVD9lBzbWqV2hn+7DZzliSqL9PKsG9yMCVSHWDcWQ511IcTY+TZyV4YhK0KYdJCLXECAfXCuE9WTuCCpCabW8MdfKnbrENTi0T7jNtqt8JOaYaU0aZLgIAV2VeGwES0aHyp++A6MI05ttv/px2IXRknPIDfQizNJ8dzYD27ztWnEk1F81/ISGmAFavY0azb19l/t4xDqkuYc"
-          ],
           "priority": [
             "100"
           ],
@@ -1760,7 +2000,7 @@
   "supportedLocales": [],
   "authenticationFlows": [
     {
-      "id": "8da3a73e-9903-4ed9-867a-7dbc56d2a13f",
+      "id": "ea936563-1475-421e-9ff7-146fd7ef6fe1",
       "alias": "Account verification options",
       "description": "Method with which to verity the existing account",
       "providerId": "basic-flow",
@@ -1786,7 +2026,7 @@
       ]
     },
     {
-      "id": "a3ac1d36-de6c-4f75-a1b5-caa88f8e7685",
+      "id": "8e4edd65-5d12-4c87-97f9-e2fc2f15747f",
       "alias": "Browser - Conditional OTP",
       "description": "Flow to determine if the OTP is required for the authentication",
       "providerId": "basic-flow",
@@ -1812,7 +2052,7 @@
       ]
     },
     {
-      "id": "a09dd486-1392-4018-a869-8f4c636f9ee3",
+      "id": "2a345f9d-38aa-4920-850f-f5f60fc2c138",
       "alias": "Browser - Conditional Organization",
       "description": "Flow to determine if the organization identity-first login is to be used",
       "providerId": "basic-flow",
@@ -1838,7 +2078,7 @@
       ]
     },
     {
-      "id": "1317bae6-3167-4c0e-8b51-7507629d6f86",
+      "id": "a82bb047-8086-42d9-bdc5-98b5dcb53bcb",
       "alias": "Direct Grant - Conditional OTP",
       "description": "Flow to determine if the OTP is required for the authentication",
       "providerId": "basic-flow",
@@ -1864,7 +2104,7 @@
       ]
     },
     {
-      "id": "9ac39ee7-9f67-4789-b42c-abe02d0015a9",
+      "id": "c5875e27-9f89-409a-94f9-80e9b60d9591",
       "alias": "First Broker Login - Conditional Organization",
       "description": "Flow to determine if the authenticator that adds organization members is to be used",
       "providerId": "basic-flow",
@@ -1874,7 +2114,7 @@
         {
           "authenticator": "conditional-user-configured",
           "authenticatorFlow": false,
-          "requirement": "REQUIRED",
+          "requirement": "DISABLED",
           "priority": 10,
           "autheticatorFlow": false,
           "userSetupAllowed": false
@@ -1882,7 +2122,7 @@
         {
           "authenticator": "idp-add-organization-member",
           "authenticatorFlow": false,
-          "requirement": "REQUIRED",
+          "requirement": "DISABLED",
           "priority": 20,
           "autheticatorFlow": false,
           "userSetupAllowed": false
@@ -1890,7 +2130,7 @@
       ]
     },
     {
-      "id": "f9f65cbd-4d17-4b2c-951d-60bbdb565f3a",
+      "id": "91f90477-454d-49d4-8a05-e16dc443226d",
       "alias": "First broker login - Conditional OTP",
       "description": "Flow to determine if the OTP is required for the authentication",
       "providerId": "basic-flow",
@@ -1916,7 +2156,7 @@
       ]
     },
     {
-      "id": "e3b25848-4b2e-4faf-9954-a388f87be705",
+      "id": "98c1da5b-bb4f-44a5-af96-cc019bff5528",
       "alias": "Handle Existing Account",
       "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider",
       "providerId": "basic-flow",
@@ -1942,7 +2182,7 @@
       ]
     },
     {
-      "id": "8829cf07-eab6-4983-8f4c-c6c1a66abc1b",
+      "id": "94c39473-f5f3-459d-b840-3e1f8ef9a3d1",
       "alias": "Organization",
       "providerId": "basic-flow",
       "topLevel": false,
@@ -1959,7 +2199,7 @@
       ]
     },
     {
-      "id": "95d0d782-26f7-4da5-8d9e-8347615d1913",
+      "id": "58a52386-b4c9-48b8-a09f-879ad4ef845e",
       "alias": "Reset - Conditional OTP",
       "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.",
       "providerId": "basic-flow",
@@ -1985,7 +2225,7 @@
       ]
     },
     {
-      "id": "e3445865-5d41-4991-be13-263ba66c098b",
+      "id": "fc088bc8-50de-46eb-b9fd-ef4a4e86a887",
       "alias": "User creation or linking",
       "description": "Flow for the existing/non-existing user alternatives",
       "providerId": "basic-flow",
@@ -2012,7 +2252,7 @@
       ]
     },
     {
-      "id": "c128c461-bce6-482f-86da-4f8ca9d7c03f",
+      "id": "d39e47b0-9dc0-4270-a8e0-cbb55ad8878d",
       "alias": "Verify Existing Account by Re-authentication",
       "description": "Reauthentication of existing account",
       "providerId": "basic-flow",
@@ -2038,7 +2278,7 @@
       ]
     },
     {
-      "id": "8e16d85d-0fde-4335-9dbc-5b2bfe9e0ea5",
+      "id": "c0edffc3-af03-4f75-8136-d812cd34193b",
       "alias": "browser",
       "description": "Browser based authentication",
       "providerId": "basic-flow",
@@ -2088,7 +2328,7 @@
       ]
     },
     {
-      "id": "269b0006-18a6-4d6a-bf52-77441cddd549",
+      "id": "21e53212-dcbd-4097-8d07-a323e67b7edd",
       "alias": "clients",
       "description": "Base authentication for clients",
       "providerId": "client-flow",
@@ -2130,7 +2370,7 @@
       ]
     },
     {
-      "id": "2c746bda-5747-4a9c-93ce-c90ff888ebd8",
+      "id": "e360cce3-9ac2-48b2-bcd3-7b810a60b768",
       "alias": "direct grant",
       "description": "OpenID Connect Resource Owner Grant",
       "providerId": "basic-flow",
@@ -2164,7 +2404,7 @@
       ]
     },
     {
-      "id": "a4373a3f-c091-4974-8362-e61e2457f627",
+      "id": "b45a890d-19a7-4ce8-8d50-061e790b9620",
       "alias": "docker auth",
       "description": "Used by Docker clients to authenticate against the IDP",
       "providerId": "basic-flow",
@@ -2182,7 +2422,7 @@
       ]
     },
     {
-      "id": "e7f27dfc-5d29-4dc4-93de-46c3e3c24af5",
+      "id": "b0b92860-d2a1-4387-a722-91e9fc10b003",
       "alias": "first broker login",
       "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account",
       "providerId": "basic-flow",
@@ -2193,7 +2433,7 @@
           "authenticatorConfig": "review profile config",
           "authenticator": "idp-review-profile",
           "authenticatorFlow": false,
-          "requirement": "REQUIRED",
+          "requirement": "DISABLED",
           "priority": 10,
           "autheticatorFlow": false,
           "userSetupAllowed": false
@@ -2208,7 +2448,7 @@
         },
         {
           "authenticatorFlow": true,
-          "requirement": "CONDITIONAL",
+          "requirement": "DISABLED",
           "priority": 50,
           "autheticatorFlow": true,
           "flowAlias": "First Broker Login - Conditional Organization",
@@ -2217,7 +2457,7 @@
       ]
     },
     {
-      "id": "157e4d26-9fd3-4ea3-8934-a1bc6eb975ef",
+      "id": "a22ff732-991b-4859-8b7e-724f3d4fb06c",
       "alias": "forms",
       "description": "Username, password, otp and other auth forms.",
       "providerId": "basic-flow",
@@ -2243,7 +2483,7 @@
       ]
     },
     {
-      "id": "f4547d63-b3c4-45ec-9900-54337c61d9ec",
+      "id": "04850820-288b-40a6-91a6-57a376a890f7",
       "alias": "registration",
       "description": "Registration flow",
       "providerId": "basic-flow",
@@ -2262,7 +2502,7 @@
       ]
     },
     {
-      "id": "12ce8bd4-6429-4acb-853b-cc3eb94beb35",
+      "id": "6c0e1d24-088a-4037-83c1-e91c90823347",
       "alias": "registration form",
       "description": "Registration form",
       "providerId": "form-flow",
@@ -2304,7 +2544,7 @@
       ]
     },
     {
-      "id": "2060ea95-cd6b-4401-9ba3-370a7a3332f8",
+      "id": "26e01a79-f726-40c3-affe-56d8fcf6df04",
       "alias": "reset credentials",
       "description": "Reset credentials for a user if they forgot their password or something",
       "providerId": "basic-flow",
@@ -2346,7 +2586,7 @@
       ]
     },
     {
-      "id": "971886b4-7052-4996-96e2-181ec856ca10",
+      "id": "f503bb8c-18f7-485f-af4d-0914a60b0f91",
       "alias": "saml ecp",
       "description": "SAML ECP Profile Authentication Flow",
       "providerId": "basic-flow",
@@ -2366,14 +2606,14 @@
   ],
   "authenticatorConfig": [
     {
-      "id": "033c8283-bef5-4e1a-b37d-962b6dc5eb54",
+      "id": "c6f49d11-e80d-4690-b6cd-000319f48b51",
       "alias": "create unique user config",
       "config": {
         "require.password.update.after.registration": "false"
       }
     },
     {
-      "id": "88206b72-9acb-4b36-b029-3bfdc3f40eff",
+      "id": "0ac32ab1-e693-4f32-9d87-024b351ebdb5",
       "alias": "review profile config",
       "config": {
         "update.profile.on.first.login": "missing"
@@ -2381,13 +2621,22 @@
     }
   ],
   "requiredActions": [
+    {
+      "alias": "VERIFY_EMAIL",
+      "name": "Verify Email",
+      "providerId": "VERIFY_EMAIL",
+      "enabled": true,
+      "defaultAction": false,
+      "priority": 10,
+      "config": {}
+    },
     {
       "alias": "CONFIGURE_TOTP",
       "name": "Configure OTP",
       "providerId": "CONFIGURE_TOTP",
       "enabled": true,
       "defaultAction": false,
-      "priority": 10,
+      "priority": 20,
       "config": {}
     },
     {
@@ -2396,7 +2645,7 @@
       "providerId": "TERMS_AND_CONDITIONS",
       "enabled": false,
       "defaultAction": false,
-      "priority": 20,
+      "priority": 30,
       "config": {}
     },
     {
@@ -2405,7 +2654,7 @@
       "providerId": "UPDATE_PASSWORD",
       "enabled": true,
       "defaultAction": false,
-      "priority": 30,
+      "priority": 40,
       "config": {}
     },
     {
@@ -2414,15 +2663,6 @@
       "providerId": "UPDATE_PROFILE",
       "enabled": true,
       "defaultAction": false,
-      "priority": 40,
-      "config": {}
-    },
-    {
-      "alias": "VERIFY_EMAIL",
-      "name": "Verify Email",
-      "providerId": "VERIFY_EMAIL",
-      "enabled": true,
-      "defaultAction": false,
       "priority": 50,
       "config": {}
     },
@@ -2490,22 +2730,23 @@
   "firstBrokerLoginFlow": "first broker login",
   "attributes": {
     "cibaBackchannelTokenDeliveryMode": "poll",
+    "cibaExpiresIn": "120",
     "cibaAuthRequestedUserHint": "login_hint",
+    "oauth2DeviceCodeLifespan": "600",
     "clientOfflineSessionMaxLifespan": "0",
     "oauth2DevicePollingInterval": "5",
     "clientSessionIdleTimeout": "0",
-    "clientOfflineSessionIdleTimeout": "0",
-    "cibaInterval": "5",
-    "realmReusableOtpCode": "false",
-    "cibaExpiresIn": "120",
-    "oauth2DeviceCodeLifespan": "600",
     "parRequestUriLifespan": "60",
     "clientSessionMaxLifespan": "0",
-    "organizationsEnabled": "false"
+    "clientOfflineSessionIdleTimeout": "0",
+    "cibaInterval": "5",
+    "realmReusableOtpCode": "false"
   },
-  "keycloakVersion": "26.0.7",
+  "keycloakVersion": "26.1.3",
   "userManagedAccessAllowed": false,
   "organizationsEnabled": false,
+  "verifiableCredentialsEnabled": false,
+  "adminPermissionsEnabled": false,
   "clientProfiles": {
     "profiles": []
   },
diff --git a/keycloakify/Dockerfile b/keycloakify/Dockerfile
index f976d63c8..735d3b31a 100644
--- a/keycloakify/Dockerfile
+++ b/keycloakify/Dockerfile
@@ -20,7 +20,9 @@ COPY . .
 RUN yarn run build-keycloak-theme
 
 # Stage 2: Build Keycloak image
-FROM quay.io/keycloak/keycloak:26.1.4
+# We use the specific version of Keycloak to avoid any changes in the realm.json file
+# Do NOT update on renovatebot PRs
+FROM quay.io/keycloak/keycloak:26.1.3
 
 # Copy the built keycloakify theme to keycloak
 COPY --from=build /app/dist_keycloak /opt/keycloak/providers/
diff --git a/server/application-server/.env.example b/server/application-server/.env.example
index bb2b0577c..50d91750a 100644
--- a/server/application-server/.env.example
+++ b/server/application-server/.env.example
@@ -16,4 +16,6 @@ GITHUB_APP_ID=Refer to documentation
 GITHUB_CLIENT_ID=Refer to documentation
 ORGANIZATION_NAME=Refer to documentation
 GITHUB_INSTALLATION_ID=Refer to documentation
-GITHUB_PRIVATE_KEY_PATH=Refer to documentation
\ No newline at end of file
+GITHUB_PRIVATE_KEY_PATH=Refer to documentation
+HELIOS_TOKEN_EXCHANGE_CLIENT=helios-token-exchange
+HELIOS_TOKEN_EXCHANGE_SECRET=wHBfstYLRLDG8xmJtHNsrGjo40UULNmy
\ No newline at end of file
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/GitHubAuthBroker.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/GitHubAuthBroker.java
new file mode 100644
index 000000000..ddb837ef2
--- /dev/null
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/GitHubAuthBroker.java
@@ -0,0 +1,82 @@
+package de.tum.cit.aet.helios.auth.github;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import okhttp3.FormBody;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+@Log4j2
+@Service
+@RequiredArgsConstructor
+public class GitHubAuthBroker {
+
+  private final ObjectMapper objectMapper;
+  private final OkHttpClient okHttpClient;
+
+  @Value("${github.tokenExchangeClientId}")
+  private String tokenExchangeClient;
+
+  @Value("${github.tokenExchangeClientSecret}")
+  private String tokenExchangeSecret;
+
+  @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
+  private String issuerUri;
+
+  /**
+   * Performs a token exchange operation with the identity provider.
+   *
+   * @param username the username for which to request a token
+   * @return the token exchange response
+   * @throws IOException if an I/O error occurs during the token exchange
+   */
+  public TokenExchangeResponse exchangeToken(String username) throws IOException {
+
+    FormBody requestBody =
+        new FormBody.Builder()
+            .add("client_id", tokenExchangeClient)
+            .add("client_secret", tokenExchangeSecret)
+            .add("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
+            .add("requested_subject", username)
+            .add("requested_token_type", "urn:ietf:params:oauth:token-type:access_token")
+            .add("requested_issuer", "github")
+            .build();
+
+    Request request =
+        new Request.Builder()
+            .url(issuerUri + "/protocol/openid-connect/token")
+            .post(requestBody)
+            .build();
+
+    try (Response response = okHttpClient.newCall(request).execute()) {
+      if (!response.isSuccessful()) {
+        throw new IOException("Token exchange failed with response code: " + response.code());
+      }
+
+      ResponseBody responseBody = response.body();
+      if (responseBody == null) {
+        throw new IOException("Response body is null");
+      }
+
+      String responseBodyContent = responseBody.string();
+      if (responseBodyContent.isEmpty()) {
+        throw new IOException("Response body is empty");
+      }
+
+      return objectMapper.readValue(responseBodyContent, TokenExchangeResponse.class);
+    } catch (JsonProcessingException e) {
+      log.error("Error processing JSON response: {}", e.getMessage());
+      throw new IOException("Error processing token exchange response", e);
+    } catch (IOException e) {
+      log.error("Error occurred during token exchange: {}", e.getMessage());
+      throw e;
+    }
+  }
+}
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/TokenExchangeResponse.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/TokenExchangeResponse.java
new file mode 100644
index 000000000..7625491db
--- /dev/null
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/TokenExchangeResponse.java
@@ -0,0 +1,27 @@
+package de.tum.cit.aet.helios.auth.github;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class TokenExchangeResponse {
+  @JsonProperty("access_token")
+  private String accessToken;
+
+  @JsonProperty("expires_in")
+  private Integer expiresIn;
+
+  @JsonProperty("refresh_expires_in")
+  private Integer refreshExpiresIn;
+
+  @JsonProperty("token_type")
+  private String tokenType;
+
+  @JsonProperty("not-before-policy")
+  private Integer notBeforePolicy;
+
+  @JsonProperty("scope")
+  private String scope;
+}
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/approval/ApprovalService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/approval/ApprovalService.java
new file mode 100644
index 000000000..887ce93a8
--- /dev/null
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/approval/ApprovalService.java
@@ -0,0 +1,62 @@
+package de.tum.cit.aet.helios.deployment.approval;
+
+import de.tum.cit.aet.helios.deployment.github.DeploymentSource;
+import de.tum.cit.aet.helios.environment.Environment;
+import de.tum.cit.aet.helios.github.GitHubService;
+import de.tum.cit.aet.helios.gitrepo.GitRepository;
+import de.tum.cit.aet.helios.heliosdeployment.HeliosDeployment;
+import de.tum.cit.aet.helios.heliosdeployment.HeliosDeploymentRepository;
+import de.tum.cit.aet.helios.user.User;
+import java.io.IOException;
+import java.util.Optional;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Service;
+
+@Log4j2
+@Service
+@RequiredArgsConstructor
+public class ApprovalService {
+  private final GitHubService gitHubService;
+  private final HeliosDeploymentRepository heliosDeploymentRepository;
+
+  public void reviewDeployment(
+      DeploymentSource deploymentSource,
+      GitRepository gitRepository,
+      Environment environment,
+      User user) {
+
+    Optional<HeliosDeployment> heliosDeploymentOpt =
+        heliosDeploymentRepository.findByDeploymentId(deploymentSource.getId());
+
+    // If deployment not found in Helios, it was triggered directly from GitHub
+    if (heliosDeploymentOpt.isEmpty()) {
+      log.info("Skipping approval for non-Helios deployment with ID {}", deploymentSource.getId());
+      return;
+    }
+
+    HeliosDeployment heliosDeployment = heliosDeploymentOpt.get();
+
+    // Check if this is a Helios deployment with workflow run ID
+    if (heliosDeployment.getWorkflowRunId() == null) {
+      // Workflow run ID missing - this is unusual for a Helios deployment
+      log.warn(
+          "Missing workflow run ID for Helios deployment with ID {}. Possible sync issue.",
+          deploymentSource.getId());
+      return;
+    }
+
+    log.info("Approving Helios Deployment: {}", heliosDeployment);
+
+    Long workflowRunId = heliosDeployment.getWorkflowRunId();
+    String repoNameWithOwner = gitRepository.getNameWithOwner();
+    String githubUserLogin = heliosDeployment.getCreator().getLogin();
+
+    try {
+      this.gitHubService.approveDeploymentOnBehalfOfUser(
+          repoNameWithOwner, workflowRunId, environment.getId(), githubUserLogin);
+    } catch (IOException e) {
+      log.error("Error approving deployment: {}", e.getMessage());
+    }
+  }
+}
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/github/GitHubDeploymentStatusMessageHandler.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/github/GitHubDeploymentStatusMessageHandler.java
index 5cfc2b52d..cca9ad327 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/github/GitHubDeploymentStatusMessageHandler.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/github/GitHubDeploymentStatusMessageHandler.java
@@ -1,6 +1,7 @@
 package de.tum.cit.aet.helios.deployment.github;
 
 import de.tum.cit.aet.helios.deployment.Deployment;
+import de.tum.cit.aet.helios.deployment.approval.ApprovalService;
 import de.tum.cit.aet.helios.environment.Environment;
 import de.tum.cit.aet.helios.environment.EnvironmentLockHistory;
 import de.tum.cit.aet.helios.environment.EnvironmentLockHistoryRepository;
@@ -13,6 +14,7 @@
 import de.tum.cit.aet.helios.user.User;
 import de.tum.cit.aet.helios.user.github.GitHubUserSyncService;
 import java.io.IOException;
+import java.time.Instant;
 import java.util.Optional;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.log4j.Log4j2;
@@ -31,6 +33,7 @@ public class GitHubDeploymentStatusMessageHandler
     extends GitHubMessageHandler<GHEventPayload.DeploymentStatus> {
 
   private final GitHubDeploymentSyncService deploymentSyncService;
+  private final ApprovalService approvalService;
   private final GitRepoRepository gitRepoRepository;
   private final EnvironmentRepository environmentRepository;
   private final DeploymentSourceFactory deploymentSourceFactory;
@@ -39,6 +42,8 @@ public class GitHubDeploymentStatusMessageHandler
   private final GitHubFacade github;
   private final GitHubDataSyncOrchestrator gitHubDataSyncOrchestrator;
 
+  private final Instant applicationStartTime = Instant.now();
+
   @Override
   protected Class<GHEventPayload.DeploymentStatus> getPayloadClass() {
     return GHEventPayload.DeploymentStatus.class;
@@ -138,5 +143,22 @@ protected void handleInstalledRepositoryEvent(GHEventPayload.DeploymentStatus ev
     // Process this single deployment
     deploymentSyncService.processDeployment(
         deploymentSource, repository, environment, convertedUser);
+
+    // Auto-approve deployments in WAITING state
+    if (deploymentSource.getState() == Deployment.State.WAITING) {
+      // Check if this is a recent event (after app startup) or an old event being replayed
+      Instant eventTime;
+      try {
+        eventTime = eventPayload.getDeploymentStatus().getCreatedAt().toInstant();
+      } catch (IOException e) {
+        eventTime = Instant.now();
+      }
+
+      // Only automatically approve if the event is recent (after application startup)
+      if (eventTime.isAfter(applicationStartTime)) {
+        log.info("Recent deployment in WAITING state. Reviewing the deployment.");
+        approvalService.reviewDeployment(deploymentSource, repository, environment, convertedUser);
+      }
+    }
   }
 }
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/github/GitHubService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/github/GitHubService.java
index c21a40c11..11aeb154d 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/github/GitHubService.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/github/GitHubService.java
@@ -3,6 +3,8 @@
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import de.tum.cit.aet.helios.auth.AuthService;
+import de.tum.cit.aet.helios.auth.github.GitHubAuthBroker;
+import de.tum.cit.aet.helios.auth.github.TokenExchangeResponse;
 import de.tum.cit.aet.helios.deployment.github.GitHubDeploymentDto;
 import de.tum.cit.aet.helios.environment.github.GitHubEnvironmentApiResponse;
 import de.tum.cit.aet.helios.environment.github.GitHubEnvironmentDto;
@@ -25,6 +27,7 @@
 import okhttp3.Request.Builder;
 import okhttp3.RequestBody;
 import okhttp3.Response;
+import okhttp3.ResponseBody;
 import org.kohsuke.github.GHArtifact;
 import org.kohsuke.github.GHCommitState;
 import org.kohsuke.github.GHOrganization;
@@ -47,6 +50,7 @@ public class GitHubService {
   private final ObjectMapper objectMapper;
   private final OkHttpClient okHttpClient;
   private final AuthService authService;
+  private final GitHubAuthBroker gitHubAuthBroker;
   private final GitHubClientManager clientManager;
   private GHOrganization gitHubOrganization;
 
@@ -341,4 +345,78 @@ public void createCommitStatusForPullRequest(GHPullRequest pullRequest) {
       log.error("Error occurred while creating commit status: {}", e.getMessage());
     }
   }
+
+  /**
+   * Approves GitHub pending deployments for a workflow run on behalf of user.
+   *
+   * @param repoNameWithOwner the repository name with owner
+   * @param runId the workflow run ID
+   * @param environmentId the IDs of environments to approve
+   * @param githubUserLogin the GitHub user login
+   * @throws IOException if an I/O error occurs during the API call
+   */
+  public void approveDeploymentOnBehalfOfUser(
+      String repoNameWithOwner, long runId, Long environmentId, String githubUserLogin)
+      throws IOException {
+    // Construct the approval payload
+    Map<String, Object> requestPayload =
+        Map.of(
+            "environment_ids", List.of(environmentId),
+            "state", "approved",
+            "comment", "Automatically approved by Helios");
+
+    String jsonPayload = objectMapper.writeValueAsString(requestPayload);
+
+    // Construct the URL
+    String url =
+        String.format(
+            "https://api.github.com/repos/%s/actions/runs/%d/pending_deployments",
+            repoNameWithOwner, runId);
+
+    // Build the request with the GitHub token from Keycloak
+    RequestBody requestBody =
+        RequestBody.create(jsonPayload, MediaType.get("application/json; charset=utf-8"));
+
+    TokenExchangeResponse tokenExchangeResponse =
+        this.gitHubAuthBroker.exchangeToken(githubUserLogin);
+    if (tokenExchangeResponse == null) {
+      log.error("Token exchange response is null");
+      return;
+    }
+    String userGithubToken = tokenExchangeResponse.getAccessToken();
+
+    Request request =
+        new Request.Builder()
+            .url(url)
+            .post(requestBody)
+            .header("Authorization", "Bearer " + userGithubToken)
+            .header("Accept", "application/json")
+            .build();
+
+    // Execute the request
+    try (Response response = okHttpClient.newCall(request).execute()) {
+      if (!response.isSuccessful()) {
+        String errorBody = "No error details";
+        ResponseBody responseBody = response.body();
+        if (responseBody != null) {
+          try {
+            errorBody = responseBody.string();
+          } catch (IOException e) {
+            log.warn("Failed to read error response body", e);
+          }
+        }
+
+        log.error(
+            "GitHub API call failed with response code: {} and body: {}",
+            response.code(),
+            errorBody);
+        throw new IOException("GitHub API call failed with response code: " + response.code());
+      }
+
+      log.info("Successfully approved deployment for run ID: {}", runId);
+    } catch (IOException e) {
+      log.error("Error occurred while approving deployment: {}", e.getMessage());
+      throw e;
+    }
+  }
 }
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/heliosdeployment/HeliosDeployment.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/heliosdeployment/HeliosDeployment.java
index ac6fd2c4c..e8a4d72f2 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/heliosdeployment/HeliosDeployment.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/heliosdeployment/HeliosDeployment.java
@@ -72,6 +72,9 @@ public class HeliosDeployment {
   @Column(name = "deployment_id", nullable = true)
   private Long deploymentId;
 
+  @Column(name = "workflow_run_id", nullable = true)
+  private Long workflowRunId;
+
   @Column(name = "workflow_run_html_url", nullable = true)
   private String workflowRunHtmlUrl;
 
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/heliosdeployment/HeliosDeploymentRepository.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/heliosdeployment/HeliosDeploymentRepository.java
index e284d099f..c9af948f4 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/heliosdeployment/HeliosDeploymentRepository.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/heliosdeployment/HeliosDeploymentRepository.java
@@ -29,4 +29,6 @@ Optional<HeliosDeployment> findTopByEnvironmentAndBranchNameOrderByCreatedAtDesc
           + "AND hd.sha = :sha "
           + "ORDER BY hd.createdAt")
   List<HeliosDeployment> findByRepositoryIdAndSha(Long repositoryId, String sha);
+
+  Optional<HeliosDeployment> findByDeploymentId(Long deploymentId);
 }
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/github/GitHubWorkflowRunSyncService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/github/GitHubWorkflowRunSyncService.java
index c08b8e256..799a1a560 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/github/GitHubWorkflowRunSyncService.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/github/GitHubWorkflowRunSyncService.java
@@ -160,10 +160,12 @@ private void processRunForHeliosDeployment(GHWorkflowRun workflowRun) throws IOE
             heliosDeployment -> {
               try {
                 if (workflowRun
-                    .getUpdatedAt()
-                    .toInstant()
-                    .isAfter(heliosDeployment.getUpdatedAt().toInstant())
-                    || workflowRun.getUpdatedAt().toInstant()
+                        .getUpdatedAt()
+                        .toInstant()
+                        .isAfter(heliosDeployment.getUpdatedAt().toInstant())
+                    || workflowRun
+                        .getUpdatedAt()
+                        .toInstant()
                         .equals(heliosDeployment.getUpdatedAt().toInstant())) {
                   heliosDeployment.setUpdatedAt(
                       DateUtil.convertToOffsetDateTime(workflowRun.getUpdatedAt()));
@@ -173,15 +175,16 @@ private void processRunForHeliosDeployment(GHWorkflowRun workflowRun) throws IOE
 
                   // Update the deployment status
                   heliosDeployment.setStatus(mappedStatus);
-    
+
                   // Update the workflow run html url, so we can show the approval url
                   // to the user before the Github deployment is created
+                  heliosDeployment.setWorkflowRunId(workflowRun.getId());
                   heliosDeployment.setWorkflowRunHtmlUrl(workflowRun.getHtmlUrl().toString());
 
                   log.info(
-                          "Updated HeliosDeployment {} to status {}",
-                          heliosDeployment.getId(),
-                          mappedStatus);
+                      "Updated HeliosDeployment {} to status {}",
+                      heliosDeployment.getId(),
+                      mappedStatus);
                   heliosDeploymentRepository.save(heliosDeployment);
                 }
               } catch (IOException e) {
diff --git a/server/application-server/src/main/resources/application-dev.yml b/server/application-server/src/main/resources/application-dev.yml
index 8b4e4bf81..be623fa34 100644
--- a/server/application-server/src/main/resources/application-dev.yml
+++ b/server/application-server/src/main/resources/application-dev.yml
@@ -52,6 +52,9 @@ github:
     clientId: ${GITHUB_CLIENT_ID:}
     installationId: ${GITHUB_INSTALLATION_ID:}
     privateKeyPath: ${GITHUB_PRIVATE_KEY_PATH:}
+    # GitHub Token exchange credentials
+    tokenExchangeClientId: ${HELIOS_TOKEN_EXCHANGE_CLIENT:}
+    tokenExchangeClientSecret: ${HELIOS_TOKEN_EXCHANGE_SECRET:}
 
 http:
     cache:
diff --git a/server/application-server/src/main/resources/application-openapi.yml b/server/application-server/src/main/resources/application-openapi.yml
index c669a7812..d4e1b7e74 100644
--- a/server/application-server/src/main/resources/application-openapi.yml
+++ b/server/application-server/src/main/resources/application-openapi.yml
@@ -34,6 +34,9 @@ github:
     clientId: ""
     installationId: ""
     privateKeyPath: ""
+    # GitHub Token exchange credentials
+    tokenExchangeClientId: ""
+    tokenExchangeClientSecret: ""
 
 http:
     cache:
diff --git a/server/application-server/src/main/resources/application-prod.yml b/server/application-server/src/main/resources/application-prod.yml
index 751b1ffe6..6c00b67b7 100644
--- a/server/application-server/src/main/resources/application-prod.yml
+++ b/server/application-server/src/main/resources/application-prod.yml
@@ -49,6 +49,9 @@ github:
     clientId: ${GITHUB_CLIENT_ID:}
     installationId: ${GITHUB_INSTALLATION_ID:}
     privateKeyPath: ${GITHUB_PRIVATE_KEY_PATH:}
+    # GitHub Token exchange credentials
+    tokenExchangeClientId: ${HELIOS_TOKEN_EXCHANGE_CLIENT:}
+    tokenExchangeClientSecret: ${HELIOS_TOKEN_EXCHANGE_SECRET:}
 
 http:
     cache:
diff --git a/server/application-server/src/main/resources/application-staging.yml b/server/application-server/src/main/resources/application-staging.yml
index 1756b0f50..980e588d4 100644
--- a/server/application-server/src/main/resources/application-staging.yml
+++ b/server/application-server/src/main/resources/application-staging.yml
@@ -49,6 +49,9 @@ github:
     clientId: ${GITHUB_CLIENT_ID:}
     installationId: ${GITHUB_INSTALLATION_ID:}
     privateKeyPath: ${GITHUB_PRIVATE_KEY_PATH:}
+    # GitHub Token exchange credentials
+    tokenExchangeClientId: ${HELIOS_TOKEN_EXCHANGE_CLIENT:}
+    tokenExchangeClientSecret: ${HELIOS_TOKEN_EXCHANGE_SECRET:}
 
 http:
     cache:
diff --git a/server/application-server/src/main/resources/db/migration/V21__store_workflow_run_id_helios_deployment.sql b/server/application-server/src/main/resources/db/migration/V21__store_workflow_run_id_helios_deployment.sql
new file mode 100644
index 000000000..53e5d1e6b
--- /dev/null
+++ b/server/application-server/src/main/resources/db/migration/V21__store_workflow_run_id_helios_deployment.sql
@@ -0,0 +1,15 @@
+-- Add workflow_run_id column to the helios_deployment table
+ALTER TABLE helios_deployment
+    ADD COLUMN workflow_run_id BIGINT;
+
+-- Update the new column with values extracted from the workflow_run_html_url column
+UPDATE helios_deployment
+SET workflow_run_id = CAST(
+    substring(workflow_run_html_url from '/runs/([0-9]+)') AS BIGINT
+)
+WHERE workflow_run_html_url IS NOT NULL;
+
+-- Add foreign key constraint to ensure referential integrity
+ALTER TABLE helios_deployment
+    ADD CONSTRAINT fk_helios_deployment_workflow_run
+    FOREIGN KEY (workflow_run_id) REFERENCES public.workflow_run(id);
\ No newline at end of file


Reviews:
user: login: egekocabas
body: Thank you for this huge improvement to our deployments 💯  i am aware that testing these changes is quite challenging  🙏🏻 

I only did code review right now and will test later on 👍🏻 Also it would also be really helpful if you could document the steps you took on the Keycloak side—this will be valuable not only for future staging/production deployments but also as a reference for how we enabled the key exchange.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-29T22:11:49Z
user: login: gbanu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T11:08:01Z
user: login: gbanu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T11:10:15Z
user: login: gbanu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T11:11:32Z
user: login: gbanu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T11:12:35Z
user: login: gbanu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T11:13:11Z
user: login: egekocabas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-30T11:14:20Z
user: login: gbanu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T11:21:12Z
user: login: egekocabas
body: 🙏🏻🙏🏻
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-30T15:03:21Z

Review comments:
diff_hunk: @@ -20,7 +20,9 @@ COPY . .
 RUN yarn run build-keycloak-theme
 
 # Stage 2: Build Keycloak image
-FROM quay.io/keycloak/keycloak:26.1.4
path: keycloakify/Dockerfile
user: login: egekocabas
body: Doesn't the token exchange work with the version `26.1.4`?
created_at: 2025-03-29T21:51:05Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,79 @@
+package de.tum.cit.aet.helios.auth.github;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import okhttp3.FormBody;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+@Log4j2
+@Service
+@RequiredArgsConstructor
+public class GitHubAuthBroker {
+
+  private final ObjectMapper objectMapper;
+  private final OkHttpClient okHttpClient;
+
+  @Value("${github.tokenExchangeClientId}")
+  private String tokenExchangeClient;
+
+  @Value("${github.tokenExchangeClientSecret}")
+  private String tokenExchangeSecret;
+
+  /**
+   * Performs a token exchange operation with the identity provider.
+   *
+   * @param username the username for which to request a token
+   * @return the token exchange response
+   * @throws IOException if an I/O error occurs during the token exchange
+   */
+  public TokenExchangeResponse exchangeToken(String username) throws IOException {
+
+    FormBody requestBody =
+        new FormBody.Builder()
+            .add("client_id", tokenExchangeClient)
+            .add("client_secret", tokenExchangeSecret)
+            .add("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
+            .add("requested_subject", username)
+            .add("requested_token_type", "urn:ietf:params:oauth:token-type:access_token")
+            .add("requested_issuer", "github")
+            .build();
+
+    Request request =
+        new Request.Builder()
+            .url("http://localhost:8081/realms/helios-example/protocol/openid-connect/token")
path: server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/GitHubAuthBroker.java
user: login: egekocabas
body: instead of hardcoded localhost i think we should use `OAUTH_ISSUER_URL` in here since this localhost would only work for local development since staging/prod use domains and do not support localhost directly
created_at: 2025-03-29T21:54:40Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,76 @@
+package de.tum.cit.aet.helios.deployment.approval;
+
+import de.tum.cit.aet.helios.deployment.github.DeploymentSource;
+import de.tum.cit.aet.helios.environment.Environment;
+import de.tum.cit.aet.helios.github.GitHubService;
+import de.tum.cit.aet.helios.gitrepo.GitRepository;
+import de.tum.cit.aet.helios.heliosdeployment.HeliosDeployment;
+import de.tum.cit.aet.helios.heliosdeployment.HeliosDeploymentRepository;
+import de.tum.cit.aet.helios.user.User;
+import java.io.IOException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Service;
+
+@Log4j2
+@Service
+@RequiredArgsConstructor
+public class ApprovalService {
+  private final GitHubService gitHubService;
+  private final HeliosDeploymentRepository heliosDeploymentRepository;
+
+  public void reviewDeployment(
+      DeploymentSource deploymentSource,
+      GitRepository gitRepository,
+      Environment environment,
+      User user) {
+
+    log.info(
+        "Approving deployment: {} {} {} {}", deploymentSource, gitRepository, environment, user);
+
+    Boolean shouldApprove = shouldApprove();
+
+    if (!shouldApprove) {
+      log.info("Ignoring non-Helios deployment approval");
+      return;
+    }
+    Boolean isHeliosBot = user.getLogin().equals("helios-bot");
+    log.info("Is Helios Bot: {}", isHeliosBot);
+
+    HeliosDeployment heliosDeployment =
+        heliosDeploymentRepository.findByDeploymentId(deploymentSource.getId()).orElse(null);
+
+    log.info("Helios Deployment: {}", heliosDeployment);
+    if (heliosDeployment == null) {
+      log.error("Deployment with ID {} not found", deploymentSource.getId());
+      return;
+    }
+
+    Long workflowRunId =
+        Long.valueOf(
+            heliosDeployment
+                .getWorkflowRunHtmlUrl()
+                .substring(heliosDeployment.getWorkflowRunHtmlUrl().lastIndexOf("/") + 1));
+
+    String repoNameWithOwner = gitRepository.getNameWithOwner();
+    String githubUserLogin = heliosDeployment.getCreator().getLogin();
+
+    log.info("Workflow Run ID: {}", workflowRunId);
+    log.info("Repo Name with Owner: {}", repoNameWithOwner);
+    log.info("GitHub User Login: {}", githubUserLogin);
+    try {
+      this.gitHubService.approveDeploymentOnBehalfOfUser(
+          repoNameWithOwner, workflowRunId, environment.getId(), githubUserLogin);
+    } catch (IOException e) {
+      log.error("Error approving deployment: {}", e.getMessage());
+    }
+
+    // cancel workflow if not possible to approve
+    // maybe add a new status to deployment "NOT_ENOUGH_PERMISSIONS"
+  }
+
+  private Boolean shouldApprove() {
+    // check if triggered via helios
+    return true;
path: server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/approval/ApprovalService.java
user: login: egekocabas
body: we should check something here right 😅 
created_at: 2025-03-29T21:56:25Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,76 @@
+package de.tum.cit.aet.helios.deployment.approval;
+
+import de.tum.cit.aet.helios.deployment.github.DeploymentSource;
+import de.tum.cit.aet.helios.environment.Environment;
+import de.tum.cit.aet.helios.github.GitHubService;
+import de.tum.cit.aet.helios.gitrepo.GitRepository;
+import de.tum.cit.aet.helios.heliosdeployment.HeliosDeployment;
+import de.tum.cit.aet.helios.heliosdeployment.HeliosDeploymentRepository;
+import de.tum.cit.aet.helios.user.User;
+import java.io.IOException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Service;
+
+@Log4j2
+@Service
+@RequiredArgsConstructor
+public class ApprovalService {
+  private final GitHubService gitHubService;
+  private final HeliosDeploymentRepository heliosDeploymentRepository;
+
+  public void reviewDeployment(
+      DeploymentSource deploymentSource,
+      GitRepository gitRepository,
+      Environment environment,
+      User user) {
+
+    log.info(
+        "Approving deployment: {} {} {} {}", deploymentSource, gitRepository, environment, user);
+
+    Boolean shouldApprove = shouldApprove();
+
+    if (!shouldApprove) {
+      log.info("Ignoring non-Helios deployment approval");
+      return;
+    }
+    Boolean isHeliosBot = user.getLogin().equals("helios-bot");
+    log.info("Is Helios Bot: {}", isHeliosBot);
+
path: server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/approval/ApprovalService.java
user: login: egekocabas
body: This is not used i think, also we shouln't check directly like the "helios-bot" since the staging github app has a different name than the prod one.

Instead we can get the app name as `github.getGithubAppName()`

Also we are sending convertedUser to this method which in [this lines](https://github.com/ls1intum/Helios/blob/801f6771a26fdf1470edee67db3347ca958562dd/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/github/GitHubDeploymentStatusMessageHandler.java#L121-L128) the user is replaced with the user that locked the environment, so if we wanna check if the deployment is initiated with "helios-bot" then the user in here might not be the bot user
created_at: 2025-03-29T22:01:55Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,76 @@
+package de.tum.cit.aet.helios.deployment.approval;
+
+import de.tum.cit.aet.helios.deployment.github.DeploymentSource;
+import de.tum.cit.aet.helios.environment.Environment;
+import de.tum.cit.aet.helios.github.GitHubService;
+import de.tum.cit.aet.helios.gitrepo.GitRepository;
+import de.tum.cit.aet.helios.heliosdeployment.HeliosDeployment;
+import de.tum.cit.aet.helios.heliosdeployment.HeliosDeploymentRepository;
+import de.tum.cit.aet.helios.user.User;
+import java.io.IOException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Service;
+
+@Log4j2
+@Service
+@RequiredArgsConstructor
+public class ApprovalService {
+  private final GitHubService gitHubService;
+  private final HeliosDeploymentRepository heliosDeploymentRepository;
+
+  public void reviewDeployment(
+      DeploymentSource deploymentSource,
+      GitRepository gitRepository,
+      Environment environment,
+      User user) {
+
+    log.info(
+        "Approving deployment: {} {} {} {}", deploymentSource, gitRepository, environment, user);
+
+    Boolean shouldApprove = shouldApprove();
+
+    if (!shouldApprove) {
+      log.info("Ignoring non-Helios deployment approval");
+      return;
+    }
+    Boolean isHeliosBot = user.getLogin().equals("helios-bot");
+    log.info("Is Helios Bot: {}", isHeliosBot);
+
+    HeliosDeployment heliosDeployment =
+        heliosDeploymentRepository.findByDeploymentId(deploymentSource.getId()).orElse(null);
+
+    log.info("Helios Deployment: {}", heliosDeployment);
+    if (heliosDeployment == null) {
+      log.error("Deployment with ID {} not found", deploymentSource.getId());
+      return;
+    }
+
+    Long workflowRunId =
+        Long.valueOf(
+            heliosDeployment
+                .getWorkflowRunHtmlUrl()
+                .substring(heliosDeployment.getWorkflowRunHtmlUrl().lastIndexOf("/") + 1));
path: server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/approval/ApprovalService.java
user: login: egekocabas
body: Maybe we can add a comment here so that in the future we might wanna save the workflow run id inside the heliosdeployment so that it will be easier 👍🏻 
created_at: 2025-03-29T22:03:52Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,79 @@
+package de.tum.cit.aet.helios.auth.github;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import okhttp3.FormBody;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+@Log4j2
+@Service
+@RequiredArgsConstructor
+public class GitHubAuthBroker {
+
+  private final ObjectMapper objectMapper;
+  private final OkHttpClient okHttpClient;
+
+  @Value("${github.tokenExchangeClientId}")
+  private String tokenExchangeClient;
+
+  @Value("${github.tokenExchangeClientSecret}")
+  private String tokenExchangeSecret;
+
+  /**
+   * Performs a token exchange operation with the identity provider.
+   *
+   * @param username the username for which to request a token
+   * @return the token exchange response
+   * @throws IOException if an I/O error occurs during the token exchange
+   */
+  public TokenExchangeResponse exchangeToken(String username) throws IOException {
+
+    FormBody requestBody =
+        new FormBody.Builder()
+            .add("client_id", tokenExchangeClient)
+            .add("client_secret", tokenExchangeSecret)
+            .add("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
+            .add("requested_subject", username)
+            .add("requested_token_type", "urn:ietf:params:oauth:token-type:access_token")
+            .add("requested_issuer", "github")
+            .build();
+
+    Request request =
+        new Request.Builder()
+            .url("http://localhost:8081/realms/helios-example/protocol/openid-connect/token")
path: server/application-server/src/main/java/de/tum/cit/aet/helios/auth/github/GitHubAuthBroker.java
user: login: gbanu
body: Oh yes, thanks, good catch 🙏 
created_at: 2025-03-30T11:08:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,7 +20,9 @@ COPY . .
 RUN yarn run build-keycloak-theme
 
 # Stage 2: Build Keycloak image
-FROM quay.io/keycloak/keycloak:26.1.4
path: keycloakify/Dockerfile
user: login: gbanu
body: the problem is that the exported realm config format changes between the keycloak versions. This is the reason we use specific version and not latest. I started working on it when we still user 26.1.3 and it took me some quite time to sync our prod config and development config, so I didn't want to do it again.
created_at: 2025-03-30T11:10:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,76 @@
+package de.tum.cit.aet.helios.deployment.approval;
+
+import de.tum.cit.aet.helios.deployment.github.DeploymentSource;
+import de.tum.cit.aet.helios.environment.Environment;
+import de.tum.cit.aet.helios.github.GitHubService;
+import de.tum.cit.aet.helios.gitrepo.GitRepository;
+import de.tum.cit.aet.helios.heliosdeployment.HeliosDeployment;
+import de.tum.cit.aet.helios.heliosdeployment.HeliosDeploymentRepository;
+import de.tum.cit.aet.helios.user.User;
+import java.io.IOException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Service;
+
+@Log4j2
+@Service
+@RequiredArgsConstructor
+public class ApprovalService {
+  private final GitHubService gitHubService;
+  private final HeliosDeploymentRepository heliosDeploymentRepository;
+
+  public void reviewDeployment(
+      DeploymentSource deploymentSource,
+      GitRepository gitRepository,
+      Environment environment,
+      User user) {
+
+    log.info(
+        "Approving deployment: {} {} {} {}", deploymentSource, gitRepository, environment, user);
+
+    Boolean shouldApprove = shouldApprove();
+
+    if (!shouldApprove) {
+      log.info("Ignoring non-Helios deployment approval");
+      return;
+    }
+    Boolean isHeliosBot = user.getLogin().equals("helios-bot");
+    log.info("Is Helios Bot: {}", isHeliosBot);
+
+    HeliosDeployment heliosDeployment =
+        heliosDeploymentRepository.findByDeploymentId(deploymentSource.getId()).orElse(null);
+
+    log.info("Helios Deployment: {}", heliosDeployment);
+    if (heliosDeployment == null) {
+      log.error("Deployment with ID {} not found", deploymentSource.getId());
+      return;
+    }
+
+    Long workflowRunId =
+        Long.valueOf(
+            heliosDeployment
+                .getWorkflowRunHtmlUrl()
+                .substring(heliosDeployment.getWorkflowRunHtmlUrl().lastIndexOf("/") + 1));
+
+    String repoNameWithOwner = gitRepository.getNameWithOwner();
+    String githubUserLogin = heliosDeployment.getCreator().getLogin();
+
+    log.info("Workflow Run ID: {}", workflowRunId);
+    log.info("Repo Name with Owner: {}", repoNameWithOwner);
+    log.info("GitHub User Login: {}", githubUserLogin);
+    try {
+      this.gitHubService.approveDeploymentOnBehalfOfUser(
+          repoNameWithOwner, workflowRunId, environment.getId(), githubUserLogin);
+    } catch (IOException e) {
+      log.error("Error approving deployment: {}", e.getMessage());
+    }
+
+    // cancel workflow if not possible to approve
+    // maybe add a new status to deployment "NOT_ENOUGH_PERMISSIONS"
+  }
+
+  private Boolean shouldApprove() {
+    // check if triggered via helios
+    return true;
path: server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/approval/ApprovalService.java
user: login: gbanu
body: Oh, sorry for that 🙈 
Worked on it in two separate session, and somehow though this part was ready. 
I removed it completely, because checking if it is Helios deployment should be enough, I think
created_at: 2025-03-30T11:11:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,76 @@
+package de.tum.cit.aet.helios.deployment.approval;
+
+import de.tum.cit.aet.helios.deployment.github.DeploymentSource;
+import de.tum.cit.aet.helios.environment.Environment;
+import de.tum.cit.aet.helios.github.GitHubService;
+import de.tum.cit.aet.helios.gitrepo.GitRepository;
+import de.tum.cit.aet.helios.heliosdeployment.HeliosDeployment;
+import de.tum.cit.aet.helios.heliosdeployment.HeliosDeploymentRepository;
+import de.tum.cit.aet.helios.user.User;
+import java.io.IOException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Service;
+
+@Log4j2
+@Service
+@RequiredArgsConstructor
+public class ApprovalService {
+  private final GitHubService gitHubService;
+  private final HeliosDeploymentRepository heliosDeploymentRepository;
+
+  public void reviewDeployment(
+      DeploymentSource deploymentSource,
+      GitRepository gitRepository,
+      Environment environment,
+      User user) {
+
+    log.info(
+        "Approving deployment: {} {} {} {}", deploymentSource, gitRepository, environment, user);
+
+    Boolean shouldApprove = shouldApprove();
+
+    if (!shouldApprove) {
+      log.info("Ignoring non-Helios deployment approval");
+      return;
+    }
+    Boolean isHeliosBot = user.getLogin().equals("helios-bot");
+    log.info("Is Helios Bot: {}", isHeliosBot);
+
path: server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/approval/ApprovalService.java
user: login: gbanu
body: was my original thought process, but checking if it is Helios deployment should be enough, this way it is also always working in dev environment
created_at: 2025-03-30T11:12:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,76 @@
+package de.tum.cit.aet.helios.deployment.approval;
+
+import de.tum.cit.aet.helios.deployment.github.DeploymentSource;
+import de.tum.cit.aet.helios.environment.Environment;
+import de.tum.cit.aet.helios.github.GitHubService;
+import de.tum.cit.aet.helios.gitrepo.GitRepository;
+import de.tum.cit.aet.helios.heliosdeployment.HeliosDeployment;
+import de.tum.cit.aet.helios.heliosdeployment.HeliosDeploymentRepository;
+import de.tum.cit.aet.helios.user.User;
+import java.io.IOException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Service;
+
+@Log4j2
+@Service
+@RequiredArgsConstructor
+public class ApprovalService {
+  private final GitHubService gitHubService;
+  private final HeliosDeploymentRepository heliosDeploymentRepository;
+
+  public void reviewDeployment(
+      DeploymentSource deploymentSource,
+      GitRepository gitRepository,
+      Environment environment,
+      User user) {
+
+    log.info(
+        "Approving deployment: {} {} {} {}", deploymentSource, gitRepository, environment, user);
+
+    Boolean shouldApprove = shouldApprove();
+
+    if (!shouldApprove) {
+      log.info("Ignoring non-Helios deployment approval");
+      return;
+    }
+    Boolean isHeliosBot = user.getLogin().equals("helios-bot");
+    log.info("Is Helios Bot: {}", isHeliosBot);
+
+    HeliosDeployment heliosDeployment =
+        heliosDeploymentRepository.findByDeploymentId(deploymentSource.getId()).orElse(null);
+
+    log.info("Helios Deployment: {}", heliosDeployment);
+    if (heliosDeployment == null) {
+      log.error("Deployment with ID {} not found", deploymentSource.getId());
+      return;
+    }
+
+    Long workflowRunId =
+        Long.valueOf(
+            heliosDeployment
+                .getWorkflowRunHtmlUrl()
+                .substring(heliosDeployment.getWorkflowRunHtmlUrl().lastIndexOf("/") + 1));
path: server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/approval/ApprovalService.java
user: login: gbanu
body: good idea! added the workflow_run_id column to the helios deployment table now
created_at: 2025-03-30T11:13:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,7 +20,9 @@ COPY . .
 RUN yarn run build-keycloak-theme
 
 # Stage 2: Build Keycloak image
-FROM quay.io/keycloak/keycloak:26.1.4
path: keycloakify/Dockerfile
user: login: egekocabas
body: i am not really sure but i think 26.1.4 is already deployed to prod 
created_at: 2025-03-30T11:14:20Z
author_association: MEMBER
diff_hunk: @@ -20,7 +20,9 @@ COPY . .
 RUN yarn run build-keycloak-theme
 
 # Stage 2: Build Keycloak image
-FROM quay.io/keycloak/keycloak:26.1.4
path: keycloakify/Dockerfile
user: login: gbanu
body: yes, but will it cause problems if we deploy 26.1.3 now?
created_at: 2025-03-30T11:21:12Z
author_association: CONTRIBUTOR

PR comments:
user: login: codacy-production[bot]
created_at: 2025-03-25T12:19:14Z
body: ## Coverage summary from Codacy
### [See diff coverage on Codacy](https://app.codacy.com/gh/ls1intum/Helios/pull-requests/590/coverage?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummaryPullRequest)

 | Coverage variation | Diff coverage | 
 | ------------- | ------------- |
 | :white_check_mark: **-0.23%** (target: -1.00%) | :white_check_mark: **0.00%** |

<details>
<summary>Coverage variation details</summary>

| | Coverable lines | Covered lines | Coverage |
| ------------- | ------------- | ------------- | ------------- |
| Common ancestor commit (581fb66732746a9ad4ed1c3e4132cdcb8c701e60) | 6000 | 859 | 14.32% | |
| Head commit (021e67b07e8bed6493053f0ead769dad935c77c8) | 6099 (+99) | 859 (+0) | 14.08% (**-0.23%**) |

**Coverage variation** is the difference between the coverage for the head and common ancestor commits of the pull request branch: `<coverage of head commit> - <coverage of common ancestor commit>`
</details>

<details>
<summary>Diff coverage details</summary>

| | Coverable lines | Covered lines | Diff coverage |
| ------------- | ------------- | ------------- | ------------- |
| Pull request (#590) | 104 | 0 | **0.00%** |

**Diff coverage** is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: `<covered lines added or modified>/<coverable lines added or modified> * 100%`
</details>

#### [See your quality gate settings](https://app.codacy.com/gh/ls1intum/Helios/settings/quality?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummaryGates)&nbsp;&nbsp;&nbsp;&nbsp;[Change summary preferences](https://app.codacy.com/gh/ls1intum/Helios/settings/integrations?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummarySettings)

<!-- 108f945d-e553-4918-8ccf-027c6d4acc2c -->
author_association: NONE