Last active
August 15, 2025 19:25
-
-
Save devspacenine/abd51a605d7939755d0708a836037010 to your computer and use it in GitHub Desktop.
Keycloak Password Migration - Agent Prompt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| You will implement rails tools to coordinate and monitor a password migration by importing existing bcrypt hashes from Rails (`users.password_digest`) into Keycloak so users can keep their current passwords. We use the `keycloak-admin-ruby` gem for Admin API access. Reference: [keycloak-admin-ruby](https://github.com/looorent/keycloak-admin-ruby). | |
| Your work must be safe to run in production, idempotent, testable, and observable. If the migration is not running in the production environment, all passwords will be set to "abc@1234567890!" instead of using the actual password digest, in order to minimize risks of data leaks in lower environments. | |
| --- | |
| ### Current Context (read carefully) | |
| - Existing sync service updates/creates Keycloak users and can set plaintext passwords if provided: | |
| ```1:87:app/services/keycloak/sync_user.rb | |
| module Keycloak | |
| class SyncUser | |
| def upsert!(password: nil) | |
| return unless sync_enabled? | |
| if user.keycloak_id.present? | |
| update_existing_user!(password: password) | |
| else | |
| create_or_link_user!(password: password) | |
| end | |
| user.update(keycloak_synced_at: Time.current) | |
| rescue => e | |
| Rails.logger.error "[Keycloak] Sync failed for user #{user.id}: #{e.message}" | |
| raise | |
| end | |
| private | |
| def create_or_link_user!(password: nil) | |
| existing_user = Keycloak::Client.find_by_email(user.email) | |
| if existing_user | |
| Rails.logger.info "[Keycloak] Linking existing user #{existing_user.id} to Rails user #{user.id}" | |
| user.update(keycloak_id: existing_user.id) | |
| update_existing_user!(password: password) | |
| else | |
| create_new_user!(password: password) | |
| end | |
| end | |
| def create_new_user!(password: nil) | |
| representation = build_user_representation | |
| keycloak_id = Keycloak::Client.create_user!(representation) | |
| user.update(keycloak_id: keycloak_id) | |
| set_password_if_provided(keycloak_id, password) | |
| end | |
| def update_existing_user!(password: nil) | |
| representation = build_user_representation | |
| Keycloak::Client.update_user!(user.keycloak_id, representation) | |
| set_password_if_provided(user.keycloak_id, password) | |
| end | |
| def build_user_representation | |
| representation = KeycloakAdmin::UserRepresentation.new | |
| representation.username = user.email | |
| representation.email = user.email | |
| representation.first_name = user.first_name | |
| representation.last_name = user.last_name | |
| representation.enabled = user.active? | |
| representation.email_verified = true | |
| representation.attributes = { rails_user_id: [user.id.to_s] } | |
| representation | |
| end | |
| def set_password_if_provided(keycloak_id, password) | |
| return if password.blank? | |
| Keycloak::Client.set_password!(keycloak_id, password, temporary: false) | |
| end | |
| end | |
| end | |
| ``` | |
| - Current bulk sync task (only sends email; in dev sets a default password): | |
| ```1:36:lib/tasks/keycloak.rake | |
| namespace :keycloak do | |
| desc "Sync all users to Keycloak" | |
| task sync_users: :environment do | |
| User.find_each do |user| | |
| Keycloak::SyncUser.new(user).upsert!(password: ENV["ENVIRONMENT"] == "development" ? "abc@1234567890!" : nil) | |
| end | |
| end | |
| end | |
| ``` | |
| - Client wrapper: | |
| ```1:76:app/services/keycloak/client.rb | |
| module Keycloak | |
| class Client | |
| class << self | |
| def create_user!(representation) | |
| new_user = KeycloakAdmin.realm(realm).users.create!(representation.username, representation.email, nil, true, "en", representation.attributes) | |
| new_user.id | |
| end | |
| def update_user!(keycloak_id, representation) | |
| KeycloakAdmin.realm(realm).users.update(keycloak_id, representation) | |
| end | |
| def set_password!(keycloak_id, password, temporary: false) | |
| KeycloakAdmin.realm(realm).users.reset_password(keycloak_id, password, temporary) | |
| end | |
| def find_by_email(email) | |
| users = KeycloakAdmin.realm(realm).users.search(email) | |
| users.find { |u| u.email&.downcase == email.downcase } | |
| end | |
| end | |
| end | |
| end | |
| ``` | |
| - Realm password policy (Terraform) includes strong rules like `length(15)` and `passwordHistory(3)`. Importing hashed passwords should bypass policy checks; if not, we’ll handle a safe fallback. | |
| --- | |
| ### Goal | |
| Add secure, idempotent support to import bcrypt password hashes into Keycloak for both create and update flows. Update rake tasks to pass hashes. Provide robust tests, monitoring, and fallback plans. | |
| --- | |
| ### Deliverables | |
| 1) Implement hashed password import | |
| - Extend `Keycloak::SyncUser` to accept `password_hash:` (bcrypt digest string) along with existing `password:`. When `password_hash:` is present: | |
| - For updates: include a `credentials` entry on the user representation and call `update_user!`. | |
| - For creates: either call a new `create_user_with_representation!` that supports full representation (including `credentials`), or create then immediately call an import method that updates credentials with the hash. | |
| - Bcrypt credential representation for Keycloak: | |
| - Use `type: "password"`, `algorithm: "bcrypt"`, `temporary: false`. | |
| - Use `hashedSaltedValue: <password_digest>`. | |
| - Use `hashIterations: -1` for bcrypt. | |
| - No salt field is needed (bcrypt includes salt in the hash string). | |
| Example representation you’ll set on `representation.credentials`: | |
| ```ruby | |
| [ | |
| { | |
| type: "password", | |
| algorithm: "bcrypt", | |
| hashedSaltedValue: user.password_digest, | |
| hashIterations: -1, | |
| temporary: false | |
| } | |
| ] | |
| ``` | |
| - Do not log plaintext or hashed passwords anywhere. | |
| 2) Extend `Keycloak::Client` | |
| - Add a method to create from a full representation (preferred if supported by the gem): | |
| ```ruby | |
| def create_user_with_representation!(representation) | |
| # Prefer native gem support if available. If not, use the gem’s low-level request or RestClient to POST | |
| # /admin/realms/#{realm}/users with representation.as_json including credentials | |
| KeycloakAdmin.realm(realm).users.create_user!(representation) # if provided by the gem | |
| end | |
| ``` | |
| - Add a helper to import a password hash post-creation (safe fallback and used for existing users): | |
| ```ruby | |
| def import_password_hash!(keycloak_id, password_hash) | |
| rep = KeycloakAdmin::UserRepresentation.new | |
| rep.credentials = [{ | |
| type: "password", | |
| algorithm: "bcrypt", | |
| hashedSaltedValue: password_hash, | |
| hashIterations: -1, | |
| temporary: false | |
| }] | |
| KeycloakAdmin.realm(realm).users.update(keycloak_id, rep) | |
| end | |
| ``` | |
| - Keep existing `reset_password` path strictly for plaintext. | |
| 3) Update `Keycloak::SyncUser` | |
| - Signature change: | |
| ```ruby | |
| def upsert!(password: nil, password_hash: nil) | |
| ``` | |
| - Update flow: | |
| - When `password_hash` present and `user.keycloak_id` present: build representation with `credentials` as above and call `update_user!`. | |
| - When creating: | |
| - Try `create_user_with_representation!` with credentials if supported. | |
| - Else: create the user, then call `import_password_hash!` with returned `keycloak_id`. | |
| - If both `password_hash` and `password` provided, prefer `password_hash`. | |
| - If neither provided, just sync profile fields. | |
| - Add a timestamp on `User` to track import success: | |
| - Migration: `add_column :users, :keycloak_password_imported_at, :datetime` | |
| - On success: `user.update!(keycloak_password_imported_at: Time.current)` | |
| 4) Update rake tasks | |
| - Pass `password_hash: user.password_digest` in both tasks. | |
| ```ruby | |
| # lib/tasks/keycloak.rake | |
| Keycloak::SyncUser.new(user).upsert!(password_hash: user.password_digest) | |
| ``` | |
| - Add a new task `keycloak:sync_users_with_passwords` that logs totals for: | |
| - users processed, successful, failed, password hashes imported. | |
| - Write failures to `log/keycloak_password_import_failures.csv` with columns: user_id,email,error. | |
| 5) Config flags and safety | |
| - Env flag to gate import in production: | |
| - `KEYCLOAK_PASSWORD_HASH_IMPORT_ENABLED=true` | |
| - Skip hash import if not enabled. | |
| - Optional dry-run: | |
| - `KEYCLOAK_PASSWORD_HASH_IMPORT_DRY_RUN=true` logs intended actions without calling Keycloak. | |
| - Rate-limit updates (sleep or batched) if needed to avoid throttling. | |
| 6) Observability and monitoring | |
| - Add structured logs around each import attempt (no sensitive data). | |
| - Instrument with `ActiveSupport::Notifications`: | |
| - `keycloak.password_import.success` and `keycloak.password_import.failure` with tags: user_id, email, mode(create|update). | |
| - Send errors to Sentry with context. | |
| - Expose counters (if we have a metrics sink) or at least aggregate in logs at the end of rake runs. | |
| 7) Fallback plans | |
| - If Keycloak rejects hashed import (400/422) for a user: | |
| - Record failure to CSV + Sentry. | |
| - Do NOT attempt plaintext set. | |
| - Mark user for retry by leaving `keycloak_password_imported_at` nil. | |
| - Provide a separate rake task to retry only failures: `keycloak:retry_password_hash_imports`. | |
| - If import repeatedly fails for a subset: | |
| - Option A (recommended): enable a temporary “password capture on login” fallback in Rails to set Keycloak password on next successful Rails-side login (behind feature flag; do not block current release). | |
| - Option B (last resort): set a temporary password in Keycloak with `temporary: true` and notify the user to change it (keep this behind ops-only switch, not default). | |
| 8) Tests (RSpec) | |
| - Unit tests for `Keycloak::SyncUser`: | |
| - When `password_hash` provided and `keycloak_id` present: calls `Keycloak::Client.update_user!` with representation having `credentials` including `algorithm: "bcrypt"`, `hashedSaltedValue: user.password_digest`, `hashIterations: -1`. | |
| - When creating and `password_hash` provided: prefers full representation creation if available; otherwise create then `import_password_hash!`. | |
| - When neither `password` nor `password_hash` provided: performs attribute sync only. | |
| - Sets `keycloak_password_imported_at` on success. | |
| - Logs and re-raises on client errors. | |
| - Unit tests for `Keycloak::Client`: | |
| - `import_password_hash!` builds and sends the correct representation structure. | |
| - New `create_user_with_representation!` path (stub gem call or low-level request). | |
| - No logging of sensitive info. | |
| - Rake tests: | |
| - `keycloak:sync_users` continues to work. | |
| - New `keycloak:sync_users_with_passwords` passes `password_hash: user.password_digest`. | |
| - Errors recorded to CSV and totals logged. | |
| - Integration (optional; behind `KEYCLOAK_E2E=1`): | |
| - Spin up Keycloak container in CI, configure realm `carebility` minimally for Admin API. | |
| - Create a Rails `User` with known password “P@ssw0rd!” and set `password_digest = BCrypt::Password.create(“P@ssw0rd!”)`. | |
| - Run `upsert!(password_hash: user.password_digest)`. | |
| - Verify direct access grant via Keycloak returns a token when authenticating with “P@ssw0rd!”. | |
| - Ensure realm password policy doesn’t block hashed import (it should not). If it does, relax policy only for the test realm. | |
| 9) Security | |
| - Never log plaintext or hashed passwords. | |
| - Ensure all error messages scrub any sensitive fields. | |
| - Validate `password_digest` format matches bcrypt (`\A\$2[aby]\$\d{2}\$[A-Za-z0-9./]{53}\z`) before attempting import. If invalid, skip and record failure. | |
| 10) Rollout | |
| - Staging rollout first; verify a sample of users can log in with their existing passwords post-import. | |
| - In production: enable `KEYCLOAK_PASSWORD_HASH_IMPORT_ENABLED` for a canary subset (filter by user id modulo) before full run. | |
| - Keep original `password_digest` in DB for at least 90 days post-cutover; plan a follow-up migration to drop once stable. | |
| --- | |
| ### Implementation Hints | |
| - The gem supports representation objects. If it lacks a convenient create-with-representation method, use its underlying REST client to POST the full user representation (with `credentials`) to `/admin/realms/:realm/users`. Keep it encapsulated in `Keycloak::Client`. | |
| - If the `KeycloakAdmin::CredentialRepresentation` class doesn’t expose fields like `hashedSaltedValue`, set them via hash assignment or `instance_variable_set` and ensure the serializer includes them. Worst case, build the request body manually for `users.update`. | |
| - Respect realm config from `config/initializers/keycloak_admin.rb` and guard SSL verification per env. | |
| --- | |
| ### Files to edit | |
| - `app/services/keycloak/sync_user.rb` | |
| - `app/services/keycloak/client.rb` | |
| - `lib/tasks/keycloak.rake` | |
| - New migration for `users.keycloak_password_imported_at` | |
| - Specs under `spec/services/keycloak`, `spec/tasks`, optional `spec/integration` | |
| --- | |
| ### Acceptance Criteria | |
| - Running the new rake task migrates users and imports bcrypt hashes into Keycloak. | |
| - Users can log into Keycloak using their existing passwords post-migration (verified in staging). | |
| - No plaintext/hashes are logged; failures are captured to CSV and Sentry. | |
| - Tests cover success, failure, and no-op paths; optional E2E validates a known password works via Keycloak. | |
| - Safe to re-run; only updates missing credentials. | |
| --- | |
| Referenced library: [keycloak-admin-ruby](https://github.com/looorent/keycloak-admin-ruby) | |
| - Ensure API usage aligns with the gem’s capabilities; where missing, implement minimal direct REST calls within `Keycloak::Client` while reusing the gem’s auth/session. | |
| --- |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment