MCP Forge
View Markdown
Authentication & permissions.mdDownload .md

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.yaml

Run with no other arguments, it's an interactive wizard:

  1. If config.yaml doesn'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.
  2. 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.
  3. 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.
  4. 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.yaml

This 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-readonly

Visibility: 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-readonly

This 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-readonly

set-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 alice

One 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" button

A 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).