From ed1ba74797fcecf26f1d5d39f6327787ac38dd6a Mon Sep 17 00:00:00 2001 From: Masaki Yatsu Date: Fri, 15 Aug 2025 11:21:54 +0900 Subject: [PATCH] feat: add keycloak and postgres --- justfile | 2 + keycloak/.gitignore | 1 + keycloak/justfile | 408 +++++++++++++++++++++ keycloak/keycloak-values.gomplate.yaml | 43 +++ keycloak/scripts/add-audience-mapper.ts | 70 ++++ keycloak/scripts/add-minio-policy.ts | 121 ++++++ keycloak/scripts/add-user-to-group.ts | 76 ++++ keycloak/scripts/create-client.ts | 64 ++++ keycloak/scripts/create-group.ts | 82 +++++ keycloak/scripts/create-realm.ts | 50 +++ keycloak/scripts/create-user.ts | 99 +++++ keycloak/scripts/delete-client.ts | 54 +++ keycloak/scripts/delete-group.ts | 76 ++++ keycloak/scripts/delete-realm.ts | 61 +++ keycloak/scripts/delete-user-from-group.ts | 76 ++++ keycloak/scripts/delete-user.ts | 60 +++ keycloak/scripts/user-exists.ts | 57 +++ postgres/.gitignore | 2 + postgres/justfile | 248 +++++++++++++ postgres/postgres-cluster-values.yaml | 6 + 20 files changed, 1656 insertions(+) create mode 100644 keycloak/.gitignore create mode 100644 keycloak/justfile create mode 100644 keycloak/keycloak-values.gomplate.yaml create mode 100644 keycloak/scripts/add-audience-mapper.ts create mode 100644 keycloak/scripts/add-minio-policy.ts create mode 100644 keycloak/scripts/add-user-to-group.ts create mode 100644 keycloak/scripts/create-client.ts create mode 100644 keycloak/scripts/create-group.ts create mode 100644 keycloak/scripts/create-realm.ts create mode 100644 keycloak/scripts/create-user.ts create mode 100644 keycloak/scripts/delete-client.ts create mode 100644 keycloak/scripts/delete-group.ts create mode 100644 keycloak/scripts/delete-realm.ts create mode 100644 keycloak/scripts/delete-user-from-group.ts create mode 100644 keycloak/scripts/delete-user.ts create mode 100644 keycloak/scripts/user-exists.ts create mode 100644 postgres/.gitignore create mode 100644 postgres/justfile create mode 100644 postgres/postgres-cluster-values.yaml diff --git a/justfile b/justfile index 37ccb81..73cf7e3 100644 --- a/justfile +++ b/justfile @@ -7,8 +7,10 @@ default: @just --list --unsorted --list-submodules mod env +mod keycloak mod k8s mod longhorn +mod postgres mod utils mod vault diff --git a/keycloak/.gitignore b/keycloak/.gitignore new file mode 100644 index 0000000..26c068c --- /dev/null +++ b/keycloak/.gitignore @@ -0,0 +1 @@ +keycloak-values.yaml diff --git a/keycloak/justfile b/keycloak/justfile new file mode 100644 index 0000000..76f726f --- /dev/null +++ b/keycloak/justfile @@ -0,0 +1,408 @@ +set fallback := true + +export KEYCLOAK_NAMESPACE := env("KEYCLOAK_NAMESPACE", "keycloak") +export KEYCLOAK_CHART_VERSION := env("KEYCLOAK_CHART_VERSION", "25.0.2") +export KEYCLOAK_REALM := env("KEYCLOAK_REALM", "") +export KEYCLOAK_HOST := env("KEYCLOAK_HOST", "") +export K8S_OIDC_CLIENT_ID := env('K8S_OIDC_CLIENT_ID', "k8s") +export KEYCLOAK_ADMIN_USER := env("KEYCLOAK_ADMIN_USER", "") +export KEYCLOAK_ADMIN_PASSWORD := env("KEYCLOAK_ADMIN_PASSWORD", "") +export VAULT_ENABLED := env("VAULT_ENABLED", "true") + +[private] +default: + @just --list --unsorted --list-submodules + +# Create Keycloak namespace +create-namespace: + @kubectl get namespace ${KEYCLOAK_NAMESPACE} &>/dev/null || \ + kubectl create namespace ${KEYCLOAK_NAMESPACE} + +# Delete Keycloak namespace +delete-namespace: + @kubectl delete namespace ${KEYCLOAK_NAMESPACE} --ignore-not-found + +# Create Keycloak secret +create-credentials: + #!/bin/bash + set -euo pipefail + admin_user=$(gum input --prompt="Initial Keycloak admin username: " --width=100 --value="admin") + password=$( + gum input --prompt="Initial Keycloak admin password: " --password --width=100 \ + --placeholder="Empty to generate a random password" + ) + if [ -z "${password}" ]; then + password=$(just utils::random-password) + fi + just create-namespace + if kubectl get secret keycloak-credentials -n ${KEYCLOAK_NAMESPACE} &>/dev/null; then + kubectl delete --ignore-not-found secret keycloak-credentials -n ${KEYCLOAK_NAMESPACE} + fi + kubectl create secret generic keycloak-credentials -n ${KEYCLOAK_NAMESPACE} \ + --from-literal=admin-user="${admin_user}" \ + --from-literal=password="${password}" + + if [ "${VAULT_ENABLED}" != "false" ]; then + just put-admin-credentials-to-vault "${admin_user}" "${password}" + fi + +# Delete Keycloak secret +delete-credentials: + @kubectl delete secret keycloak-credentials -n ${KEYCLOAK_NAMESPACE} --ignore-not-found + +# Create Keycloak database secret +create-database-secret: + #!/bin/bash + set -euo pipefail + if kubectl get secret database-config -n ${KEYCLOAK_NAMESPACE} &>/dev/null; then + kubectl delete secret database-config -n ${KEYCLOAK_NAMESPACE} + fi + kubectl create secret generic database-config -n ${KEYCLOAK_NAMESPACE} \ + --from-literal=host=postgres-cluster-rw.postgres \ + --from-literal=port=5432 \ + --from-literal=user=$(just postgres::admin-username) \ + --from-literal=password=$(just postgres::admin-password) \ + --from-literal=database=keycloak + +# Delete Keycloak database secret +delete-database-secret: + @kubectl delete secret database-config -n ${KEYCLOAK_NAMESPACE} --ignore-not-found + +# Install Keycloak +install: + #!/bin/bash + set -euxo pipefail + just create-credentials + just postgres::create-db keycloak + just create-database-secret + KEYCLOAK_ADMIN_USER=$(just admin-username) \ + gomplate -f keycloak-values.gomplate.yaml -o keycloak-values.yaml + helm upgrade --cleanup-on-fail --install \ + keycloak oci://registry-1.docker.io/bitnamicharts/keycloak \ + --version ${KEYCLOAK_CHART_VERSION} -n ${KEYCLOAK_NAMESPACE} --wait \ + -f keycloak-values.yaml + +# Uninstall Keycloak +uninstall delete-db='true': + #!/bin/bash + set -euo pipefail + helm uninstall keycloak -n ${KEYCLOAK_NAMESPACE} --ignore-not-found --wait + just delete-namespace + if [ "{{ delete-db }}" = "true" ]; then + just postgres::delete-db keycloak + fi + +# Create Keycloak realm +create-realm create-client-for-k8s='true': + #!/bin/bash + set -euo pipefail + export KEYCLOAK_ADMIN_USER=$(just admin-username) + export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) + dotenvx run -f ../.env.local -- tsx ./scripts/create-realm.ts + if [ "{{ create-client-for-k8s }}" = "true" ]; then + just create-client ${KEYCLOAK_REALM} ${K8S_OIDC_CLIENT_ID} "http://localhost:8000" + fi + +# Delete Keycloak realm +delete-realm realm: + #!/bin/bash + set -euo pipefail + if [ -z "{{ realm }}" ]; then + echo "Error: Realm name to delete must be provided as an argument." >&2 + echo "Usage: just delete-realm " >&2 + exit 1 + fi + if [ "{{ realm }}" = "master" ]; then + echo "Error: Deleting the 'master' realm is not allowed via this script." >&2 + exit 1 + fi + echo "WARNING: You are about to delete the Keycloak realm named '{{ realm }}'." + echo "This action is irreversible and will remove all users, clients, and configurations within this realm." + if ! gum confirm "Are you absolutely sure you want to delete realm '{{ realm }}'?"; then + echo "Realm deletion cancelled." + exit 0 + fi + export KEYCLOAK_ADMIN_USER=$(just admin-username) + export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) + export KEYCLOAK_REALM_TO_DELETE={{ realm }} + dotenvx run -f ../.env.local -- tsx ./scripts/delete-realm.ts + +# Create Keycloak client +create-client realm client_id redirect_url client_secret='': + #!/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 KEYCLOAK_CLIENT_SECRET={{ client_secret }} + export KEYCLOAK_REDIRECT_URL={{ redirect_url }} + dotenvx run -f ../.env.local -- tsx ./scripts/create-client.ts + +# Delete Keycloak client +delete-client 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 -f ../.env.local -- tsx ./scripts/delete-client.ts + +# Add Keycloak client audience mapper +add-audience-mapper client_id: + #!/bin/bash + set -euo pipefail + export KEYCLOAK_ADMIN_USER=$(just admin-username) + export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) + export KEYCLOAK_REALM=${KEYCLOAK_REALM} + export KEYCLOAK_CLIENT_ID={{ client_id }} + dotenvx run -f ../.env.local -- tsx ./scripts/add-audience-mapper.ts + +# Create Keycloak group +create-group group_name parent_group='' description='': + #!/bin/bash + set -euo pipefail + export KEYCLOAK_ADMIN_USER=$(just admin-username) + export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) + export GROUP_NAME="{{ group_name }}" + export PARENT_GROUP_NAME="{{ parent_group }}" + export GROUP_DESCRIPTION="{{ description }}" + dotenvx run -f ../.env.local -- tsx ./scripts/create-group.ts + +# Create Keycloak user +create-user username='' password='' email='' first_name='' last_name='' vault_admin='false': + #!/bin/bash + set -euo pipefail + export KEYCLOAK_ADMIN_USER=$(just admin-username) + export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) + export USERNAME="{{ username }}" + export PASSWORD="{{ password }}" + while [ -z "${USERNAME}" ]; do + USERNAME=$(gum input --prompt="Username: " --width=100) + done + while [ -z "${PASSWORD}" ]; do + PASSWORD=$(gum input --prompt="Password: " --password --width=100) + done + export EMAIL="{{ email }}" + while [ -z "${EMAIL}" ]; do + EMAIL=$(gum input --prompt="Email: " --width=100) + done + export FIRST_NAME="{{ first_name }}" + while [ -z "${FIRST_NAME}" ]; do + FIRST_NAME=$(gum input --prompt="First name: " --width=100) + done + export LAST_NAME="{{ last_name }}" + while [ -z "${LAST_NAME}" ]; do + LAST_NAME=$(gum input --prompt="Last name: " --width=100) + done + + # Ask if user should be vault admin + VAULT_ADMIN="{{ vault_admin }}" + if [ "${VAULT_ADMIN}" = "false" ]; then + if gum confirm "Should this user have Vault admin access?"; then + VAULT_ADMIN="true" + fi + fi + + # Create user + dotenvx run -f ../.env.local -- tsx ./scripts/create-user.ts + + # Set up Kubernetes RBAC + kubectl delete clusterrolebinding oidc-${USERNAME} --ignore-not-found + kubectl create clusterrolebinding oidc-${USERNAME} --clusterrole=cluster-admin \ + --user="https://${KEYCLOAK_HOST}/realms/${KEYCLOAK_REALM}#${USERNAME}" + + # Add to vault-admins group if requested + if [ "${VAULT_ADMIN}" = "true" ]; then + echo "Setting up vault-admins group..." + # Create vault-admins group if it doesn't exist + just create-group "vault-admins" "" "Vault administrators group" || true + + # Add user to vault-admins group + export GROUP_NAME="vault-admins" + dotenvx run -f ../.env.local -- tsx ./scripts/add-user-to-group.ts + echo "✓ User '${USERNAME}' added to vault-admins group" + fi + +# Add user to group +add-user-to-group username group_name: + #!/bin/bash + set -euo pipefail + export KEYCLOAK_ADMIN_USER=$(just admin-username) + export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) + export USERNAME="{{ username }}" + export GROUP_NAME="{{ group_name }}" + dotenvx run -f ../.env.local -- tsx ./scripts/add-user-to-group.ts + +# Remove user from group +remove-user-from-group username group_name: + #!/bin/bash + set -euo pipefail + export KEYCLOAK_ADMIN_USER=$(just admin-username) + export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) + export USERNAME="{{ username }}" + export GROUP_NAME="{{ group_name }}" + dotenvx run -f ../.env.local -- tsx ./scripts/delete-user-from-group.ts + +# Delete Keycloak group +delete-group group_name: + #!/bin/bash + set -euo pipefail + if [ -z "{{ group_name }}" ]; then + echo "Error: Group name to delete must be provided as an argument." >&2 + echo "Usage: just delete-group " >&2 + exit 1 + fi + if [ "{{ group_name }}" = "vault-admins" ]; then + echo "WARNING: You are about to delete the 'vault-admins' group." + echo "This will remove Vault admin access for all users in this group." + if ! gum confirm "Are you sure you want to delete the '{{ group_name }}' group?"; then + echo "Group deletion cancelled." + exit 0 + fi + else + echo "You are about to delete the Keycloak group '{{ group_name }}'." + if ! gum confirm "Are you sure you want to delete the '{{ group_name }}' group?"; then + echo "Group deletion cancelled." + exit 0 + fi + fi + export KEYCLOAK_ADMIN_USER=$(just admin-username) + export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) + export GROUP_NAME="{{ group_name }}" + dotenvx run -f ../.env.local -- tsx ./scripts/delete-group.ts + +# Delete a user +delete-user username='': + #!/bin/bash + set -euo pipefail + export KEYCLOAK_ADMIN_USER=$(just admin-username) + export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) + export USERNAME="{{ username }}" + while [ -z "${USERNAME}" ]; do + USERNAME=$(gum input --prompt="Username: " --width=100) + done + dotenvx run -f ../.env.local -- tsx ./scripts/delete-user.ts + +# Create an admin user +# create-admin-user username='' password='': +# #!/bin/bash +# set -euo pipefail +# echo "Creating a new admin user in Keycloak" +# export KEYCLOAK_ADMIN_USER=$(just admin-user) +# export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) +# export USERNAME="{{ username }}" +# export PASSWORD="{{ password }}" +# while [ -z "${USERNAME}" ]; do +# USERNAME=$(gum input --prompt="Admin username: " --width=100) +# done +# if [ -z "${PASSWORD}" ]; then +# PASSWORD=$( +# gum input --prompt="Admin assword: " --password --width=100 \ +# --placeholder="Empty to generate a random password" +# ) +# fi +# if [ -z "${PASSWORD}" ]; then +# PASSWORD=$(just utils::random-password) +# fi +# export EMAIL="" +# export FIRST_NAME="" +# export LAST_NAME="" +# export CREATE_AS_ADMIN=true +# dotenvx run -f ../.env.local -- tsx ./scripts/create-user.ts +# if [ "${VAULT_ENABLED}" != "false" ]; then +# just put-admin-credentials-to-vault "${USERNAME}" "${PASSWORD}" +# fi + +# Put admin credentials to Vault +put-admin-credentials-to-vault username password: + @just vault::put keycloak/admin username={{ username }} password={{ password }} + @echo "Admin credentials stored in Vault under 'keycloak/admin'." + +# Delete admin credentials from Vault +delete-admin-credentials-from-vault: + @just vault::delete keycloak/admin + @echo "Admin credentials deleted from Vault." + +# Create system user {w/o email, first name and last name} +create-system-user username='' password='': + #!/bin/bash + set -euo pipefail + export KEYCLOAK_ADMIN_USER=$(just admin-user) + export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) + export USERNAME="{{ username }}" + export PASSWORD="{{ password }}" + while [ -z "${USERNAME}" ]; do + USERNAME=$(gum input --prompt="Keycloak username: " --width=100) + done + while [ -z "${PASSWORD}" ]; do + PASSWORD=$(gum input --prompt="Keycloak password: " --password --width=100) + done + export EMAIL="" + export FIRST_NAME="" + export LAST_NAME="" + dotenvx run -f ../.env.local -- tsx ./scripts/create-user.ts + kubectl delete clusterrolebinding oidc-${USERNAME} --ignore-not-found + kubectl create clusterrolebinding oidc-${USERNAME} --clusterrole=cluster-admin \ + --user="https://${KEYCLOAK_HOST}/realms/${KEYCLOAK_REALM}#${USERNAME}" + +# Check if user exists +user-exists username='': + #!/bin/bash + set -euo pipefail + export KEYCLOAK_ADMIN_USER=$(just admin-user) + export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) + export USERNAME="{{ username }}" + while [ -z "${USERNAME}" ]; do + USERNAME=$(gum input --prompt="Username: " --width=100) + done + dotenvx run -f ../.env.local -- tsx ./scripts/user-exists.ts + +# Print Keycloak admin username +admin-username: + #!/bin/bash + set -euxo pipefail + if [ -n "${KEYCLOAK_ADMIN_USER}" ]; then + echo "${KEYCLOAK_ADMIN_USER}" + exit 0 + fi + if [ "${VAULT_ENABLED}" != "false" ]; then + just vault::setup-env + if just vault::exist keycloak/admin; then + just vault::get keycloak/admin username + echo + exit 0 + fi + fi + just default-admin-username + +# Print Keycloak admin password +admin-password: + #!/bin/bash + set -euo pipefail + if [ -n "${KEYCLOAK_ADMIN_PASSWORD}" ]; then + echo "${KEYCLOAK_ADMIN_PASSWORD}" + exit 0 + fi + if [ "${VAULT_ENABLED}" != "false" ]; then + just vault::setup-env + if just vault::exist keycloak/admin; then + just vault::get keycloak/admin password + echo + exit 0 + fi + fi + just default-admin-password + +# Print default Keycloak admin username +default-admin-username: + @kubectl get secret keycloak-credentials -n ${KEYCLOAK_NAMESPACE} \ + -o jsonpath="{.data.admin-user}" | base64 --decode + @echo + +# Print default Keycloak admin password +default-admin-password: + @kubectl get secret keycloak-credentials -n ${KEYCLOAK_NAMESPACE} \ + -o jsonpath="{.data.password}" | base64 --decode + @echo diff --git a/keycloak/keycloak-values.gomplate.yaml b/keycloak/keycloak-values.gomplate.yaml new file mode 100644 index 0000000..4310dd6 --- /dev/null +++ b/keycloak/keycloak-values.gomplate.yaml @@ -0,0 +1,43 @@ +auth: + adminUser: {{ .Env.KEYCLOAK_ADMIN_USER }} + existingSecret: keycloak-credentials + passwordSecretKey: password + +postgresql: + enabled: false + +externalDatabase: + host: postgres-cluster-rw.postgres + port: 5432 + database: keycloak + existingSecret: database-config + existingSecretUserKey: user + existingSecretPasswordKey: password + +tls: + enabled: true + autoGenerated: + enabled: true + engine: helm + +# Keycloak pod may not start with the default memory limits +resources: + limits: + memory: 1.5Gi + requests: + memory: 1Gi + +# image: +# debug: true + +# logging: +# level: DEBUG + +ingress: + enabled: true + ingressClassName: traefik + hostname: {{ .Env.KEYCLOAK_HOST }} + annotations: + kubernetes.io/ingress.class: traefik + traefik.ingress.kubernetes.io/router.entrypoints: websecure + tls: true diff --git a/keycloak/scripts/add-audience-mapper.ts b/keycloak/scripts/add-audience-mapper.ts new file mode 100644 index 0000000..b118472 --- /dev/null +++ b/keycloak/scripts/add-audience-mapper.ts @@ -0,0 +1,70 @@ +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +const main = async () => { + 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.KEYCLOAK_CLIENT_ID; + invariant(clientId, "KEYCLOAK_CLIENT_ID environment variable is required"); + + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${keycloakHost}`, + realmName: "master", + }); + + try { + await kcAdminClient.auth({ + username: adminUsername, + password: adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + console.log("Authentication successful."); + + kcAdminClient.setConfig({ realmName }); + + const clients = await kcAdminClient.clients.find({ clientId }); + if (clients.length === 0) { + throw new Error(`Client '${clientId}' not found.`); + } + const client = clients[0]; + invariant(client.id, "Client ID is not set"); + + const mapperName = `aud-mapper-${clientId}`; + const audienceMapper = { + name: mapperName, + protocol: "openid-connect", + protocolMapper: "oidc-audience-mapper", + config: { + "included.client.audience": clientId, + "id.token.claim": "true", + "access.token.claim": "true", + }, + }; + + const existingMappers = await kcAdminClient.clients.listProtocolMappers({ id: client.id }); + + if (existingMappers.some((mapper) => mapper.name === mapperName)) { + console.warn(`Audience Mapper '${mapperName}' already exists for the client.`); + } else { + await kcAdminClient.clients.addProtocolMapper({ id: client.id }, audienceMapper); + console.log(`Audience Mapper '${mapperName}' added directly to the client.`); + } + } catch (error) { + console.error("An error occurred:", error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } +}; + +main(); diff --git a/keycloak/scripts/add-minio-policy.ts b/keycloak/scripts/add-minio-policy.ts new file mode 100644 index 0000000..c035c9a --- /dev/null +++ b/keycloak/scripts/add-minio-policy.ts @@ -0,0 +1,121 @@ +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +const main = async () => { + 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 minioClientId = process.env.MINIO_OIDC_CLIENT_ID; + invariant(minioClientId, "MINIO_OIDC_CLIENT_ID environment variable is required"); + + const policyValue = process.env.MINIO_POLICY || "readwrite"; + console.log(`Setting minioPolicy attribute with value: ${policyValue}`); + + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${keycloakHost}`, + realmName: "master", + }); + + try { + await kcAdminClient.auth({ + username: adminUsername, + password: adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + console.log("Authentication successful."); + + // Set realm to work with + kcAdminClient.setConfig({ + realmName, + }); + + // Get current User Profile configuration + const userProfile = await kcAdminClient.users.getProfile(); + + // Check if minioPolicy attribute already exists + const existingAttribute = userProfile.attributes?.find( + (attr: any) => attr.name === "minioPolicy" + ); + + if (existingAttribute) { + console.log("minioPolicy attribute already exists in User Profile."); + } else { + // Add minioPolicy attribute to User Profile with proper permissions + if (!userProfile.attributes) { + userProfile.attributes = []; + } + + userProfile.attributes.push({ + name: "minioPolicy", + displayName: "MinIO Policy", + permissions: { + view: ["admin", "user"], + edit: ["admin"], + }, + validations: { + options: { options: ["readwrite", "readonly", "writeonly"] }, + }, + }); + + // Update User Profile + await kcAdminClient.users.updateProfile(userProfile); + console.log( + "minioPolicy attribute added to User Profile successfully with admin edit permissions." + ); + } + + // Create protocol mapper for the minioPolicy attribute if it doesn't exist + const minioClient = await kcAdminClient.clients.find({ clientId: minioClientId }); + if (minioClient.length === 0) { + console.error(`Client '${minioClientId}' not found.`); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + const clientId = minioClient[0].id; + invariant(clientId, "Client ID is required"); + + // Check if the mapper already exists + const mappers = await kcAdminClient.clients.listProtocolMappers({ id: clientId }); + const existingMapper = mappers.find((mapper) => mapper.name === "MinIO Policy"); + + if (existingMapper) { + console.log("MinIO Policy mapper already exists."); + } else { + // Create the protocol mapper + await kcAdminClient.clients.addProtocolMapper( + { id: clientId }, + { + name: "MinIO Policy", + protocol: "openid-connect", + protocolMapper: "oidc-usermodel-attribute-mapper", + config: { + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "minioPolicy", + "jsonType.label": "String", + "user.attribute": "minioPolicy", + multivalued: "false", + }, + } + ); + console.log("MinIO Policy mapper created successfully."); + } + } catch (error) { + console.error("An error occurred:", error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } +}; + +main(); diff --git a/keycloak/scripts/add-user-to-group.ts b/keycloak/scripts/add-user-to-group.ts new file mode 100644 index 0000000..aa02e18 --- /dev/null +++ b/keycloak/scripts/add-user-to-group.ts @@ -0,0 +1,76 @@ +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +const main = async () => { + 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 username = process.env.USERNAME; + invariant(username, "USERNAME environment variable is required"); + + const groupName = process.env.GROUP_NAME; + invariant(groupName, "GROUP_NAME environment variable is required"); + + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${keycloakHost}`, + realmName: "master", + }); + + try { + await kcAdminClient.auth({ + username: adminUsername, + password: adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + console.log("Authentication successful."); + + kcAdminClient.setConfig({ realmName }); + + // Find user + const users = await kcAdminClient.users.find({ username }); + const user = users.find(u => u.username === username); + if (!user) { + throw new Error(`User '${username}' not found`); + } + + // Find group + const groups = await kcAdminClient.groups.find({ search: groupName }); + const group = groups.find(g => g.name === groupName); + if (!group) { + throw new Error(`Group '${groupName}' not found`); + } + + // Check if user is already in group + const userGroups = await kcAdminClient.users.listGroups({ id: user.id! }); + const isAlreadyMember = userGroups.some(ug => ug.id === group.id); + + if (isAlreadyMember) { + console.log(`User '${username}' is already a member of group '${groupName}'`); + return; + } + + // Add user to group + await kcAdminClient.users.addToGroup({ + id: user.id!, + groupId: group.id!, + }); + + console.log(`User '${username}' added to group '${groupName}' successfully`); + } catch (error) { + console.error("Error adding user to group:", error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } +}; + +main(); \ No newline at end of file diff --git a/keycloak/scripts/create-client.ts b/keycloak/scripts/create-client.ts new file mode 100644 index 0000000..faa0e91 --- /dev/null +++ b/keycloak/scripts/create-client.ts @@ -0,0 +1,64 @@ +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +const main = async () => { + 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.KEYCLOAK_CLIENT_ID; + invariant(clientId, "KEYCLOAK_CLIENT_ID environment variable is required"); + + const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET; + + const redirectUrl = process.env.KEYCLOAK_REDIRECT_URL; + invariant(redirectUrl, "KEYCLOAK_REDIRECT_URL environment variable is required"); + + const redirectUris = redirectUrl.split(',').map(url => url.trim()); + + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${keycloakHost}`, + realmName: "master", + }); + + try { + await kcAdminClient.auth({ + username: adminUsername, + password: adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + console.log("Authentication successful."); + + kcAdminClient.setConfig({ realmName }); + + const existingClients = await kcAdminClient.clients.find({ clientId }); + if (existingClients.length > 0) { + console.warn(`Client '${clientId}' already exists.`); + return; + } + + const createdClient = await kcAdminClient.clients.create({ + clientId: clientId, + secret: clientSecret, + enabled: true, + redirectUris: redirectUris, + publicClient: clientSecret && clientSecret !== '' ? false : true, + }); + console.log(`Client created successfully with ID: ${createdClient.id}`); + } catch (error) { + console.error("An error occurred:", error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } +}; + +main(); diff --git a/keycloak/scripts/create-group.ts b/keycloak/scripts/create-group.ts new file mode 100644 index 0000000..8b87f2a --- /dev/null +++ b/keycloak/scripts/create-group.ts @@ -0,0 +1,82 @@ +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +const main = async () => { + 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 groupName = process.env.GROUP_NAME; + invariant(groupName, "GROUP_NAME environment variable is required"); + + const parentGroupName = process.env.PARENT_GROUP_NAME || ""; + const groupDescription = process.env.GROUP_DESCRIPTION || ""; + + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${keycloakHost}`, + realmName: "master", + }); + + try { + await kcAdminClient.auth({ + username: adminUsername, + password: adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + console.log("Authentication successful."); + + kcAdminClient.setConfig({ realmName }); + + // Check if group already exists + const existingGroups = await kcAdminClient.groups.find({ search: groupName }); + const existingGroup = existingGroups.find(group => group.name === groupName); + + if (existingGroup) { + console.log(`Group '${groupName}' already exists with ID: ${existingGroup.id}`); + return; + } + + // Find parent group if specified + let parentGroupId: string | undefined; + if (parentGroupName) { + const parentGroups = await kcAdminClient.groups.find({ search: parentGroupName }); + const parentGroup = parentGroups.find(group => group.name === parentGroupName); + if (!parentGroup) { + throw new Error(`Parent group '${parentGroupName}' not found`); + } + parentGroupId = parentGroup.id; + } + + // Create group payload + const groupPayload = { + name: groupName, + ...(groupDescription && { attributes: { description: [groupDescription] } }), + }; + + // Create group + const group = parentGroupId + ? await kcAdminClient.groups.createChildGroup({ id: parentGroupId }, groupPayload) + : await kcAdminClient.groups.create(groupPayload); + + console.log(`Group '${groupName}' created successfully with ID: ${group.id}`); + + if (parentGroupName) { + console.log(`Group '${groupName}' created as child of '${parentGroupName}'`); + } + } catch (error) { + console.error("Error creating group:", error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } +}; + +main(); \ No newline at end of file diff --git a/keycloak/scripts/create-realm.ts b/keycloak/scripts/create-realm.ts new file mode 100644 index 0000000..358c32e --- /dev/null +++ b/keycloak/scripts/create-realm.ts @@ -0,0 +1,50 @@ +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +const main = async () => { + 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 kcAdminClient = new KcAdminClient({ + baseUrl: `https://${keycloakHost}`, + realmName: "master", + }); + + try { + await kcAdminClient.auth({ + username: adminUsername, + password: adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + console.log("Authentication successful."); + + const existingRealms = await kcAdminClient.realms.find(); + const realmExists = existingRealms.some((realm) => realm.realm === realmName); + if (realmExists) { + console.warn(`Realm '${realmName}' already exists.`); + return; + } + + await kcAdminClient.realms.create({ + realm: realmName, + enabled: true, + }); + console.log(`Realm '${realmName}' created successfully.`); + } catch (error) { + console.error("An error occurred:", error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } +}; + +main(); diff --git a/keycloak/scripts/create-user.ts b/keycloak/scripts/create-user.ts new file mode 100644 index 0000000..0a44f5e --- /dev/null +++ b/keycloak/scripts/create-user.ts @@ -0,0 +1,99 @@ +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +const main = async () => { + 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 username = process.env.USERNAME; + invariant(username, "USERNAME environment variable is required"); + + const email = process.env.EMAIL; + const firstName = process.env.FIRST_NAME; + const lastName = process.env.LAST_NAME; + + const password = process.env.PASSWORD; + invariant(password, "PASSWORD environment variable is required"); + + const createAsAdmin = process.env.CREATE_AS_ADMIN === "true"; + + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${keycloakHost}`, + realmName: "master", + }); + + try { + await kcAdminClient.auth({ + username: adminUsername, + password: adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + console.log("Authentication successful."); + + kcAdminClient.setConfig({ realmName }); + + const userPayload = { + username, + email, + emailVerified: true, + firstName, + lastName, + enabled: true, + }; + const user = await kcAdminClient.users.create(userPayload); + console.log(`User created successfully with ID: ${user.id}`); + + if (createAsAdmin && realmName === "master") { + const adminRole = await kcAdminClient.roles.findOneByName({ + realm: "master", + name: "admin", + }); + + const createRealmRole = await kcAdminClient.roles.findOneByName({ + realm: "master", + name: "create-realm", + }); + + await kcAdminClient.users.addRealmRoleMappings({ + realm: "master", + id: user.id, + roles: [ + { + id: adminRole!.id!, + name: adminRole!.name!, + }, + { + id: createRealmRole!.id!, + name: createRealmRole!.name!, + }, + ], + }); + } + + await kcAdminClient.users.resetPassword({ + id: user.id!, + credential: { + type: "password", + value: password, + temporary: false, + }, + }); + console.log(`Password set for user '${user.id}'.`); + } catch (error) { + console.error("Error creating user:", error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } +}; + +main(); diff --git a/keycloak/scripts/delete-client.ts b/keycloak/scripts/delete-client.ts new file mode 100644 index 0000000..7d720ec --- /dev/null +++ b/keycloak/scripts/delete-client.ts @@ -0,0 +1,54 @@ +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +const main = async () => { + 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.KEYCLOAK_CLIENT_ID; + invariant(clientId, "KEYCLOAK_CLIENT_ID environment variable is required"); + + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${keycloakHost}`, + realmName: 'master', + }); + + try { + await kcAdminClient.auth({ + username: adminUsername, + password: adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + console.log("Authentication successful."); + + kcAdminClient.setConfig({ realmName }); + + const existingClients = await kcAdminClient.clients.find({ clientId }); + if (existingClients.length === 0) { + console.warn(`Client '${clientId}' does not exist.`); + return; + } + + const client = existingClients[0]; + invariant(client.id, "Client ID is not set"); + + await kcAdminClient.clients.del({ id: client.id }); + console.log(`Client '${clientId}' successfully deleted.`); + } catch (error) { + console.error("An error occurred:", error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } +}; + +main(); diff --git a/keycloak/scripts/delete-group.ts b/keycloak/scripts/delete-group.ts new file mode 100644 index 0000000..e39505b --- /dev/null +++ b/keycloak/scripts/delete-group.ts @@ -0,0 +1,76 @@ +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +const main = async () => { + 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 groupName = process.env.GROUP_NAME; + invariant(groupName, "GROUP_NAME environment variable is required"); + + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${keycloakHost}`, + realmName: "master", + }); + + try { + await kcAdminClient.auth({ + username: adminUsername, + password: adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + console.log("Authentication successful."); + + kcAdminClient.setConfig({ realmName }); + + // Find group to delete + const groups = await kcAdminClient.groups.find({ search: groupName }); + const group = groups.find(g => g.name === groupName); + + if (!group) { + console.log(`Group '${groupName}' not found`); + return; + } + + // Check if group has members + const groupMembers = await kcAdminClient.groups.listMembers({ id: group.id! }); + if (groupMembers.length > 0) { + console.log(`Warning: Group '${groupName}' has ${groupMembers.length} members:`); + groupMembers.forEach(member => { + console.log(` - ${member.username} (${member.firstName} ${member.lastName})`); + }); + console.log("All members will be removed from the group when it's deleted."); + } + + // Check for subgroups + const subGroups = await kcAdminClient.groups.listSubGroups({ id: group.id! }); + if (subGroups.length > 0) { + console.log(`Warning: Group '${groupName}' has ${subGroups.length} subgroups:`); + subGroups.forEach(subGroup => { + console.log(` - ${subGroup.name}`); + }); + console.log("All subgroups will be deleted as well."); + } + + // Delete group + await kcAdminClient.groups.del({ id: group.id! }); + + console.log(`Group '${groupName}' deleted successfully`); + } catch (error) { + console.error("Error deleting group:", error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } +}; + +main(); \ No newline at end of file diff --git a/keycloak/scripts/delete-realm.ts b/keycloak/scripts/delete-realm.ts new file mode 100644 index 0000000..acaabf2 --- /dev/null +++ b/keycloak/scripts/delete-realm.ts @@ -0,0 +1,61 @@ +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +const main = async () => { + 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 realmNameToDelete = process.env.KEYCLOAK_REALM_TO_DELETE; + invariant(realmNameToDelete, "KEYCLOAK_REALM_TO_DELETE environment variable is required"); + + if (realmNameToDelete === "master") { + console.error("Error: Deleting the 'master' realm is a highly destructive operation and is not allowed by this script."); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${keycloakHost}`, + realmName: "master", // Authenticate against master realm to delete other realms + }); + + try { + await kcAdminClient.auth({ + username: adminUsername, + password: adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + console.log("Authentication successful with master realm."); + + // Check if realm exists before attempting deletion + const realm = await kcAdminClient.realms.findOne({ realm: realmNameToDelete }); + + if (!realm) { + console.warn(`Realm '${realmNameToDelete}' not found. Nothing to delete.`); + return; // Exit gracefully if realm doesn't exist + } + + console.log(`Attempting to delete realm: '${realmNameToDelete}'...`); + await kcAdminClient.realms.del({ realm: realmNameToDelete }); + console.log(`Realm '${realmNameToDelete}' deleted successfully.`); + + } catch (error) { + console.error(`An error occurred while trying to delete realm '${realmNameToDelete}':`, error); + const err = error as any; + if (err.response?.data) { + console.error("Error details:", JSON.stringify(err.response.data, undefined, 2)); + } + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } +}; + +main(); + diff --git a/keycloak/scripts/delete-user-from-group.ts b/keycloak/scripts/delete-user-from-group.ts new file mode 100644 index 0000000..312c6a4 --- /dev/null +++ b/keycloak/scripts/delete-user-from-group.ts @@ -0,0 +1,76 @@ +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +const main = async () => { + 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 username = process.env.USERNAME; + invariant(username, "USERNAME environment variable is required"); + + const groupName = process.env.GROUP_NAME; + invariant(groupName, "GROUP_NAME environment variable is required"); + + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${keycloakHost}`, + realmName: "master", + }); + + try { + await kcAdminClient.auth({ + username: adminUsername, + password: adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + console.log("Authentication successful."); + + kcAdminClient.setConfig({ realmName }); + + // Find user + const users = await kcAdminClient.users.find({ username }); + const user = users.find(u => u.username === username); + if (!user) { + throw new Error(`User '${username}' not found`); + } + + // Find group + const groups = await kcAdminClient.groups.find({ search: groupName }); + const group = groups.find(g => g.name === groupName); + if (!group) { + throw new Error(`Group '${groupName}' not found`); + } + + // Check if user is in group + const userGroups = await kcAdminClient.users.listGroups({ id: user.id! }); + const isMember = userGroups.some(ug => ug.id === group.id); + + if (!isMember) { + console.log(`User '${username}' is not a member of group '${groupName}'`); + return; + } + + // Remove user from group + await kcAdminClient.users.delFromGroup({ + id: user.id!, + groupId: group.id!, + }); + + console.log(`User '${username}' removed from group '${groupName}' successfully`); + } catch (error) { + console.error("Error removing user from group:", error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } +}; + +main(); \ No newline at end of file diff --git a/keycloak/scripts/delete-user.ts b/keycloak/scripts/delete-user.ts new file mode 100644 index 0000000..d85cb03 --- /dev/null +++ b/keycloak/scripts/delete-user.ts @@ -0,0 +1,60 @@ +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +const main = async () => { + 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 usernameToDelete = process.env.USERNAME; + invariant( + usernameToDelete, + "USERNAME environment variable (for the user to be deleted) is required" + ); + + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${keycloakHost}`, + realmName: "master", + }); + + try { + await kcAdminClient.auth({ + username: adminUsername, + password: adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + console.log("Authentication successful."); + + kcAdminClient.setConfig({ realmName }); + + const users = await kcAdminClient.users.find({ username: usernameToDelete }); + + if (users.length === 0) { + console.warn(`User '${usernameToDelete}' not found in realm '${realmName}'.`); + return; + } + + const user = users[0]; + invariant(user.id, `User ID not found for user '${usernameToDelete}'.`); + + await kcAdminClient.users.del({ id: user.id }); + console.log( + `User '${usernameToDelete}' (ID: ${user.id}) successfully deleted from realm '${realmName}'.` + ); + } catch (error) { + console.error("An error occurred during user deletion:", error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } +}; + +main(); diff --git a/keycloak/scripts/user-exists.ts b/keycloak/scripts/user-exists.ts new file mode 100644 index 0000000..8c5e19f --- /dev/null +++ b/keycloak/scripts/user-exists.ts @@ -0,0 +1,57 @@ +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +const main = async () => { + 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 username = process.env.USERNAME; + invariant(username, "USERNAME environment variable is required"); + + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${keycloakHost}`, + realmName: "master", + }); + + try { + await kcAdminClient.auth({ + username: adminUsername, + password: adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + console.log("Authentication successful."); + + kcAdminClient.setConfig({ realmName }); + + const users = await kcAdminClient.users.find({ + username, + exact: true, + }); + + if (users && users.length > 0) { + console.log(`User '${username}' exists with ID: ${users[0].id}`); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); + } else { + console.log(`User '${username}' does not exist.`); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + } catch (error) { + console.error("Error checking user existence:", error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } +}; + +main(); diff --git a/postgres/.gitignore b/postgres/.gitignore new file mode 100644 index 0000000..cec533e --- /dev/null +++ b/postgres/.gitignore @@ -0,0 +1,2 @@ +create-database.yaml +delete-database.yaml diff --git a/postgres/justfile b/postgres/justfile new file mode 100644 index 0000000..c243d38 --- /dev/null +++ b/postgres/justfile @@ -0,0 +1,248 @@ +set fallback := true + +export CNPG_NAMESPACE := env("CNPG_NAMESPACE", "postgres") +export CNPG_CHART_VERSION := env("CNPG_CHART_VERSION", "0.26.0") +export CNPG_CLUSTER_CHART_VERSION := env("CNPG_CLUSTER_CHART_VERSION", "0.3.1") +export VAULT_ENABLED := env("VAULT_ENABLED", "true") + +[private] +default: + @just --list --unsorted --list-submodules + +# Add Helm repository +add-helm-repo: + @helm repo add cnpg https://cloudnative-pg.github.io/charts + @helm repo update + +# Remove Helm repository +remove-helm-repo: + @helm repo remove cnpg + +# Install CloudNativePG and create a cluster +install: + @just install-cnpg + @just create-cluster + +# Uninstall CloudNativePG and delete the cluster +uninstall: + @just delete-cluster + @just uninstall-cnpg + +# Install CloudNativePG +install-cnpg: + @just add-helm-repo + @helm upgrade --cleanup-on-fail --install cnpg cnpg/cloudnative-pg \ + --version ${CNPG_CHART_VERSION} \ + -n ${CNPG_NAMESPACE} --create-namespace --wait + +# Uninstall CloudNativePG +uninstall-cnpg: + @helm uninstall cnpg -n ${CNPG_NAMESPACE} --wait + @kubectl delete namespace ${CNPG_NAMESPACE} --ignore-not-found + +# Create Postgres cluster +create-cluster: + #!/bin/bash + set -euo pipefail + helm upgrade --install postgres-cluster cnpg/cluster \ + --version ${CNPG_CLUSTER_CHART_VERSION} \ + -n ${CNPG_NAMESPACE} --create-namespace --wait \ + -f postgres-cluster-values.yaml + + if [ "${VAULT_ENABLED}" != "false" ]; then + just put-admin-credentials-to-vault + fi + +# Delete Postgres cluster +delete-cluster: + @helm uninstall postgres-cluster -n ${CNPG_NAMESPACE} --ignore-not-found --wait + +# Print Postgres username +admin-username: + @echo "postgres" + +# Print Postgres password +admin-password: + @kubectl get -n ${CNPG_NAMESPACE} secret postgres-cluster-superuser \ + -o jsonpath="{.data.password}" | base64 --decode + @echo + +# Put admin credentials to Vault +put-admin-credentials-to-vault: + @echo vault::put postgres/admin username=$(just admin-username) password=$(just admin-password) + @just vault::put postgres/admin username=$(just admin-username) password=$(just admin-password) + @echo "Admin credentials stored in Vault under 'postgres/admin'." + +# Create Postgres database +create-db db_name='': + #!/bin/bash + set -euo pipefail + DB_NAME=${DB_NAME:-{{ db_name }}} + while [ -z "${DB_NAME}" ]; do + DB_NAME=$(gum input --prompt="Database name: " --width=80) + done + if just db-exists ${DB_NAME} >&/dev/null; then + echo "Database ${DB_NAME} already exists" >&2 + exit + fi + echo "Creating database ${DB_NAME}..." + just psql -c "\"CREATE DATABASE ${DB_NAME};\"" + echo "Database ${DB_NAME} created." + +# Delete Postgres database +delete-db db_name='': + #!/bin/bash + set -euo pipefail + DB_NAME=${DB_NAME:-{{ db_name }}} + if ! just db-exists ${DB_NAME} >&/dev/null; then + echo "Database ${DB_NAME} does not exist." >&2 + exit + fi + # Terminate all connections to the database + just psql -U postgres -P pager=off -c "\"SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = '${DB_NAME}' AND pid <> pg_backend_pid();\"" + just psql -U postgres -c "\"DROP DATABASE ${DB_NAME};\"" + echo "Database ${DB_NAME} deleted." + +# Check if database exists +db-exists db_name='': + #!/bin/bash + set -euo pipefail + DB_NAME=${DB_NAME:-{{ db_name }}} + while [ -z "${DB_NAME}" ]; do + DB_NAME=$(gum input --prompt="Database name: " --width=80) + done + if echo '\l' | just postgres::psql | grep -E "^ *${DB_NAME} *\|" &>/dev/null; then + echo "Database ${DB_NAME} exists." + else + echo "Database ${DB_NAME} does not exist." >&2 + exit 1 + fi + +# Create Postgres user +create-user username='' password='': + #!/bin/bash + set -euo pipefail + USERNAME=${USERNAME:-"{{ username }}"} + PASSWORD=${PASSWORD:-"{{ password }}"} + while [ -z "${USERNAME}" ]; do + USERNAME=$(gum input --prompt="Username: " --width=80) + done + if just user-exists ${USERNAME}; then + echo "User ${USERNAME} already exists" >&2 + exit + fi + if [ -z "${PASSWORD}" ]; then + PASSWORD=$(gum input --prompt="Password: " --password --width=80 \ + --placeholder="Empty to generate a random password") + fi + if [ -z "${PASSWORD}" ]; then + PASSWORD=$(just random-password) + fi + just psql -c "\"CREATE USER ${USERNAME} WITH LOGIN PASSWORD '${PASSWORD}';\"" + echo "User ${USERNAME} created." + +# Delete Postgres user +delete-user username='': + #!/bin/bash + set -euo pipefail + USERNAME=${USERNAME:-"{{ username }}"} + if ! just user-exists ${USERNAME}; then + echo "User ${USERNAME} does not exist." >&2 + exit + fi + just psql -c "\"ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM ${USERNAME};\"" + just psql -c "\"ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM ${USERNAME};\"" + just psql -c "\"ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM ${USERNAME};\"" + just psql -c "\"ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TYPES FROM ${USERNAME};\"" + just psql -c "\"ALTER SCHEMA public OWNER TO postgres;\"" + just psql -c "\"DROP USER ${USERNAME};\"" + echo "User ${USERNAME} deleted." + +# Check if user exists +user-exists username='': + #!/bin/bash + set -euo pipefail + USERNAME=${USERNAME:-"{{ username }}"} + while [ -z "${USERNAME}" ]; do + USERNAME=$(gum input --prompt="Username: " --width=80) + done + if echo '\du' | just postgres::psql | grep -E "^ *${USERNAME} *\|" &>/dev/null; then + echo "User ${USERNAME} exists." + else + echo "User ${USERNAME} does not exist." >&2 + exit 1 + fi + +# Grant all privileges on database to user +grant db_name='' username='': + #!/bin/bash + set -euo pipefail + DB_NAME=${DB_NAME:-"{{ db_name }}"} + USERNAME=${USERNAME:-"{{ username }}"} + while [ -z "${DB_NAME}" ]; do + DB_NAME=$(gum input --prompt="Database name: " --width=80) + done + while [ -z "${USERNAME}" ]; do + USERNAME=$(gum input --prompt="Username: " --width=80) + done + if ! just psql ${DB_NAME} -U postgres -P pager=off -c "\"SELECT 1;\""; then + echo "Database ${DB_NAME} does not exist." >&2 + exit 1 + fi + just psql -c "\"GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${USERNAME};\"" + echo "Privileges granted." + +# Revoke all privileges on database from user +revoke db_name='' username='': + #!/bin/bash + set -euo pipefail + DB_NAME=${DB_NAME:-"{{ db_name }}"} + USERNAME=${USERNAME:-"{{ username }}"} + while [ -z "${DB_NAME}" ]; do + DB_NAME=$(gum input --prompt="Database name: " --width=80) + done + while [ -z "${USERNAME}" ]; do + USERNAME=$(gum input --prompt="Username: " --width=80) + done + if ! just psql -U postgres ${DB_NAME} -P pager=off -c "\"SELECT 1;\""; then + echo "Database ${DB_NAME} does not exist." >&2 + exit 1 + fi + just psql -c "\"REVOKE ALL PRIVILEGES ON DATABASE ${DB_NAME} FROM ${USERNAME};\"" + echo "Privileges revoked." + +# Create Postgres database and user +create-user-and-db db_name='' username='' password='': + just create-db "{{ db_name }}" + just create-user "{{ username }}" "{{ password }}" + just grant "{{ db_name }}" "{{ username }}" + +# Delete Postgres database and user +delete-user-and-db db_name='' username='': + just revoke "{{ db_name }}" "{{ username }}" + just delete-user "{{ username }}" + just delete-db "{{ db_name }}" + +# Run psql +[no-exit-message] +psql *args='': + @kubectl exec -it -n postgres postgres-cluster-1 -c postgres -- psql {{ args }} + +# Dump Postgres database by pg_dump +dump db_name file: + kubectl exec -it -n ${CNPG_NAMESPACE} postgres-cluster-1 -c postgres -- bash -c \ + "pg_dump -d postgresql://postgres:$(just password)@localhost/{{ db_name }} -Fc > \ + /var/lib/postgresql/data/db.dump" + kubectl cp -n ${CNPG_NAMESPACE} -c postgres \ + postgres-cluster-1:/var/lib/postgresql/data/db.dump {{ file }} + kubectl exec -it -n ${CNPG_NAMESPACE} postgres-cluster-1 -c postgres -- rm /var/lib/postgresql/data/db.dump + +# Restore Postgres database by pg_restore +restore db_name file: + just create-db {{ db_name }} + kubectl cp {{ file }} -n ${CNPG_NAMESPACE} -c postgres \ + postgres-cluster-1:/var/lib/postgresql/data/db.dump + kubectl exec -it -n ${CNPG_NAMESPACE} postgres-cluster-1 -c postgres -- bash -c \ + "pg_restore -d postgresql://postgres:$(just password)@localhost/{{ db_name }} \ + /var/lib/postgresql/data/db.dump" diff --git a/postgres/postgres-cluster-values.yaml b/postgres/postgres-cluster-values.yaml new file mode 100644 index 0000000..abdce46 --- /dev/null +++ b/postgres/postgres-cluster-values.yaml @@ -0,0 +1,6 @@ +cluster: + instances: 1 + + initdb: + postInitTemplateSQL: + - CREATE EXTENSION IF NOT EXISTS vector;