diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f2d7bb8e..06ea57441 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -218,6 +218,23 @@ jobs: --set networkPolicy.enabled=true \ --set computers.extraEnv[0].name=EGRESS_PROXY_DEFAULT \ --set-string computers.extraEnv[0].value=http://proxy.internal:3128 + # A delivery or directory switch with no secret behind it. Armed on every target: the chart + # requires the value when it holds the secret, and requires the store to name the key when + # a store does. + refuses "OpenTag switched on with no shared secret" \ + --set config.opentag.enabled=true + refuses "SCIM switched on with no bearer token" \ + --set config.scim.enabled=true + # The rest are refusals the server makes at boot, mirrored so the value is named at install. + refuses "an OpenTag URL with the pairing switched off" \ + --set-string config.opentag.url=https://opentag.example.com + refuses "a partial set of Twilio settings" \ + --set config.sms.enabled=true --set-string secrets.twilioAuthToken=token \ + --set-string config.sms.accountSid=AC0 --set-string config.sms.verifyServiceSid=VA0 + refuses "a push project id that is not a UUID" \ + --set-string config.push.projectId=not-a-uuid + refuses "a delivery public URL that is not http(s)" \ + --set-string config.deliveryPublicUrl=hooks.example.com # A warm pool nothing claims from. Only meaningful where the target asks for per-Bot # computers; on the others the mode is not sandbox and the refusal is not armed. if grep -qE '^ *mode: sandbox' charts/openbot/ci/${{ matrix.target }}-values.yaml; then diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f8d05154..18ae593db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### The Helm chart configures Slack, Teams, text messages, push, SCIM, inbound email and OpenTelemetry + +These settings had no chart values and could only be passed through `config.extraEnv`. They now have +their own: `config.opentag`, `config.sms`, `config.push`, `config.deliveryPublicUrl`, `config.scim`, +`config.inboundEmail` and `config.otel`, with the OpenTag secret, the Twilio auth token, the Expo +access token, the SCIM bearer tokens and the OpenTelemetry headers under `secrets` (or an existing +Secret or store, by key). The install refuses what the server would refuse at boot, such as an +OpenTag secret under 32 characters or a partial set of Twilio settings. With none of them set, the +chart renders exactly as before, so a deployment already passing these through `config.extraEnv` +keeps working unchanged until it moves them across. + ### A coworker at its own endpoint can hand work to another Bot A grant letting a remote Bot (a coworker at its own endpoint) hand work to another Bot was accepted and stored, but the grant read kept only built-in Bots, so the remote Bot was never diff --git a/charts/openbot/README.md b/charts/openbot/README.md index cf34d3c75..1002ba719 100644 --- a/charts/openbot/README.md +++ b/charts/openbot/README.md @@ -327,6 +327,41 @@ Left empty, this deployment has the Bots its tenant package declares as built-in package entry pointing at an endpoint that resolves to nothing is dropped rather than registered as a coworker nobody can talk to. +## Slack, Teams, text messages, push, SCIM, inbound email and OpenTelemetry + +Each is off until its values are set, and each is the same setting [docs/configuration.md](../../docs/configuration.md) +describes for a `.env`. Secrets go under `secrets`; with an existing Secret or an external store, use +the key in the last column. + +| Feature | Switch | Plain values | Secret key | +| -------------------------------- | ------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------- | +| Slack and Teams, through OpenTag | `config.opentag.enabled` | `url`, `botIconUrlTemplate` | `opentag-shared-secret` (required, 32+ characters) | +| Text messages, through Twilio | `config.sms.enabled` | `accountSid`, `verifyServiceSid`, `fromNumber` (all three) | `twilio-auth-token` (required) | +| Push and native-app sign-in | `config.push.projectId` (the EAS project UUID) | | `expo-access-token` (optional) | +| Where Twilio calls back | `config.deliveryPublicUrl` | defaults to `config.publicUrl` | | +| SCIM provisioning | `config.scim.enabled` | `connectionId` | `scim-bearer-token` (required), `scim-bearer-token-next` (optional) | +| Inbound email triggers | `config.inboundEmail.domain` and `snsTopicArns` | both needed, or the route is not mounted | | +| OpenTelemetry export | `config.otel.endpoint` or `logsEndpoint` | `serviceName`, `paused` | `otel-logs-headers`, `otel-headers` (optional) | + +```yaml +config: + opentag: + enabled: true + url: https://opentag.example.com + scim: + enabled: true +secrets: + opentagSharedSecret: + scimBearerToken: +``` + +The install refuses what the server would refuse at boot: a switch with no secret behind it (or, on +`externalSecrets`, no key named for it), an OpenTag secret under 32 characters, an OpenTag URL or icon +with the pairing off, an OpenTag URL that is not http(s) or an icon that is not https, a partial set +of Twilio settings, a push project id that is not a UUID, and a `deliveryPublicUrl` that is not +http(s). With `networkPolicy.enabled`, a collector inside the cluster has to be named in +`networkPolicy.extraEgress`; the providers themselves are reached on 443. + ## Upgrading the server without the computers `computers.mode: shared` runs one browser for every Bot, and on that shape the transcript's kept diff --git a/charts/openbot/templates/_helpers.tpl b/charts/openbot/templates/_helpers.tpl index f7f47efe9..21cce80c5 100644 --- a/charts/openbot/templates/_helpers.tpl +++ b/charts/openbot/templates/_helpers.tpl @@ -428,6 +428,120 @@ and in whatever holds the release, which is not where `KEY_ENCRYPTION_KEY` belon name: {{ default (include "openbot.secretName" .) .Values.computers.existingTokenSecret }} key: computer-token optional: {{ eq .Values.computers.mode "external" }} +{{- /* + Delivery (Slack and Teams through OpenTag, text messages, push), SCIM, inbound email and + OpenTelemetry export. + + PARENTHESISED, every map, for the reason `config.handoff` is above: these are keys this chart did + not have before, and `helm upgrade --reuse-values` leaves them absent on an existing deployment. + + A secret the server requires is referenced whenever its switch is on, never `optional`, so a + Secret that lacks it fails to start the pod with the key named rather than starting a server that + then refuses. A secret the server treats as optional is referenced `optional: true` whenever its + feature is, so it can live in `secrets.existingSecret` or a store without the chart reading it. +*/}} +{{- $opentag := .Values.config.opentag | default dict }} +{{- if $opentag.enabled }} +- name: OPENTAG_SHARED_SECRET + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" . }} + key: opentag-shared-secret +{{- with $opentag.url }} +- name: OPENTAG_URL + value: {{ . | quote }} +{{- end }} +{{- with $opentag.botIconUrlTemplate }} +- name: OPENTAG_BOT_ICON_URL_TEMPLATE + value: {{ . | quote }} +{{- end }} +{{- end }} +{{- $sms := .Values.config.sms | default dict }} +{{- if $sms.enabled }} +- name: TWILIO_ACCOUNT_SID + value: {{ $sms.accountSid | quote }} +- name: TWILIO_AUTH_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" . }} + key: twilio-auth-token +- name: TWILIO_VERIFY_SERVICE_SID + value: {{ $sms.verifyServiceSid | quote }} +- name: TWILIO_FROM_NUMBER + value: {{ $sms.fromNumber | quote }} +{{- end }} +{{- with (.Values.config.push | default dict).projectId }} +- name: EXPO_PROJECT_ID + value: {{ . | quote }} +- name: EXPO_ACCESS_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" $ }} + key: expo-access-token + optional: true +{{- end }} +{{- with .Values.config.deliveryPublicUrl }} +- name: DELIVERY_PUBLIC_URL + value: {{ . | quote }} +{{- end }} +{{- $scim := .Values.config.scim | default dict }} +{{- if $scim.enabled }} +- name: SCIM_BEARER_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" . }} + key: scim-bearer-token +- name: SCIM_BEARER_TOKEN_NEXT + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" . }} + key: scim-bearer-token-next + optional: true +{{- with $scim.connectionId }} +- name: SCIM_CONNECTION_ID + value: {{ . | quote }} +{{- end }} +{{- end }} +{{- $inboundEmail := .Values.config.inboundEmail | default dict }} +{{- with $inboundEmail.domain }} +- name: OPENBOT_INBOUND_EMAIL_DOMAIN + value: {{ . | quote }} +{{- end }} +{{- with $inboundEmail.snsTopicArns }} +- name: OPENBOT_INBOUND_EMAIL_SNS_TOPIC_ARNS + value: {{ . | quote }} +{{- end }} +{{- $otel := .Values.config.otel | default dict }} +{{- if or $otel.endpoint $otel.logsEndpoint }} +{{- with $otel.endpoint }} +- name: OTEL_EXPORTER_OTLP_ENDPOINT + value: {{ . | quote }} +{{- end }} +{{- with $otel.logsEndpoint }} +- name: OTEL_EXPORTER_OTLP_LOGS_ENDPOINT + value: {{ . | quote }} +{{- end }} +{{- with $otel.serviceName }} +- name: OTEL_SERVICE_NAME + value: {{ . | quote }} +{{- end }} +{{- if $otel.paused }} +- name: OPENBOT_OTEL_EXPORT + value: "off" +{{- end }} +- name: OTEL_EXPORTER_OTLP_LOGS_HEADERS + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" . }} + key: otel-logs-headers + optional: true +- name: OTEL_EXPORTER_OTLP_HEADERS + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" . }} + key: otel-headers + optional: true +{{- end }} {{- /* One definition, for the same reason `openbot.databaseUrlEnv` is one (see its comment above): the API server needs this value to RECOGNISE the worker, and the routines CronJob needs the same value diff --git a/charts/openbot/templates/secret.yaml b/charts/openbot/templates/secret.yaml index a6ac6d093..901e35979 100644 --- a/charts/openbot/templates/secret.yaml +++ b/charts/openbot/templates/secret.yaml @@ -57,4 +57,29 @@ stringData: {{- if (.Values.routines).enabled }} worker-shared-secret: {{ required "secrets.workerSharedSecret is required when routines.enabled. Generate one with: openssl rand -base64 32" .Values.secrets.workerSharedSecret | quote }} {{- end }} + {{- /* + The same reasoning for the delivery and directory switches: `commonEnv` references these keys + whenever the switch is on, so the switch without its secret fails here rather than in a pod. + */}} + {{- if (.Values.config.opentag).enabled }} + opentag-shared-secret: {{ required "secrets.opentagSharedSecret is required when config.opentag.enabled. Generate one with: openssl rand -hex 32" .Values.secrets.opentagSharedSecret | quote }} + {{- end }} + {{- if (.Values.config.sms).enabled }} + twilio-auth-token: {{ required "secrets.twilioAuthToken is required when config.sms.enabled." .Values.secrets.twilioAuthToken | quote }} + {{- end }} + {{- if (.Values.config.scim).enabled }} + scim-bearer-token: {{ required "secrets.scimBearerToken is required when config.scim.enabled. Generate one with: openssl rand -hex 32" .Values.secrets.scimBearerToken | quote }} + {{- end }} + {{- with .Values.secrets.scimBearerTokenNext }} + scim-bearer-token-next: {{ . | quote }} + {{- end }} + {{- with .Values.secrets.expoAccessToken }} + expo-access-token: {{ . | quote }} + {{- end }} + {{- with .Values.secrets.otelLogsHeaders }} + otel-logs-headers: {{ . | quote }} + {{- end }} + {{- with .Values.secrets.otelHeaders }} + otel-headers: {{ . | quote }} + {{- end }} {{- end }} diff --git a/charts/openbot/templates/validation.yaml b/charts/openbot/templates/validation.yaml index 16b75c26b..337b401db 100644 --- a/charts/openbot/templates/validation.yaml +++ b/charts/openbot/templates/validation.yaml @@ -318,3 +318,64 @@ This template renders nothing. {{- fail "routines.enabled but externalSecrets.data does not name worker-shared-secret. The API server reads it to recognise the worker and the CronJob reads it to be one, so every pod that mounts it fails to start." }} {{- end }} {{- end }} + +{{- /* + Delivery, the directory and OpenTelemetry export: the refusals the server makes at boot. + + Each mirrors a throw in `configuredDeliveryProviders` (server/src/delivery/routes.ts), so the value + to change is named by `helm install` rather than by a pod's crash loop. Nothing is refused here that + the server would accept: inbound email with one half set is not mounted rather than refused, and + is left alone here too. +*/}} +{{- $opentag := .Values.config.opentag | default dict }} +{{- if and (not $opentag.enabled) (or $opentag.url $opentag.botIconUrlTemplate) }} +{{- fail "config.opentag.url or config.opentag.botIconUrlTemplate is set but config.opentag.enabled is not. The server refuses either without the shared secret; set config.opentag.enabled and secrets.opentagSharedSecret." }} +{{- end }} +{{- if $opentag.enabled }} +{{- if and $opentag.url (not (regexMatch "^https?://" $opentag.url)) }} +{{- fail "config.opentag.url must be an http(s) URL." }} +{{- end }} +{{- if and $opentag.botIconUrlTemplate (not (hasPrefix "https://" $opentag.botIconUrlTemplate)) }} +{{- fail "config.opentag.botIconUrlTemplate must be an https URL." }} +{{- end }} +{{- if and .Values.secrets.opentagSharedSecret (not .Values.secrets.existingSecret) (not .Values.externalSecrets.enabled) }} +{{- if lt (len .Values.secrets.opentagSharedSecret) 32 }} +{{- fail "secrets.opentagSharedSecret must be at least 32 characters. Generate one with: openssl rand -hex 32" }} +{{- end }} +{{- end }} +{{- end }} +{{- $sms := .Values.config.sms | default dict }} +{{- if $sms.enabled }} +{{- if or (not $sms.accountSid) (not $sms.verifyServiceSid) (not $sms.fromNumber) }} +{{- fail "config.sms.enabled needs config.sms.accountSid, config.sms.verifyServiceSid and config.sms.fromNumber. The server takes all four Twilio settings or none." }} +{{- end }} +{{- else if or $sms.accountSid $sms.verifyServiceSid $sms.fromNumber }} +{{- fail "config.sms has Twilio settings but config.sms.enabled is not set. The server takes all four Twilio settings or none; set config.sms.enabled and secrets.twilioAuthToken, or clear them." }} +{{- end }} +{{- with (.Values.config.push | default dict).projectId }} +{{- if not (regexMatch "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" .) }} +{{- fail "config.push.projectId must be the native app's EAS project UUID." }} +{{- end }} +{{- end }} +{{- with .Values.config.deliveryPublicUrl }} +{{- if not (regexMatch "^https?://" .) }} +{{- fail "config.deliveryPublicUrl must be an http(s) URL." }} +{{- end }} +{{- end }} +{{- /* + The same secrets, for a deployment whose secrets come from a store: the value is not readable at + template time, but the list of keys is, checked the way `worker-shared-secret` is above. +*/}} +{{- if .Values.externalSecrets.enabled }} +{{- $named := list }} +{{- range .Values.externalSecrets.data }}{{- $named = append $named .secretKey }}{{- end }} +{{- if and $opentag.enabled (not (has "opentag-shared-secret" $named)) }} +{{- fail "config.opentag.enabled but externalSecrets.data does not name opentag-shared-secret, so the server pod cannot start." }} +{{- end }} +{{- if and $sms.enabled (not (has "twilio-auth-token" $named)) }} +{{- fail "config.sms.enabled but externalSecrets.data does not name twilio-auth-token, so the server pod cannot start." }} +{{- end }} +{{- if and (.Values.config.scim | default dict).enabled (not (has "scim-bearer-token" $named)) }} +{{- fail "config.scim.enabled but externalSecrets.data does not name scim-bearer-token, so the server pod cannot start." }} +{{- end }} +{{- end }} diff --git a/charts/openbot/values.yaml b/charts/openbot/values.yaml index 813c55bad..be9490730 100644 --- a/charts/openbot/values.yaml +++ b/charts/openbot/values.yaml @@ -195,6 +195,73 @@ config: # The token travels in `secrets.managedAgentToken`. Required whenever a url is set: the server # refuses to start with one and not the other, because an unauthenticated Bot endpoint is an # open door to whatever that Bot can reach. + + # Slack and Microsoft Teams, through the OpenTag pairing. + # + # `enabled` is the switch, rather than the secret's presence, because a secret held in + # `secrets.existingSecret` or a store is not readable at template time. The shared secret travels in + # `secrets.opentagSharedSecret`; the server refuses one shorter than 32 characters, and refuses a + # `url` or icon template with no secret at all. + opentag: + enabled: false + # OpenTag's base URL, for messages sent when no Slack turn is open. Without it those sends fail + # visibly in the outbox and Slack triggers are refused. Must be http(s). + url: "" + # Optional https PNG URL with `{seed}` or `{agentId}`, used as the Bot's Slack icon. + botIconUrlTemplate: "" + + # Text messages, through Twilio. The server takes all four Twilio settings or none, so `enabled` + # needs the three below and `secrets.twilioAuthToken`. + sms: + enabled: false + accountSid: "" + verifyServiceSid: "" + fromNumber: "" + + # Push notifications and sign-in from the native app, through Expo. + # + # Setting `projectId`, the app's EAS project UUID, switches both on; the server refuses anything + # that is not a UUID. `secrets.expoAccessToken` is optional and sent to Expo's push service when set. + push: + projectId: "" + + # The public origin Twilio calls back on, when it is not `publicUrl`. Twilio signs each request over + # the URL it was given, so this has to be the address Twilio was configured with. Must be http(s). + deliveryPublicUrl: "" + + # SCIM provisioning at `/api/auth/scim/v2`. + # + # `enabled` needs `secrets.scimBearerToken`, the token the directory sends. A second token in + # `secrets.scimBearerTokenNext` is accepted beside it, so the directory can move to a new one before + # the old one is removed. + scim: + enabled: false + # The name of the directory connection. Empty means the server's default, `directory`. + connectionId: "" + + # Inbound email triggers: an SES receipt rule delivering through SNS to + # `/api/events/email/sns`. Both are needed; with either missing the route is not mounted. + inboundEmail: + # The domain the receipt rule covers, such as `in.example.com`. + domain: "" + # Comma-separated ARNs of the SNS topics allowed to deliver mail. + snsTopicArns: "" + + # Every audit row as an OTLP log record over HTTP, for a SIEM or an OpenTelemetry Collector. + # + # Setting either endpoint switches export on. Headers, which usually carry a collector's API key, + # are secrets: `secrets.otelHeaders` and `secrets.otelLogsHeaders`, as `key=value` pairs separated + # by commas. With `networkPolicy.enabled`, a collector inside the cluster has to be named in + # `networkPolicy.extraEgress`. + otel: + # Collector base URL; the server appends `/v1/logs`. + endpoint: "" + # Where log records go, used exactly as written. Takes precedence over `endpoint`. + logsEndpoint: "" + # The `service.name` on every record. Empty means the server's default, `openbot`. + serviceName: "" + # Stops export while an endpoint is still set. + paused: false logLevel: "" # Free-form additions, for anything this chart has no opinion about. extraEnv: [] @@ -484,6 +551,20 @@ secrets: googleClientSecret: "" microsoftClientSecret: "" oktaClientSecret: "" + # Required when `config.opentag.enabled`. At least 32 characters. Generate one with: + # openssl rand -hex 32 + opentagSharedSecret: "" + # Required when `config.sms.enabled`. + twilioAuthToken: "" + # Optional, with `config.push.projectId`. + expoAccessToken: "" + # Required when `config.scim.enabled`; the next one is optional, for rotation. + scimBearerToken: "" + scimBearerTokenNext: "" + # Optional, with `config.otel`. Headers for the logs endpoint, and the general ones used when the + # logs ones are unset. + otelLogsHeaders: "" + otelHeaders: "" externalSecrets: enabled: false