# 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](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](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.

```sh
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:

```sh
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.

```sh
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

```sh
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).

```sh
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

```sh
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:

```sh
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.

```sh
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

```sh
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`:

```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**:

```sh
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](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).
