Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
107 commits
Select commit Hold shift + click to select a range
d3f83cf
fix: make the realm importable on Keycloak 26 and onboard naruon as f…
seonghobae Jul 21, 2026
16ec55b
feat: move external federation out of realm code into a DB-backed run…
seonghobae Jul 21, 2026
1538427
fix: replace the broad urllib opener in the container healthcheck
seonghobae Jul 21, 2026
446c8a0
fix(security): authenticate the admin API and harden identifier + sec…
seonghobae Jul 21, 2026
8cf239f
chore: re-trigger required reviews after enabling keyverse in the rob…
seonghobae Jul 21, 2026
a215574
feat(realm): email-first self-registration under the passkey contract
seonghobae Jul 22, 2026
9eacf74
feat(registration): headless signup API with bootstrap-password contract
seonghobae Jul 22, 2026
03d5fbb
fix(admin-api): re-authenticate once when the cached admin token expires
seonghobae Jul 22, 2026
2fd34a9
fix(healthcheck): allow-list URL scheme and clear Semgrep dynamic-url…
claude Jul 29, 2026
5e9c4eb
fix(scim): refuse SCIM PUT that would resurrect a tombstoned merged d…
claude Jul 30, 2026
e95e87b
fix(merge): refuse explicit-link merge when the only tie is an unveri…
claude Jul 31, 2026
4e2b9d2
Revert "fix(merge): refuse explicit-link merge when the only tie is a…
claude Jul 31, 2026
ee085a3
fix(healthcheck): constrain redirect targets to HTTP(S), drop ftp/fil…
claude Jul 31, 2026
7342859
test(security): reproduce SCIM merge tombstone race
seonghobae Aug 3, 2026
06abe3a
fix(security): add shared user-operation lock abstraction
seonghobae Aug 3, 2026
9375b4e
fix(security): serialize merge mutations with SCIM
seonghobae Aug 3, 2026
ce436a2
fix(security): make SCIM tombstone check and PUT atomic
seonghobae Aug 3, 2026
ac3b056
fix(security): wire one shared mutation lock manager
seonghobae Aug 3, 2026
5220bab
test: inject shared user-operation locks into service fixtures
seonghobae Aug 3, 2026
3c4d098
fix(api): surface user-operation lock contention as retryable
seonghobae Aug 3, 2026
68ad2a1
test(security): verify shared lock serialization and timeout
seonghobae Aug 3, 2026
cbd283c
docs: define SCIM and merge serialization invariant
seonghobae Aug 3, 2026
1051ceb
fix(security): preserve the unverified-email hard rule under serializ…
seonghobae Aug 3, 2026
5dbba0a
fix(security): harden healthcheck redirects and document the handler
seonghobae Aug 3, 2026
624e0e5
test(security): retain explicit-link unverified-email regressions
seonghobae Aug 3, 2026
7e10e89
merge(main): preserve current main while rebasing SCIM serialization
seonghobae Aug 3, 2026
f31bd22
fix(security): remove registration ReDoS and sensitive log wording
seonghobae Aug 3, 2026
76458c3
test(registration): require non-sensitive janitor result naming
seonghobae Aug 3, 2026
d002352
fix(security): keep credential counts out of sensitive-data logging h…
seonghobae Aug 3, 2026
1d21d8e
fix(security): log only aggregate janitor credential counts
seonghobae Aug 3, 2026
f119c50
merge(product): integrate Keycloak 26 and SCIM hardening
seonghobae Aug 3, 2026
9885cde
build(integration): retain current pinned CI actions
seonghobae Aug 3, 2026
b53cdc9
build(integration): retain current pinned CodeQL actions
seonghobae Aug 3, 2026
cafd07d
build(integration): retain current application dependencies
seonghobae Aug 3, 2026
fd7d656
build(integration): retain current Python image digest
seonghobae Aug 3, 2026
4c2988f
fix(integration): clear Semgrep lock false positive
seonghobae Aug 3, 2026
21a0c50
fix(security): log only aggregate janitor counts
seonghobae Aug 3, 2026
b8fd4bd
fix(security): rename janitor result away from secret terminology
seonghobae Aug 3, 2026
72617e2
test: align janitor result terminology
seonghobae Aug 3, 2026
e235328
build(integration): refresh dependency lock evidence
seonghobae Aug 3, 2026
1339c1c
merge(main): align commercial hardening with current dependency baseline
seonghobae Aug 3, 2026
2bd866d
fix(security): bind Admin REST calls to known route shapes
seonghobae Aug 3, 2026
bc8f00a
fix(security): reject query and fragment delimiters in identifiers
seonghobae Aug 3, 2026
2264fa8
test: prove Admin REST route-shape enforcement
seonghobae Aug 3, 2026
8c873db
fix(security): validate every dynamic Admin REST segment
seonghobae Aug 3, 2026
fbddba5
test: require supported secret-safe kcadm authentication
seonghobae Aug 3, 2026
f611610
fix(keycloak): use supported secret-safe Admin CLI login
seonghobae Aug 3, 2026
1e67863
test: preserve KV helper environment during kcadm login
seonghobae Aug 3, 2026
cba88b6
fix(keycloak): isolate Admin CLI state without changing KV home
seonghobae Aug 3, 2026
64d1cb6
ci: cancel superseded pull-request validation runs
seonghobae Aug 3, 2026
2b333bc
ci: cancel superseded CodeQL runs
seonghobae Aug 3, 2026
e9c6fd1
test(config): reject unsafe runtime configuration
seonghobae Aug 3, 2026
74d41ff
fix(config): fail closed on unsafe runtime values
seonghobae Aug 3, 2026
9d376f2
test(keycloak): require idempotent secret-safe bootstrap
seonghobae Aug 3, 2026
02cb796
fix(keycloak): make bootstrap idempotent and keep secrets out of argv
seonghobae Aug 3, 2026
f408200
docs(keycloak): align bootstrap and federation source-of-truth
seonghobae Aug 3, 2026
b5faf47
docs(plan): record protected review remediation
seonghobae Aug 3, 2026
3c33048
test(registration): specify passwordless action-email enrollment
seonghobae Aug 3, 2026
a3976fd
test(realm): specify passwordless and token lifetime policy
seonghobae Aug 3, 2026
1572c28
test(config): define action-email registration contract
seonghobae Aug 3, 2026
e8ee1f9
feat(registration): validate passwordless action-email settings
seonghobae Aug 3, 2026
b02e507
feat(registration): send one-time passkey enrollment actions
seonghobae Aug 3, 2026
f38fdea
test(mock): record passwordless enrollment action emails
seonghobae Aug 3, 2026
874d397
feat(registration): eliminate bound-flow bootstrap passwords
seonghobae Aug 3, 2026
d986f5a
refactor(lifecycle): remove password janitor and secure memory lock path
seonghobae Aug 3, 2026
9c7f1cb
fix(realm): enforce passwordless flow and public token ceiling
seonghobae Aug 3, 2026
e10badb
fix(realm): remove password authenticator and shorten public tokens
seonghobae Aug 3, 2026
8abb302
test(keycloak): cover one-time passwordless action email
seonghobae Aug 3, 2026
af0f153
chore(config): seed passwordless enrollment settings safely
seonghobae Aug 3, 2026
d0da6b5
test(federation): specify fail-closed reconciliation semantics
seonghobae Aug 3, 2026
97c81f4
fix(federation): make convergence explicit and fail closed
seonghobae Aug 3, 2026
4184fe8
fix(health): raise HTTPError for non-success probes
seonghobae Aug 3, 2026
08a79de
test(scim): require protocol-native path errors
seonghobae Aug 3, 2026
1d4b6f3
fix(scim): return protocol-native path validation errors
seonghobae Aug 3, 2026
32f2266
fix(scim): register protocol-native path error handler
seonghobae Aug 3, 2026
376b18b
fix(deploy): persist account-unification state
seonghobae Aug 3, 2026
82dd30e
feat(helm): require immutable production images and durable state
seonghobae Aug 3, 2026
5af308d
fix(helm): fail closed on mutable images and mount durable data
seonghobae Aug 3, 2026
69f2d3e
docs(tests): document user-operation lock regressions
seonghobae Aug 3, 2026
c565439
test(sqlite): close concurrent stores safely and clarify ordering
seonghobae Aug 3, 2026
e21b3bd
docs(passwordless): describe one-time action-email enrollment
seonghobae Aug 3, 2026
6e62d37
docs(keycloak): align bootstrap and passwordless enrollment model
seonghobae Aug 3, 2026
46d5483
test(lifecycle): cover in-memory lock sidecar cleanup
seonghobae Aug 3, 2026
029e1c1
test(bootstrap): anchor ordering to executable step
seonghobae Aug 3, 2026
bc6db2f
test(health): cover non-success HTTP handling
seonghobae Aug 3, 2026
9d6dc05
test(scim): exercise production lock manager in race regression
seonghobae Aug 3, 2026
815f7bb
test(deploy): protect durable state and immutable image contracts
seonghobae Aug 3, 2026
c1b64a7
docs(changelog): record passwordless commercial hardening
seonghobae Aug 3, 2026
6a311a5
docs(design): record password-free commercial architecture
seonghobae Aug 3, 2026
0e8c5a8
docs(plan): align product hardening completion record
seonghobae Aug 3, 2026
413e6bf
test(federation): prove network calls do not hold storage lock
seonghobae Aug 3, 2026
b1f2d86
test(scim): make merge lock contention proof deterministic
seonghobae Aug 3, 2026
e821db5
docs(plan): mark review remediation implementation progress
seonghobae Aug 3, 2026
77e19d6
test(registration): isolate caller quotas without client API assumptions
seonghobae Aug 3, 2026
8863a33
fix(config): keep development registration disabled by default
seonghobae Aug 3, 2026
2c7c740
test(config): keep local registration opt-in
seonghobae Aug 3, 2026
70070b9
fix(federation): keep desired-state reads available during outages
seonghobae Aug 3, 2026
b6f0928
test(federation): keep desired state readable during Keycloak outage
seonghobae Aug 3, 2026
94b7ffe
ci: integrate hourly protected PR stewardship
seonghobae Aug 3, 2026
9a000e6
test(ci): protect hourly fail-closed stewardship contract
seonghobae Aug 3, 2026
25dafce
fix(release): align Helm tag with unreleased package version
seonghobae Aug 3, 2026
f02068a
test(release): keep Helm and package versions synchronized
seonghobae Aug 3, 2026
6d3ff28
test(ci): require read-only workflow token defaults
seonghobae Aug 3, 2026
965ea33
fix(ci): narrow hourly steward token permissions
seonghobae Aug 3, 2026
db03ef6
test(bootstrap): tolerate secure multiline kcadm invocation
seonghobae Aug 3, 2026
51d68d9
test(paths): use route-stable encoded delimiter probes
seonghobae Aug 3, 2026
618cc0e
fix(realm): keep validator errors free of credential literals
seonghobae Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ IDP_DB_PASSWORD= # from KV: secret/idp/db
IDP_EXTERNAL_PORT=8080
# Public base URL / hostname Keycloak advertises (behind the WAF in prod).
IDP_EXTERNAL_HOSTNAME=http://localhost:8080
# Cache stack: `local` (default; single-node standalone compose) or `ispn`
# for clustered deployments. See the KC_CACHE note in docker-compose.yml.
IDP_CACHE_MODE=local

# Bootstrap admin. Created ONCE; retire after registering a passkey.
IDP_BOOTSTRAP_ADMIN_USERNAME=idp-admin
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ on:
branches: [main]
pull_request:

# Cancel superseded evidence for the same pull request or branch. This keeps the
# runner queue bounded during review-fix loops while preserving the newest head.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

# Least-privilege default token (OSSF Scorecard: Token-Permissions).
permissions:
contents: read
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ on:
branches: [main]
pull_request:

# Code scanning evidence is head-specific; cancel scans made obsolete by a
# newer commit on the same pull request or branch.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
actions: read
contents: read
Expand Down
105 changes: 105 additions & 0 deletions .github/workflows/hourly-pr-steward.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
name: Hourly PR steward

on:
schedule:
# Avoid the top-of-hour congestion window. Scheduled runs use UTC and the
# latest commit on the default branch.
- cron: "17 * * * *"
workflow_dispatch:

# Keep the workflow token read-only by default. Only the single steward job
# receives the narrowly scoped writes required to update trusted branches and
# arm GitHub-native auto-merge after protected evidence is complete.
permissions:
contents: read

concurrency:
group: hourly-pr-steward
cancel-in-progress: false

jobs:
advance-approved-pull-requests:
name: Advance approved pull requests
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
checks: read
steps:
- name: Update, verify, and arm trusted pull requests
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
shell: bash
run: |
set -euo pipefail

gh pr list \
--repo "$REPOSITORY" \
--state open \
--limit 100 \
--json number,isDraft,author,headRepositoryOwner,headRefOid,mergeStateStatus,reviewDecision \
> "$RUNNER_TEMP/open-pull-requests.json"

jq -c '.[]' "$RUNNER_TEMP/open-pull-requests.json" | while IFS= read -r pull_request; do
number="$(jq -r '.number' <<<"$pull_request")"
is_draft="$(jq -r '.isDraft' <<<"$pull_request")"
author="$(jq -r '.author.login // ""' <<<"$pull_request")"
head_owner="$(jq -r '.headRepositoryOwner.login // ""' <<<"$pull_request")"
head_sha="$(jq -r '.headRefOid' <<<"$pull_request")"
merge_state="$(jq -r '.mergeStateStatus // "UNKNOWN"' <<<"$pull_request")"
review_decision="$(jq -r '.reviewDecision // ""' <<<"$pull_request")"

if [[ "$is_draft" != "false" || "$head_owner" != "ContextualWisdomLab" ]]; then
continue
fi

trusted_author=false
for allowed_author in \
seonghobae \
dependabot \
'dependabot[bot]' \
app/dependabot \
github-actions \
'github-actions[bot]' \
app/github-actions \
opencode-agent
do
if [[ "$author" == "$allowed_author" ]]; then
trusted_author=true
break
fi
done
if [[ "$trusted_author" != "true" ]]; then
continue
fi

# Keep trusted branches current. A successful update invalidates the
# old check evidence, so the steward waits for the next hourly pass.
if [[ "$merge_state" == "BEHIND" ]]; then
gh pr update-branch "$number" --repo "$REPOSITORY" || true
continue
fi

if [[ "$review_decision" != "APPROVED" ]]; then
continue
fi

# Never infer safety from optional checks. The repository's required
# check set remains the source of truth. `gh pr checks` exits nonzero
# for failed checks and uses exit code 8 for pending checks, so either
# condition leaves the PR untouched.
if ! gh pr checks "$number" --repo "$REPOSITORY" --required; then
continue
fi

# Arm GitHub's native auto-merge service rather than creating the
# merge commit directly with GITHUB_TOKEN. Rulesets remain final,
# and the exact reviewed/check head must still match.
gh pr merge "$number" \
--repo "$REPOSITORY" \
--auto \
--squash \
--match-head-commit "$head_sha"
done
60 changes: 60 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Changelog

All notable changes to Keyverse are documented in this file. The format follows
Keep a Changelog, and releases use semantic versioning.

## [Unreleased]

### Added

- Password-free headless registration that sends one bounded Keycloak action
email for address verification and passkey enrollment, with failure-atomic
account rollback.
- Modular product-facing Keycloak Admin API extensions for registration and
runtime identity-provider federation.
- Runtime federation desired-state convergence with explicit applied-state
reporting and fail-closed operator-response redaction.
- Router-level validation for decoded privileged and SCIM path parameters,
including protocol-native SCIM error responses.
- Persistent Compose and Helm storage for audit and user-operation lock data.
- Optional Helm enforcement of immutable account-unification image digests.
- Concurrency and lifecycle regressions for SQLite-backed configuration, audit,
and mutation-lock persistence.

### Changed

- The bound Keycloak browser flow is now strictly passkey-only; registration no
longer creates a bootstrap password or runs a credential janitor.
- The public `naruon-web` access-token lifespan is reduced to five minutes while
the longer SSO session remains available through token refresh and reissue.
- Registration configuration is all-or-nothing and requires a distinct bearer
token, relying-party client, HTTPS redirect URI, and bounded action-link
lifetime.
- Registration throttling is isolated by direct peer address rather than one
process-wide counter.
- Account merge and SCIM replacement now share the same user-operation lock
boundary.
- SQLite configuration and audit stores support safe multi-threaded access with
WAL mode and bounded busy timeouts.
- Application shutdown closes Keycloak, audit, and configuration resources and
removes temporary test-only mutation-lock sidecars deterministically.
- Keycloak Admin API requests refresh an expired bearer token once, including
account creation and action-email enrollment.

### Fixed

- Prevented registration races from surfacing raw Keycloak duplicate-user
errors by mapping exact HTTP 409 responses to a stable product conflict.
- Prevented unusable registration orphans by deleting accounts when Keycloak
rejects the verification/passkey action email.
- Prevented external network calls from executing while the federation desired-
state storage lock is held.
- Prevented unknown federation configuration keys, credentials, and private
values from being echoed through list, get, or update responses.
- Rejected Unicode-confusable federation aliases outside the explicit ASCII
slug alphabet.
- Raised non-success health responses correctly in the restricted stdlib HTTP
opener.
- Replaced a potentially expensive registration email regular expression with
deterministic bounded parsing.
- Made standalone audit history survive container replacement.
97 changes: 70 additions & 27 deletions deploy/keycloak/README.md
Original file line number Diff line number Diff line change
@@ -1,43 +1,86 @@
# Keycloak config-as-code

cwl-idp runs on **Keycloak** (Apache-2.0). The realm is declared as-code and
imported at container start; secrets are patched afterwards from the KV store.
Keyverse runs on **Keycloak** (Apache-2.0). The portable `cwl` realm shape is
imported at container start; deployment-specific secrets and external identity
providers are converged afterwards from the KV/DB source of truth.

| File | What |
| File | Responsibility |
| --- | --- |
| `realm-cwl.json` | The `cwl` realm: passkey-first passwordless browser flow, OIDC/OAuth2.1 RP client template, employer ADFS SAML IdP (inbound), LDAP/AD federation, and the account-unification service-account client. Imported via `start --import-realm`. |
| `kcadm-bootstrap.sh` | Post-import patch: injects secrets/URLs (ADFS metadata, LDAP bind credential, service-account client secret) from KV using `kcadm.sh`, and grants the service account `realm-management` view-users/manage-users. |
| `realm-cwl.json` | Portable passwordless realm, shared client scopes, RP template, concrete `naruon-web` PKCE client, and account-unification service client |
| `kcadm-bootstrap.sh` | Idempotently inject the service-client secret, grant least-privilege realm-management roles, and reconcile the role mapper |
| `../templates/` | Reference payloads for runtime federation and additional relying-party registrations |

## Passwordless-first (passkeys)
## Passwordless browser and enrollment flows

`realm-cwl.json` defines an authentication flow **`browser-passwordless`** with
`auth-username-form` → `webauthn-authenticator-passwordless` and **no password
authenticator**, and binds it as the realm `browserFlow`. Combined with
`resetPasswordAllowed:false`, `registrationAllowed:false`, and a default
`webauthn-register-passwordless` required action, ecosystem-local accounts
authenticate with a **passkey (FIDO2/WebAuthn)**, never a password. See
[`../../docs/passwordless-policy.md`](../../docs/passwordless-policy.md).
The bound `browser-passwordless` flow accepts an existing session, a federated
identity, or username followed by `webauthn-authenticator-passwordless`. It has
**no password authenticator**.

## What is committed vs. patched from KV
First-party products create password-free accounts through
`POST /registration/accounts`. The account-unification service then invokes
Keycloak's `execute-actions-email` Admin REST operation with `VERIFY_EMAIL` and
`webauthn-register-passwordless`. The resulting bounded link verifies control of
the address and enrolls the first passkey before normal login. A failed email
request rolls the new account back.

Committed (non-secret shape): realm, flows, client template, IdP + LDAP
*structure*, mappers. Patched from KV at bootstrap (never committed): ADFS
metadata URL, LDAP connection URL / bind DN / bind credential, and every client
secret. Placeholders read `__set_from_kv__`.
A deployment that enables registration must configure Keycloak SMTP and set the
following account-unification KV entries:

## Apply
- `registration_api_token`
- `registration_client_id`
- `registration_redirect_uri`
- `registration_action_lifespan_seconds`

Without the registration token the endpoint is unavailable rather than open.
See [`../../docs/passwordless-policy.md`](../../docs/passwordless-policy.md).

## Portable realm versus deployment data

The committed realm contains no employer ADFS, LDAP/AD source, or other external
federation. Those objects are customer/deployment data and are managed through
`/federation/identity-providers`. Desired state is stored in the KV/DB backend
and can be reapplied after a realm rebuild with
`POST /federation/identity-providers:apply`.

This separation also avoids Keycloak 26 import failures from placeholder SAML
URLs or invalid placeholder LDAP distinguished names.

## Keycloak 26 import rules

`scripts/validate_realm.py` enforces these fail-closed rules:

- no `$`-prefixed annotation keys;
- no committed external federation or user-storage provider;
- no password authenticator in any subflow reachable from `browserFlow`;
- `webauthn-register-passwordless` remains enabled;
- `basic`, `profile`, and `email` scopes exist, with `basic` providing `sub`;
- public `naruon-web` requires PKCE S256 and an access-token lifespan no greater
than 900 seconds;
- committed client secrets are placeholders only.

## RP clients

`ecosystem-rp-template` is a confidential PKCE S256 blueprint. It uses the
reserved `rp.example.invalid` host so no product-specific deployment value is
silently inherited. Clones must replace redirect/origin values, client ID,
secret, and audience mapper together.

`naruon-web` is the first concrete public PKCE client. It carries the audience
and `role`/`org`/`workspace` claims required by the current Naruon session
contract. Its access tokens last 300 seconds; the longer SSO session is serviced
through normal token refresh/reissue rather than a twelve-hour bearer token.

## Bootstrap

```bash
# 1. Keycloak imports realm-cwl.json automatically on first start
# (docker-compose mounts it at /opt/keycloak/data/import).
# Keycloak imports the realm at first start.
docker compose up -d

# 2. Once Keycloak is READY, patch secrets from KV:
# Once Keycloak is ready, converge the service client and its scoped roles.
KC_SERVER=http://localhost:8080 deploy/keycloak/kcadm-bootstrap.sh
```

## Federation & client registration templates

Additional Admin-API request bodies for registering more RPs / IdPs live in
[`../templates/`](../templates/) (Keycloak client / SAML IdP / LDAP component
representations).
The bootstrap obtains credentials from the platform `kv` helper, keeps kcadm
session material inside a private temporary directory, never places reusable
secrets in process arguments, validates every resolved identifier, and
reconciles the protocol mapper without creating duplicates.
Loading
Loading