Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
47 changes: 47 additions & 0 deletions adr/2026-08-14-provisioned-owner-and-provider-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# 2026-08-14: Provision the first owner and prove a replacement auth provider before it serves logins

- **Status:** Accepted
- **Date:** 2026-08-14
- **Supersedes:** None
- **Superseded by:** None

## Related issues

- [obot-platform/obot#7565](https://github.com/obot-platform/obot/issues/7565) — set up a trial with an initial owner and a password setup link, without asking the user to configure an auth provider first.

## Related ODPs

- [2026-08-14: Set up the first Local auth owner from deployment settings](https://github.com/obot-platform/obot-design-proposals/blob/main/proposals/2026-08-14-provisioned-local-owner-activation/README.md)

## Context

Provisioned Obot environments need to give a predetermined owner access without exposing the general bootstrap credential or requiring that owner to configure an identity provider first. Allowing an unauthenticated caller to select an email and set its password would permit account takeover, and treating an environment-provided password as permanent would leave reusable credentials in provisioning state.

Those environments start on Local auth and are expected to move to an external provider, which makes changing providers a normal step rather than an edge case. Obot serves logins from one configured auth provider. Replacing it by deconfiguring the old one and configuring the new one leaves an interval where none is configured, and a misconfigured replacement makes that interval permanent: nobody can sign in, and no signed-in session remains to correct it. The replacement also needs an owner, and an identity under a new provider does not exist until someone signs in through it, so the outgoing owner cannot grant a role to it in advance.

## Decision

Obot can provision one initial Local-provider owner from an email and high-entropy setup secret. It stores only the secret's hash and binds it to that local user. The emailed URL carries the secret in its fragment; the UI removes the fragment and exchanges the secret for an HTTP-only session. A setup session is marked as requiring a password change, and authorization restricts it to its own profile, password completion, UI assets, and sign-out. The setup link remains usable until it expires or password completion succeeds, so interrupted setup can resume. Completion atomically transitions the account out of its pending state, clears the setup-secret hash, invalidates every other session, and preserves only the completing session; the first concurrent completion wins. A pending secret can be rotated from deployment configuration, but an unchanged secret cannot have its absolute expiration extended and a completed account cannot be rearmed or reset that way. When initial-owner provisioning is configured, bootstrap-token generation and authentication are disabled.

A replacement provider's settings are staged into a separate credential context rather than the provider's own. Provider configuration is read from the provider's own context, so a staged provider is not configured, does not serve logins, and does not appear as the current login provider. Staging, discarding, and switching are submitted as `ProviderConfigurationChange` objects under one fixed name, so concurrent changes serialize and the one-configured-provider and one-staged-provider rules are evaluated under that serialization rather than in the API handler. A one-time login through the staged provider is authorized only for the owner who started the switch, and the staged provider is loginable only for a request presenting that verification. The identity it returns is granted the Owner role and recorded in the existing temporary setup user cache. Activation requires that record to name the staged provider, then promotes the staged credential before deconfiguring the outgoing provider, in one reconcile. Deconfiguring the provider that is currently serving logins is refused.

## Rationale

A bearer setup capability supports a one-click provisioning email while keeping the owner email insufficient to claim the account. A URL fragment avoids sending the secret in HTTP request targets and common access logs. Keeping the capability resumable until password completion avoids stranding a user who closes the browser or loses the first session; backend restrictions limit those sessions to completing setup. Hashing, expiration, rotation, and revocation reduce the impact of database disclosure or stale provisioning state.

Keeping the staged settings out of the provider's own credential context means nothing else has to learn about staging: every existing reader of "which provider is configured" continues to give the right answer during a switch, and a restart mid-switch leaves the outgoing provider serving. Verification is recorded rather than inferred from the caller's session. An earlier revision gated activation on the request's own auth provider, which could not survive a refresh, could not be read back to drive the interface, and disagreed with anything shown to a second administrator. The temporary setup user cache already stores exactly this — which identity signed in through which provider — with a lifetime that matches a pending switch, so the switch reuses it instead of introducing a parallel record.

The Owner grant happens at verification because that is the first moment the identity exists, and from that point the browser is signed in as it rather than as the owner who started the switch. Deferring the grant to activation strands the switch: the browser returns as an ordinary user and cannot reach the control that would promote it. The grant is safe there because the callback requires the identity to have come from the staged provider, and a login through the staged provider is only authorized for the owner who started the verification. Promoting the credential before deconfiguring means a failure in the second half leaves the outgoing provider configured, which is the state an operator can recover from.

## Consequences

Provisioners must generate a unique, high-entropy random secret, store it as a deployment secret, and construct the activation URL. Anyone possessing an unexpired setup link can race to complete owner setup, so the delivery channel remains security-sensitive. Obot must preserve the restricted-session authorization boundary for future APIs and must never treat deployment configuration as account recovery after setup completes.

One replacement may be staged at a time, and re-staging discards any verification recorded against the previous settings, because that verification no longer describes what activation would promote. Switching between any two providers uses this path, including switching back to Local. A switch does not move Local users or their work: the identity the replacement provider returns is a new Obot user, so anything the outgoing provider's users set up stays with those accounts. Deconfiguring the last configured provider is no longer possible through the API, so an operator who wants no auth provider at all must change deployment configuration instead.

The temporary setup user cache now backs two flows. A bootstrap owner confirmation and a provider switch cannot be in progress at the same time, which is acceptable because both establish the first owner for a provider Obot is about to depend on, but it is a coupling future work has to respect.

## References

- [Enabling authentication](../docs/docs/installation/enabling-authentication.md)
- [Auth providers](../docs/docs/configuration/auth-providers.md)
7 changes: 7 additions & 0 deletions apiclient/types/authprovider.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ type AuthProviderManifest struct {
type AuthProviderStatus struct {
CommonProviderStatus
Namespace string `json:"namespace,omitempty"`
// Staged means this provider's settings are saved as a replacement while another provider
// still serves logins.
Staged bool `json:"staged,omitempty"`
// VerifiedEmail is the address that signed in through this provider to prove the staged
// settings work, and that will hold Owner once the switch completes. It is set only while the
// provider is staged.
VerifiedEmail string `json:"verifiedEmail,omitempty"`
}

type AuthProviderList List[AuthProvider]
1 change: 1 addition & 0 deletions apiclient/types/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ type User struct {
DeletedAt *Time `json:"deletedAt,omitempty"`
OriginalEmail string `json:"originalEmail,omitempty"`
OriginalUsername string `json:"originalUsername,omitempty"`
RequirePasswordChange bool `json:"requirePasswordChange,omitempty"`
}

type UserList List[User]
Expand Down
11 changes: 10 additions & 1 deletion chart/templates/NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,16 @@ To learn more about the release, try:
$ helm status {{ .Release.Name }}
$ helm get all {{ .Release.Name }}

{{ if and (eq .Values.config.OBOT_SERVER_ENABLE_AUTHENTICATION true) (not .Values.secret.existingSecret) }}
{{ if eq .Values.config.OBOT_SERVER_ENABLE_AUTHENTICATION true }}
{{- if .Values.config.OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_EMAIL }}
The initial local-auth owner is configured for {{ .Values.config.OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_EMAIL }}.
Send that user a link in this form, substituting the setup token supplied in the Secret:
https://your-obot-host/activate#token=<setup-token>
{{- end }}
{{- /* Provisioning an initial owner disables bootstrap login and suppresses the generated
token, so this command would have nothing to retrieve. */}}
{{- if and (not .Values.config.OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_EMAIL) (not .Values.secret.existingSecret) }}
You can retrieve the bootstrap token by running:
$ kubectl get secret -n {{ .Release.Namespace }} {{ include "obot.secret.secretName" . }} -ojson | jq -r .data.OBOT_BOOTSTRAP_TOKEN | base64 -d; echo
{{- end }}
Comment thread
g-linville marked this conversation as resolved.
{{ end }}
2 changes: 1 addition & 1 deletion chart/templates/internal-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ metadata:
{{- include "obot.labels" . | nindent 4 }}
data:
{{- $cfg := .Values.config -}}
{{- $forbidden := list "AWS_ACCESS_KEY_ID" "AWS_SECRET_ACCESS_KEY" "OBOT_SERVER_OTEL_BEARER_TOKEN" "OBOT_SERVER_MCPIMAGE_PULL_SECRET" "OBOT_SERVER_DSN" "OPENAI_API_KEY" "ANTHROPIC_API_KEY" "GITHUB_AUTH_TOKEN" "OBOT_SERVER_ENCRYPTION_KEY" "OBOT_BOOTSTRAP_TOKEN" "OBOT_SERVER_LICENSE_KEY" "OBOT_SERVER_MCPIMAGE_PULL_SECRETS" "OBOT_ARTIFACT_S3_ACCESS_KEY_ID" "OBOT_ARTIFACT_S3_SECRET_ACCESS_KEY" "OBOT_ARTIFACT_GCS_SERVICE_ACCOUNT_JSON" "OBOT_ARTIFACT_AZURE_CLIENT_SECRET" -}}
{{- $forbidden := list "AWS_ACCESS_KEY_ID" "AWS_SECRET_ACCESS_KEY" "OBOT_SERVER_OTEL_BEARER_TOKEN" "OBOT_SERVER_MCPIMAGE_PULL_SECRET" "OBOT_SERVER_DSN" "OPENAI_API_KEY" "ANTHROPIC_API_KEY" "GITHUB_AUTH_TOKEN" "OBOT_SERVER_ENCRYPTION_KEY" "OBOT_BOOTSTRAP_TOKEN" "OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_SETUP_TOKEN" "OBOT_SERVER_LICENSE_KEY" "OBOT_SERVER_MCPIMAGE_PULL_SECRETS" "OBOT_ARTIFACT_S3_ACCESS_KEY_ID" "OBOT_ARTIFACT_S3_SECRET_ACCESS_KEY" "OBOT_ARTIFACT_GCS_SERVICE_ACCOUNT_JSON" "OBOT_ARTIFACT_AZURE_CLIENT_SECRET" -}}
{{- range $key := keys $cfg | sortAlpha }}
{{- if has $key $forbidden -}}
{{- fail (printf "config.%s must be set under .Values.secret (not .Values.config)" $key) -}}
Expand Down
8 changes: 7 additions & 1 deletion chart/templates/secret.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
{{- if not .Values.secret.existingSecret -}}
{{- if not (eq (empty .Values.config.OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_EMAIL) (empty .Values.secret.OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_SETUP_TOKEN)) -}}
{{- fail "config.OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_EMAIL and secret.OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_SETUP_TOKEN must be set together" -}}
{{- end -}}
{{- if and .Values.secret.OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_SETUP_TOKEN (lt (len .Values.secret.OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_SETUP_TOKEN) 32) -}}
{{- fail "secret.OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_SETUP_TOKEN must be at least 32 characters" -}}
{{- end -}}
{{- if not .Values.dev.useEmbeddedDb }}
{{- $_ := required "For production use, you must provide a value for `secret.OBOT_SERVER_DSN`. For testing or development purposes, you can set `dev.useEmbeddedDb` to true." .Values.secret.OBOT_SERVER_DSN -}}
{{- end }}
Expand All @@ -21,7 +27,7 @@ data:
{{- if gt (len .Values.mcpImagePullSecrets) 0 }}
OBOT_SERVER_MCPIMAGE_PULL_SECRETS: {{ include "obot.config.mcpImagePullSecrets" . | b64enc | quote }}
{{- end }}
{{- if eq .Values.secret.OBOT_BOOTSTRAP_TOKEN "" }}
{{- if and (eq .Values.secret.OBOT_BOOTSTRAP_TOKEN "") (eq .Values.secret.OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_SETUP_TOKEN "") }}
{{- $existing := (lookup "v1" "Secret" .Release.Namespace $secretName) }}
{{- if $existing }}
{{- $val := index $existing.data "OBOT_BOOTSTRAP_TOKEN" }}
Expand Down
7 changes: 7 additions & 0 deletions chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ secret:
OBOT_SERVER_ENCRYPTION_KEY: ""
# secret.OBOT_BOOTSTRAP_TOKEN -- Sets a bootstrap token. If the chart is managing the Secret, one will be autogenerated (and preserved across upgrades) when this is left empty.
OBOT_BOOTSTRAP_TOKEN: ""
# secret.OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_SETUP_TOKEN -- Randomly generated, high-entropy secret used by the initial owner's setup link (for example, `openssl rand -hex 32`). Set with config.OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_EMAIL. It is never stored in plaintext by Obot.
OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_SETUP_TOKEN: ""
# secret.OBOT_SERVER_DSN -- The DSN for your database. For example: postgres://<username>:<password>@<hostname>/<db_name>
OBOT_SERVER_DSN: ""
# secret.OPENAI_API_KEY -- An OpenAI API Key used to configure access to OpenAI models, which are the default in Obot.
Expand Down Expand Up @@ -128,6 +130,11 @@ config:
# config.OBOT_GCP_KMS_KEY_URI -- The URI of a Google Cloud KMS key, used for encryption
OBOT_GCP_KMS_KEY_URI: ""

# config.OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_EMAIL -- Provisions this email as the initial owner through the built-in local auth provider. Requires secret.OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_SETUP_TOKEN.
OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_EMAIL: ""
# config.OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_SETUP_TOKEN_EXPIRATION_HOURS -- How long the initial owner setup link remains valid. The link can be reopened until password setup completes.
OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_SETUP_TOKEN_EXPIRATION_HOURS: "168"

# config.NAH_THREADINESS -- Advanced - sets the number of concurrent threads that can run in the Obot controller
NAH_THREADINESS: "10000"
# config.KINM_DB_CONNECTIONS -- Advanced - the number of connections in the database pool for kinm, both open and idle connections
Expand Down
8 changes: 4 additions & 4 deletions docs/docs/configuration/auth-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,12 @@ Passwords are hashed with [argon2id](https://en.wikipedia.org/wiki/Argon2) and a
To set it up:

1. Go to Admin -> Auth Providers and configure the **Local** provider, setting the email domains that local users are allowed to have (`*` allows any domain).
2. Click the **Manage Users** button on the Local provider card, and create a user. Share the password with them over a secure channel.
2. Click the **Manage Users** button on the Local provider card, and create a user. Share the initial password with them over a secure channel.
3. Local users sign in from the Obot login page by choosing **Local**, then entering their email and password.

:::note
Local users cannot change their own password. An administrator resets a password from the same Manage Users dialog, which also signs the user out of all of their existing sessions.
:::
New and administrator-reset local passwords require a password change at next sign-in by default. The administrator can turn this off in the Manage Users dialog. Until the change is complete, the backend restricts that session to the password-change flow. A successful change signs out the user's other sessions while preserving the current one.

Local users cannot currently rotate their password voluntarily after completing a required change. An administrator must reset it from **Manage Users**; the user will then be prompted to choose a new password at their next sign-in.

Deleting a local user prevents them from signing in again, but it does not delete the Obot user account they created by signing in. Delete that from the Users page, as you would for any other user.

Expand Down
3 changes: 3 additions & 0 deletions docs/docs/configuration/server-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ The Obot server is configured via environment variables. The following configura
| `OBOT_SERVER_ENCRYPTION_CONFIG_FILE` | The path to a file containing the encryption configuration. Only used when `OBOT_SERVER_ENCRYPTION_PROVIDER` is `custom` | - |
| `OBOT_SERVER_ENCRYPTION_KEY` | Sets the key to be used for encryption. Should only be set if `OBOT_SERVER_ENCRYPTION_PROVIDER` is `custom` | - |
| `OBOT_BOOTSTRAP_TOKEN` | Sets a bootstrap token. If authentication is enabled, one will be autogenerated for you if this is not set. | - |
| `OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_EMAIL` | Provisions this email as the initial owner with the built-in Local provider. Must be set with `OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_SETUP_TOKEN`. This disables bootstrap-token login. | - |
| `OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_SETUP_TOKEN` | Randomly generated, high-entropy secret (at least 32 characters; for example, `openssl rand -hex 32`) bound to the initial owner setup link. Obot stores only its hash and revokes it when password setup completes. | - |
| `OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_SETUP_TOKEN_EXPIRATION_HOURS` | Number of hours the initial owner setup link remains valid. | `168` |
| `OBOT_SERVER_AUTH_OWNER_EMAILS` | A comma separated list of email addresses that will have the Owner role in Obot. Email matching is case-insensitive. | - |
| `OBOT_SERVER_AUTH_ADMIN_EMAILS` | A comma separated list of email addresses that will have the Admin role in Obot. Email matching is case-insensitive. | - |
| `OBOT_SERVER_MCPAUDIT_LOG_RETENTION_DAYS` | The number of days to retain MCP audit logs before they are automatically deleted. Set to `0` to disable automatic cleanup. Use the [audit log export](./audit-log-export.md) functionality to preserve logs beyond this period. | `90` |
Expand Down
3 changes: 3 additions & 0 deletions docs/docs/installation/enabling-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ config:
| `OBOT_BOOTSTRAP_TOKEN` | No | Token used for bootstrap login while no auth provider is configured or no non-bootstrap owner user exists. If not set, a token will be generated and printed to the logs. |
| `OBOT_SERVER_AUTH_OWNER_EMAILS` | No | Email address that will have owner access after logging in via the auth provider. If not set, the bootstrap user will be prompted to log in via the auth provider and set themselves as the owner. |
| `OBOT_SERVER_AUTH_ADMIN_EMAILS` | No | Additional email addresses that will have admin access |
| `OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_EMAIL` | No | Initial local-auth owner's email. Must be set with the setup token. |
| `OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_SETUP_TOKEN` | No | At least 32 characters of high-entropy, randomly generated secret material used to activate the initial owner. Store as a secret; `openssl rand -hex 32` is the recommended generator. |
| `OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_SETUP_TOKEN_EXPIRATION_HOURS` | No | Setup-link validity in hours. Defaults to `168`. |
Comment thread
njhale marked this conversation as resolved.

## Step 2: Start Obot and Login

Expand Down
Loading
Loading