Skip to content

Instantly share code, notes, and snippets.

@devspacenine
Last active August 15, 2025 19:25
Show Gist options
  • Select an option

  • Save devspacenine/abd51a605d7939755d0708a836037010 to your computer and use it in GitHub Desktop.

Select an option

Save devspacenine/abd51a605d7939755d0708a836037010 to your computer and use it in GitHub Desktop.
Keycloak Password Migration - Agent Prompt
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