From 87577cb7805bfe340c2f75126b3218bc7ec4e244 Mon Sep 17 00:00:00 2001 From: DJ Mountney Date: Tue, 13 Oct 2026 13:54:40 -0700 Subject: [PATCH 1/4] feat: expose the app-content bucket, MCP and OAuth [ENG-1740] - currents.appContentStorage.* renders the PERMANENT_STORAGE_* the server, change-streams and writer read for context shares, organization logos and session files. Unset fields take the objectStorage values; the internal endpoint and region only with the endpoint. Without a bucket, sharing and logo editing stay off, so upgrades are unchanged until it is set. - currents.mcp.enabled (default true) and currents.oauth.* render MCP_* and OAUTH_* on the server. OAuth defaults on, and also turns on for existing releases on upgrade, but renders only with domains.https or an appHost of localhost or *.localhost: on any other http host the API refuses to start. A *.localhost appHost gets OAUTH_HTTP_ALLOWED_HOSTS. - OAUTH_JWKS_URL points at the API on localhost: a pod cannot resolve a *.localhost appHost, and may not reach a real one without going out through the ingress, so every access token was rejected. - The RustFS bucket job creates currents-app-content with lifecycle rules for the share prefixes. The docs cover the bucket, its CORS and IAM, and artifact expiry on external storage. - Drop SSO_SAML_SP_CERT_FILE and currents.sso.saml.spCertKey: the API never read them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhM4wHR2xPP8bbrkXGaqUD --- charts/currents/templates/_common.tpl | 92 ++++++++++++++++++- .../templates/changestreams/deployment.yaml | 1 + .../currents/templates/server/deployment.yaml | 2 + .../currents/templates/writer/deployment.yaml | 1 + charts/currents/values.yaml | 68 +++++++++++++- docs/configuration.md | 28 +++++- docs/developer-guide/README.md | 2 +- docs/eks/dependencies.md | 54 ++++++++++- docs/eks/iam.md | 6 +- docs/eks/quickstart.md | 11 +++ samples/eks/eks-config.yaml | 3 + samples/local/chart-config.yaml | 3 + samples/rustfs-create-bucket-job.yaml | 15 ++- 13 files changed, 269 insertions(+), 17 deletions(-) diff --git a/charts/currents/templates/_common.tpl b/charts/currents/templates/_common.tpl index 7946123..b535d25 100644 --- a/charts/currents/templates/_common.tpl +++ b/charts/currents/templates/_common.tpl @@ -296,10 +296,96 @@ ClickHouse data is loaded from an external export (scripts/org-import). {{- if .Values.currents.sso.saml.authnRequestsSigned }} - name: SSO_SAML_AUTHN_REQUESTS_SIGNED value: "true" -- name: SSO_SAML_SP_CERT_FILE - value: {{ printf "/etc/currents/sso/%s" .Values.currents.sso.saml.spCertKey | quote }} - name: SSO_SAML_SP_KEY_FILE value: {{ printf "/etc/currents/sso/%s" .Values.currents.sso.saml.spKeyKey | quote }} {{- end }} {{- end }} -{{- end -}} \ No newline at end of file +{{- end -}} + +{{/* +Whether the OAuth provider can run on appHost. The API refuses to start with it on +over http unless the host is loopback or in OAUTH_HTTP_ALLOWED_HOSTS, and Better +Auth advertises an https issuer for any http host but localhost and *.localhost, +so OAuth over http works only on those names. +*/}} +{{- define "currents.oauthSupported" -}} +{{- $hostname := regexReplaceAll ":[0-9]+$" (lower .Values.currents.domains.appHost) "" -}} +{{- if or .Values.currents.domains.https (eq $hostname "localhost") (hasSuffix ".localhost" $hostname) -}} +true +{{- end -}} +{{- end -}} + +{{- define "currents.mcpOAuthEnv" -}} +- name: MCP_ENABLED + value: {{ .Values.currents.mcp.enabled | toString | quote }} +{{- if .Values.currents.mcp.origins }} +- name: MCP_ORIGINS + value: {{ .Values.currents.mcp.origins | quote }} +{{- end }} +{{- $oauth := .Values.currents.oauth }} +{{- if and $oauth.enabled (include "currents.oauthSupported" .) }} +- name: OAUTH_PROVIDER_ENABLED + value: "true" +{{- /* The API verifies each access token against its own signing keys. The + default URL is on appHost, which a pod may not resolve (*.localhost) or + reach without going out through the ingress. */}} +- name: OAUTH_JWKS_URL + value: {{ printf "http://localhost:%v/api/auth/jwks" .Values.server.service.port | quote }} +{{- $hostname := regexReplaceAll ":[0-9]+$" (lower .Values.currents.domains.appHost) "" }} +{{- if and (not .Values.currents.domains.https) (hasSuffix ".localhost" $hostname) }} +- name: OAUTH_HTTP_ALLOWED_HOSTS + value: {{ $hostname | quote }} +{{- end }} +{{- if $oauth.dynamicClientRegistration }} +- name: OAUTH_DYNAMIC_CLIENT_REGISTRATION_ENABLED + value: "true" +{{- end }} +{{- if not $oauth.clientIdMetadataDocuments }} +- name: OAUTH_CIMD_ENABLED + value: "false" +{{- end }} +{{- end }} +{{- end -}} + +{{/* +The app-content bucket, rendered as the PERMANENT_STORAGE_* the services read. +Unset fields take the artifact bucket's values, so one endpoint and one set of +credentials serve both buckets by default. The internal endpoint and region come +along only with the endpoint: with its own endpoint, the artifact storage's +internal endpoint would send the pods to the wrong storage. +*/}} +{{- define "currents.appContentStorageEnv" -}} +{{- $acs := .Values.currents.appContentStorage }} +{{- $os := .Values.currents.objectStorage }} +{{- if $acs.bucket }} +{{- $location := ternary $acs $os (not (empty $acs.endpoint)) }} +- name: PERMANENT_STORAGE_BUCKET + value: {{ $acs.bucket | quote }} +- name: PERMANENT_STORAGE_ENDPOINT + value: {{ required "currents.appContentStorage.endpoint or currents.objectStorage.endpoint is required when currents.appContentStorage.bucket is set" $location.endpoint | quote }} +{{- with (default $location.internalEndpoint $acs.internalEndpoint) }} +- name: PERMANENT_STORAGE_INTERNAL_ENDPOINT + value: {{ . | quote }} +{{- end }} +{{- with (default $location.region $acs.region) }} +- name: PERMANENT_STORAGE_REGION + value: {{ . | quote }} +{{- end }} +{{- with (default $os.secretName $acs.secretName) }} +- name: PERMANENT_STORAGE_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: {{ . }} + key: {{ default $os.secretIdKey $acs.secretIdKey }} +- name: PERMANENT_STORAGE_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ . }} + key: {{ default $os.secretAccessKey $acs.secretAccessKey }} +{{- end }} +{{- if (kindIs "bool" $acs.pathStyle | ternary $acs.pathStyle $os.pathStyle) }} +- name: PERMANENT_STORAGE_FORCE_PATH_STYLE + value: "true" +{{- end }} +{{- end }} +{{- end -}} diff --git a/charts/currents/templates/changestreams/deployment.yaml b/charts/currents/templates/changestreams/deployment.yaml index dea91dc..4109431 100644 --- a/charts/currents/templates/changestreams/deployment.yaml +++ b/charts/currents/templates/changestreams/deployment.yaml @@ -48,6 +48,7 @@ spec: {{- include "currents.connectionConfigEnv" . | nindent 12 }} {{- include "currents.URLConfigEnv" . | nindent 12 }} {{- include "currents.clickhouseRestoreModeEnv" . | nindent 12 }} + {{- include "currents.appContentStorageEnv" . | nindent 12 }} {{- with (concat .Values.global.env .Values.changestreams.env) }} {{- toYaml . | nindent 12 }} {{- end }} diff --git a/charts/currents/templates/server/deployment.yaml b/charts/currents/templates/server/deployment.yaml index 94bd8b3..51606c1 100644 --- a/charts/currents/templates/server/deployment.yaml +++ b/charts/currents/templates/server/deployment.yaml @@ -71,6 +71,8 @@ spec: {{- include "currents.URLConfigEnv" . | nindent 12 }} {{- include "currents.emailEnv" . | nindent 12 }} {{- include "currents.samlSSOEnv" . | nindent 12 }} + {{- include "currents.mcpOAuthEnv" . | nindent 12 }} + {{- include "currents.appContentStorageEnv" . | nindent 12 }} {{- if .Values.currents.betterAuth.secretName }} - name: BETTER_AUTH_SECRET valueFrom: diff --git a/charts/currents/templates/writer/deployment.yaml b/charts/currents/templates/writer/deployment.yaml index e7104ca..01604dd 100644 --- a/charts/currents/templates/writer/deployment.yaml +++ b/charts/currents/templates/writer/deployment.yaml @@ -50,6 +50,7 @@ spec: {{- include "currents.connectionConfigEnv" . | nindent 12 }} {{- include "currents.URLConfigEnv" . | nindent 12 }} {{- include "currents.emailEnv" . | nindent 12 }} + {{- include "currents.appContentStorageEnv" . | nindent 12 }} {{- with (concat .Values.global.env .Values.writer.env) }} {{- toYaml . | nindent 12 }} {{- end }} diff --git a/charts/currents/values.yaml b/charts/currents/values.yaml index 466cd2a..64d1158 100644 --- a/charts/currents/values.yaml +++ b/charts/currents/values.yaml @@ -93,7 +93,7 @@ currents: # -- Provider id; recommend your IdP service name (e.g. `okta`). Becomes the last path segment of the ACS callback URL. # @section -- SAML SSO providerId: onprem-saml - # -- K8s secret holding the IdP metadata XML (and any SP cert/key). Mounted read-only at /etc/currents/sso. See the SAML SSO guide for how to create it. + # -- K8s secret holding the IdP metadata XML (and the SP key, when signing requests). Mounted read-only at /etc/currents/sso. See the SAML SSO guide for how to create it. # @section -- SAML SSO metadataSecretName: "" # -- The secret key (file name) of the IdP metadata XML @@ -105,15 +105,37 @@ currents: # -- Comma-separated email domains allowed to sign in via SSO (optional allow-list) # @section -- SAML SSO allowedDomains: "" - # -- Sign outbound AuthnRequests. Requires the SP cert/key (below) to be present in the same secret. + # -- Sign outbound AuthnRequests. Requires the SP key (below) in the same secret; upload the matching certificate to your IdP. # @section -- SAML SSO authnRequestsSigned: false - # -- Secret key (file name) of the SP certificate PEM (only when authnRequestsSigned=true) - # @section -- SAML SSO - spCertKey: sp-cert.pem # -- Secret key (file name) of the SP private key PEM (only when authnRequestsSigned=true) # @section -- SAML SSO spKeyKey: sp-key.pem + mcp: + # -- Serve remote MCP at `/mcp`, so coding agents can use Currents tools over HTTP. Clients + # authenticate with an API key or personal access token, or with OAuth (below). Requests + # without a valid credential get a 401. + # @section -- Remote MCP and OAuth + enabled: true + # -- Other origins clients reach `/mcp` on, comma separated (e.g. `https://mcp.example.com`) + # @section -- Remote MCP and OAuth + origins: "" + oauth: + # -- Let MCP clients sign in with a Currents account instead of a key. Serves + # `/api/auth/oauth2/*` and `/.well-known/oauth-*`; the issuer and token audience come from + # `domains.appHost`. Rendered only when `domains.https` is true, or `appHost` is + # `localhost` or a `*.localhost` name (for an evaluation without TLS): on any other http host + # the API would refuse to start, so OAuth stays off there. + # @section -- Remote MCP and OAuth + enabled: true + # -- Let clients register themselves, the way to add a client without internet access + # @section -- Remote MCP and OAuth + dynamicClientRegistration: true + # -- Accept clients identified by a client ID metadata document: an https URL the API fetches. + # Hosts that resolve to private, loopback or link-local addresses are refused, so a client + # can't name a service inside the cluster. + # @section -- Remote MCP and OAuth + clientIdMetadataDocuments: true apiInternalToken: # -- The K8s secret to use for the internal API token # @section -- Required @@ -191,6 +213,42 @@ currents: bucket: "" # -- Whether to use path style access for the object storage pathStyle: false + appContentStorage: + # -- The bucket for context shares, organization logos and session files, separate from the + # artifact bucket. Unset, context sharing and logo editing are off and session file uploads + # answer 503. Create it with the lifecycle rules in + # [App content bucket](./eks/dependencies.md#app-content-bucket). + # @section -- App Content Storage + bucket: "" + # -- Endpoint browsers reach the bucket on: session files and logos use presigned URLs + # @section -- App Content Storage + # @default -- `objectStorage.endpoint` + endpoint: "" + # -- Endpoint the pods reach the bucket on + # @section -- App Content Storage + # @default -- `objectStorage.internalEndpoint` when `endpoint` is unset + internalEndpoint: "" + # -- The bucket's region + # @section -- App Content Storage + # @default -- `objectStorage.region` when `endpoint` is unset + region: "" + # -- K8s secret holding the access key. Leave this and `objectStorage.secretName` unset to use + # the pod's IAM role (IRSA). + # @section -- App Content Storage + # @default -- `objectStorage.secretName` + secretName: "" + # -- The K8s secret key for the access key ID + # @section -- App Content Storage + # @default -- `objectStorage.secretIdKey` + secretIdKey: "" + # -- The K8s secret key for the secret access key + # @section -- App Content Storage + # @default -- `objectStorage.secretAccessKey` + secretAccessKey: "" + # -- (bool) Whether to use path style access + # @section -- App Content Storage + # @default -- `objectStorage.pathStyle` + pathStyle: logger: # -- The coralogix API endpoint to use apiEndpoint: "" diff --git a/docs/configuration.md b/docs/configuration.md index bed5cd2..1678a47 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -76,14 +76,36 @@ The following table lists the configurable parameters of the `currents` chart an | currents.sso.saml.enabled | bool | `false` | Enable SAML SSO. Requires `issuer` and `metadataSecretName` below (a K8s secret holding your IdP metadata XML). Turns on email-first SSO login. | | currents.sso.saml.issuer | string | `""` | SP entityID / audience presented to your IdP. A stable opaque identifier (e.g. `currents-onprem:your-org`), NOT a URL. Must match the Audience / SP Entity ID configured in your IdP. | | currents.sso.saml.providerId | string | `"onprem-saml"` | Provider id; recommend your IdP service name (e.g. `okta`). Becomes the last path segment of the ACS callback URL. | -| currents.sso.saml.metadataSecretName | string | `""` | K8s secret holding the IdP metadata XML (and any SP cert/key). Mounted read-only at /etc/currents/sso. See the SAML SSO guide for how to create it. | +| currents.sso.saml.metadataSecretName | string | `""` | K8s secret holding the IdP metadata XML (and the SP key, when signing requests). Mounted read-only at /etc/currents/sso. See the SAML SSO guide for how to create it. | | currents.sso.saml.metadataKey | string | `"idp-metadata.xml"` | The secret key (file name) of the IdP metadata XML | | currents.sso.saml.defaultRole | string | `"member"` | Role granted to auto-provisioned SSO users | | currents.sso.saml.allowedDomains | string | `""` | Comma-separated email domains allowed to sign in via SSO (optional allow-list) | -| currents.sso.saml.authnRequestsSigned | bool | `false` | Sign outbound AuthnRequests. Requires the SP cert/key (below) to be present in the same secret. | -| currents.sso.saml.spCertKey | string | `"sp-cert.pem"` | Secret key (file name) of the SP certificate PEM (only when authnRequestsSigned=true) | +| currents.sso.saml.authnRequestsSigned | bool | `false` | Sign outbound AuthnRequests. Requires the SP key (below) in the same secret; upload the matching certificate to your IdP. | | currents.sso.saml.spKeyKey | string | `"sp-key.pem"` | Secret key (file name) of the SP private key PEM (only when authnRequestsSigned=true) | +### Remote MCP and OAuth + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| currents.mcp.enabled | bool | `true` | Serve remote MCP at `/mcp`, so coding agents can use Currents tools over HTTP. Clients authenticate with an API key or personal access token, or with OAuth (below). Requests without a valid credential get a 401. | +| currents.mcp.origins | string | `""` | Other origins clients reach `/mcp` on, comma separated (e.g. `https://mcp.example.com`) | +| currents.oauth.enabled | bool | `true` | Let MCP clients sign in with a Currents account instead of a key. Serves `/api/auth/oauth2/*` and `/.well-known/oauth-*`; the issuer and token audience come from `domains.appHost`. Rendered only when `domains.https` is true, or `appHost` is `localhost` or a `*.localhost` name (for an evaluation without TLS): on any other http host the API would refuse to start, so OAuth stays off there. | +| currents.oauth.dynamicClientRegistration | bool | `true` | Let clients register themselves, the way to add a client without internet access | +| currents.oauth.clientIdMetadataDocuments | bool | `true` | Accept clients identified by a client ID metadata document: an https URL the API fetches. Hosts that resolve to private, loopback or link-local addresses are refused, so a client can't name a service inside the cluster. | + +### App Content Storage + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| currents.appContentStorage.bucket | string | `""` | The bucket for context shares, organization logos and session files, separate from the artifact bucket. Unset, context sharing and logo editing are off and session file uploads answer 503. Create it with the lifecycle rules in [App content bucket](./eks/dependencies.md#app-content-bucket). | +| currents.appContentStorage.endpoint | string | `objectStorage.endpoint` | Endpoint browsers reach the bucket on: session files and logos use presigned URLs | +| currents.appContentStorage.internalEndpoint | string | `objectStorage.internalEndpoint` when `endpoint` is unset | Endpoint the pods reach the bucket on | +| currents.appContentStorage.region | string | `objectStorage.region` when `endpoint` is unset | The bucket's region | +| currents.appContentStorage.secretName | string | `objectStorage.secretName` | K8s secret holding the access key. Leave this and `objectStorage.secretName` unset to use the pod's IAM role (IRSA). | +| currents.appContentStorage.secretIdKey | string | `objectStorage.secretIdKey` | The K8s secret key for the access key ID | +| currents.appContentStorage.secretAccessKey | string | `objectStorage.secretAccessKey` | The K8s secret key for the secret access key | +| currents.appContentStorage.pathStyle | bool | `objectStorage.pathStyle` | Whether to use path style access | + ### Other Values | Key | Type | Default | Description | diff --git a/docs/developer-guide/README.md b/docs/developer-guide/README.md index 4b81b16..1d4b617 100644 --- a/docs/developer-guide/README.md +++ b/docs/developer-guide/README.md @@ -78,7 +78,7 @@ Install RustFS helm install rustfs rustfs --repo https://charts.rustfs.com --version 1.0.1 -f samples/rustfs-helm-config.yaml ``` -Create the `currents` bucket +Create the `currents` and `currents-app-content` buckets ```sh kubectl apply -f samples/rustfs-create-bucket-job.yaml diff --git a/docs/eks/dependencies.md b/docs/eks/dependencies.md index d303225..7e6fe95 100644 --- a/docs/eks/dependencies.md +++ b/docs/eks/dependencies.md @@ -190,6 +190,43 @@ Follow this step if you plan to use provider (S3, Cloudflare) object storage (re 4. You will configure the Currents Helm chart to use `currents-storage-user` and your Object Storage bucket later in these instructions. +#### App content bucket + +Context shares, organization logos and session files go to a second bucket, set with `currents.appContentStorage.bucket`. Without it, context sharing and logo editing are off and session file uploads answer `503`. The other `appContentStorage` values default to the `objectStorage` ones, so the same endpoint and secret work for both buckets. + +1. Create the bucket, e.g. `currents-app-content`, next to the artifact bucket. Grant the same access as the artifact bucket, including `s3:DeleteObject`: deleting a session or organization removes its files. + +2. In the bucket's CORS rule, allow `PUT` from your `appHost` origin as well as `GET`: admins upload the organization logo from the browser. + +3. Add lifecycle rules for the share prefixes. Shares expire, but nothing deletes their objects. Scope the rules to these prefixes only: the bucket also holds logos (`/logo/`) and session files (`/sessions/`), and a bucket-wide rule deletes them. + + ```sh + aws s3api put-bucket-lifecycle-configuration --bucket currents-app-content --lifecycle-configuration '{ + "Rules": [ + { "ID": "ai-context-share", "Status": "Enabled", "Filter": { "Prefix": "ai-context-share/" }, "Expiration": { "Days": 2 } }, + { "ID": "share-3d", "Status": "Enabled", "Filter": { "Prefix": "share-3d/" }, "Expiration": { "Days": 4 } }, + { "ID": "share-7d", "Status": "Enabled", "Filter": { "Prefix": "share-7d/" }, "Expiration": { "Days": 8 } }, + { "ID": "trace-links", "Status": "Enabled", "Filter": { "Prefix": "trace-links/" }, "Expiration": { "Days": 8 } } + ] + }' + ``` + + Each rule outlives the longest share written under its prefix by a day. + +#### Artifact expiry + +Currents does not delete artifacts. To limit how long they are kept, add a lifecycle rule to the artifact bucket, replacing `30` with the number of days: + +```sh +aws s3api put-bucket-lifecycle-configuration --bucket currents-my-org-name --lifecycle-configuration '{ + "Rules": [ + { "ID": "delete-artifacts-after-days", "Status": "Enabled", "Filter": { "Prefix": "" }, "Expiration": { "Days": 30 } } + ] +}' +``` + +Runs older than that still show in the dashboard, without their screenshots, videos, traces and stdout. The same command works against the in-cluster RustFS below, run from a Job like the bucket one. + ### Alternative Object Storage (in cluster) Install RustFS if you don't have access to an Object Storage provider (S3, Cloudflare). You will need an additional subdomain for RustFS. @@ -304,7 +341,7 @@ Creates a single Pod instance of RustFS with 10Gi of storage. kubectl apply -f rustfs-eks-ingress.yaml ``` -5. Create the `currents` bucket by applying a Job that uses the AWS CLI +5. Create the `currents` and `currents-app-content` buckets, and the app content bucket's [lifecycle rules](#app-content-bucket), by applying a Job that uses the AWS CLI `rustfs-create-bucket-job.yaml` ```yaml @@ -339,7 +376,20 @@ Creates a single Pod instance of RustFS with 10Gi of storage. - /bin/sh - -c - | - aws s3api head-bucket --bucket currents 2>/dev/null || aws s3 mb s3://currents + set -e + for bucket in currents currents-app-content; do + aws s3api head-bucket --bucket "$bucket" 2>/dev/null || aws s3 mb "s3://$bucket" + done + # The app-content bucket also keeps organization logos and session + # files, so the rules expire the share prefixes only, never the bucket. + aws s3api put-bucket-lifecycle-configuration --bucket currents-app-content --lifecycle-configuration '{ + "Rules": [ + { "ID": "ai-context-share", "Status": "Enabled", "Filter": { "Prefix": "ai-context-share/" }, "Expiration": { "Days": 2 } }, + { "ID": "share-3d", "Status": "Enabled", "Filter": { "Prefix": "share-3d/" }, "Expiration": { "Days": 4 } }, + { "ID": "share-7d", "Status": "Enabled", "Filter": { "Prefix": "share-7d/" }, "Expiration": { "Days": 8 } }, + { "ID": "trace-links", "Status": "Enabled", "Filter": { "Prefix": "trace-links/" }, "Expiration": { "Days": 8 } } + ] + }' ``` ```sh diff --git a/docs/eks/iam.md b/docs/eks/iam.md index bf04eab..d2e9ccc 100644 --- a/docs/eks/iam.md +++ b/docs/eks/iam.md @@ -77,7 +77,7 @@ Steps: **1. Create the IAM Policy** -This policy allows the Currents service account to perform actions such as listing and accessing objects in the specified S3 bucket. Replace the two instances of `my-currents-bucket` in the policy below with the name of your bucket. +This policy allows the Currents service account to perform actions such as listing and accessing objects in the artifact and app content buckets. Replace `my-currents-bucket` and `my-currents-app-content` in the policy below with the names of your buckets. ```json { @@ -91,7 +91,9 @@ This policy allows the Currents service account to perform actions such as listi ], "Resource": [ "arn:aws:s3:::my-currents-bucket/*", - "arn:aws:s3:::my-currents-bucket" + "arn:aws:s3:::my-currents-bucket", + "arn:aws:s3:::my-currents-app-content/*", + "arn:aws:s3:::my-currents-app-content" ] } ] diff --git a/docs/eks/quickstart.md b/docs/eks/quickstart.md index c136d52..ed2aa3e 100644 --- a/docs/eks/quickstart.md +++ b/docs/eks/quickstart.md @@ -65,6 +65,7 @@ Configure and install the Currents Helm Chart once all the services are ready. - `currents.email.host` - `currents.objectStorage.endpoint` - `currents.objectStorage.bucket` + - `currents.appContentStorage.bucket` (see [App content bucket](./dependencies.md#app-content-bucket)) - `director.ingress.annotations` - `director.ingress.hosts` - `server.ingress.annotations` @@ -124,6 +125,10 @@ Configure and install the Currents Helm Chart once all the services are ready. # internalEndpoint: http://rustfs-svc:9000 # bucket: currents # pathStyle: true + # Context shares, organization logos and session files. Endpoint and + # credentials default to the objectStorage ones above. + appContentStorage: + bucket: currents-app-content-my-org-name gitlab: state: @@ -219,6 +224,12 @@ CURRENTS_API_URL=https://currents-record.eks.example.com npx pwc --key /mcp`. MCP clients authenticate with a Currents API key or personal access token, or sign in with a Currents account through the OAuth provider. Both are on by default; turn them off with `currents.mcp.enabled: false` and `currents.oauth.enabled: false`. + +OAuth needs `currents.domains.https: true`. With `https: false`, the chart leaves OAuth off unless `appHost` is `localhost` or a `*.localhost` name, which is enough for an evaluation but not for a real setup. See [Remote MCP and OAuth](../configuration.md#remote-mcp-and-oauth) for the values. + ## Enable SAML SSO (optional) Delegate sign-in to your SAML 2.0 identity provider (Okta, Microsoft Entra ID, etc.). Users sign in **email-first** — they enter their email, are redirected to your IdP to authenticate, and are auto-provisioned into your organization on return. SMTP must be configured (used for provisioning and invitations). diff --git a/samples/eks/eks-config.yaml b/samples/eks/eks-config.yaml index d5fae97..cead1a0 100644 --- a/samples/eks/eks-config.yaml +++ b/samples/eks/eks-config.yaml @@ -47,6 +47,9 @@ currents: secretAccessKey: RUSTFS_SECRET_KEY bucket: currents pathStyle: true + # Context shares, organization logos and session files; the rest comes from objectStorage + appContentStorage: + bucket: currents-app-content director: ingress: diff --git a/samples/local/chart-config.yaml b/samples/local/chart-config.yaml index 3d1cb6d..61f0b5e 100644 --- a/samples/local/chart-config.yaml +++ b/samples/local/chart-config.yaml @@ -52,6 +52,9 @@ currents: secretAccessKey: RUSTFS_SECRET_KEY bucket: currents pathStyle: true + # Context shares, organization logos and session files; the rest comes from objectStorage + appContentStorage: + bucket: currents-app-content redis: enabled: true \ No newline at end of file diff --git a/samples/rustfs-create-bucket-job.yaml b/samples/rustfs-create-bucket-job.yaml index 45f7f8a..5fb46bc 100644 --- a/samples/rustfs-create-bucket-job.yaml +++ b/samples/rustfs-create-bucket-job.yaml @@ -29,4 +29,17 @@ spec: - /bin/sh - -c - | - aws s3api head-bucket --bucket currents 2>/dev/null || aws s3 mb s3://currents + set -e + for bucket in currents currents-app-content; do + aws s3api head-bucket --bucket "$bucket" 2>/dev/null || aws s3 mb "s3://$bucket" + done + # The app-content bucket also keeps organization logos and session + # files, so the rules expire the share prefixes only, never the bucket. + aws s3api put-bucket-lifecycle-configuration --bucket currents-app-content --lifecycle-configuration '{ + "Rules": [ + { "ID": "ai-context-share", "Status": "Enabled", "Filter": { "Prefix": "ai-context-share/" }, "Expiration": { "Days": 2 } }, + { "ID": "share-3d", "Status": "Enabled", "Filter": { "Prefix": "share-3d/" }, "Expiration": { "Days": 4 } }, + { "ID": "share-7d", "Status": "Enabled", "Filter": { "Prefix": "share-7d/" }, "Expiration": { "Days": 8 } }, + { "ID": "trace-links", "Status": "Enabled", "Filter": { "Prefix": "trace-links/" }, "Expiration": { "Days": 8 } } + ] + }' From 6c1082d337e3796a711fd125bca17b69d9f58f86 Mon Sep 17 00:00:00 2001 From: DJ Mountney Date: Tue, 13 Oct 2026 15:03:50 -0700 Subject: [PATCH 2/4] fix: set OAUTH_JWKS_URL only for an http localhost appHost [ENG-1740] With domains.https the API fetches its signing keys from appHost, as cloud does. Over http the appHost is localhost or *.localhost, which a pod resolves to itself or not at all, so the chart points the API at its own port there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhM4wHR2xPP8bbrkXGaqUD --- charts/currents/templates/_common.tpl | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/charts/currents/templates/_common.tpl b/charts/currents/templates/_common.tpl index b535d25..8ca44ad 100644 --- a/charts/currents/templates/_common.tpl +++ b/charts/currents/templates/_common.tpl @@ -326,16 +326,18 @@ true {{- if and $oauth.enabled (include "currents.oauthSupported" .) }} - name: OAUTH_PROVIDER_ENABLED value: "true" -{{- /* The API verifies each access token against its own signing keys. The - default URL is on appHost, which a pod may not resolve (*.localhost) or - reach without going out through the ingress. */}} +{{- if not .Values.currents.domains.https }} +{{- /* Over http, appHost is localhost or *.localhost. The API checks each access + token against its own signing keys, fetched from appHost by default, and a + pod resolves neither name to the API. */}} - name: OAUTH_JWKS_URL value: {{ printf "http://localhost:%v/api/auth/jwks" .Values.server.service.port | quote }} {{- $hostname := regexReplaceAll ":[0-9]+$" (lower .Values.currents.domains.appHost) "" }} -{{- if and (not .Values.currents.domains.https) (hasSuffix ".localhost" $hostname) }} +{{- if hasSuffix ".localhost" $hostname }} - name: OAUTH_HTTP_ALLOWED_HOSTS value: {{ $hostname | quote }} {{- end }} +{{- end }} {{- if $oauth.dynamicClientRegistration }} - name: OAUTH_DYNAMIC_CLIENT_REGISTRATION_ENABLED value: "true" From 1cb0931db2a943746bb616dbc946916373193582 Mon Sep 17 00:00:00 2001 From: DJ Mountney Date: Tue, 13 Oct 2026 15:32:25 -0700 Subject: [PATCH 3/4] fix: keep existing lifecycle rules when the RustFS bucket job runs again [ENG-1740] The job replaced the app-content bucket's lifecycle configuration on every apply, dropping rules added since. It now writes its rules only to a bucket without any. The quickstart says when to point OAUTH_JWKS_URL at the API itself: a server pod that cannot reach or trust appHost rejects every token. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhM4wHR2xPP8bbrkXGaqUD --- docs/eks/dependencies.md | 21 +++++++++++++-------- docs/eks/quickstart.md | 9 +++++++++ samples/rustfs-create-bucket-job.yaml | 21 +++++++++++++-------- 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/docs/eks/dependencies.md b/docs/eks/dependencies.md index 7e6fe95..a276f2f 100644 --- a/docs/eks/dependencies.md +++ b/docs/eks/dependencies.md @@ -382,14 +382,19 @@ Creates a single Pod instance of RustFS with 10Gi of storage. done # The app-content bucket also keeps organization logos and session # files, so the rules expire the share prefixes only, never the bucket. - aws s3api put-bucket-lifecycle-configuration --bucket currents-app-content --lifecycle-configuration '{ - "Rules": [ - { "ID": "ai-context-share", "Status": "Enabled", "Filter": { "Prefix": "ai-context-share/" }, "Expiration": { "Days": 2 } }, - { "ID": "share-3d", "Status": "Enabled", "Filter": { "Prefix": "share-3d/" }, "Expiration": { "Days": 4 } }, - { "ID": "share-7d", "Status": "Enabled", "Filter": { "Prefix": "share-7d/" }, "Expiration": { "Days": 8 } }, - { "ID": "trace-links", "Status": "Enabled", "Filter": { "Prefix": "trace-links/" }, "Expiration": { "Days": 8 } } - ] - }' + # They are written only to a bucket without lifecycle rules, so + # applying the Job again keeps any rules added since. + if ! out=$(aws s3api get-bucket-lifecycle-configuration --bucket currents-app-content 2>&1); then + case "$out" in *NoSuchLifecycleConfiguration*) ;; *) echo "$out" >&2; exit 1 ;; esac + aws s3api put-bucket-lifecycle-configuration --bucket currents-app-content --lifecycle-configuration '{ + "Rules": [ + { "ID": "ai-context-share", "Status": "Enabled", "Filter": { "Prefix": "ai-context-share/" }, "Expiration": { "Days": 2 } }, + { "ID": "share-3d", "Status": "Enabled", "Filter": { "Prefix": "share-3d/" }, "Expiration": { "Days": 4 } }, + { "ID": "share-7d", "Status": "Enabled", "Filter": { "Prefix": "share-7d/" }, "Expiration": { "Days": 8 } }, + { "ID": "trace-links", "Status": "Enabled", "Filter": { "Prefix": "trace-links/" }, "Expiration": { "Days": 8 } } + ] + }' + fi ``` ```sh diff --git a/docs/eks/quickstart.md b/docs/eks/quickstart.md index ed2aa3e..511c938 100644 --- a/docs/eks/quickstart.md +++ b/docs/eks/quickstart.md @@ -230,6 +230,15 @@ Remote MCP is served at `https:///mcp`. MCP clients authenticate with a OAuth needs `currents.domains.https: true`. With `https: false`, the chart leaves OAuth off unless `appHost` is `localhost` or a `*.localhost` name, which is enough for an evaluation but not for a real setup. See [Remote MCP and OAuth](../configuration.md#remote-mcp-and-oauth) for the values. +The API checks each OAuth access token against its own signing keys, fetched from `https:///api/auth/jwks`. If the server pod cannot reach or trust that URL (split DNS, an internal CA), every token is rejected at `/mcp`; point the API at itself instead: + +```yaml +server: + env: + - name: OAUTH_JWKS_URL + value: http://localhost:4000/api/auth/jwks +``` + ## Enable SAML SSO (optional) Delegate sign-in to your SAML 2.0 identity provider (Okta, Microsoft Entra ID, etc.). Users sign in **email-first** — they enter their email, are redirected to your IdP to authenticate, and are auto-provisioned into your organization on return. SMTP must be configured (used for provisioning and invitations). diff --git a/samples/rustfs-create-bucket-job.yaml b/samples/rustfs-create-bucket-job.yaml index 5fb46bc..7f08287 100644 --- a/samples/rustfs-create-bucket-job.yaml +++ b/samples/rustfs-create-bucket-job.yaml @@ -35,11 +35,16 @@ spec: done # The app-content bucket also keeps organization logos and session # files, so the rules expire the share prefixes only, never the bucket. - aws s3api put-bucket-lifecycle-configuration --bucket currents-app-content --lifecycle-configuration '{ - "Rules": [ - { "ID": "ai-context-share", "Status": "Enabled", "Filter": { "Prefix": "ai-context-share/" }, "Expiration": { "Days": 2 } }, - { "ID": "share-3d", "Status": "Enabled", "Filter": { "Prefix": "share-3d/" }, "Expiration": { "Days": 4 } }, - { "ID": "share-7d", "Status": "Enabled", "Filter": { "Prefix": "share-7d/" }, "Expiration": { "Days": 8 } }, - { "ID": "trace-links", "Status": "Enabled", "Filter": { "Prefix": "trace-links/" }, "Expiration": { "Days": 8 } } - ] - }' + # They are written only to a bucket without lifecycle rules, so + # applying the Job again keeps any rules added since. + if ! out=$(aws s3api get-bucket-lifecycle-configuration --bucket currents-app-content 2>&1); then + case "$out" in *NoSuchLifecycleConfiguration*) ;; *) echo "$out" >&2; exit 1 ;; esac + aws s3api put-bucket-lifecycle-configuration --bucket currents-app-content --lifecycle-configuration '{ + "Rules": [ + { "ID": "ai-context-share", "Status": "Enabled", "Filter": { "Prefix": "ai-context-share/" }, "Expiration": { "Days": 2 } }, + { "ID": "share-3d", "Status": "Enabled", "Filter": { "Prefix": "share-3d/" }, "Expiration": { "Days": 4 } }, + { "ID": "share-7d", "Status": "Enabled", "Filter": { "Prefix": "share-7d/" }, "Expiration": { "Days": 8 } }, + { "ID": "trace-links", "Status": "Enabled", "Filter": { "Prefix": "trace-links/" }, "Expiration": { "Days": 8 } } + ] + }' + fi From 38aae0bc7d3487d3bed54da80b40d9719e0ee194 Mon Sep 17 00:00:00 2001 From: DJ Mountney Date: Tue, 13 Oct 2026 15:39:03 -0700 Subject: [PATCH 4/4] fix: add missing share rules when the bucket already has lifecycle rules [ENG-1740] The RustFS bucket job skipped the share rules whenever the app-content bucket had any lifecycle configuration, so a bucket with only other rules never expired its share prefixes. It now rewrites the four share rules, matched by ID, and keeps the bucket's other rules, as the Docker rustfs-init does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhM4wHR2xPP8bbrkXGaqUD --- docs/eks/dependencies.md | 25 +++++++++++++------------ samples/rustfs-create-bucket-job.yaml | 25 +++++++++++++------------ 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/docs/eks/dependencies.md b/docs/eks/dependencies.md index a276f2f..8b01825 100644 --- a/docs/eks/dependencies.md +++ b/docs/eks/dependencies.md @@ -382,19 +382,20 @@ Creates a single Pod instance of RustFS with 10Gi of storage. done # The app-content bucket also keeps organization logos and session # files, so the rules expire the share prefixes only, never the bucket. - # They are written only to a bucket without lifecycle rules, so - # applying the Job again keeps any rules added since. - if ! out=$(aws s3api get-bucket-lifecycle-configuration --bucket currents-app-content 2>&1); then - case "$out" in *NoSuchLifecycleConfiguration*) ;; *) echo "$out" >&2; exit 1 ;; esac - aws s3api put-bucket-lifecycle-configuration --bucket currents-app-content --lifecycle-configuration '{ - "Rules": [ - { "ID": "ai-context-share", "Status": "Enabled", "Filter": { "Prefix": "ai-context-share/" }, "Expiration": { "Days": 2 } }, - { "ID": "share-3d", "Status": "Enabled", "Filter": { "Prefix": "share-3d/" }, "Expiration": { "Days": 4 } }, - { "ID": "share-7d", "Status": "Enabled", "Filter": { "Prefix": "share-7d/" }, "Expiration": { "Days": 8 } }, - { "ID": "trace-links", "Status": "Enabled", "Filter": { "Prefix": "trace-links/" }, "Expiration": { "Days": 8 } } - ] - }' + # Each apply rewrites these four rules, matched by ID, and keeps any + # other rules on the bucket. + share_rules=' + { "ID": "ai-context-share", "Status": "Enabled", "Filter": { "Prefix": "ai-context-share/" }, "Expiration": { "Days": 2 } }, + { "ID": "share-3d", "Status": "Enabled", "Filter": { "Prefix": "share-3d/" }, "Expiration": { "Days": 4 } }, + { "ID": "share-7d", "Status": "Enabled", "Filter": { "Prefix": "share-7d/" }, "Expiration": { "Days": 8 } }, + { "ID": "trace-links", "Status": "Enabled", "Filter": { "Prefix": "trace-links/" }, "Expiration": { "Days": 8 } }' + filter="ID!='ai-context-share' && ID!='share-3d' && ID!='share-7d' && ID!='trace-links'" + if ! others=$(aws s3api get-bucket-lifecycle-configuration --bucket currents-app-content --query "Rules[?$filter]" --output json 2>&1); then + case "$others" in *NoSuchLifecycleConfiguration*) others="[]" ;; *) echo "$others" >&2; exit 1 ;; esac fi + others=$(printf '%s' "$others" | tr -d '\n' | sed 's/^ *\[ *//; s/ *\] *$//') + aws s3api put-bucket-lifecycle-configuration --bucket currents-app-content \ + --lifecycle-configuration "{ \"Rules\": [${others:+$others, }$share_rules] }" ``` ```sh diff --git a/samples/rustfs-create-bucket-job.yaml b/samples/rustfs-create-bucket-job.yaml index 7f08287..4d35f96 100644 --- a/samples/rustfs-create-bucket-job.yaml +++ b/samples/rustfs-create-bucket-job.yaml @@ -35,16 +35,17 @@ spec: done # The app-content bucket also keeps organization logos and session # files, so the rules expire the share prefixes only, never the bucket. - # They are written only to a bucket without lifecycle rules, so - # applying the Job again keeps any rules added since. - if ! out=$(aws s3api get-bucket-lifecycle-configuration --bucket currents-app-content 2>&1); then - case "$out" in *NoSuchLifecycleConfiguration*) ;; *) echo "$out" >&2; exit 1 ;; esac - aws s3api put-bucket-lifecycle-configuration --bucket currents-app-content --lifecycle-configuration '{ - "Rules": [ - { "ID": "ai-context-share", "Status": "Enabled", "Filter": { "Prefix": "ai-context-share/" }, "Expiration": { "Days": 2 } }, - { "ID": "share-3d", "Status": "Enabled", "Filter": { "Prefix": "share-3d/" }, "Expiration": { "Days": 4 } }, - { "ID": "share-7d", "Status": "Enabled", "Filter": { "Prefix": "share-7d/" }, "Expiration": { "Days": 8 } }, - { "ID": "trace-links", "Status": "Enabled", "Filter": { "Prefix": "trace-links/" }, "Expiration": { "Days": 8 } } - ] - }' + # Each apply rewrites these four rules, matched by ID, and keeps any + # other rules on the bucket. + share_rules=' + { "ID": "ai-context-share", "Status": "Enabled", "Filter": { "Prefix": "ai-context-share/" }, "Expiration": { "Days": 2 } }, + { "ID": "share-3d", "Status": "Enabled", "Filter": { "Prefix": "share-3d/" }, "Expiration": { "Days": 4 } }, + { "ID": "share-7d", "Status": "Enabled", "Filter": { "Prefix": "share-7d/" }, "Expiration": { "Days": 8 } }, + { "ID": "trace-links", "Status": "Enabled", "Filter": { "Prefix": "trace-links/" }, "Expiration": { "Days": 8 } }' + filter="ID!='ai-context-share' && ID!='share-3d' && ID!='share-7d' && ID!='trace-links'" + if ! others=$(aws s3api get-bucket-lifecycle-configuration --bucket currents-app-content --query "Rules[?$filter]" --output json 2>&1); then + case "$others" in *NoSuchLifecycleConfiguration*) others="[]" ;; *) echo "$others" >&2; exit 1 ;; esac fi + others=$(printf '%s' "$others" | tr -d '\n' | sed 's/^ *\[ *//; s/ *\] *$//') + aws s3api put-bucket-lifecycle-configuration --bucket currents-app-content \ + --lifecycle-configuration "{ \"Rules\": [${others:+$others, }$share_rules] }"