Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
ad6c005
scaffold keycloak service
kmein Jun 23, 2026
9e8783d
refactor(modules/lib): add dynamicUser param to mkReconcileService
kmein Jun 23, 2026
3d6b63e
feat(services/keycloak): declarative reconciler for realms
kmein Jun 23, 2026
d7848aa
refactor(modules/lib): derive StateDirectory from stateDir
kmein Jun 23, 2026
d5b1fb7
docs(services/keycloak): trim verbose inline comments
kmein Jun 23, 2026
991d22a
feat(services/keycloak): expand keycloak_realm flat attribute surface
kmein Jun 23, 2026
ecb640e
feat(services/keycloak): add roles and default_roles
kmein Jun 23, 2026
8ac7530
feat(services/keycloak): add groups + default_groups + memberships + …
kmein Jun 23, 2026
4d12ed9
feat(services/keycloak): add users + user_roles + user_groups
kmein Jun 23, 2026
aef3482
feat(services/keycloak): add openid_client_scopes + saml_client_scopes
kmein Jun 23, 2026
aa3e6a7
feat(services/keycloak): add openid_clients + scope/service-account b…
kmein Jun 23, 2026
f9952f3
feat(services/keycloak): add saml_clients + saml_client_default_scopes
kmein Jun 23, 2026
b435f7a
feat(services/keycloak): add 13 openid protocol mappers
kmein Jun 23, 2026
5bc99ea
feat(services/keycloak): add saml + generic protocol mappers
kmein Jun 23, 2026
bc951fa
feat(services/keycloak): add 6 identity providers
kmein Jun 23, 2026
ba75093
feat(services/keycloak): add 7 identity provider mappers
kmein Jun 23, 2026
3851cf0
feat(services/keycloak): add 5 authentication primitives
kmein Jun 23, 2026
5e88765
feat(services/keycloak): add openid authorization + 8 policies
kmein Jun 23, 2026
411eb39
feat(services/keycloak): add ldap_user_federations + 10 mappers
kmein Jun 23, 2026
289c5f2
feat(services/keycloak): add custom_user_federation + hardcoded_attri…
kmein Jun 23, 2026
241a39c
feat(services/keycloak): add 6 realm keystores
kmein Jun 23, 2026
120908a
refactor(services/keycloak): render TypeList+MaxItems:1 as a block-list
kmein Jun 23, 2026
856cbed
feat(services/keycloak): add 7 realm-level resources
kmein Jun 23, 2026
0396551
feat(services/keycloak): add client-policy + fine-grained permissions
kmein Jun 23, 2026
e7764ba
feat(services/keycloak): add openid_client_permissions
kmein Jun 23, 2026
feb5110
feat(services/keycloak): wire deferred nested blocks; recurse wrapBlocks
kmein Jun 23, 2026
2f36650
feat(services/keycloak): add realm_user_profile
kmein Jun 23, 2026
507ffb9
refactor(services/keycloak): nested-secret <attr>File via recursive walk
kmein Jun 24, 2026
14614e1
refactor(services/keycloak): list-of-managed-refs for 13 binding attrs
kmein Jun 24, 2026
aef8e16
docs(services/keycloak): refresh README for the full ~95 resource sur…
kmein Jun 24, 2026
caacf25
test(services/keycloak): split into 5 per-family tests
kmein Jun 24, 2026
d17ca39
docs(services/keycloak,modules/lib): simpler comment wording
kmein Jun 24, 2026
ac92e21
ci: add GitHub Actions workflow running nix flake check
kmein Jun 24, 2026
a0469fa
docs: rename project to `declarative-runtime`
kmein Jun 24, 2026
e8d1624
ci: enable auto-allocate-uids + cgroups for nspawn container tests
kmein Jun 24, 2026
dd7ef2e
docs(README): cover the keycloak pairing alongside forgejo
kmein Jun 24, 2026
92efec4
refactor(modules/lib): hoist the renderer + helpers shared with forgejo
kmein Jun 24, 2026
2c968e8
test: drop plain-text secrets from fixtures, use <attr>File indirection
kmein Jun 24, 2026
dc6cb5d
fix(services/keycloak): drop _authorization_ infix from 7 policy types
kmein Jun 24, 2026
48d038e
fix(modules/lib): reqAttrChecks honours nameInject + rejects empty st…
kmein Jun 24, 2026
423fbd9
fix(modules/lib): substituteSecrets uses collection name in error + i…
kmein Jun 24, 2026
6f8eac5
fix(services/keycloak): make realm_user_profile permissions.{view,edi…
kmein Jun 24, 2026
5734fb9
feat(services/keycloak): expose adminRealm escape hatch for non-maste…
kmein Jun 24, 2026
30739ff
test(checks): assert reapply reports 0 added / 0 changed / 0 destroyed
kmein Jun 24, 2026
35bfd44
feat(modules/lib): add oneOfRefs enforcement to the renderer
kmein Jun 24, 2026
623cb4c
fix(services/keycloak): require exactly one of client / client_scope …
kmein Jun 24, 2026
df70e5f
fix(services/keycloak): refresh bootstrap credentials when secret rot…
kmein Jun 24, 2026
7500bfd
feat(examples): add keycloak-forgejo VM example
kmein Jun 25, 2026
90b0509
feat(flake): expose examples as packages + eval checks
kmein Jun 25, 2026
4d99834
test(services/keycloak): OIDC password-grant end-to-end check
kmein Jun 25, 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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI

on:
push:
pull_request:

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: DeterminateSystems/nix-installer-action@main
with:
# nspawn container tests need auto-allocate-uids + cgroups.
extra-conf: |
extra-experimental-features = auto-allocate-uids cgroups
auto-allocate-uids = true
use-cgroups = true

- uses: DeterminateSystems/magic-nix-cache-action@main

- name: nix flake check
run: nix flake check -L
85 changes: 68 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
# Declarative NixOS services via paired Terraform providers
# declarative-runtime

**Declarative NixOS service runtime config via paired OpenTofu providers.**
Make NixOS services **more declaratively configurable** than upstream Nixpkgs
modules allow, by pairing each service with its Terraform provider and
reconciling the service's _runtime state_ once it is up.

> **Status:** the pattern is implemented and the **Forgejo pairing is the
> worked reference** (see [`services/forgejo`](services/forgejo/README.md)).
> **Status:** two pairings implemented.
>
> - **[Forgejo](services/forgejo/README.md)** — 15 resource types
> (organizations, users, repositories, teams, action secrets/variables,
> webhooks, branch protection, SSH/GPG/deploy keys, collaborators).
> - **[Keycloak](services/keycloak/README.md)** — ~95 resource types
> (realms, clients, scopes, ~20 protocol mappers, identity providers,
> IdP mappers, roles, groups, users, authentication flows, fine-grained
> authorization + policies, LDAP federation + mappers, realm keystores,
> realm-level config). Includes a service-account bootstrap and nested
> `<attr>File` indirection for every secret attribute at any depth.

## The gap this closes

Expand All @@ -31,6 +41,24 @@ services.forgejo = {
};
};
};

services.keycloak = {
enable = true;
initialAdminPassword = "REPLACE_ME";
database.passwordFile = "/run/secrets/keycloak-db-password";
runtime = {
enable = true;
bootstrapAdminPasswordFile = "/run/secrets/keycloak-admin-password";
realms.staff.display_name = "Staff SSO";
openid_clients.app = {
realm = "staff";
client_id = "app";
access_type = "CONFIDENTIAL";
client_secretFile = "/run/secrets/staff-app-client-secret";
valid_redirect_uris = [ "https://app.example.com/*" ];
};
};
};
```

A pairing only makes sense when a service has **admin-declarative runtime state
Expand All @@ -53,11 +81,27 @@ unit_ visibly (`systemctl status`) without tearing down the service.
## Usage

Add this flake as an input and import the pairing's NixOS module
(`nixosModules.forgejo`, or `nixosModules.default` for all pairings). Full
installation, configuration examples, the option reference, the resource table,
and the secrets guide live in the per-pairing README:
(`nixosModules.forgejo`, `nixosModules.keycloak`, or
`nixosModules.default` for all pairings). Full installation, configuration
examples, the option reference, the resource table, and the secrets guide
live in the per-pairing README:

- [Forgejo pairing](services/forgejo/README.md)
- [Keycloak pairing](services/keycloak/README.md) — includes the
service-account bootstrap flow and the operator-supplied client
override.

### Runnable examples

Each entry under [`examples/`](examples/) is a complete NixOS
configuration with its own walkthrough. `nix run .#<example>` builds
and boots the example as a QEMU VM (host ports forwarded so you can
hit the services from a browser).

- [`keycloak-forgejo`](examples/keycloak-forgejo/README.md) — both
pairings together, a custom Keycloak login theme, SSO from Forgejo
into Keycloak, a private internal repo, and per-user avatars served
over a side nginx. `nix run .#keycloak-forgejo`.

### Secrets

Expand All @@ -71,18 +115,25 @@ path — prefer it over the literal for any real secret.
## Repository layout

```
flake.nix # outputs: nixosModules, checks, formatter
treefmt.nix # treefmt + nixfmt config
flake.nix # outputs: nixosModules, packages (examples), checks, formatter
treefmt.nix # treefmt + nixfmt config
modules/
default.nix # aggregates per-pairing modules into nixosModules.default
lib/ # provider-agnostic helpers: tf-label/file, run-once reconciler
services/ # one directory per service<->provider pairing
forgejo/ # the worked Forgejo <-> svalabs/forgejo pairing
module.nix # NixOS module: services.forgejo.runtime + systemd wiring
lib.nix # provider specifics: wrapped executor + .tf.json generation
pkg.nix # vendor the provider (not in nixpkgs)
checks.nix # NixOS VM test
README.md # usage docs
default.nix # aggregates per-pairing modules into nixosModules.default
lib/ # shared helpers: tf-label/file, run-once reconciler
services/ # one directory per service<->provider pairing
forgejo/ # Forgejo <-> svalabs/forgejo
module.nix # NixOS module: services.forgejo.runtime + systemd wiring
lib.nix # provider specifics: wrapped executor + .tf.json generation
pkg.nix # vendor the provider (not in nixpkgs)
checks.nix # NixOS VM test
README.md # usage docs
keycloak/ # Keycloak <-> keycloak/keycloak (in nixpkgs)
module.nix # services.keycloak.runtime + reconciler + bootstrap unit
lib.nix # ~95 typed resourceTypes + the value-tree renderer
checks.nix # 1 VM + 4 nspawn-container tests, one per resource family
README.md # usage docs
examples/ # runnable demos; one configuration.nix + README per example
keycloak-forgejo/ # both pairings together with theme, SSO, avatar
```

## Development
Expand Down
87 changes: 87 additions & 0 deletions examples/keycloak-forgejo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Example: keycloak + forgejo (Dunder Mifflin Paper Co.)

A disposable NixOS VM that exercises both pairings together:
`services.keycloak.runtime.*` and `services.forgejo.runtime.*` plus
the SSO loop wiring Keycloak as a Forgejo OAuth2 login source.

## Run it

From the repository root:

```sh
nix run .#keycloak-forgejo
```

Boot takes ~3-4 min. The console auto-logs in as `root` / `hackme`.
A `scranton.qcow2` lands in CWD; delete it to start over. Exit with
`Ctrl+a x` or `poweroff` from the guest.

## Forwarded ports

| Host port | Guest | What |
| --------- | ----- | ----------------------------------------------------- |
| 2222 | 22 | SSH (`ssh -p 2222 root@localhost`, password `hackme`) |
| 8080 | 8080 | Keycloak |
| 3000 | 3000 | Forgejo |
| 8888 | 8888 | Static avatar host (`jhalpert.png`) |

## What it demonstrates

**Keycloak (`services.keycloak.runtime`):**

- Realm `dunder_mifflin` with a custom `dunder_mifflin` login theme
(Scranton blue on corporate beige -- see `themes/dunder_mifflin/`).
- Declarative user-profile schema (`realm_user_profiles`) so the
`picture` attribute survives Keycloak v24+'s default
`unmanaged_attribute_policy = DISABLED`.
- User `jhalpert` (Jim Halpert) with `initial_password.valueFile`
indirection and a `picture` attribute pointing at the avatar host.
- OIDC client `dunder-mifflin-infinity` with `client_secretFile`
indirection and a redirect URI pointed at Forgejo's OAuth2 callback.

**Forgejo (`services.forgejo.runtime`):**

- Org `dunder_mifflin`, public repo `scranton_branch`, private repo
`intranet`.
- Pre-created users `jhalpert` and `dschrute` (passwords via
`passwordFile`).
- Collaborator binding granting `jhalpert` write on `intranet`.

**Glue (plain systemd one-shots, not runtime-state):**

- `nginx` serving the rasterised PNG avatar at
`http://localhost:8888/jhalpert.png`.
- `forgejo-oauth-setup` calling `forgejo admin auth add-oauth` to
register Keycloak as a Forgejo login source named
`DunderMifflinInfinity` (the svalabs/forgejo terraform provider
doesn't model auth sources, so the runtime layer can't reach it).

## Try it

1. **Forgejo (local user).** <http://localhost:3000> -> login
`dschrute` / `hackme`. See the org and the public `scranton_branch`
repo; the private `intranet` repo is not visible.
2. **Keycloak account console.**
<http://localhost:8080/realms/dunder_mifflin/account/> -> login
`jhalpert` / `hackme`. The themed login page (beige + Scranton
blue) and Jim's pre-populated profile.
3. **SSO loop.** Sign out of Forgejo. Visit
<http://localhost:3000/user/login> and click **Sign in with
DunderMifflinInfinity** (below the local login form) -> log in
as `jhalpert` / `hackme` on the themed Keycloak page -> Forgejo
links the SSO identity to the pre-created `jhalpert` (matched by
email), pulls his avatar via the OIDC `picture` claim, and lands
you on his dashboard.
4. **Internal repo.** As Jim, navigate to
<http://localhost:3000/dunder_mifflin/intranet>. He has write
access. Sign out (or sign in as `dschrute`), the URL returns 404.

## Known wrinkles

- **First-boot avatar.** Tofu creates `keycloak_user` and
`keycloak_realm_user_profile` in parallel; the user-create request
can race the schema, so on first apply Keycloak drops the `picture`
attribute silently. Restarting `declarative-keycloak.service` (or
rebooting) refreshes drift and re-PUTs the attribute. The fix is to
emit a `depends_on` edge from user resources to their realm's
user-profile -- a renderer change worth landing soon.
7 changes: 7 additions & 0 deletions examples/keycloak-forgejo/avatars/jhalpert.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading