diff --git a/airflow/README.md b/airflow/README.md index 5c29e6a..bdaddf8 100644 --- a/airflow/README.md +++ b/airflow/README.md @@ -27,15 +27,14 @@ This document covers Airflow installation, deployment, and debugging in the buun just airflow::install ``` -3. **Access Airflow Web UI**: - - Navigate to your Airflow instance (e.g., `https://airflow.buun.dev`) - - Login with your Keycloak credentials - -4. **Assign User Roles** (if needed): +3. **Assign User Roles** (required for DAG execution): ```bash # Add user role for DAG execution permissions - just airflow::assign-role airflow_user + just airflow::assign-role airflow_op + + # Check user's current roles + just airflow::list-user-roles # Available roles: # - airflow_admin: Full administrative access @@ -44,6 +43,12 @@ This document covers Airflow installation, deployment, and debugging in the buun # - airflow_viewer: Viewer access (read-only) ``` + **Note**: New users have only Viewer access by default and cannot execute DAGs without role assignment. + +4. **Access Airflow Web UI**: + - Navigate to your Airflow instance (e.g., `https://airflow.buun.dev`) + - Login with your Keycloak credentials + ### Uninstalling ```bash diff --git a/airflow/justfile b/airflow/justfile index 285d8e4..38d5c83 100644 --- a/airflow/justfile +++ b/airflow/justfile @@ -140,16 +140,23 @@ create-oauth-client: exit 1 fi echo "Creating Airflow OAuth client in Keycloak..." - # Delete existing client to ensure fresh creation - echo "Removing existing client if present..." - just keycloak::delete-client ${KEYCLOAK_REALM} airflow || true - CLIENT_SECRET=$(just utils::random-password) - just keycloak::create-client \ - ${KEYCLOAK_REALM} \ - airflow \ - "https://${AIRFLOW_HOST}/auth/oauth-authorized/keycloak" \ - "$CLIENT_SECRET" + # Check if client already exists + if just keycloak::client-exists ${KEYCLOAK_REALM} airflow &>/dev/null; then + echo "Client 'airflow' already exists, skipping creation..." + echo "Existing client will preserve roles and mappers" + # Get existing client secret for Vault/Secret synchronization + CLIENT_SECRET=$(just keycloak::get-client-secret ${KEYCLOAK_REALM} airflow | grep "Client 'airflow' secret:" | cut -d' ' -f4) + echo "Retrieved existing client secret for synchronization" + else + echo "Creating new client..." + CLIENT_SECRET=$(just utils::random-password) + just keycloak::create-client \ + ${KEYCLOAK_REALM} \ + airflow \ + "https://${AIRFLOW_HOST}/auth/oauth-authorized/keycloak" \ + "$CLIENT_SECRET" + fi if helm status external-secrets -n ${EXTERNAL_SECRETS_NAMESPACE} &>/dev/null; then echo "External Secrets available. Storing credentials in Vault and creating ExternalSecret..." @@ -188,6 +195,14 @@ create-keycloak-roles: just keycloak::create-client-role ${KEYCLOAK_REALM} airflow "$role" || true done + # Create client roles mapper to include roles in JWT tokens + echo "Creating client roles mapper..." + just keycloak::add-client-roles-mapper airflow "airflow_roles" "Airflow Roles Mapper" + + # Add client roles mapper to profile scope for userinfo endpoint + echo "Adding client roles mapper to profile scope..." + just keycloak::add-client-roles-to-profile-scope ${KEYCLOAK_REALM} airflow "airflow_roles" + echo "Keycloak roles created successfully" echo "Role mappings:" echo " - airflow_admin -> Airflow Admin (full access)" @@ -238,6 +253,20 @@ assign-role username='' role='': ;; esac +# List user's Airflow roles +list-user-roles username='': + #!/bin/bash + set -euo pipefail + USERNAME="{{ username }}" + + # Interactive input if not provided + while [ -z "${USERNAME}" ]; do + USERNAME=$(gum input --prompt="Username: " --width=100) + done + + echo "Checking Airflow roles for user '${USERNAME}'..." + just keycloak::list-user-client-roles ${KEYCLOAK_REALM} "${USERNAME}" airflow + # Remove Airflow role from user remove-role username='' role='': #!/bin/bash @@ -298,7 +327,7 @@ install: fi KEYCLOAK_HOST=${KEYCLOAK_HOST} KEYCLOAK_REALM=${KEYCLOAK_REALM} \ - gomplate -f webserver_config.py.gomplate -o webserver_config.py + gomplate -f webserver_config.gomplate.py -o webserver_config.py kubectl delete configmap airflow-api-server-config -n ${AIRFLOW_NAMESPACE} --ignore-not-found kubectl create configmap airflow-api-server-config -n ${AIRFLOW_NAMESPACE} \ --from-file=webserver_config.py=webserver_config.py @@ -494,15 +523,15 @@ logs-dag-errors dag_file='': LOG_DATE=$(date +%Y-%m-%d) LOG_DIR="/opt/airflow/logs/dag_processor/${LOG_DATE}/dags-folder" - if [ -n "{{dag_file}}" ]; then + if [ -n "{{ dag_file }}" ]; then # Show specific DAG file errors - LOG_FILE="${LOG_DIR}/{{dag_file}}.log" - echo "๐Ÿ“‹ Showing errors for DAG file: {{dag_file}}" + LOG_FILE="${LOG_DIR}/{{ dag_file }}.log" + echo "๐Ÿ“‹ Showing errors for DAG file: {{ dag_file }}" echo "๐Ÿ“‚ Log file: ${LOG_FILE}" echo "" kubectl exec -n ${AIRFLOW_NAMESPACE} ${DAG_PROCESSOR_POD} -c dag-processor -- \ cat "${LOG_FILE}" 2>/dev/null | jq -r 'select(.level == "error") | .timestamp + " " + .event + ": " + .error_detail[0].exc_value' || \ - echo "โŒ No error log found for {{dag_file}} or file doesn't exist" + echo "โŒ No error log found for {{ dag_file }} or file doesn't exist" else # List all DAG files with errors echo "๐Ÿ“‹ Available DAG error logs:" @@ -566,10 +595,10 @@ logs-test-import dag_file: echo "โŒ DAG processor pod not found" exit 1 fi - echo "๐Ÿงช Testing import of DAG file: {{dag_file}}" + echo "๐Ÿงช Testing import of DAG file: {{ dag_file }}" echo "" kubectl exec -n ${AIRFLOW_NAMESPACE} ${DAG_PROCESSOR_POD} -c dag-processor -- \ - python /opt/airflow/dags/{{dag_file}} + python /opt/airflow/dags/{{ dag_file }} # Clean up database and secrets cleanup: diff --git a/airflow/webserver_config.gomplate.py b/airflow/webserver_config.gomplate.py new file mode 100644 index 0000000..0e16fe3 --- /dev/null +++ b/airflow/webserver_config.gomplate.py @@ -0,0 +1,139 @@ +import os +import logging +from flask_appbuilder.security.manager import AUTH_OAUTH +from airflow.providers.fab.auth_manager.security_manager.override import FabAirflowSecurityManagerOverride + +log = logging.getLogger(__name__) + +AUTH_TYPE = AUTH_OAUTH +AUTH_USER_REGISTRATION = True +AUTH_ROLES_SYNC_AT_LOGIN = True +AUTH_USER_REGISTRATION_ROLE = "Viewer" + +# Keycloak OIDC configuration +KEYCLOAK_HOST = "{{ .Env.KEYCLOAK_HOST }}" +KEYCLOAK_REALM = "{{ .Env.KEYCLOAK_REALM }}" +OIDC_ISSUER = f"https://{KEYCLOAK_HOST}/realms/{KEYCLOAK_REALM}" + +# OAuth Providers configuration +OAUTH_PROVIDERS = [{ + 'name': 'keycloak', + 'icon': 'fa-key', + 'token_key': 'access_token', + 'remote_app': { + 'client_id': os.environ.get('AIRFLOW_OAUTH_CLIENT_ID', ''), + 'client_secret': os.environ.get('AIRFLOW_OAUTH_CLIENT_SECRET', ''), + 'server_metadata_url': f'{OIDC_ISSUER}/.well-known/openid-configuration', + 'api_base_url': f'{OIDC_ISSUER}/protocol/openid-connect', + 'access_token_url': f'{OIDC_ISSUER}/protocol/openid-connect/token', + 'authorize_url': f'{OIDC_ISSUER}/protocol/openid-connect/auth', + 'request_token_url': None, + 'client_kwargs': { + 'scope': 'openid profile email' + } + } +}] + +# Role mappings from Keycloak to Airflow +AUTH_ROLES_MAPPING = { + "airflow_admin": ["Admin"], + "airflow_op": ["Op"], + "airflow_user": ["User"], + "airflow_viewer": ["Viewer"] +} + +# Use the correct claim name for client roles +AUTH_ROLE_CLAIM = "airflow_roles" + +# Security Manager Override +class KeycloakSecurityManager(FabAirflowSecurityManagerOverride): + """Custom Security Manager for Keycloak integration""" + + def __init__(self, appbuilder): + super().__init__(appbuilder) + + def get_oauth_user_info(self, provider, response): + """Extract user info and roles from Keycloak token""" + if provider == "keycloak": + import jwt + import base64 + import json + + # Get access token + token = response.get("access_token") + if not token: + log.error("No access token found in OAuth response") + return None + + try: + # Decode token without verification for debugging + # In production, you should verify the signature + parts = token.split('.') + if len(parts) == 3: + # Decode payload + payload_b64 = parts[1] + # Add padding if needed + payload_b64 += '=' * (4 - len(payload_b64) % 4) + payload = json.loads(base64.b64decode(payload_b64)) + + log.info(f"Decoded token payload: {payload}") + + # Extract user information + userinfo = { + "username": payload.get("preferred_username"), + "email": payload.get("email"), + "first_name": payload.get("given_name"), + "last_name": payload.get("family_name"), + } + + # Extract roles from different possible locations + roles = [] + + # Check realm access roles + realm_access = payload.get("realm_access", {}) + realm_roles = realm_access.get("roles", []) + + # Check resource access (client roles) + resource_access = payload.get("resource_access", {}) + client_access = resource_access.get("airflow", {}) + client_roles = client_access.get("roles", []) + + # Check airflow_roles claim directly + direct_roles = payload.get("airflow_roles", []) + + log.info(f"Realm roles: {realm_roles}") + log.info(f"Client roles: {client_roles}") + log.info(f"Direct airflow roles: {direct_roles}") + + # Prefer client roles, then direct roles, then realm roles + if client_roles: + roles = client_roles + log.info(f"Using client roles: {roles}") + elif direct_roles: + roles = direct_roles + log.info(f"Using direct airflow roles: {roles}") + elif realm_roles: + # Map common realm roles to Airflow roles + role_mapping = { + 'admin': 'Admin', + 'user': 'User', + 'viewer': 'Viewer' + } + roles = [role_mapping.get(role.lower(), 'Viewer') for role in realm_roles] + log.info(f"Using mapped realm roles: {roles}") + else: + roles = ['Viewer'] + log.info("No roles found, defaulting to Viewer") + + userinfo["role_keys"] = roles + log.info(f"Final userinfo: {userinfo}") + + return userinfo + + except Exception as e: + log.error(f"Error decoding JWT token: {e}") + + # Fallback to default behavior + return super().get_oauth_user_info(provider, response) + +SECURITY_MANAGER_CLASS = KeycloakSecurityManager diff --git a/airflow/webserver_config.py.gomplate b/airflow/webserver_config.py.gomplate deleted file mode 100644 index 1ae8a54..0000000 --- a/airflow/webserver_config.py.gomplate +++ /dev/null @@ -1,52 +0,0 @@ -import os -import logging -from flask_appbuilder.security.manager import AUTH_OAUTH -from airflow.providers.fab.auth_manager.security_manager.override import FabAirflowSecurityManagerOverride - -log = logging.getLogger(__name__) - -AUTH_TYPE = AUTH_OAUTH -AUTH_USER_REGISTRATION = True -AUTH_ROLES_SYNC_AT_LOGIN = True -AUTH_USER_REGISTRATION_ROLE = "Viewer" - -# Keycloak OIDC configuration -KEYCLOAK_HOST = "{{ .Env.KEYCLOAK_HOST }}" -KEYCLOAK_REALM = "{{ .Env.KEYCLOAK_REALM }}" -OIDC_ISSUER = f"https://{KEYCLOAK_HOST}/realms/{KEYCLOAK_REALM}" - -# OAuth Providers configuration -OAUTH_PROVIDERS = [{ - 'name': 'keycloak', - 'icon': 'fa-key', - 'token_key': 'access_token', - 'remote_app': { - 'client_id': os.environ.get('AIRFLOW_OAUTH_CLIENT_ID', ''), - 'client_secret': os.environ.get('AIRFLOW_OAUTH_CLIENT_SECRET', ''), - 'server_metadata_url': f'{OIDC_ISSUER}/.well-known/openid-configuration', - 'api_base_url': f'{OIDC_ISSUER}/protocol/openid-connect', - 'access_token_url': f'{OIDC_ISSUER}/protocol/openid-connect/token', - 'authorize_url': f'{OIDC_ISSUER}/protocol/openid-connect/auth', - 'request_token_url': None, - 'client_kwargs': { - 'scope': 'openid profile email' - } - } -}] - -# Role mappings from Keycloak to Airflow -AUTH_ROLES_MAPPING = { - "airflow_admin": ["Admin"], - "airflow_op": ["Op"], - "airflow_user": ["User"], - "airflow_viewer": ["Viewer"] -} - -# Security Manager Override -class KeycloakSecurityManager(FabAirflowSecurityManagerOverride): - """Custom Security Manager for Keycloak integration""" - - def __init__(self, appbuilder): - super().__init__(appbuilder) - -SECURITY_MANAGER_CLASS = KeycloakSecurityManager diff --git a/keycloak/justfile b/keycloak/justfile index 9170b61..752f330 100644 --- a/keycloak/justfile +++ b/keycloak/justfile @@ -191,6 +191,16 @@ delete-realm realm: export KEYCLOAK_REALM_TO_DELETE={{ realm }} dotenvx run -q -f ../.env.local -- tsx ./scripts/delete-realm.ts +# Check if Keycloak client exists +client-exists realm client_id: + #!/bin/bash + set -euo pipefail + export KEYCLOAK_ADMIN_USER=$(just admin-username) + export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) + export KEYCLOAK_REALM={{ realm }} + export KEYCLOAK_CLIENT_ID={{ client_id }} + dotenvx run -q -f ../.env.local -- tsx ./scripts/client-exists.ts + # Create Keycloak client create-client realm client_id redirect_url client_secret='' session_idle='' session_max='': #!/bin/bash @@ -243,6 +253,30 @@ add-attribute-mapper client_id attribute_name display_name='' claim_name='' opti export ATTRIBUTE_EDIT_PERMISSIONS="{{ edit_perms }}" dotenvx run -q -f ../.env.local -- tsx ./scripts/add-attribute-mapper.ts +# Add client roles mapper for Keycloak client +add-client-roles-mapper client_id claim_name='client_roles' mapper_name='': + #!/bin/bash + set -euo pipefail + export KEYCLOAK_ADMIN_USER=$(just keycloak::admin-username) + export KEYCLOAK_ADMIN_PASSWORD=$(just keycloak::admin-password) + export KEYCLOAK_REALM=${KEYCLOAK_REALM} + export CLIENT_ID={{ client_id }} + export CLAIM_NAME="{{ claim_name }}" + export MAPPER_NAME="{{ mapper_name }}" + dotenvx run -q -f ../.env.local -- tsx ./scripts/add-client-roles-mapper.ts + +# Update client roles mapper for Keycloak client (force recreation) +update-client-roles-mapper client_id claim_name='client_roles' mapper_name='': + #!/bin/bash + set -euo pipefail + export KEYCLOAK_ADMIN_USER=$(just keycloak::admin-username) + export KEYCLOAK_ADMIN_PASSWORD=$(just keycloak::admin-password) + export KEYCLOAK_REALM=${KEYCLOAK_REALM} + export CLIENT_ID={{ client_id }} + export CLAIM_NAME="{{ claim_name }}" + export MAPPER_NAME="{{ mapper_name }}" + dotenvx run -q -f ../.env.local -- tsx ./scripts/update-client-roles-mapper.ts + # Add Keycloak client groups mapper add-groups-mapper client_id: #!/bin/bash @@ -495,6 +529,59 @@ add-user-to-client-role realm username client_id role_name: export KEYCLOAK_ROLE_NAME={{ role_name }} dotenvx run -q -f ../.env.local -- tsx ./scripts/add-user-to-client-role.ts +# List user's client roles +list-user-client-roles realm username client_id: + #!/bin/bash + set -euo pipefail + export KEYCLOAK_ADMIN_USER=$(just admin-username) + export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) + export KEYCLOAK_REALM={{ realm }} + export USERNAME={{ username }} + export KEYCLOAK_CLIENT_ID={{ client_id }} + dotenvx run -q -f ../.env.local -- tsx ./scripts/list-user-client-roles.ts + +# Get user token information and client configuration +get-user-token-info realm username client_id: + #!/bin/bash + set -euo pipefail + export KEYCLOAK_ADMIN_USER=$(just admin-username) + export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) + export KEYCLOAK_REALM={{ realm }} + export USERNAME={{ username }} + export KEYCLOAK_CLIENT_ID={{ client_id }} + dotenvx run -q -f ../.env.local -- tsx ./scripts/get-user-token.ts + +# Get client secret from Keycloak +get-client-secret realm client_id: + #!/bin/bash + set -euo pipefail + export KEYCLOAK_ADMIN_USER=$(just admin-username) + export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) + export KEYCLOAK_REALM={{ realm }} + export KEYCLOAK_CLIENT_ID={{ client_id }} + dotenvx run -q -f ../.env.local -- tsx ./scripts/get-client-secret.ts + +# Check detailed mapper configuration +check-mapper-details realm client_id: + #!/bin/bash + set -euo pipefail + export KEYCLOAK_ADMIN_USER=$(just admin-username) + export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) + export KEYCLOAK_REALM={{ realm }} + export KEYCLOAK_CLIENT_ID={{ client_id }} + dotenvx run -q -f ../.env.local -- tsx ./scripts/check-mapper-details.ts + +# Add client roles mapper to profile scope (generic) +add-client-roles-to-profile-scope realm client_id claim_name='client_roles': + #!/bin/bash + set -euo pipefail + export KEYCLOAK_ADMIN_USER=$(just admin-username) + export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) + export KEYCLOAK_REALM={{ realm }} + export KEYCLOAK_CLIENT_ID={{ client_id }} + export CLAIM_NAME="{{ claim_name }}" + dotenvx run -q -f ../.env.local -- tsx ./scripts/add-client-roles-to-profile-scope.ts + # Remove user from client role remove-user-from-client-role realm username client_id role_name: #!/bin/bash diff --git a/keycloak/scripts/add-client-roles-mapper.ts b/keycloak/scripts/add-client-roles-mapper.ts new file mode 100644 index 0000000..84aca88 --- /dev/null +++ b/keycloak/scripts/add-client-roles-mapper.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env tsx + +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +async function main() { + const keycloakHost = process.env.KEYCLOAK_HOST; + invariant(keycloakHost, "KEYCLOAK_HOST environment variable is required."); + + const adminUsername = process.env.KEYCLOAK_ADMIN_USER; + invariant(adminUsername, "KEYCLOAK_ADMIN_USER environment variable is required."); + + const adminPassword = process.env.KEYCLOAK_ADMIN_PASSWORD; + invariant(adminPassword, "KEYCLOAK_ADMIN_PASSWORD environment variable is required"); + + const realmName = process.env.KEYCLOAK_REALM; + invariant(realmName, "KEYCLOAK_REALM environment variable is required"); + + const clientId = process.env.CLIENT_ID; + invariant(clientId, "CLIENT_ID environment variable is required"); + + const mapperName = process.env.MAPPER_NAME || `${clientId} client roles`; + const claimName = process.env.CLAIM_NAME || "client_roles"; + + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${keycloakHost}`, + realmName: "master", + }); + + try { + await kcAdminClient.auth({ + username: adminUsername, + password: adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + + // Set realm to work with + kcAdminClient.setConfig({ + realmName, + }); + + // Find the client + const clients = await kcAdminClient.clients.find({ clientId }); + if (clients.length === 0) { + throw new Error(`Client '${clientId}' not found in realm '${realmName}'`); + } + + const client = clients[0]; + const clientInternalId = client.id!; + + // Check if the mapper already exists + const mappers = await kcAdminClient.clients.listProtocolMappers({ id: clientInternalId }); + const existingMapper = mappers.find((mapper) => mapper.name === mapperName); + + if (existingMapper) { + console.log(`Client roles mapper '${mapperName}' already exists for client '${clientId}'`); + return; + } + + // Create the client roles protocol mapper + await kcAdminClient.clients.addProtocolMapper( + { id: clientInternalId }, + { + name: mapperName, + protocol: "openid-connect", + protocolMapper: "oidc-usermodel-client-role-mapper", + config: { + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": claimName, + "jsonType.label": "String", + "multivalued": "true", + "usermodel.clientRoleMapping.clientId": clientId, + }, + } + ); + + console.log(`โœ“ Client roles mapper '${mapperName}' created for client '${clientId}' in realm '${realmName}'`); + console.log(` Claim name: ${claimName}`); + console.log(` Maps client roles from '${clientId}' to JWT token`); + } catch (error) { + console.error("An error occurred:", error); + process.exit(1); + } +} + +main().catch(console.error); \ No newline at end of file diff --git a/keycloak/scripts/add-client-roles-to-profile-scope.ts b/keycloak/scripts/add-client-roles-to-profile-scope.ts new file mode 100644 index 0000000..bb3edb4 --- /dev/null +++ b/keycloak/scripts/add-client-roles-to-profile-scope.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env tsx + +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +async function main() { + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${process.env.KEYCLOAK_HOST}`, + realmName: "master", + }); + + await kcAdminClient.auth({ + username: process.env.KEYCLOAK_ADMIN_USER!, + password: process.env.KEYCLOAK_ADMIN_PASSWORD!, + grantType: "password", + clientId: "admin-cli", + }); + + const realm = process.env.KEYCLOAK_REALM!; + const clientId = process.env.KEYCLOAK_CLIENT_ID!; + const claimName = process.env.CLAIM_NAME || "client_roles"; + + invariant(realm, "KEYCLOAK_REALM is required"); + invariant(clientId, "KEYCLOAK_CLIENT_ID is required"); + + kcAdminClient.setConfig({ realmName: realm }); + + try { + // Find the profile client scope + const clientScopes = await kcAdminClient.clientScopes.find({ realm }); + const profileScope = clientScopes.find(scope => scope.name === 'profile'); + + if (!profileScope) { + throw new Error("Profile client scope not found"); + } + + console.log(`Found profile scope: ${profileScope.id}`); + + // Check existing mappers in profile scope + const existingMappers = await kcAdminClient.clientScopes.listProtocolMappers({ + realm, + id: profileScope.id!, + }); + + console.log("Existing mappers in profile scope:"); + existingMappers.forEach(mapper => { + console.log(`- ${mapper.name} (${mapper.protocolMapper})`); + }); + + // Check if our client roles mapper already exists in profile scope + const clientRolesMapper = existingMappers.find(m => + m.config?.['usermodel.clientRoleMapping.clientId'] === clientId + ); + + if (clientRolesMapper) { + console.log(`Client roles mapper already exists in profile scope: ${clientRolesMapper.name}`); + } else { + console.log(`Adding ${clientId} client roles mapper to profile scope...`); + + // Add client roles mapper to profile scope + await kcAdminClient.clientScopes.addProtocolMapper( + { realm, id: profileScope.id! }, + { + name: `${clientId} Client Roles`, + protocol: "openid-connect", + protocolMapper: "oidc-usermodel-client-role-mapper", + config: { + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": claimName, + "jsonType.label": "String", + "multivalued": "true", + "usermodel.clientRoleMapping.clientId": clientId, + }, + } + ); + + console.log(`โœ“ Added ${clientId} client roles mapper to profile scope`); + } + + } catch (error) { + console.error(`Error: ${error}`); + process.exit(1); + } +} + +main().catch(console.error); \ No newline at end of file diff --git a/keycloak/scripts/check-mapper-details.ts b/keycloak/scripts/check-mapper-details.ts new file mode 100644 index 0000000..593658b --- /dev/null +++ b/keycloak/scripts/check-mapper-details.ts @@ -0,0 +1,78 @@ +#!/usr/bin/env tsx + +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +async function main() { + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${process.env.KEYCLOAK_HOST}`, + realmName: "master", + }); + + await kcAdminClient.auth({ + username: process.env.KEYCLOAK_ADMIN_USER!, + password: process.env.KEYCLOAK_ADMIN_PASSWORD!, + grantType: "password", + clientId: "admin-cli", + }); + + const realm = process.env.KEYCLOAK_REALM!; + const clientId = process.env.KEYCLOAK_CLIENT_ID!; + + invariant(realm, "KEYCLOAK_REALM is required"); + invariant(clientId, "KEYCLOAK_CLIENT_ID is required"); + + try { + // Find the client + const clients = await kcAdminClient.clients.find({ realm, clientId }); + if (clients.length === 0) { + throw new Error(`Client '${clientId}' not found in realm '${realm}'`); + } + + const client = clients[0]; + + // Get all protocol mappers with full details + const mappers = await kcAdminClient.clients.listProtocolMappers({ + realm, + id: client.id!, + }); + + console.log(`=== All Protocol Mappers for client '${clientId}' ===`); + mappers.forEach((mapper, index) => { + console.log(`\n${index + 1}. ${mapper.name}`); + console.log(` Protocol: ${mapper.protocol}`); + console.log(` Type: ${mapper.protocolMapper}`); + console.log(` Config:`); + if (mapper.config) { + Object.entries(mapper.config).forEach(([key, value]) => { + console.log(` ${key}: ${value}`); + }); + } + }); + + // Check client scope assignments + console.log(`\n=== Client Scope Assignments ===`); + + // Get default client scopes + const defaultScopes = await kcAdminClient.clients.listDefaultClientScopes({ + realm, + id: client.id!, + }); + + console.log(`Default scopes: ${defaultScopes.map(s => s.name).join(', ')}`); + + // Get optional client scopes + const optionalScopes = await kcAdminClient.clients.listOptionalClientScopes({ + realm, + id: client.id!, + }); + + console.log(`Optional scopes: ${optionalScopes.map(s => s.name).join(', ')}`); + + } catch (error) { + console.error(`Error: ${error}`); + process.exit(1); + } +} + +main().catch(console.error); \ No newline at end of file diff --git a/keycloak/scripts/client-exists.ts b/keycloak/scripts/client-exists.ts new file mode 100644 index 0000000..c6a9555 --- /dev/null +++ b/keycloak/scripts/client-exists.ts @@ -0,0 +1,42 @@ +#!/usr/bin/env tsx + +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +async function main() { + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${process.env.KEYCLOAK_HOST}`, + realmName: "master", + }); + + await kcAdminClient.auth({ + username: process.env.KEYCLOAK_ADMIN_USER!, + password: process.env.KEYCLOAK_ADMIN_PASSWORD!, + grantType: "password", + clientId: "admin-cli", + }); + + const realm = process.env.KEYCLOAK_REALM!; + const clientId = process.env.KEYCLOAK_CLIENT_ID!; + + invariant(realm, "KEYCLOAK_REALM is required"); + invariant(clientId, "KEYCLOAK_CLIENT_ID is required"); + + try { + // Find the client by clientId + const clients = await kcAdminClient.clients.find({ realm, clientId }); + + if (clients.length > 0) { + console.log(`Client '${clientId}' exists in realm '${realm}'`); + process.exit(0); // Success - client exists + } else { + console.log(`Client '${clientId}' does not exist in realm '${realm}'`); + process.exit(1); // Client doesn't exist + } + } catch (error) { + console.error(`Error checking client existence: ${error}`); + process.exit(1); + } +} + +main().catch(console.error); \ No newline at end of file diff --git a/keycloak/scripts/get-client-secret.ts b/keycloak/scripts/get-client-secret.ts new file mode 100644 index 0000000..77a5d4c --- /dev/null +++ b/keycloak/scripts/get-client-secret.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env tsx + +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +async function main() { + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${process.env.KEYCLOAK_HOST}`, + realmName: "master", + }); + + await kcAdminClient.auth({ + username: process.env.KEYCLOAK_ADMIN_USER!, + password: process.env.KEYCLOAK_ADMIN_PASSWORD!, + grantType: "password", + clientId: "admin-cli", + }); + + const realm = process.env.KEYCLOAK_REALM!; + const clientId = process.env.KEYCLOAK_CLIENT_ID!; + + invariant(realm, "KEYCLOAK_REALM is required"); + invariant(clientId, "KEYCLOAK_CLIENT_ID is required"); + + try { + // Find the client + const clients = await kcAdminClient.clients.find({ realm, clientId }); + if (clients.length === 0) { + throw new Error(`Client '${clientId}' not found in realm '${realm}'`); + } + + const client = clients[0]; + + // Get client secret + const clientSecret = await kcAdminClient.clients.getClientSecret({ + realm, + id: client.id!, + }); + + console.log(`Client '${clientId}' secret: ${clientSecret.value}`); + + } catch (error) { + console.error(`Error retrieving client secret: ${error}`); + process.exit(1); + } +} + +main().catch(console.error); \ No newline at end of file diff --git a/keycloak/scripts/get-user-token.ts b/keycloak/scripts/get-user-token.ts new file mode 100644 index 0000000..f592a88 --- /dev/null +++ b/keycloak/scripts/get-user-token.ts @@ -0,0 +1,112 @@ +#!/usr/bin/env tsx + +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +async function main() { + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${process.env.KEYCLOAK_HOST}`, + realmName: "master", + }); + + await kcAdminClient.auth({ + username: process.env.KEYCLOAK_ADMIN_USER!, + password: process.env.KEYCLOAK_ADMIN_PASSWORD!, + grantType: "password", + clientId: "admin-cli", + }); + + const realm = process.env.KEYCLOAK_REALM!; + const username = process.env.USERNAME!; + const clientId = process.env.KEYCLOAK_CLIENT_ID!; + + invariant(realm, "KEYCLOAK_REALM is required"); + invariant(username, "USERNAME is required"); + invariant(clientId, "KEYCLOAK_CLIENT_ID is required"); + + // Find the user + const users = await kcAdminClient.users.find({ realm, username }); + if (users.length === 0) { + throw new Error(`User '${username}' not found in realm '${realm}'`); + } + const user = users[0]; + + // Find the client + const clients = await kcAdminClient.clients.find({ realm, clientId }); + if (clients.length === 0) { + throw new Error(`Client '${clientId}' not found in realm '${realm}'`); + } + const client = clients[0]; + + try { + // Get a token for this user (impersonation) + console.log(`Getting token for user '${username}' with client '${clientId}'...`); + + // Get client secret + const clientSecret = await kcAdminClient.clients.getClientSecret({ + realm, + id: client.id!, + }); + + // Create a new client instance for the specific realm/client + const userClient = new KcAdminClient({ + baseUrl: `https://${process.env.KEYCLOAK_HOST}`, + realmName: realm, + }); + + // Note: This requires the user's actual password or impersonation capability + console.log("To get actual user token, you need:"); + console.log("1. User's password, or"); + console.log("2. Impersonation permissions"); + console.log("\nAlternatively, check the browser's Network tab:"); + console.log("1. Open DevTools -> Network tab"); + console.log("2. Try to trigger a DAG in Airflow"); + console.log("3. Look for the request with 403 error"); + console.log("4. Check Authorization header: 'Bearer '"); + console.log("5. Decode at https://jwt.io"); + + // Show client configuration that affects tokens + console.log("\n=== Client Configuration ==="); + console.log(`Client ID: ${client.clientId}`); + console.log(`Client Name: ${client.name}`); + console.log(`Protocol: ${client.protocol}`); + console.log(`Public Client: ${client.publicClient}`); + + // Get protocol mappers + const mappers = await kcAdminClient.clients.listProtocolMappers({ + realm, + id: client.id!, + }); + + console.log("\n=== Protocol Mappers ==="); + mappers.forEach(mapper => { + console.log(`- ${mapper.name} (${mapper.protocolMapper})`); + if (mapper.config) { + console.log(` Claim: ${mapper.config['claim.name'] || 'N/A'}`); + console.log(` Access Token: ${mapper.config['access.token.claim'] || 'false'}`); + console.log(` ID Token: ${mapper.config['id.token.claim'] || 'false'}`); + } + }); + + // Show user's client roles + const clientRoles = await kcAdminClient.users.listClientRoleMappings({ + realm, + id: user.id!, + clientUniqueId: client.id!, + }); + + console.log("\n=== User's Client Roles ==="); + if (clientRoles.length === 0) { + console.log("No client roles assigned"); + } else { + clientRoles.forEach(role => { + console.log(`- ${role.name} (${role.id})`); + }); + } + + } catch (error) { + console.error(`Error: ${error}`); + } +} + +main().catch(console.error); \ No newline at end of file diff --git a/keycloak/scripts/list-user-client-roles.ts b/keycloak/scripts/list-user-client-roles.ts new file mode 100644 index 0000000..34fd0dc --- /dev/null +++ b/keycloak/scripts/list-user-client-roles.ts @@ -0,0 +1,85 @@ +#!/usr/bin/env tsx + +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +async function main() { + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${process.env.KEYCLOAK_HOST}`, + realmName: "master", + }); + + await kcAdminClient.auth({ + username: process.env.KEYCLOAK_ADMIN_USER!, + password: process.env.KEYCLOAK_ADMIN_PASSWORD!, + grantType: "password", + clientId: "admin-cli", + }); + + const realm = process.env.KEYCLOAK_REALM!; + const username = process.env.USERNAME!; + const clientId = process.env.KEYCLOAK_CLIENT_ID!; + + invariant(realm, "KEYCLOAK_REALM is required"); + invariant(username, "USERNAME is required"); + invariant(clientId, "KEYCLOAK_CLIENT_ID is required"); + + // Find the user + const users = await kcAdminClient.users.find({ realm, username }); + + if (users.length === 0) { + throw new Error(`User '${username}' not found in realm '${realm}'`); + } + + const user = users[0]; + + // Find the client + const clients = await kcAdminClient.clients.find({ realm, clientId }); + + if (clients.length === 0) { + throw new Error(`Client '${clientId}' not found in realm '${realm}'`); + } + + const client = clients[0]; + + try { + // Get user's client role mappings + const clientRoles = await kcAdminClient.users.listClientRoleMappings({ + realm, + id: user.id!, + clientUniqueId: client.id!, + }); + + console.log(`Client roles for user '${username}' in client '${clientId}':`); + + if (clientRoles.length === 0) { + console.log(" No client roles assigned"); + } else { + clientRoles.forEach((role) => { + console.log(` - ${role.name}`); + if (role.description) { + console.log(` Description: ${role.description}`); + } + }); + } + + // Also show available roles for reference + const availableRoles = await kcAdminClient.clients.listRoles({ + realm, + id: client.id!, + }); + + console.log(`\nAvailable client roles in '${clientId}':`); + availableRoles.forEach((role) => { + const isAssigned = clientRoles.some((assigned) => assigned.id === role.id); + const status = isAssigned ? "โœ“ assigned" : " available"; + console.log(` ${status}: ${role.name}`); + }); + + } catch (error) { + console.error(`Error retrieving client roles: ${error}`); + process.exit(1); + } +} + +main().catch(console.error); \ No newline at end of file diff --git a/keycloak/scripts/update-client-roles-mapper.ts b/keycloak/scripts/update-client-roles-mapper.ts new file mode 100644 index 0000000..6a3da1a --- /dev/null +++ b/keycloak/scripts/update-client-roles-mapper.ts @@ -0,0 +1,94 @@ +#!/usr/bin/env tsx + +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +async function main() { + const keycloakHost = process.env.KEYCLOAK_HOST; + invariant(keycloakHost, "KEYCLOAK_HOST environment variable is required."); + + const adminUsername = process.env.KEYCLOAK_ADMIN_USER; + invariant(adminUsername, "KEYCLOAK_ADMIN_USER environment variable is required."); + + const adminPassword = process.env.KEYCLOAK_ADMIN_PASSWORD; + invariant(adminPassword, "KEYCLOAK_ADMIN_PASSWORD environment variable is required"); + + const realmName = process.env.KEYCLOAK_REALM; + invariant(realmName, "KEYCLOAK_REALM environment variable is required"); + + const clientId = process.env.CLIENT_ID; + invariant(clientId, "CLIENT_ID environment variable is required"); + + const mapperName = process.env.MAPPER_NAME || `${clientId} client roles`; + const claimName = process.env.CLAIM_NAME || "client_roles"; + + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${keycloakHost}`, + realmName: "master", + }); + + try { + await kcAdminClient.auth({ + username: adminUsername, + password: adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + + // Set realm to work with + kcAdminClient.setConfig({ + realmName, + }); + + // Find the client + const clients = await kcAdminClient.clients.find({ clientId }); + if (clients.length === 0) { + throw new Error(`Client '${clientId}' not found in realm '${realmName}'`); + } + + const client = clients[0]; + const clientInternalId = client.id!; + + // Find existing mapper + const mappers = await kcAdminClient.clients.listProtocolMappers({ id: clientInternalId }); + const existingMapper = mappers.find((mapper) => mapper.name === mapperName); + + if (existingMapper) { + console.log(`Deleting existing mapper '${mapperName}'...`); + await kcAdminClient.clients.delProtocolMapper({ + id: clientInternalId, + mapperId: existingMapper.id!, + }); + } + + // Create updated client roles protocol mapper + await kcAdminClient.clients.addProtocolMapper( + { id: clientInternalId }, + { + name: mapperName, + protocol: "openid-connect", + protocolMapper: "oidc-usermodel-client-role-mapper", + config: { + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": claimName, + "jsonType.label": "String", + "multivalued": "true", + "usermodel.clientRoleMapping.clientId": clientId, + }, + } + ); + + console.log(`โœ“ Client roles mapper '${mapperName}' updated for client '${clientId}' in realm '${realmName}'`); + console.log(` Claim name: ${claimName}`); + console.log(` User Info: enabled`); + console.log(` Access Token: enabled`); + console.log(` ID Token: enabled`); + } catch (error) { + console.error("An error occurred:", error); + process.exit(1); + } +} + +main().catch(console.error); \ No newline at end of file