Authentication and permissions
Roles, identities, API keys and connector policy all live in Runku's own
admin database (store: in config.yaml — see
configuration.md), never in config.yaml itself.
This is modeled deliberately on how real IAM systems work (AWS IAM,
Kubernetes RBAC, Keycloak itself): roles (named, reusable permission
sets) are separate from role bindings (who has which role) are
separate from resource policy (what a connector can ever show — see
policy.md). You manage all of it live with the runku auth
CLI, or watch it in the portal's Agent Config tab.
First-time setup: runku auth init
This is the supported way to bootstrap a deployment. Never reuse credentials or stores from another environment.
runku auth init --config config.yamlRun with no other arguments, it's an interactive wizard:
- If
config.yamldoesn't exist yet, it walks you through adding one or more connectors — id, engine, DSN — testing the real connection and introspecting the schema before accepting it, then writes the file. If a config already exists, it health-checks the connectors already in it instead. - It refuses to run at all if the admin store already has data — this is a first-boot tool, not a way to reset a live deployment.
- If no OIDC provider is configured, it generates one initial API key, bound to no role — which means it's unrestricted by construction: it gets whatever each connector's policy exposes, now and as that evolves, rather than baking in an assumption about which tables should be visible on day one. If OIDC is configured, this step is skipped — log in via your provider instead.
- The raw key is printed once and written to a plaintext backup
file next to your config (
.runku-init-token).
runku serve will refuse to start while that file exists. Copy the
token into a real secrets manager or password manager, then delete the
file, and only then start the server. This is deliberate friction — the
alternative (seed a default credential and hope someone rotates it
later) is exactly the class of incident this exists to prevent. If a
fresh config was scaffolded from nothing, auth.enabled is also turned
on automatically; if you pointed the wizard at an existing config that
had auth.enabled: false, it prints a warning instead of silently
flipping that setting for you.
To import a pre-built seed file instead of answering prompts (e.g. a policy you've already written and version-controlled), skip the wizard:
runku auth init --config config.yaml --from my-seed.yamlThis runs the same import runku auth seed --file does, refuses a
non-empty store the same way, and — unlike the interactive path —
generates no token file, since there's no wizard-created credential to
back up.
Identities
An identity is who is calling — an API key or an OIDC login.
runku auth identity create --kind api_key my-agent
runku auth identity create --kind oidc alice
runku auth identity list
runku auth identity delete my-agent--kind is informational (api_key | oidc | service) — it
doesn't itself grant an authentication method; an API key still needs
runku auth apikey create to actually be usable, and an OIDC identity
is matched by the verified token's preferred_username claim against
Keycloak (or any standards-compliant provider).
API keys
runku auth apikey create --label "CI pipeline" my-agent
# api key created for identity "my-agent" — this is the ONLY time the raw token is shown:
#
# a1b2c3...
#
runku auth apikey list
runku auth apikey revoke <hash>Only the SHA-256 hash is ever stored — the raw token is shown exactly once, at creation, and cannot be retrieved again (if lost, revoke it and create a new one). Lookups happen live, per request, against the admin store — a freshly created key works on the very next call; a revoked one stops working on the very next call. Nothing is cached, so there's nothing to invalidate.
Roles
A role is a named, reusable permission set, scoped per connector — never
global, never matched by table name across connectors (two unrelated
connectors can both have a customers table that means something
completely different; grants are always (role, connector, table), no
wildcards).
runku auth role create --description "Read-only support agent" support-readonly
runku auth role list
runku auth role delete support-readonlyVisibility: which tables/procedures a role can even see
runku auth role grant-table --connector postgres-main --table customers support-readonly
runku auth role revoke-table --connector postgres-main --table customers support-readonly
runku auth role grant-procedure --connector postgres-main --procedure recalc_totals support-readonly
runku auth role revoke-procedure --connector postgres-main --procedure recalc_totals support-readonlyThis is an opt-in restriction list, not an allow-list: a role with
no grants at all for a connector it's never been mentioned on keeps
unrestricted access to whatever that connector's policy exposes
globally — turning on auth.enabled never silently locks out an
identity nobody explicitly restricted. But once a role has any grant
on a connector, that connector becomes fully explicit for it — a table
the role's grants don't mention is then invisible, not "unrestricted
because unmentioned." This closes the gap where an identity's access
could be broader than intended on a connector nobody thought to
configure.
A table blocked this way doesn't just reject calls to it — it's absent
from schema_overview too, the same "doesn't exist for this caller"
treatment masked columns get.
Verb restriction: read vs. write, on tables the role can already see
Two independent mechanisms, and they compose:
runku auth role create --read-only support-readonly--read-only (or runku auth role create ... --read-only) blocks
every mutation tool and every procedure call for identities
bound to this role, unconditionally — regardless of what the table's own
mutation policy enables. Procedures have no inherent read/write
classification (a stored procedure can do anything), so there's no safe
way to guess which ones a read-only identity should still be allowed to
call — read-only blocks all of them.
runku auth role set-permission --connector postgres-main --table customers --update support-readonlyset-permission is the finer-grained override: for a table listed this
way, only the verbs explicitly marked true are permitted for the role,
even if the table's own policy enables more (e.g. grant update but not
create/delete on a table where the policy enables all three). A
table never mentioned this way stays unrestricted at the verb level —
this only ever narrows, never grants beyond what the table's own
policy already allows. Setting it requires the verb to already be
enabled at the table-policy level, and the table to already be granted
to the role — a --create true on a table with no
mutations.create.enabled is rejected as misleading dead config, not
silently accepted.
--read-only and non-empty set-permission overrides on the same role
are mutually exclusive (contradictory — read-only already blocks
everything, the finer overrides would never be consulted).
Binding an identity to a role
runku auth identity bind alice support-readonly
runku auth identity unbind aliceOne role per identity (no composition/stacking of multiple roles in v1). Binding is idempotent — re-binding replaces the existing binding rather than erroring.
OIDC / SSO
Runku never issues or stores end-user credentials — it only verifies
bearer tokens against a provider you already run (Keycloak, or any
standards-compliant OIDC issuer), via standard discovery
(/.well-known/openid-configuration) and JWKS. Configure it in
config.yaml:
# Authentication verifies callers; roles in the admin store authorize them.
auth:
# Require an API key or an OIDC bearer token on protected endpoints.
enabled: true
oidc:
# Trusted token issuer and required audience claim.
issuer: https://idp.example.com/realms/runku
audience: runku-mcp
# Optional public PKCE client for the portal's interactive SSO login.
portal_client_id: runku-portal # optional — enables the portal's "Log in with SSO" buttonA request bearing a valid token is matched to an identity by the token's
preferred_username claim — create that identity (--kind oidc) and
bind it to a role the same way as an API-key identity; everything from
"Roles" above applies identically regardless of which credential type
authenticated the call.
Both credential types can be configured at once — a request is accepted if either verification method accepts it.
What happens with auth.enabled: false
No middleware is added at all. /mcp and /reports/* answer with no
Authorization header required — this is the default, matching the
plain quickstart. Identity in the audit log, in this mode, is only the
self-reported client_name/client_version from the MCP initialize
handshake, not a cryptographically verified identity.
Applying changes without a restart
runku serve re-reads policy and identity/role bindings from the admin
store on SIGHUP:
kill -HUP <pid>This reruns schema introspection, policy validation, and tool
generation, and swaps the result in atomically — old connections finish
against the old state, new ones see the new state. This is "no restart
needed," not "instant automatic reload": something has to actually send
the signal (you, or a process manager configured to do so) after a
runku auth/runku policy change.
Seeing what an identity can actually do
The portal's Agent Config tab (/portal/, see
portal.md) renders the same information GET /reports/config exposes: every connector's policy, and every
identity's read_only/table_permissions state — visually, without
reconstructing it from CLI output by hand. Nothing sensitive is in that
response (no DSNs, no key hashes, no OAuth2 secrets).