Skip to content

feat: integrate Keycloak 26 commercial hardening - #42

Merged
seonghobae merged 107 commits into
mainfrom
agent/product-hardening-integration
Aug 3, 2026
Merged

feat: integrate Keycloak 26 commercial hardening#42
seonghobae merged 107 commits into
mainfrom
agent/product-hardening-integration

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Consolidates the overlapping Keycloak 26 bring-up, SCIM/merge serialization, SQLite runtime safety, deployment durability, protected hourly PR stewardship, and buyer-visible product hardening work into one integration branch.

Buyer-visible product gaps closed

  • makes the committed realm importable on Keycloak 26 and registers the concrete naruon-web PKCE relying party;
  • keeps the bound browser flow strictly passkey-only and caps the public-client access-token lifetime at 300 seconds;
  • provides password-free headless registration through one bounded Keycloak action-email link for VERIFY_EMAIL and webauthn-register-passwordless;
  • rolls back newly created accounts when enrollment email initialization fails and reports rollback failure separately;
  • isolates registration throttling by direct caller address and maps concurrent duplicate creation to a stable HTTP 409 product error;
  • moves employer federation out of realm source code into a KV/DB-backed desired-state API with rebuild convergence;
  • redacts every unknown provider configuration value by default and reports applied_to_keycloak=false when desired state is stored but Keycloak convergence fails;
  • releases federation storage locks before Keycloak network calls and keeps stored desired state observable during Keycloak outages;
  • prevents SCIM full replacement from resurrecting a merged/tombstoned account by sharing one user-operation lock boundary with account merge;
  • returns root-level RFC 7644 path errors with application/scim+json;
  • makes SQLite configuration and audit persistence safe for FastAPI worker-thread access;
  • persists standalone and Helm audit/lock data across container or pod replacement;
  • supports fail-closed Helm enforcement of immutable account-unification image digests;
  • refreshes an expired Keycloak service-account token once and closes HTTP/database resources deterministically;
  • schedules a fail-closed PR steward every hour at minute 17, updating only trusted same-repository branches and arming exact-head auto-merge only after aggregate approval and all required Checks pass;
  • adds CHANGELOG.md, architecture/design documentation, implementation plans, and regression coverage for the new invariants.

Integration policy

  • retains the currently merged SHA-pinned GitHub Actions, CodeQL versions, Python image digest, and application dependency policy;
  • preserves two-word-or-longer snake_case database object names;
  • requires 100% application docstring coverage and 100% production statement/branch coverage;
  • keeps the core merge/SCIM API modular while isolating registration/federation extensions behind a separate adapter contract;
  • never bypasses independent review or protected GitHub Checks.

Verification required on the exact final head

  • uv sync --locked --extra dev
  • uv run ruff check app tests tools
  • uv run interrogate . at 100%
  • uv run pytest -q at 100% production statement and branch coverage
  • realm, Docker Compose, Helm, and hourly-steward contract validation
  • CodeQL, Semgrep, container security scan, and central coverage evidence
  • independent approving review

Supersedes #18, #39, #40, and #41.

seonghobae and others added 30 commits July 21, 2026 13:58
…irst RP

Real-world bring-up on Keycloak 26.3.2 surfaced four import/runtime failures
in the committed realm, each reproduced and fixed:

- RealmRepresentation rejects unknown fields, so the $-prefixed annotation
  keys aborted --import-realm and crash-looped the container. Annotations
  moved to deploy/keycloak/README.md; the validator now fails on any $ key.
- SAML IdP URLs are URL-validated at import: the bare __set_from_kv__
  placeholder aborted the import. Placeholders are now URL-shaped
  (https://set-from-kv.invalid/__set_from_kv__) and still patched from KV.
- An ENABLED committed LDAP source with placeholder DNs breaks every realm
  user operation (Invalid DN). The committed source now ships disabled and
  kcadm-bootstrap.sh enables it only after patching real values from KV.
- The default Infinispan jdbc-ping stack crash-loops single-node compose
  restarts (each aborted boot leaves a stale jgroups_ping coordinator row the
  next boot fatally tries to join). The standalone compose now defaults to
  KC_CACHE=local via IDP_CACHE_MODE (clustered deployments set ispn).

Imported realms also lack the standard client scopes, and without `basic`
Keycloak 26 lightweight access tokens omit `sub`, breaking any RP that
authenticates by subject. The realm now commits basic/profile/email scopes as
realm defaults, adds an audience mapper to the RP template, and registers
naruon-web as the first concrete RP (public PKCE S256 client with the
sub/aud/role/org/workspace claims naruon's session contract requires).

Verified: scripts/validate_realm.py passes; the realm imports cleanly into
quay.io/keycloak/keycloak:26.3.2 with no sanitization; a live naruon
authorization-code login (PKCE, RS256, JWKS) established a backend session
end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…time API

Employer-specific federation (the hssmartdev ADFS SAML IdP, corporate LDAP)
was committed into realm-cwl.json with KV placeholders. That hardcodes one
employer into the ecosystem IdP's code and also breaks bring-up mechanically:
SAML IdP URLs are validated at import (placeholders abort it) and an enabled
LDAP source with placeholder DNs fails every realm user operation
(Invalid DN: __set_from_kv__).

External IdPs are deployment data, so they now live behind a runtime admin
API on the account-unification service:

- GET/PUT/DELETE /federation/identity-providers[/{alias}] and
  POST /federation/identity-providers:apply. Desired state persists in the
  KV/DB config store (source of truth) and is converged into Keycloak via
  the Admin REST API, so a realm rebuild re-converges with one apply call.
- AdminApi gains identity-provider CRUD (HttpAdminApi + mock); KvStore gains
  delete() on the protocol and both backends.
- realm-cwl.json commits no identityProviders and no user-storage federation;
  scripts/validate_realm.py now fails closed on committed federation instead
  of requiring it, and kcadm-bootstrap.sh drops the ADFS/LDAP patch steps and
  additionally grants manage-identity-providers to the service account.
- deploy/templates/ remain as ready-made payload references for the API.

Verified: scripts/validate_realm.py passes; the account-unification suite
passes (58 tests) including new federation registry tests that register the
employer ADFS as runtime data; the realm still imports cleanly and a live
naruon OIDC login flow reaches the passwordless form after removing the
hardcoded federation from a running realm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Semgrep (dynamic-urllib-use-detected) flags urllib.request.urlopen because it
accepts file:// and other schemes. The probe only ever talks to the local
listener, so it now validates the scheme and issues the request over an
explicit plain-HTTP http.client connection; non-http probe URLs are refused
before any connection is opened, with a regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ret handling

Strix flagged the account-unification service (which holds realm-management
privileges) as reachable unauthenticated, plus path-traversal and secret-in-argv
issues. Remediated without suppression:

- Operator bearer auth (app/auth.py) now gates every privileged router — merge,
  identity reads, SCIM provisioning/deactivation, and the federation registry —
  via a shared operator token from the KV/DB config store (constant-time
  compare, fails closed when unconfigured). /healthz stays open for probes.
  (VULN-0001 merge, VULN-0002 federation, VULN-0003 SCIM, VULN-0007 reads)
- Path-segment identifier validation (app/identifiers.py) rejects '/', '\\',
  '.', '..', percent-encoding, and control characters. Applied at the API/SCIM
  boundary (400) and as a centralized chokepoint inside the Admin REST client
  before any URL is built, so a user_id like '../users/victim' or '%2e%2e'
  cannot escape the intended resource. (VULN-0006)
- kcadm bootstrap no longer passes the admin password on argv: it fetches a
  short-lived admin token via curl with the password sourced from a 0600 temp
  file (--data-urlencode "@file"), then configures kcadm with that bearer
  token. (VULN-0005)
- The Helm chart supports and documents an immutable image digest for the
  privileged account-unification image, rendered as tag@sha256 when set.
  (VULN-0004)

New tests: operator-auth gating (missing/wrong/valid token, fail-closed,
/healthz open), path-segment validation + Admin-client traversal rejection via
MockTransport. Full suite passes (73 tests); ruff clean; interrogate 97.4%;
validate_realm.py OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ot-review dispatch allowlist

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The login page offered no way to create an account, so a fresh deployment
could onboard nobody without operator kcadm surgery. Registration is now
allowed with the email address as the account identity; the throwaway
registration password never becomes a usable credential because the
browser flow has no password authenticator and the default
webauthn-register-passwordless required action enrolls a passkey in the
first session. verifyEmail stays false while the realm has no smtpServer
— the validator now enforces both pairings fail-closed (email-first +
default passkey enrollment when registration is on; verifyEmail only with
SMTP), each proven by mutation tests.

Evidence: scripts/validate_realm.py OK on the shipped realm and rejects
all three mutations; account-unification pytest 77 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Product frontends now own the signup UX: POST /registration/accounts on the
account-unification service (own bearer token, distinct from the operator
credential) creates the Keycloak account via the Admin API with an initial
password and the webauthn-register-passwordless required action. The
IdP-hosted registration form goes back off (registrationAllowed:false).

The browser flow gains a browser-passwordless-credentials subflow where the
passkey and the credential form are ALTERNATIVE siblings: the form is
offered only while the account has no passkey, and the in-process password
janitor (plus /registration/password-janitor:run) revokes the bootstrap
password after enrollment, keeping the steady state passwordless. The
validator enforces this exact bootstrap shape and still bans every other
credential-form authenticator.

Fresh-bring-up fixes found while wiring this live: the audit sink wrote
into the read-only /bootstrap mount (now a separate writable path with a
Dockerfile-owned directory), and kcadm-bootstrap.sh granted realm-management
roles without scope mappings or a client-role protocol mapper, so the
fullScopeAllowed:false service-account token never carried them and every
Admin call failed 403.

Evidence: validate_realm.py OK plus REQUIRED-password-form and
username-password-form mutations rejected; service pytest 90 passed; live
compose bring-up serves /healthz ok and POST /registration/accounts returns
201 with the account id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HttpAdminApi cached the service-account token for the connection lifetime,
so after the token lifespan every Admin REST call failed 401 until a
process restart — first observed as registration 500s minutes after
bring-up. Each verb now routes through _send_with_reauth, which refreshes
the token exactly once on a 401 and then fails honestly. Regression test
drives a MockTransport that rejects the stale token and asserts the retry
carries a freshly issued one.

Evidence: service pytest 91 passed; live compose registration returns 201
again after the previous token aged out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lib finding

The central SAST Semgrep gate fails on the base branch because p/default's
python.lang.security.audit.dynamic-urllib-use-detected flags
app/healthcheck.py: urlopen() receives a non-literal url, which urllib would
happily open as a file:// path. This Medium finding blocks every open
keyverse PR, since each PR scans a tree that still contains this file.

Harden the probe by rejecting any URL whose scheme is not http/https before
opening it, so a stray value can never coerce urlopen into a file:// read or
another protocol handler. The residual audit finding on the (still non-literal)
urlopen call is suppressed narrowly with an inline
`# nosemgrep: dynamic-urllib-use-detected`, justified by the scheme allow-list
and the fact that the container self-probe URL is not attacker-controlled.
Add a regression test for the rejected-scheme path.

Verified locally: semgrep marks the finding suppressed (gate passes), ruff is
clean, and the healthcheck tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdCssGnNMhKHNu3TXFstWH
…uplicate

A merged-away duplicate is tombstoned (disabled + a `merged_into_user_id`
pointer to the survivor) so it can never authenticate again, per
docs/merge-unification-flow.md and the CLAUDE.md merge invariant. But the SCIM
shim's `PUT /scim/v2/Users/{id}` (`replace_user`) only did a 404 existence
check and then translated the resource (whose `active` defaults to true) into a
replace — with no tombstone guard.

SCIM PUT is the *only* reactivation vector: `create` guards uniqueness and
`patch`/`delete` only ever disable. So a routine upstream HR/IGA full-sync PUT
that still lists the decommissioned person silently re-enabled the tombstoned
account (restoring its untouched passkey/WebAuthn login), and against a live
Keycloak the PUT would also overwrite the user representation, wiping the
`merged_into_user_id` pointer that resolves stale references to the survivor.
SCIM endpoints carry no app-level authz (trust terminates at the WAF edge), so
the trigger is unprivileged.

Fix: in `replace_user`, refuse with SCIM 409 when the target carries the
tombstone attribute, keeping a merged duplicate immutable via SCIM. Adds
`get_user_attribute` to the `AdminApi` protocol, the HTTP client, and the mock
so the guard reads the pointer uniformly. Regression test asserts the PUT is
refused and the duplicate stays disabled with its survivor pointer intact
(verified red→green: without the guard the test fails as the account is
re-enabled). Full suite 54 passed; interrogate 100%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdCssGnNMhKHNu3TXFstWH
…fied email

`UnificationService.merge_accounts` nested the unverified-email refusal inside
`if not decision.matched:`. Because `decide_match(..., explicit_link=True)`
returns `matched=True, reason=EXPLICIT_LINK`, an operator "explicit link" merge
skipped the guard entirely and merged + tombstoned two accounts whose only
shared tie was an UNVERIFIED email — the account-takeover vector the hard rule
exists to block (an attacker registers a duplicate holding the victim's
unverified email, then one explicit_link=True merge folds it into the victim).

This violates three contract sources:
- `app/models.py` MergeRequest.explicit_link docstring: "Even so, the service
  refuses if the only tie is an UNVERIFIED email."
- `docs/merge-unification-flow.md`: "reject if only tie is unverified email ->
  422 UnverifiedEmailMerge" is an unconditional step after decide_match.
- `CLAUDE.md`: "Never link or merge accounts on an unverified email."

Fix: hoist the unverified-email guard out of the not-matched branch and run it
for every decision reason except a genuine tie (EXACT_IDP_SUBJECT / VERIFIED_
EMAIL), so explicit-link and no-match are both covered. Legitimate merges are
preserved: an explicit link with different/absent emails still merges, and
verified-email / exact-(idp,subject) matches are exempt.

TDD: added test_refuse_explicit_merge_on_shared_unverified_email — confirmed it
fails on the pre-fix code ("DID NOT RAISE UnverifiedEmailMergeError", duplicate
gets tombstoned) and passes after. No existing test changes.
Verified (CI parity, py3.12, services/account_unification): ruff clean,
interrogate 100% (>=80 gate), pytest all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdCssGnNMhKHNu3TXFstWH
…e handlers

The healthcheck validated only the *initial* URL scheme, then used the default
`urllib` opener -- which follows redirects and carries an `FTPHandler`. A
`http:// -> ftp://` (or `file://`) redirect from the probed endpoint would have
been followed by another protocol handler (the code comment even wrongly claimed
it could not be). CodeRabbit flagged it (CWE-918 SSRF, Major).

Fix: route the probe through a purpose-built opener that (1) carries only
HTTP/HTTPS handlers -- no ftp/file/data handler exists to open such a target --
and (2) uses `_HttpOnlyRedirectHandler`, which drops any redirect whose
`Location` scheme is not in the http/https allow-list. Both are belt-and-
suspenders; either alone fails the ftp redirect closed. `main` now opens via the
patchable `_open_health_url` seam.

Added `test_healthcheck_opener_drops_non_http_redirect_target` (ftp target
dropped, same-scheme redirect kept, no ftp/file/data handler on the opener); the
three existing tests re-point to the new seam.

Verified (CI parity, services/account_unification): pytest all pass, ruff clean,
interrogate 99.4% (>=80 gate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdCssGnNMhKHNu3TXFstWH

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

Please review the exact current head. The previous actionable and nitpick findings were re-evaluated and addressed, including the password-free action-email enrollment redesign, storage-lock-free federation I/O, fail-closed redaction, protocol-native SCIM errors, durable deployment state, and deterministic production-lock race regression.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

@seonghobae I will perform a complete review of the exact current head of PR #42. I will re-evaluate the listed resolved areas.

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 36 minutes.

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

Please review the exact current head f02068a9c0f7299c8a204c134b2c2033af701a32. Since the previous review, the only additional corrections align the Helm image tag with the unreleased package version and add a contract test preventing chart/package version drift.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

@seonghobae I will review the current head f02068a9c0f7299c8a204c134b2c2033af701a32. I will check the Helm image tag alignment and the contract test for chart/package version drift.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor Author

Current-head remediation status (f02068a9c0f7299c8a204c134b2c2033af701a32):

  • all previously reported review threads are resolved or outdated;
  • registration is password-free and failure-atomic through bounded Keycloak action email;
  • federation storage locks no longer cover network I/O, unknown values redact by default, and desired state remains readable during Keycloak outages;
  • SCIM path errors are protocol-native; SCIM/merge serialization uses the production lock manager in a deterministic regression;
  • Compose/Helm persistence and digest enforcement contracts are present;
  • hourly protected PR stewardship is integrated; ci: schedule hourly approved-PR steward #40 was closed as superseded;
  • Helm image tag is aligned with the still-unreleased package version and protected by a drift test;
  • auto-merge is enabled.

Remaining protected gates are exact-head CI/CodeQL/Semgrep/Security execution and an independent approving review. They are not bypassed.

Comment thread .github/workflows/hourly-pr-steward.yml Fixed
@opencode-agent
opencode-agent Bot disabled auto-merge August 3, 2026 08:50
@seonghobae
seonghobae enabled auto-merge (squash) August 3, 2026 23:13
@seonghobae
seonghobae merged commit de2fabf into main Aug 3, 2026
31 checks passed
@seonghobae
seonghobae deleted the agent/product-hardening-integration branch August 3, 2026 23:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants