sandstorm / filament-keycloak-admin
Filament v4 admin UI for managing Keycloak users through the Keycloak Admin REST API
Package info
github.com/sandstorm/filament-keycloak-admin
pkg:composer/sandstorm/filament-keycloak-admin
Requires
- php: ^8.3
- filament/filament: ^4.0
- guzzlehttp/guzzle: ^7.0
- sandstorm/keycloak-admin-api: ^1.0.0
- spatie/laravel-package-tools: ^1.15.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- nunomaduro/collision: ^8.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
Suggests
- heloufir/filament-keycloak-sso: OIDC login + session store for auth_mode=sso; the bundled HeloufirAdminKeycloakSession adapter reads the admin's token from it (or bind your own AdminKeycloakSession).
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-04 09:08:36 UTC
README
A Filament v4 admin panel to manage Keycloak users through the Keycloak Admin REST API. Keycloak is the source of
truth — there is no local user mirror. This package is UI only; every HTTP call goes through the standalone client
library sandstorm/keycloak-admin-api.
- Target: Filament v4, PHP ^8.3, Keycloak 26.5.3+.
- Features: searchable user list; per-user detail with Identity (edit + enable toggle + User-Profile attributes), Groups (add/remove), Security/2FA (remove second factors), Active sessions (log out all), User events, and Admin history; and a "Send password-reset email" action.
The whole package is work in progress, and is extended as needed.
Thanks to BroodfondsMakers for sponsoring the development of this package, and for agreeing to Open Source it!
Installation
composer require sandstorm/filament-keycloak-admin
Register the plugin on a panel:
use Sandstorm\FilamentKeycloakAdmin\FilamentKeycloakAdminPlugin; $panel->plugin(FilamentKeycloakAdminPlugin::make());
Panel integration
Where the module shows up, and who sees it, is configured on the plugin instance:
use Filament\Support\Icons\Heroicon; use Sandstorm\FilamentKeycloakAdmin\FilamentKeycloakAdminPlugin; $panel->plugin( FilamentKeycloakAdminPlugin::make() ->authorize(fn (): bool => auth()->user()?->can('manage-keycloak-users') ?? false) ->navigationLabel('Login accounts') ->navigationGroup('User management') ->navigationIcon(Heroicon::OutlinedKey) ->navigationSort(30), );
| Method | Default | Effect |
|---|---|---|
authorize(Closure|bool) |
true (everyone) |
Who may use the module. Gates canAccess() on both the list and the detail page, so an unauthorized admin gets a 403 on the URL as well as no menu entry. |
navigationLabel(Closure|string|null) |
'Keycloak Users' |
Name in the main menu; also the list page's own title. |
navigationGroup(Closure|string|UnitEnum|null) |
null (top level) |
Parent menu category to list the module under. |
navigationParentItem(Closure|string|null) |
null |
Nests the module under another navigation item (by its label) instead of under a group. |
navigationIcon(Closure|string|BackedEnum|null) |
Heroicon::OutlinedUsers |
Menu icon. Pass null for no icon. Ignored while a navigation group is set — see below. |
navigationSort(Closure|int|null) |
null |
Position within its menu group. |
registerNavigation(Closure|bool) |
true |
Whether the module appears in the menu at all. false keeps it reachable by URL (still subject to authorize()) — for panels that link to it themselves. |
Every setter also accepts a closure, evaluated on each access, so the value may depend on the authenticated admin or on runtime configuration:
Configuration
The plugin reads resolved config('filament-keycloak-admin.*'), the consuming app owns
the authoritative config. Provide config/filament-keycloak-admin.php in your app:
return [ 'connection' => [ 'backchannel_url' => 'http://keycloak.internal:8080/', // required 'frontend_url' => 'https://login.example/', // optional, defaults to backchannel_url 'administration_url' => null, // optional, defaults to frontend_url 'realm' => 'YourRealm', 'client_id' => 'admin-panel-serviceaccount', 'client_secret' => '…', ], 'auth_mode' => 'service_account', // 'service_account' | 'sso' (act-as-user; see Auth modes below) 'http' => [ 'connect_timeout' => 5, 'timeout' => 15, ], 'pw_reset' => [ 'lifespan' => 43200, 'client_id' => null, 'redirect_uri' => null, ], ];
The package publishes only a structure-only stub (keys + docs, no values, no env()).
Connection URLs
The three URLs follow Keycloak's hostname nomenclature, because a Keycloak instance can be reachable under a different address per channel:
| Key | Keycloak option | Used for |
|---|---|---|
backchannel_url |
--hostname-backchannel-dynamic |
This application's own calls: the Admin REST API and the service-account token endpoint. Frequently an internal/cluster address. |
frontend_url |
--hostname |
Anything the admin's browser is sent to. |
administration_url |
--hostname-admin |
The administration console's own base URL. |
Only backchannel_url is required; administration_url falls back to frontend_url, which falls back to
backchannel_url.
Only the backchannel URL is used today; the other two exist so a browser is never handed an internal address.
Auth modes
service_account— aclient_credentialsgrant on a confidential client with the requiredrealm-managementroles:view-users,manage-users,query-users,query-groups(plusview-eventsfor the event tabs). This is one shared identity for all actions.sso(act-as-user) — the Admin-API bearer is the logged-in admin's own Keycloak token, so Keycloak evaluates that person's fine-grained permissions and attributes admin events to them.FilamentSsoTokenProviderreads the token throughAdminKeycloakSession. We recommend you to installheloufir/filament-keycloak-ssofor the bundledHeloufirAdminKeycloakSessionadapter.
Logging
The plugin does not log anywhere by default — no channel, no fallback. It offers two independent, opt-in extension points instead: bind a PSR-3 logger to get an audit trail of write actions, and/or bind a Guzzle handler-stack customizer to get HTTP tracing. Neither is required; the plugin works exactly as before if you bind nothing.
Info audit logging
Every write action (enable/disable a user, edit identity fields, group add/remove, send a password-reset
email, remove a 2FA credential, log out all sessions) can log an audit line — info on success, warning
when Keycloak denies the write (401/403). To receive it, bind a PSR-3 logger instance under
KeycloakAdminLogger in your own service provider:
use Psr\Log\LoggerInterface; use Sandstorm\FilamentKeycloakAdmin\Logging\KeycloakAdminLogger; $this->app->singleton(KeycloakAdminLogger::class, fn () => app(LoggerInterface::class)); // or a dedicated channel: fn () => Log::channel('keycloak-admin')
The bound instance does not need to implement KeycloakAdminLogger itself — any real PSR-3 logger works,
since the interface exists only as a collision-free container key (so binding it can't accidentally hijack
some other package's generic Psr\Log\LoggerInterface binding). Without a binding, write actions still
work, they just aren't logged.
Every call is a static message plus a context array — never a string-interpolated message, never PII beyond ids:
[info] Keycloak admin write succeeded {"admin_id":42,"action":"group.add","target_user_id":"…","group_ids":["…"]}
[warning] Keycloak admin write denied {"admin_id":42,"action":"user.set_enabled","target_user_id":"…","enabled":false}
HTTP outbound logging
The Guzzle clients used for Keycloak Admin API calls and token requests carry no logging middleware of
their own and never will: token requests carry the client secret, and admin responses carry user PII, so
redaction is a decision only your app can make correctly for its own environment. To add your own
request/response logging, bind an implementation of KeycloakAdminHttpHandlerStackCustomizer:
use GuzzleHttp\HandlerStack; use Sandstorm\FilamentKeycloakAdmin\Http\KeycloakAdminHttpHandlerStackCustomizer; use Sandstorm\FilamentKeycloakAdmin\Http\KeycloakHttpClientName; class MyHandlerStackCustomizer implements KeycloakAdminHttpHandlerStackCustomizer { public function customizeHandlerStack(HandlerStack $handlerStack, KeycloakHttpClientName $clientName): HandlerStack { $handlerStack->push($myLoggingMiddleware, 'http-logging'); return $handlerStack; } } $this->app->bind(KeycloakAdminHttpHandlerStackCustomizer::class, MyHandlerStackCustomizer::class);
The plugin builds a separate Guzzle client per concern — one for the token endpoint
(KeycloakHttpClientName::KEYCLOAK_TOKEN_PROVIDER) and one for the Admin REST API itself
(KeycloakHttpClientName::KEYCLOAK_TRANSPORT) — and each is built lazily, the first time something
actually needs it, not eagerly at boot. So when bound, the plugin resolves the customizer and passes each
client's real handler stack through customizeHandlerStack() once per client, tagged with which one it is;
your middleware then runs on every request that client makes. Switch on $clientName if you want to treat
the two differently (e.g. only trace Admin API calls, not token requests). Keep your middleware from
logging the Authorization header or any response/request body.
Testing
Use mise (see mise.toml):
composer install mise run test # unit/feature suite (hermetic, no Keycloak) mise run analyse # PHPStan mise run lint # Pint mise run e2e # full E2E cycle: boot Keycloak → integration suite → tear down
End-to-end against a real Keycloak
mise run e2e boots a throwaway Keycloak 26.5.3 (tests/Integration/docker-compose.yml) importing two realms and runs
the opt-in integration suite against it. To iterate:
mise run e2e:up # boot Keycloak (both realms) on http://localhost:9911 mise run test:integration # run the integration suite (sets KEYCLOAK_E2E_BASE_URL for you) mise run e2e:down # tear down
Log into the Keycloak admin console at http://localhost:9911 with admin / admin (the
KC_BOOTSTRAP_ADMIN_* creds in the compose file). Seeded users all have password changeit.
Two realms are imported so the sso act-as-user path is proven in both authorization modes:
| Realm | Admin Permissions (FGAP) | Notable seeded users |
|---|---|---|
test-realm |
off (classic roles) | admin-user (realm-management roles), login-user (none), jane in /staff |
test-realm-fgap |
on | admin-user, login-user, sarah + jane in /staff, emma in /endusers |
FGAP staff policy (baked into realm-import-fgap.json)
Staff read everyone, edit endusers, can't touch other staff:
match-staff = Group policy → members of /staff
│
┌────────────┴─────────────────────────────┐
▼ ▼
"staff can view all" "staff can manage endusers"
Users · view · All users Groups · manage-members · group /endusers
│ │
▼ ▼
sarah ──view──▶ everyone sarah ──manage──▶ emma (∈/endusers) ✔
sarah ──manage──▶ jane (∈/staff) ✘ 403
License
MIT. See LICENSE.md.