diff --git a/.github/workflows/ai-workspace-pr-check.yml b/.github/workflows/ai-workspace-pr-check.yml index 266effb2fb..e727003177 100644 --- a/.github/workflows/ai-workspace-pr-check.yml +++ b/.github/workflows/ai-workspace-pr-check.yml @@ -16,9 +16,6 @@ concurrency: jobs: pr-check: runs-on: ubuntu-24.04 - env: - APIP_CP_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - APIP_CP_AUTH_JWT_SECRET_KEY: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -58,6 +55,22 @@ jobs: - name: Map host.docker.internal on CI host run: echo "127.0.0.1 host.docker.internal" | sudo tee -a /etc/hosts + - name: Generate quickstart keys, credentials and certificates + # setup.sh writes api-platform.env (encryption/JWT keys + admin credentials) and + # the TLS certs both services now require. Credentials are pinned to the + # Cypress defaults (admin/admin) for the E2E suite. + run: ADMIN_USERNAME=admin ADMIN_PASSWORD=admin ./setup.sh + working-directory: portals/ai-workspace + + - name: Export quickstart credentials as environment variables + # api-platform.env is loaded into the containers via docker-compose's env_file, + # but later steps in this job (readiness probe, Cypress) run outside those + # containers and need the same values as real env vars. Export every generated + # key to $GITHUB_ENV so each subsequent step ("test") in this job sees them. + run: | + grep -v '^#' api-platform.env | grep -v '^\s*$' >> "$GITHUB_ENV" + working-directory: portals/ai-workspace + - name: Start quickstart stack # --wait blocks until all healthchecks pass. The ai-workspace healthcheck # now verifies the nginx→platform-api proxy route (not just the static diff --git a/distribution/all-in-one/docker-compose.yaml b/distribution/all-in-one/docker-compose.yaml index 0e2cc04b76..c4a6c7b5f0 100644 --- a/distribution/all-in-one/docker-compose.yaml +++ b/distribution/all-in-one/docker-compose.yaml @@ -50,6 +50,23 @@ services: depends_on: postgres: condition: service_healthy + platform-api-certgen: + condition: service_completed_successfully + + # One-shot init container: generates the TLS pair the platform-api HTTPS + # listener requires (the server no longer generates a self-signed fallback). + platform-api-certgen: + image: alpine/openssl + entrypoint: ["/bin/sh", "-c"] + command: + - | + [ -f /certs/cert.pem ] && [ -f /certs/key.pem ] && exit 0 + openssl req -x509 -newkey rsa:2048 -sha256 -days 365 -nodes \ + -keyout /certs/key.pem -out /certs/cert.pem \ + -subj "/O=WSO2 API Platform/CN=platform-api" \ + -addext "subjectAltName=DNS:localhost,DNS:platform-api,IP:127.0.0.1" + volumes: + - platform-api-certs:/certs platform-api: image: ghcr.io/wso2/api-platform/platform-api:latest @@ -62,6 +79,7 @@ services: - "9243:9243" volumes: - platform-api-data:/api-platform/data + - platform-api-certs:/app/data/certs environment: - APIP_CP_DEFAULT_DEVPORTAL_API_URL=http://devportal:3001 - APIP_CP_DATABASE_DRIVER=postgres @@ -100,4 +118,6 @@ services: volumes: platform-api-data: driver: local + platform-api-certs: + driver: local postgres_data: diff --git a/docs/ai-workspace/authentication/asgardeo-setup.md b/docs/ai-workspace/authentication/asgardeo-setup.md index 19f9f8b2eb..31b4cd222c 100644 --- a/docs/ai-workspace/authentication/asgardeo-setup.md +++ b/docs/ai-workspace/authentication/asgardeo-setup.md @@ -161,7 +161,7 @@ client_id = "" redirect_url = "https:///api/auth/callback" # the BFF callback (section 2) post_logout_redirect_url = "https:///login" -# Preferred — a mounted secret file. To read it from a git-ignored .env instead, swap the +# Preferred — a mounted secret file. To read it from the git-ignored api-platform.env instead, swap the # token for '{{ env "APIP_AIW_OIDC_CLIENT_SECRET" }}': the key needs one token or the other. client_secret = '{{ file "/secrets/ai-workspace/oidc_client_secret" }}' diff --git a/docs/ai-workspace/authentication/oidc-auth.md b/docs/ai-workspace/authentication/oidc-auth.md index cce32a5497..62cea56b2d 100644 --- a/docs/ai-workspace/authentication/oidc-auth.md +++ b/docs/ai-workspace/authentication/oidc-auth.md @@ -61,7 +61,7 @@ The redirect URLs are ordinary `config.toml` keys; the secret is *referenced* by than written into it, so the raw value never lands in a committed file. For a simpler local setup, swap the `{{ file }}` token for `'{{ env "APIP_AIW_OIDC_CLIENT_SECRET" }}'` and keep -the value in a git-ignored `.env`. The key must carry one token or the other — a variable set with +the value in the git-ignored `api-platform.env`. The key must carry one token or the other — a variable set with no token to read it is ignored. Either token fails closed: a missing secret aborts startup rather than yielding an empty credential. See [Configuration → Secrets](../configuration.md#secrets). diff --git a/docs/ai-workspace/configuration.md b/docs/ai-workspace/configuration.md index 156915de94..af9befdfa7 100644 --- a/docs/ai-workspace/configuration.md +++ b/docs/ai-workspace/configuration.md @@ -16,7 +16,7 @@ A key written this way can be set from the environment without editing the file. By convention the variable a token names is the key's full path — table and key, uppercased, dots as underscores — prefixed with **`APIP_AIW_`**: `[oidc] client_id` → `APIP_AIW_OIDC_CLIENT_ID`, `[platform_api] url` → `APIP_AIW_PLATFORM_API_URL`, and a top-level `log_level` → `APIP_AIW_LOG_LEVEL`. (The same prefix convention gives the Platform API `APIP_CP_` and the Developer Portal `APIP_DP_`.) It is only a convention: a token may name any variable, which is what lets a key read an existing secret under its own name. -The file's own location is not a config key — it cannot be, since it is needed before the file is read. The server reads its mount, `/etc/ai-workspace/config.toml`, unless `-config` names another path (`bff -config ../configs/config.toml`, which is what `make bff-run` does). Two variables are likewise read directly by the server rather than through a token: `APIP_DEMO_MODE` (a stack-wide runtime flag) and `APIP_CONFIG_FILE_SOURCE_ALLOWLIST`, which bounds where `{{ file }}` tokens may read from (see below). +The file's own location is not a config key — it cannot be, since it is needed before the file is read. The server reads its mount, `/etc/ai-workspace/config.toml`, unless `-config` names another path (`bff -config ../configs/config.toml`, which is what `make bff-run` does). One variable is likewise read directly by the server rather than through a token: `APIP_CONFIG_FILE_SOURCE_ALLOWLIST`, which bounds where `{{ file }}` tokens may read from (see below). Copy `configs/config-template.toml` to `configs/config.toml` and fill in the values for your deployment before starting the stack. @@ -24,7 +24,7 @@ Copy `configs/config-template.toml` to `configs/config.toml` and fill in the val Never write a secret as a literal in `config.toml`, and never hardcode one in `docker-compose.yaml`. There are two supported ways to supply the OIDC client secret: -**Environment variable (default)** — the key's token names the variable and has no default value, so an unset variable fails startup rather than running with an empty credential. Keep the value in a git-ignored `.env`: +**Environment variable (default)** — the key's token names the variable and has no default value, so an unset variable fails startup rather than running with an empty credential. Keep the value in the git-ignored `api-platform.env` (loaded into both services via `env_file`): ```toml [oidc] @@ -59,7 +59,7 @@ The "Env var" column is the variable each key's shipped `{{ env }}` token names | Key | Env var | Default | Description | |-----|-------------|---------|-------------| | `url` | `APIP_AIW_PLATFORM_API_URL` | — | **Required.** Absolute URL the BFF uses to reach the Platform API server-to-server (e.g. `https://platform-api:9243`) — an origin, not a base path; the API paths are appended by the proxy. Its scheme decides whether the upstream hop uses TLS. | -| `tls_skip_verify` | `APIP_AIW_PLATFORM_API_TLS_SKIP_VERIFY` | `false` | Accept the upstream's self-signed certificate. Rejected when `APIP_DEMO_MODE=false`. | +| `tls_skip_verify` | `APIP_AIW_PLATFORM_API_TLS_SKIP_VERIFY` | `false` | Skip upstream certificate verification entirely (local development only) — prefer `ca_file`. | | `ca_file` | `APIP_AIW_PLATFORM_API_CA_FILE` | — | PEM bundle trusted for the upstream certificate, appended to the system roots. Prefer this over `tls_skip_verify`. | ### `[oidc]` (only required when `auth_mode = "oidc"`) @@ -164,7 +164,7 @@ encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' # from an env v secret_key = '{{ file "/secrets/platform-api/jwt_secret" }}' # from a file (preferred) ``` -Supply the env values from a git-ignored `keys.env` and start with `docker compose --env-file keys.env up` +Supply the env values from a git-ignored `api-platform.env` and start with `docker compose up` Or mount a secret file and use `{{ file }}`. The `APIP_CP_`-prefixed names referenced by the tokens above: diff --git a/docs/ai-workspace/features/secrets-management.md b/docs/ai-workspace/features/secrets-management.md index 90ca6d2f29..66586b1c83 100644 --- a/docs/ai-workspace/features/secrets-management.md +++ b/docs/ai-workspace/features/secrets-management.md @@ -347,7 +347,7 @@ Generate a stable key with: openssl rand -hex 32 ``` -For Docker Compose deployments, set the key in a `keys.env` file next to `docker-compose.yaml`. First generate a key: +For Docker Compose deployments, set the key in a `api-platform.env` file next to `docker-compose.yaml`. First generate a key: ```sh openssl rand -hex 32 @@ -378,17 +378,18 @@ a Docker/Kubernetes secret) under an allowed directory (`/etc/platform-api` or with `{{ file "..." }}`. The value never appears in the environment or the compose file. **Simple — an env file (`{{ env "..." }}`).** For local/quickstart Docker Compose, put the -values in `keys.env` (git-ignored) and start the stack with `--env-file`. The compose forwards them into the container via an `environment:` -`${APIP_CP_…}` passthrough — never an `env_file:` block or a hardcoded value: +values in `api-platform.env` (git-ignored). The compose loads it into the container via an +`env_file:` entry with `format: raw` (raw so the bcrypt hash's `$` is not treated as compose +interpolation) — never a hardcoded value in an `environment:` block: ```sh -cp keys.env.example keys.env -# then edit keys.env (values are the `openssl rand -hex 32` output — .env files +cp api-platform.env.example api-platform.env +# then edit api-platform.env (values are the `openssl rand -hex 32` output — .env files # do NOT run command substitution, so paste the generated string, not the command): # APIP_CP_ENCRYPTION_KEY=a3f1e2d4b5c6... # APIP_CP_AUTH_JWT_SECRET_KEY=b7c8d9e0f1a2... -docker compose --env-file keys.env up -d +docker compose up -d ``` > **Warning:** Use the **same** stable keys across restarts and across all replicas. Changing diff --git a/platform-api/README.md b/platform-api/README.md index 3e75ee5402..1cb53628cd 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -236,15 +236,11 @@ token placed in the config file, which is resolved at load time via `os.LookupEn with no token always takes its literal TOML value (or the built-in default). See "Providing secrets via the config file" below. -By convention the samples name these interpolation variables with an `APIP_CP_` prefix for one -consistent namespace — e.g. `encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}'`. The prefix -is only a naming convention on the token argument; there is no prefix-stripping or automatic -key mapping. So the `APIP_CP_*` names in the tables below are the variable names you place in an -`{{ env "…" }}` token (shown next to the config key they populate), not standalone overrides. - -Two variables are read directly, outside any token: `APIP_DEMO_MODE` (a standalone runtime flag) -and `APIP_CONFIG_FILE_SOURCE_ALLOWLIST` (the allowlist of directories a `{{ file "…" }}` token -may read from). +One variable is intentionally **not** prefixed: the shared `APIP_CONFIG_FILE_SOURCE_ALLOWLIST`. The `{{ env "NAME" }}` interpolation +tokens in the config file read the literal name via `os.LookupEnv` (independent of the koanf +prefix mechanism); the samples use the same `APIP_CP_`-prefixed names for one consistent +namespace — e.g. `{{ env "APIP_CP_ENCRYPTION_KEY" }}` (see "Providing secrets via the config +file" below). ### Authentication @@ -255,8 +251,9 @@ APIP_CP_AUTH_IDP_ENABLED=false (default) → Local JWT mode (HMAC signature v APIP_CP_AUTH_IDP_ENABLED=true → IDP mode (JWKS-based verification) ``` -> **Demo mode (`APIP_DEMO_MODE`).** Defaults to `true`; an explicit `false`/`0` opts into -> production-grade startup checks. Note that `APIP_CP_ENCRYPTION_KEY` and `APIP_CP_AUTH_JWT_SECRET_KEY` are **required**. +> `APIP_CP_ENCRYPTION_KEY` and `APIP_CP_AUTH_JWT_SECRET_KEY` are **required**; startup +> fails without them. TLS certificates are likewise required whenever the HTTPS +> listener is enabled — the server never generates a self-signed pair. --- @@ -420,7 +417,7 @@ a missing/empty required env var, or a missing/disallowed/oversize file, aborts | Variable | Default | Description | |---|---|---| | `LOG_LEVEL` | `DEBUG` | Log verbosity (`DEBUG`, `INFO`, `WARN`, `ERROR`) | -| `HTTPS_ENABLED` | `true` | Enable the TLS listener. Certificates are read from `HTTPS_CERT_DIR` (or generated in demo mode) | +| `HTTPS_ENABLED` | `true` | Enable the TLS listener. Certificates are read from `HTTPS_CERT_DIR` (cert.pem / key.pem — required) | | `HTTPS_PORT` | `9243` | Port for the TLS listener | | `HTTPS_CERT_DIR` | `./data/certs` | Directory holding `cert.pem` / `key.pem` (used only when `HTTPS_ENABLED=true`) | | `HTTP_ENABLED` | `false` | Enable the plain-HTTP listener. Use only behind a TLS-terminating ingress/sidecar or for internal traffic — never expose directly to untrusted networks | diff --git a/portals/ai-workspace/configs/config-platform-api-template.toml b/platform-api/config/config-template.toml similarity index 51% rename from portals/ai-workspace/configs/config-platform-api-template.toml rename to platform-api/config/config-template.toml index 44d650294a..d3535ee3ba 100644 --- a/portals/ai-workspace/configs/config-platform-api-template.toml +++ b/platform-api/config/config-template.toml @@ -9,15 +9,17 @@ # # Platform API configuration template # -# Copy this file to config-platform-api.toml and edit the values for your -# deployment. This file is read by the Platform API (Go binary). +# Copy this file to your deployment's config location and edit the values. +# This file is read by the Platform API (Go binary). # # This template lists EVERY key the Platform API reads, so it doubles as the -# configuration reference. Commented-out keys show the built-in default; the -# handful of uncommented keys are the quickstart choices. Deleting a key simply -# restores its default. +# configuration reference. Every key is set to its built-in default unless the +# comment above it says otherwise; deleting a key simply restores its default. # -# Precedence: this file > built-in default. +# This template is maintained here, next to the Platform API source, and is +# copied into distributions (e.g. the AI Workspace zip) at build time. +# +# Precedence: environment variable (APIP_CP_ prefix) > this file > built-in default. # # QUICK START (file-based auth mode — no external IDP needed): # 1. Copy this file to config-platform-api.toml. @@ -31,20 +33,19 @@ # # Secrets are resolved at load time from environment variables ({{ env "..." }}) or # mounted files ({{ file "..." }}, preferred in production). Do not hardcode secret -# values in this file or as literals in docker-compose.yaml. +# values in this file or as literals in docker-compose.yaml. A {{ env }} / {{ file }} +# token whose source is unset fails config load — only reference sources that exist. # -# OIDC mode: keep file_based disabled, set [auth.idp] fields, -# and set auth_mode = "oidc" in config.toml with oidc_* fields. +# OIDC mode: keep file_based disabled and set the [auth.idp] fields. # -------------------------------------------------------------------- # --------------------------------------------------------------------------- # Server # --------------------------------------------------------------------------- log_level = "INFO" # DEBUG | INFO | WARN | ERROR -port = "9243" # HTTP/HTTPS listen port for Platform API # Log output encoding. Use "json" when shipping logs to an aggregator. -# log_format = "text" # text | json +log_format = "text" # text | json # Scope-based authorization for API endpoints. When true, every request must # carry the OAuth2 scope its endpoint declares. @@ -53,16 +54,18 @@ enable_scope_validation = true # Paths to resources the binary loads at startup. The defaults are correct for # the published container image; override only for a custom layout. -# db_schema_path = "./internal/database/schema.sql" -# openapi_spec_path = "./resources/openapi.yaml" -# llm_template_definitions_path = "./resources/default-llm-provider-templates" +db_schema_path = "./internal/database/schema.sql" +openapi_spec_path = "./resources/openapi.yaml" +llm_template_definitions_path = "./resources/default-llm-provider-templates" # --------------------------------------------------------------------------- # Encryption # --------------------------------------------------------------------------- # Single 32-byte key (64 hex chars or base64) used for ALL at-rest encryption -# (secrets, subscription tokens, WebSub HMAC secrets) -# Resolved from the APIP_CP_ENCRYPTION_KEY env var in `api-platform.env` file. Files preferred: +# (secrets, subscription tokens, WebSub HMAC secrets). +# REQUIRED — the server refuses to start without it. Generate with: +# openssl rand -hex 32 +# Resolved from the APIP_CP_ENCRYPTION_KEY env var in `api-platform.env`. Files preferred: # encryption_key = '{{ file "/secrets/platform-api/encryption_key" }}' encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' @@ -72,37 +75,56 @@ encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' [database] driver = "sqlite3" # "sqlite3" or "postgres" -# SQLite — ignored when driver = "postgres". +# SQLite — ignored when driver = "postgres". Default: ./data/api_platform.db path = "/app/data/api_platform.db" -# PostgreSQL — uncomment and set driver = "postgres" above to use -# host = "localhost" -# port = 5432 -# name = "platform_api" -# user = "platform_api" -# password = '{{ file "/secrets/platform-api/postgres_password" }}' -# ssl_mode = "disable" # "disable" | "require" | "verify-ca" | "verify-full" +# PostgreSQL — ignored when driver = "sqlite3"; set driver = "postgres" above to use. +host = "localhost" +port = 5432 +name = "platform_api" +user = "platform_api" +# Resolve the password from a mounted secret file (preferred) or env var, e.g. +# password = '{{ file "/secrets/platform-api/postgres_password" }}' +# The referenced file/env var must exist or config load fails. +password = "" +ssl_mode = "disable" # "disable" | "require" | "verify-ca" | "verify-full" # Connection pool — tune for production workloads -# max_open_conns = 25 # maximum open connections -# max_idle_conns = 10 # maximum idle connections in the pool -# conn_max_lifetime = 300 # seconds before a connection is recycled - +max_open_conns = 25 # maximum open connections +max_idle_conns = 10 # maximum idle connections in the pool +conn_max_lifetime = 300 # seconds before a connection is recycled # --------------------------------------------------------------------------- # Authentication # # Exactly one of jwt / idp / file_based drives request authentication; enabling -# more than one real auth mode is rejected at startup. +# the IDP alongside a local mode is rejected at startup. # --------------------------------------------------------------------------- - -# Paths that bypass the auth middleware entirely. The default list covers the -# health/metrics probes, the file-based login endpoint, and the internal gateway -# routes (which authenticate with a gateway token instead). Setting this key -# REPLACES the default list — include the defaults you still need. -# skip_paths is under [auth] comma-separated. -# [auth] -# skip_paths = ["/health", "/metrics", "/api/portal/v0.9/auth/login"] +[auth] + +# Paths that bypass the auth middleware entirely. The list below is the built-in +# default: the health/metrics probes, the file-based login endpoint, and the +# internal gateway routes (which authenticate with a gateway token instead). +# Setting this key REPLACES the default list — keep the entries you still need. +# Env var is APIP_CP_AUTH_SKIP_PATHS (comma-separated). +skip_paths = [ + "/health", + "/metrics", + "/api/portal/v0.9/auth/login", + "/api/internal/v1/ws/gateways/connect", + "/api/internal/v1/apis", + "/api/internal/v1/llm-providers", + "/api/internal/v1/llm-proxies", + "/api/internal/v1/subscription-plans", + "/api/internal/v1/mcp-proxies", + "/api/internal/v1/gateways", + "/api/internal/v1/deployments", + "/api/internal/v1/artifacts", + "/api/internal/v1/secrets", + "/api/internal/v1/websub-apis", + "/api/internal/v1/webbroker-apis", + "/api/internal/v0.9/webhook/events", +] # JWT (local HMAC) — the default for self-hosted deployments. # Disable when delegating auth entirely to an external IDP. @@ -111,39 +133,39 @@ path = "/app/data/api_platform.db" [auth.jwt] enabled = true issuer = "platform-api" +# Signing key. REQUIRED — a 32-byte key (64 hex chars or base64); never generated. secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' -# Skip signature verification. Defaults to true for the zero-config quickstart. -# NEVER set true in production. -# skip_validation = true +# Skip signature verification. MUST stay false — the server refuses to start +# when signature validation is disabled. +skip_validation = false # IDP (JWKS-based) — enable instead of JWT when using Asgardeo, Keycloak, Auth0, etc. +# jwks_url and issuer are required when enabled = true. [auth.idp] enabled = false -# name = "asgardeo" # friendly name for logging -# jwks_url = "https://accounts.example.com/oauth2/jwks" -# issuer = ["https://accounts.example.com"] # list of accepted issuers -# audience — list of accepted "aud" values. When set, a token is rejected -# unless its "aud" claim contains at least one of these values. Leave empty -# to skip the check. -# audience = [] +name = "asgardeo" # friendly name for logging +jwks_url = "https://accounts.example.com/oauth2/jwks" +issuer = ["https://accounts.example.com"] # list of accepted issuers +audience = [] # accepted "aud" values; empty = skip audience check # Authorization mode: "scope" (default) checks the scope claim; # "role" checks a roles claim at claim_mappings.roles_claim_path. -# validation_mode = "scope" -# role_mappings_file = "" # path to a YAML file mapping IDP roles to platform scopes +validation_mode = "scope" +role_mappings_file = "" # path to a YAML file mapping IDP roles to platform scopes # JWT claim name mappings — configure to match your IDP's token structure. [auth.idp.claim_mappings] -# organization_claim_name = "organization" # claim carrying the org ID -# org_name_claim_name = "org_name" # claim carrying the org display name -# org_handle_claim_name = "org_handle" # claim carrying the org URL slug -# user_id_claim_name = "sub" # claim used as the user's unique ID -# username_claim_name = "username" -# email_claim_name = "email" -# scope_claim_name = "scope" # space-separated scope string -# roles_claim_path = "" # JSON path to roles array, e.g. "realm_access.roles" - -# File-based auth — local username/password login. Ideal for initial / air-gapped setup +organization_claim_name = "organization" # claim carrying the org ID +org_name_claim_name = "org_name" # claim carrying the org display name +org_handle_claim_name = "org_handle" # claim carrying the org URL slug +user_id_claim_name = "sub" # claim used as the user's unique ID +username_claim_name = "username" +email_claim_name = "email" +scope_claim_name = "scope" # space-separated scope string +roles_claim_path = "" # JSON path to roles array, e.g. "realm_access.roles" + +# File-based auth — local username/password login. Ideal for initial / air-gapped +# setup; not recommended for production — prefer an IDP. # When enabled, IDP is NOT used for Platform API authentication. [auth.file_based] enabled = true @@ -155,18 +177,20 @@ region = "us" # Platform organization UUID, emitted as the `organization` claim in issued # tokens. Generated at startup when unset; pin it to keep the organization # stable across fresh databases. -# uuid = "99089a17-72e0-4dd8-a2f4-c8dfbb085295" +uuid = "99089a17-72e0-4dd8-a2f4-c8dfbb085295" # Add one [[auth.file_based.users]] block per user. # Generate a bcrypt hash with: htpasswd -bnBC 12 "" | tr -d ':\n' # The env-var equivalent (APIP_CP_AUTH_FILE_BASED_USERS) takes a JSON array instead. [[auth.file_based.users]] -username = "admin" -password_hash = "$2a$12$" +# The quickstart resolves these from api-platform.env (generated by setup.sh). +username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' +password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' # Full scope set — trim to restrict permissions for this user. scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:webbroker_api:manage ap:websub_api:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:api_key:read ap:secret:manage" -# Additional users — uncomment and repeat this block as needed +# Additional users — uncomment the WHOLE block (including the [[...]] header) +# and replace the placeholder hash with a real bcrypt hash before use. # [[auth.file_based.users]] # username = "readonly" # password_hash = "$2a$12$" @@ -176,28 +200,29 @@ scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:mana # Listeners (HTTP + HTTPS) # --------------------------------------------------------------------------- # The plain-HTTP and TLS listeners are independent — enable either or both, each -# on its own port. This follows the gateway router's http/https split, and lets -# you serve plain HTTP internally, HTTPS externally, or run both at once (e.g. to -# migrate clients between them without downtime). +# on its own port. Serve plain HTTP internally, HTTPS externally, or run both at +# once (e.g. to migrate clients between them without downtime). # # Defaults: the HTTPS listener is on at 9243; the plain-HTTP listener is off. # Enable the plain-HTTP listener only when a trusted upstream (ingress, -# service-mesh sidecar) terminates TLS, or for internal traffic — never expose it -# directly to untrusted networks. +# service-mesh sidecar) terminates TLS, or for internal traffic — never expose +# it directly to untrusted networks. # -# https.cert_dir must contain cert.pem and key.pem when https.enabled = true and -# APIP_DEMO_MODE=false. In demo mode a self-signed pair is generated there. +# https.cert_dir must contain cert.pem and key.pem when https.enabled = true. +# Certificates are always required — there is no self-signed fallback. +# setup.sh generates a pair for the quickstart. # -# Override with HTTP_ENABLED / HTTP_PORT and HTTPS_ENABLED / HTTPS_PORT / -# HTTPS_CERT_DIR. Legacy TLS_ENABLED / TLS_CERT_DIR / PORT still map onto the -# HTTPS listener. -# [http] -# enabled = false -# port = "9080" -# [https] -# enabled = true -# port = "9243" -# cert_dir = "/app/data/certs" +# Override with APIP_CP_HTTP_ENABLED / APIP_CP_HTTP_PORT and APIP_CP_HTTPS_ENABLED / +# APIP_CP_HTTPS_PORT / APIP_CP_HTTPS_CERT_DIR. Legacy TLS_ENABLED / TLS_CERT_DIR / +# PORT still map onto the HTTPS listener. +[http] +enabled = false +port = "9080" + +[https] +enabled = true +port = "9243" +cert_dir = "/app/data/certs" # default: ./data/certs # --------------------------------------------------------------------------- # Listener timeouts @@ -213,38 +238,23 @@ scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:mana # # WebSocket routes are unaffected — the deadlines are cleared on upgrade. # -# Override with TIMEOUTS_READ_HEADER / TIMEOUTS_READ / TIMEOUTS_WRITE / -# TIMEOUTS_IDLE. -# [timeouts] -# read_header = "10s" -# read = "60s" -# write = "120s" -# idle = "120s" - -# --------------------------------------------------------------------------- -# TLS -# --------------------------------------------------------------------------- -[tls] -# Terminate TLS on the Platform API listener. Set false only when a trusted -# upstream — an ingress controller or a service-mesh sidecar — terminates TLS -# for you. The listener then serves plain HTTP and no certificate is read or -# generated, so cert_dir may be left unmounted. -# enabled = true - -# Directory where the Platform API reads cert.pem and key.pem. A self-signed -# pair is generated here on first startup in demo mode; when APIP_DEMO_MODE=false -# the certificates must already exist (or TLS must be disabled above). -cert_dir = "/app/data/certs" +# Override with APIP_CP_TIMEOUTS_READ_HEADER / APIP_CP_TIMEOUTS_READ / +# APIP_CP_TIMEOUTS_WRITE / APIP_CP_TIMEOUTS_IDLE. +[timeouts] +read_header = "10s" +read = "60s" +write = "120s" +idle = "120s" # --------------------------------------------------------------------------- # CORS # --------------------------------------------------------------------------- # Origins allowed to make credentialed cross-origin requests, e.g. the Developer -# Portal and AI Workspace origins. Unset (or ["*"]) is permitted only in demo -# mode; an explicit non-wildcard list is REQUIRED when APIP_DEMO_MODE=false. -# Env var is APIP_CP_CORS_ALLOWED_ORIGINS (comma-separated). -# [cors] -# allowed_origins = ["https://workspace.example.com", "https://devportal.example.com"] +# Portal and AI Workspace origins. Must never contain "*"; leave empty to +# disable cross-origin access (the default). Env var is +# APIP_CP_CORS_ALLOWED_ORIGINS (comma-separated). +[cors] +allowed_origins = [] # e.g. ["https://workspace.example.com", "https://devportal.example.com"] # --------------------------------------------------------------------------- # API Key @@ -258,25 +268,37 @@ hashing_algorithms = ["sha256"] # WebSocket # --------------------------------------------------------------------------- [websocket] -max_connections = 1000 # global WebSocket connection limit -connection_timeout = 30 # seconds before an idle connection is closed -rate_limit_per_min = 1000 # maximum messages per minute per connection -max_connections_per_org = 3 # maximum concurrent WebSocket connections per org -metrics_log_enabled = true # emit WebSocket metrics to the log -metrics_log_interval = 10 # seconds between metrics log lines +max_connections = 1000 # global WebSocket connection limit +connection_timeout = 30 # seconds before an idle connection is closed +rate_limit_per_min = 1000 # maximum messages per minute per connection +max_connections_per_org = 3 # maximum concurrent WebSocket connections per org +metrics_log_enabled = true # emit WebSocket metrics to the log +metrics_log_interval = 10 # seconds between metrics log lines # --------------------------------------------------------------------------- # Deployments # --------------------------------------------------------------------------- [deployments] -max_per_api_gateway = 20 # maximum API deployments per gateway -transitional_status_enabled = false # expose "pending" deployment states in responses +max_per_api_gateway = 20 # maximum API deployments per gateway +transitional_status_enabled = false # expose "pending" deployment states in responses # Deployment timeout — mark stuck deployments as failed after timeout_duration seconds. timeout_enabled = true timeout_interval = 20 # seconds between timeout check sweeps timeout_duration = 60 # seconds before a stuck deployment is timed out +# --------------------------------------------------------------------------- +# Artifact limits +# --------------------------------------------------------------------------- +# Maximum number of each artifact kind an organization may create. +# A value <= 0 (the default) means unlimited. +[artifact_limits] +max_llm_providers_per_org = 0 +max_llm_proxies_per_org = 0 +max_mcp_proxies_per_org = 0 +max_websub_apis_per_org = 0 +max_webbroker_apis_per_org = 0 + # --------------------------------------------------------------------------- # Gateway # --------------------------------------------------------------------------- @@ -287,43 +309,30 @@ enable_version_verification = false enable_functionality_type_verification = false # --------------------------------------------------------------------------- -# DevPortal integration +# EventHub — multi-replica HA event delivery # --------------------------------------------------------------------------- -# Disable for standalone deployments that do not use a DevPortal. -[default_devportal] -enabled = false -# name = "Default DevPortal" -# identifier = "default" # unique slug used internally -# api_url = "http://devportal:3001" -# hostname = "devportal.example.com" # public hostname shown in setup instructions -# api_key = "change-me" -# header_key_name = "x-wso2-api-key" -# timeout = 10 # HTTP client timeout in seconds - -# JWT claim name mappings for DevPortal token validation. -# role_claim_name = "roles" -# groups_claim_name = "groups" -# organization_claim_name = "organizationID" - -# Role names that map to DevPortal access levels. -# admin_role = "admin" -# subscriber_role = "Internal/subscriber" -# super_admin_role = "superAdmin" +# Values are durations, e.g. "3s", "10m", "1h". All must be positive. +[event_hub] +poll_interval = "3s" # how often each replica polls for new events +cleanup_interval = "10m" # how often delivered events are purged +retention_period = "1h" # how long delivered events are retained # --------------------------------------------------------------------------- -# Environment-only settings (no config file key) +# Webhook — control-plane webhook receiver # --------------------------------------------------------------------------- -# -# APIP_DEMO_MODE "true" (default) relaxes startup checks for quickstart: -# file-based auth is allowed, a self-signed TLS certificate -# and an ephemeral JWT / secret-encryption key are generated -# when none are supplied, and CORS may be left open. -# -# Setting it to "false" turns each of those into a startup -# requirement: a real auth mode (IDP or JWT with signature -# validation on), an explicit CORS allow-list, a stable -# secret-encryption key, and either mounted certificates or -# tls.enabled = false above. -# -# The same variable drives the AI Workspace BFF, so one value -# governs the whole stack. +# The Developer Portal delivers signed events (API key / subscription changes) +# to this endpoint. +[webhook] +enabled = false +# HMAC-SHA256 shared secret used to verify request signatures. REQUIRED when +# enabled. Resolve via a token, e.g. secret = '{{ env "APIP_CP_WEBHOOK_SECRET" }}' +# — the referenced env var/file must exist or config load fails. +secret = "" +# PEM RSA private key used to decrypt encrypted_key fields. Required only for +# events that carry encrypted secrets (API key generate/regenerate). +private_key_path = "" +# Events with a different gateway_type are accepted as a no-op. +gateway_type = "wso2/api-platform" +signature_tolerance = "5m" # max age of a signed request (replay protection) +max_body_size = 1048576 # request body cap in bytes (1 MiB) +signature_header = "X-Devportal-Signature" # header carrying the "t=...,v1=..." signature diff --git a/platform-api/config/config.go b/platform-api/config/config.go index eb25684210..18d19e4b1b 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -185,8 +185,7 @@ type HTTPListener struct { } // HTTPSListener configures the TLS listener. CertDir must contain cert.pem and -// key.pem when Enabled is true; in demo mode a self-signed pair is generated -// there when none is present. +// key.pem when Enabled is true; there is no self-signed fallback. type HTTPSListener struct { Enabled bool `koanf:"enabled"` Port string `koanf:"port"` @@ -220,7 +219,7 @@ type Timeouts struct { // CORS holds cross-origin resource sharing configuration. type CORS struct { // AllowedOrigins lists the exact origins permitted to make credentialed - // cross-origin requests. Must not be ["*"] outside demo mode — wildcard + // cross-origin requests. Must never be ["*"] — wildcard // origins cannot be combined with credentialed requests. AllowedOrigins []string `koanf:"allowed_origins"` } @@ -626,6 +625,14 @@ func validateFileBasedConfig(cfg *FileBased) error { if len(cfg.Users) == 0 { return fmt.Errorf("auth.file_based.enabled=true requires at least one user in auth.file_based.users") } + for i, u := range cfg.Users { + if u.Username == "" { + return fmt.Errorf("auth.file_based.users[%d]: username is required (set it in config via {{ env }}/{{ file }})", i) + } + if u.PasswordHash == "" { + return fmt.Errorf("auth.file_based.users[%d] (%s): password_hash is required (set it in config via {{ env }}/{{ file }})", i, u.Username) + } + } return nil } diff --git a/platform-api/config/config.toml b/platform-api/config/config.toml index bd74eb1f87..375178d21b 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -37,7 +37,7 @@ encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' # --------------------------------------------------------------------------- [database] driver = "sqlite3" # "sqlite3" or "postgres" -path = "/app/data/api_platform.db" # SQLite file path (ignored for postgres) +# path = "/app/data/api_platform.db" # SQLite file path (ignored for postgres) # --------------------------------------------------------------------------- # Authentication @@ -45,38 +45,17 @@ path = "/app/data/api_platform.db" # SQLite file path (ignored for postgres) # JWT (local HMAC) — issues signed tokens after file-based login. [auth.jwt] -enabled = true -issuer = "platform-api" -secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' -skip_validation = false # verify JWT signature +enabled = true +issuer = "platform-api" +secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' +skip_validation = false # IDP (JWKS-based) — disabled in quickstart mode. # Enable and configure when using Asgardeo, Keycloak, Auth0, etc. [auth.idp] enabled = false +audience = [] # File-based auth — local username/password login. [auth.file_based] enabled = false - -[auth.file_based.organization] -id = "default" # organization handle (URL-safe slug) -display_name = "Default" -region = "us" - -[[auth.file_based.users]] -username = "admin" -password_hash = "$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ." -scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:webbroker_api:manage ap:websub_api:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:api_key:read ap:secret:manage" - -# --------------------------------------------------------------------------- -# TLS -# --------------------------------------------------------------------------- -[tls] -cert_dir = "/app/data/certs" - -# --------------------------------------------------------------------------- -# DevPortal integration — disable for standalone deployments -# --------------------------------------------------------------------------- -[default_devportal] -enabled = false diff --git a/platform-api/config/config_test.go b/platform-api/config/config_test.go index 6e4fa6069c..635960df77 100644 --- a/platform-api/config/config_test.go +++ b/platform-api/config/config_test.go @@ -61,7 +61,6 @@ func loadWithKeys(t *testing.T, extra string) (*Server, error) { t.Helper() t.Setenv("APIP_CP_ENCRYPTION_KEY", validInlineKey) t.Setenv("APIP_CP_AUTH_JWT_SECRET_KEY", validJWTKey) - t.Setenv("APIP_DEMO_MODE", "") return loadTOML(t, validKeysBase+extra) } @@ -72,11 +71,9 @@ func TestLoadConfig_ValidKeys_Succeeds(t *testing.T) { assert.Equal(t, validInlineKey, cfg.EncryptionKey) } -// The encryption key is required and never generated — a config that omits it fails -// startup (even in demo mode). +// The encryption key is required and never generated — a config that omits it fails startup. func TestLoadConfig_MissingEncryptionKey_Errors(t *testing.T) { t.Setenv("APIP_CP_AUTH_JWT_SECRET_KEY", validJWTKey) - t.Setenv("APIP_DEMO_MODE", "true") // demo does not relax the requirement // Encryption key omitted entirely; the JWT secret resolves so the JWT check passes first. _, err := loadTOML(t, ` @@ -106,7 +103,6 @@ secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' // The JWT secret is required (JWT auth is enabled) and never generated. func TestLoadConfig_MissingJWTSecretKey_Errors(t *testing.T) { t.Setenv("APIP_CP_ENCRYPTION_KEY", validInlineKey) - t.Setenv("APIP_DEMO_MODE", "true") // demo does not relax the requirement _, err := loadTOML(t, ` encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' @@ -150,7 +146,6 @@ func init() { for _, v := range []string{ "APIP_CP_ENCRYPTION_KEY", "APIP_CP_AUTH_JWT_SECRET_KEY", - "APIP_DEMO_MODE", } { os.Unsetenv(v) } diff --git a/platform-api/config/default_config.go b/platform-api/config/default_config.go index a8fd701034..7552606718 100644 --- a/platform-api/config/default_config.go +++ b/platform-api/config/default_config.go @@ -105,7 +105,7 @@ func defaultConfig() *Server { MetricsLogInterval: 10, }, DefaultDevPortal: DefaultDevPortal{ - Enabled: true, + Enabled: false, Name: "Default DevPortal", Identifier: "default", APIUrl: "http://localhost:3001", diff --git a/platform-api/internal/integration/harness_test.go b/platform-api/internal/integration/harness_test.go index f23acb3067..9599d9fbf5 100644 --- a/platform-api/internal/integration/harness_test.go +++ b/platform-api/internal/integration/harness_test.go @@ -45,8 +45,6 @@ func TestMain(m *testing.M) { // the at-rest encryption key). It resolves keys from the config file only — env vars reach // config exclusively through {{ env }} tokens now — so seed the singleton with a temp // config carrying throwaway 64-hex test keys. - os.Setenv("APIP_DEMO_MODE", "true") - const testKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" f, err := os.CreateTemp("", "it-config-*.toml") if err != nil { diff --git a/platform-api/internal/repository/api_deployments_test.go b/platform-api/internal/repository/api_deployments_test.go index 801156b5f4..463624e816 100644 --- a/platform-api/internal/repository/api_deployments_test.go +++ b/platform-api/internal/repository/api_deployments_test.go @@ -596,7 +596,6 @@ func TestGetControlPlaneDeploymentsByGateway_ExcludesGatewayOrigin(t *testing.T) func TestMain(m *testing.M) { // APIP_CP_ENCRYPTION_KEY and APIP_CP_AUTH_JWT_SECRET_KEY are required. - os.Setenv("APIP_DEMO_MODE", "true") os.Setenv("APIP_CP_ENCRYPTION_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") os.Setenv("APIP_CP_AUTH_JWT_SECRET_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index 0f70ae290a..d8d59c0db6 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -19,17 +19,10 @@ package server import ( "context" - "crypto/rand" - "crypto/rsa" "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" "errors" "fmt" "log/slog" - "math/big" - "net" "net/http" "os" "os/signal" @@ -73,22 +66,17 @@ type Server struct { logger *slog.Logger } -// validateAuthConfig enforces production auth requirements when demo mode is off. +// validateAuthConfig enforces auth requirements at startup. All checks are +// unconditional: there is no relaxed/demo mode. func validateAuthConfig(cfg *config.Server) error { - if demoMode() { - return nil - } - if cfg.Auth.FileBased.Enabled { - return fmt.Errorf("file-based authentication (APIP_CP_AUTH_FILE_BASED_ENABLED=true) is not allowed when APIP_DEMO_MODE=false; configure an IDP (APIP_CP_AUTH_IDP_ENABLED=true) or JWT (APIP_CP_AUTH_JWT_ENABLED=true) instead") - } - if !cfg.Auth.IDP.Enabled && !cfg.Auth.JWT.Enabled { - return fmt.Errorf("APIP_DEMO_MODE=false requires a real auth mode; set APIP_CP_AUTH_IDP_ENABLED=true or APIP_CP_AUTH_JWT_ENABLED=true") + if !cfg.Auth.FileBased.Enabled && !cfg.Auth.IDP.Enabled && !cfg.Auth.JWT.Enabled { + return fmt.Errorf("no authentication mode is enabled; set auth.file_based.enabled=true, auth.jwt.enabled=true, or auth.idp.enabled=true") } if cfg.Auth.JWT.Enabled && cfg.Auth.JWT.SkipValidation { - return fmt.Errorf("JWT signature validation cannot be skipped (APIP_CP_AUTH_JWT_SKIP_VALIDATION=true) when APIP_DEMO_MODE=false; set APIP_CP_AUTH_JWT_SKIP_VALIDATION=false for production") + return fmt.Errorf("JWT signature validation cannot be skipped (auth.jwt.skip_validation=true / APIP_CP_AUTH_JWT_SKIP_VALIDATION=true); set it to false") } - if len(cfg.CORS.AllowedOrigins) == 0 || slices.Contains(cfg.CORS.AllowedOrigins, "*") { - return fmt.Errorf("APIP_CP_CORS_ALLOWED_ORIGINS must be set to an explicit, non-wildcard list of origins when APIP_DEMO_MODE=false") + if slices.Contains(cfg.CORS.AllowedOrigins, "*") { + return fmt.Errorf("cors.allowed_origins (APIP_CP_CORS_ALLOWED_ORIGINS) must not contain \"*\"; list explicit origins, or leave it empty to disable cross-origin access") } return nil } @@ -362,7 +350,6 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, timeoutService := service.NewDeploymentTimeoutService(deploymentRepo, timeoutConfig, slogger) slogger.Info("Initialized all services and handlers successfully") - slogger.Info("Platform API configuration", slog.Bool("demoMode", demoMode())) // Setup mux and register all routes. mux := http.NewServeMux() @@ -484,7 +471,6 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, // Order: CORS → auth → scope enforcer → mux var chain []func(http.Handler) http.Handler - // validateAuthConfig already rejected a missing/wildcard allowlist outside demo mode. // Cross-origin access is disabled by default (empty AllowedOrigins fails closed in the // CORS middleware); operators must opt in explicitly via CORS.AllowedOrigins in config. corsOrigins := cfg.CORS.AllowedOrigins @@ -497,14 +483,12 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, AllowedOrigins: corsOrigins, AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}, AllowedHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"}, - AllowCredentials: !slices.Contains(corsOrigins, "*"), + AllowCredentials: true, })) if cfg.Auth.FileBased.Enabled { slogger.Info("Auth mode: file-based (HMAC-signed JWT)") - if !demoMode() { - slogger.Warn("file-based authentication is enabled — this is not recommended for production; please configure an IDP of your choice") - } + slogger.Warn("file-based authentication is enabled — this is not recommended for production; please configure an IDP of your choice") chain = append(chain, middleware.LocalJWTAuthMiddleware(middleware.AuthConfig{ SecretKey: cfg.Auth.JWT.SecretKey, TokenIssuer: cfg.Auth.JWT.Issuer, @@ -563,37 +547,19 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, }, nil } -// demoMode reports whether APIP_DEMO_MODE is enabled. -// Defaults to true when the variable is unset. -func demoMode() bool { - v := strings.ToLower(strings.TrimSpace(os.Getenv("APIP_DEMO_MODE"))) - if v == "" { - return true - } - return v == "true" || v == "1" -} - // buildAuthenticator constructs an Authenticator from the server configuration. // Only called when file-based auth is disabled. func buildAuthenticator(cfg *config.Server, slogger *slog.Logger, roleScopeMap map[string][]string) (middleware.Authenticator, error) { if !cfg.Auth.IDP.Enabled { - if cfg.Auth.JWT.SkipValidation { - if !demoMode() { - slogger.Warn("WARNING: JWT signature validation is DISABLED (APIP_CP_AUTH_JWT_SKIP_VALIDATION=true) but APIP_DEMO_MODE=false. " + - "Tokens are NOT verified — any bearer value will be accepted. " + - "Set APIP_DEMO_MODE=true to suppress this warning, or set APIP_CP_AUTH_JWT_SKIP_VALIDATION=false for production.") - } else { - slogger.Warn("JWT mode: signature validation disabled (APIP_CP_AUTH_JWT_SKIP_VALIDATION=true) [APIP_DEMO_MODE=true]") - } - } else { - slogger.Info("JWT mode: HMAC signature validation enabled") - } + // validateAuthConfig already rejected skip_validation=true, so signatures + // are always verified here. + slogger.Info("JWT mode: HMAC signature validation enabled") return middleware.NewJWTAuthenticator( middleware.LocalJWTAuthMiddleware(middleware.AuthConfig{ SecretKey: cfg.Auth.JWT.SecretKey, TokenIssuer: cfg.Auth.JWT.Issuer, SkipPaths: cfg.Auth.SkipPaths, - SkipValidation: cfg.Auth.JWT.SkipValidation, + SkipValidation: false, }), ), nil } @@ -668,110 +634,25 @@ func loadRoleScopeMap(cfg *config.Server, registry *middleware.ScopeRegistry, sl return m, nil } -// generateSelfSignedCert creates a self-signed certificate for development and saves it to disk -func generateSelfSignedCert(certPath, keyPath string, logger *slog.Logger) (tls.Certificate, error) { - // Generate private key - priv, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return tls.Certificate{}, err - } - - // CreateOrganization certificate template - template := x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{ - Organization: []string{"Platform API Dev"}, - Country: []string{"US"}, - Province: []string{""}, - Locality: []string{""}, - StreetAddress: []string{""}, - PostalCode: []string{""}, - }, - NotBefore: time.Now(), - NotAfter: time.Now().Add(365 * 24 * time.Hour), // Valid for 1 year - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, - DNSNames: []string{"localhost"}, - } - - // CreateOrganization certificate - certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) - if err != nil { - return tls.Certificate{}, err - } - - // CreateOrganization PEM blocks - certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) - keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) - - // Save certificate and key to disk for persistence - if err := os.WriteFile(certPath, certPEM, 0644); err != nil { - return tls.Certificate{}, fmt.Errorf("failed to save certificate: %v", err) - } - if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { - return tls.Certificate{}, fmt.Errorf("failed to save private key: %v", err) - } - logger.Info("Saved certificate", "certPath", certPath, "keyPath", keyPath) - - // CreateOrganization TLS certificate - cert, err := tls.X509KeyPair(certPEM, keyPEM) - if err != nil { - logger.Error("Failed to create TLS certificate", "error", err) - return tls.Certificate{}, err - } - - return cert, nil -} - // buildTLSConfig resolves the TLS listener configuration. The caller invokes it -// only when the HTTPS listener is enabled, so certificates are always read (or, -// in demo mode, generated) here. +// only when the HTTPS listener is enabled. Certificates are always required — +// there is no self-signed fallback; use the quickstart setup script (or your +// own tooling) to generate a pair and mount it. func (s *Server) buildTLSConfig(httpsCfg config.HTTPSListener) (*tls.Config, error) { certDir := httpsCfg.CertDir certPath := filepath.Join(certDir, "cert.pem") keyPath := filepath.Join(certDir, "key.pem") - var cert tls.Certificate - - // Try to load existing certificates first - if _, certErr := os.Stat(certPath); certErr == nil { - if _, keyErr := os.Stat(keyPath); keyErr == nil { - loadedCert, err := tls.LoadX509KeyPair(certPath, keyPath) - if err != nil { - s.logger.Warn("Failed to load certificates", "error", err) - } else { - s.logger.Info("Using existing certificates", "certDir", certDir) - cert = loadedCert - } - } - } - - // Generate new certificate if not loaded - if cert.Certificate == nil { - if !demoMode() { - return nil, fmt.Errorf( - "no TLS certificates found at %q (cert.pem / key.pem) and APIP_DEMO_MODE=false: "+ - "mount real certificates, set APIP_CP_HTTPS_CERT_DIR to a directory containing cert.pem and key.pem, "+ - "or set APIP_CP_HTTPS_ENABLED=false to serve plain HTTP behind a TLS-terminating proxy; "+ - "self-signed certificate generation is only permitted in demo mode", - certDir, - ) - } - s.logger.Info("Generating self-signed certificate for development...") - // Ensure cert directory exists - if err := os.MkdirAll(certDir, 0755); err != nil { - s.logger.Error("Failed to create cert directory", "error", err) - return nil, fmt.Errorf("failed to create cert directory: %v", err) - } - generatedCert, err := generateSelfSignedCert(certPath, keyPath, s.logger) - if err != nil { - s.logger.Error("Failed to generate self-signed certificate", "error", err) - return nil, fmt.Errorf("failed to generate self-signed certificate: %v", err) - } - cert = generatedCert - s.logger.Warn("Note: Using self-signed certificate for development. Browsers will show security warnings.") + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, fmt.Errorf( + "failed to load TLS certificates from %q (cert.pem / key.pem): %w. "+ + "Mount certificates there, set APIP_CP_HTTPS_CERT_DIR to a directory containing cert.pem and key.pem, "+ + "or set APIP_CP_HTTPS_ENABLED=false to serve plain HTTP behind a TLS-terminating proxy", + certDir, err, + ) } + s.logger.Info("Using mounted certificates", "certDir", certDir) return &tls.Config{ Certificates: []tls.Certificate{cert}, @@ -827,12 +708,10 @@ func (s *Server) Start(httpCfg config.HTTPListener, httpsCfg config.HTTPSListene // Plain-HTTP listener. if httpCfg.Enabled { // Plain HTTP is only safe when something upstream terminates TLS, or for - // internal traffic. Say so loudly outside demo mode. - if !demoMode() { - s.logger.Warn("Plain-HTTP listener is enabled (http.enabled=true)" + + // internal traffic. Say so loudly. + s.logger.Warn("Plain-HTTP listener is enabled (http.enabled=true)" + "terminate TLS at an ingress or service-mesh sidecar and never expose this listener " + "directly to untrusted networks.") - } httpServer := &http.Server{ Addr: ":" + httpCfg.Port, Handler: s.handler, @@ -866,11 +745,7 @@ func (s *Server) Start(httpCfg config.HTTPListener, httpsCfg config.HTTPSListene }() } - mode := "PRODUCTION" - if demoMode() { - mode = "DEMO" - } - s.logger.Info("Platform API started", "mode", mode) + s.logger.Info("Platform API started") quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) diff --git a/platform-api/internal/server/server_tls_test.go b/platform-api/internal/server/server_tls_test.go index c057e3d709..c555a058c2 100644 --- a/platform-api/internal/server/server_tls_test.go +++ b/platform-api/internal/server/server_tls_test.go @@ -17,10 +17,19 @@ package server import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "io" "log/slog" + "math/big" + "os" "path/filepath" "testing" + "time" "github.com/wso2/api-platform/platform-api/config" ) @@ -29,29 +38,63 @@ func testServer() *Server { return &Server{logger: slog.New(slog.NewTextHandler(io.Discard, nil))} } -// HTTPS listener outside demo mode with no certificates: a fatal misconfiguration. -func TestBuildTLSConfig_NonDemo_MissingCert_Errors(t *testing.T) { - t.Setenv("APIP_DEMO_MODE", "false") - +// HTTPS listener with no certificates: a fatal misconfiguration — certificates +// are always required, there is no self-signed fallback. +func TestBuildTLSConfig_MissingCert_Errors(t *testing.T) { _, err := testServer().buildTLSConfig(config.HTTPSListener{ Enabled: true, CertDir: filepath.Join(t.TempDir(), "does-not-exist"), }) if err == nil { - t.Fatal("expected an error when the HTTPS listener has no certificates outside demo mode") + t.Fatal("expected an error when the HTTPS listener has no certificates") } } -// HTTPS listener in demo mode with no certificates: a self-signed pair is generated. -func TestBuildTLSConfig_Demo_GeneratesSelfSigned(t *testing.T) { - t.Setenv("APIP_DEMO_MODE", "true") - certDir := filepath.Join(t.TempDir(), "certs") +// HTTPS listener with a mounted certificate pair: loaded successfully. +func TestBuildTLSConfig_MountedCert_Loads(t *testing.T) { + certDir := t.TempDir() + writeTestCertPair(t, certDir) tlsConfig, err := testServer().buildTLSConfig(config.HTTPSListener{Enabled: true, Port: "9243", CertDir: certDir}) if err != nil { - t.Fatalf("expected self-signed generation to succeed in demo mode, got %v", err) + t.Fatalf("expected mounted certificates to load, got %v", err) } if tlsConfig == nil || len(tlsConfig.Certificates) != 1 { - t.Fatal("expected exactly one generated certificate in demo mode") + t.Fatal("expected exactly one loaded certificate") + } +} + +// writeTestCertPair writes a throwaway self-signed cert.pem / key.pem into dir. +func writeTestCertPair(t *testing.T, dir string) { + t.Helper() + + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{Organization: []string{"Platform API Test"}}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{"localhost"}, + } + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + keyDER, err := x509.MarshalECPrivateKey(priv) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + if err := os.WriteFile(filepath.Join(dir, "cert.pem"), certPEM, 0o644); err != nil { + t.Fatalf("write cert: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "key.pem"), keyPEM, 0o600); err != nil { + t.Fatalf("write key: %v", err) } } diff --git a/portals/ai-workspace/.env.example b/portals/ai-workspace/.env.example deleted file mode 100644 index 3d3a7699f3..0000000000 --- a/portals/ai-workspace/.env.example +++ /dev/null @@ -1,35 +0,0 @@ -# -------------------------------------------------------------------- -# Secrets for the AI Workspace + Platform API stack. -# -# cp .env.example .env # .env is git-ignored — never commit it -# -# Generate each key with: openssl rand -hex 32 -# -# These values are injected into the containers (env_file) and resolved by -# configs/config-platform-api.toml and configs/config.toml via {{ env "..." }} -# tokens. Never hardcode secret values in docker-compose.yaml or a config TOML. -# For production, prefer mounting a secret file and using {{ file "..." }}. -# -------------------------------------------------------------------- - -# Signs Platform API login JWTs. Keep stable across restarts (a change -# invalidates all issued tokens). 32-byte key: 64 hex chars or base64. -AUTH_JWT_SECRET_KEY= - -# Encrypts secrets, subscription tokens, and WebSub HMAC secrets at rest. -# Keep stable across restarts and replicas (a change makes stored data -# unreadable). 32-byte key: 64 hex chars or base64. -ENCRYPTION_KEY= - -# Optional — only when running the Platform API against PostgreSQL -# (driver = "postgres" in config-platform-api.toml). -# DATABASE_PASSWORD= - -# Optional — only in OIDC mode (auth_mode = "oidc" in configs/config.toml). -# The AI Workspace confidential-client secret, issued by your IDP. It stays -# server-side and is never sent to the browser. It is read only if config.toml -# carries the key with a token naming this variable: -# [oidc] -# client_secret = '{{ env "APIP_AIW_OIDC_CLIENT_SECRET" }}' -# In production, prefer a mounted secret file and swap that token for -# client_secret = '{{ file "/secrets/ai-workspace/oidc_client_secret" }}' -# APIP_AIW_OIDC_CLIENT_SECRET= diff --git a/portals/ai-workspace/.gitignore b/portals/ai-workspace/.gitignore index 0b45664748..97628a0377 100644 --- a/portals/ai-workspace/.gitignore +++ b/portals/ai-workspace/.gitignore @@ -4,3 +4,5 @@ reports target .env .vscode +api-platform.env +resources/certificates/ diff --git a/portals/ai-workspace/Makefile b/portals/ai-workspace/Makefile index 7bcc24bd03..56e5de1731 100644 --- a/portals/ai-workspace/Makefile +++ b/portals/ai-workspace/Makefile @@ -56,9 +56,12 @@ bff-build: ## Build the BFF binary # cannot be set here. To test against an IDP, add APIP_AIW_AUTH_MODE=oidc and the # APIP_AIW_OIDC_* variables the [oidc] tokens name — see README.md. bff-run: ## Run the BFF locally (proxies to PLATFORM_API_URL; pair with `make run`) + ./setup.sh --certs-only cd $(BFF_DIR) && \ APIP_AIW_PLATFORM_API_URL=$(PLATFORM_API_URL) \ APIP_AIW_PLATFORM_API_TLS_SKIP_VERIFY=true \ + APIP_AIW_TLS_CERT_FILE=../resources/certificates/cert.pem \ + APIP_AIW_TLS_KEY_FILE=../resources/certificates/key.pem \ APIP_AIW_LISTEN_ADDR=$(BFF_DEV_ADDR) \ APIP_AIW_STATIC_DIR=../dist \ APIP_AIW_LOG_LEVEL=debug \ @@ -183,7 +186,7 @@ endif .PHONY: dist clean-dist dist: clean-dist ## Build standalone AI Workspace + Platform API distribution zip @echo "Building distribution $(DIST_NAME)..." - @mkdir -p $(DIST_DIR)/configs $(DIST_DIR)/resources/certificates $(DIST_DIR)/resources/platform-api/db-scripts + @mkdir -p $(DIST_DIR)/configs $(DIST_DIR)/scripts $(DIST_DIR)/resources/certificates $(DIST_DIR)/resources/platform-api/db-scripts @cp -R configs/. $(DIST_DIR)/configs/ @cp ../../platform-api/resources/roles.yaml $(DIST_DIR)/resources/roles.yaml ifeq ($(PLATFORM_API_FROM_TAG),true) @@ -199,9 +202,19 @@ ifeq ($(PLATFORM_API_FROM_TAG),true) if [ -z "$$files" ]; then echo "Error: no .sql files found under platform-api/internal/database at $$tag" >&2; exit 1; fi; \ for f in $$files; do git -C ../.. show "$$tag:$$f" > "$$dest/$$(basename $$f)"; done; \ echo "✓ Fetched $$(echo "$$files" | wc -l | tr -d ' ') db-script(s) from $$tag" + @git -C ../.. show "$(PLATFORM_API_TAG):platform-api/config/config-template.toml" \ + > $(DIST_DIR)/configs/config-platform-api-template.toml else @cp ../../platform-api/internal/database/schema.*.sql $(DIST_DIR)/resources/platform-api/db-scripts/ + @cp ../../platform-api/config/config-template.toml $(DIST_DIR)/configs/config-platform-api-template.toml endif + @printf '%s\n' \ + '# Generated secrets — never commit these.' \ + '*.env' \ + 'resources/certificates/*' \ + > $(DIST_DIR)/.gitignore + @cp setup.sh $(DIST_DIR)/scripts/setup.sh + @chmod +x $(DIST_DIR)/scripts/setup.sh @cp docker-compose.yaml $(DIST_DIR)/docker-compose.yaml @cp distribution/README.md $(DIST_DIR)/README.md @sed -i.bak -E \ diff --git a/portals/ai-workspace/QUICKSTART.md b/portals/ai-workspace/QUICKSTART.md index f380a816dd..fa49f508cc 100644 --- a/portals/ai-workspace/QUICKSTART.md +++ b/portals/ai-workspace/QUICKSTART.md @@ -21,23 +21,32 @@ curl -sLO https://github.com/wso2/api-platform/releases/download/ai-workspace/v1 unzip wso2apip-ai-workspace-1.0.0-alpha.zip ``` -### 2. Start the stack +### 2. Run the setup script ```bash cd wso2apip-ai-workspace-1.0.0-alpha -docker compose up -d +./scripts/setup.sh ``` -### 3. Open the workspace +The script generates everything the stack requires: the encryption and JWT +signing keys and the admin login credentials (written to `api-platform.env`), and the +TLS certificate shared by both services (written to `resources/certificates/`). + +> **Save the printed admin username and password** — the password is shown only +> once and stored nowhere. Rerun `./scripts/setup.sh --force` to rotate it. + +### 3. Start the stack + +```bash +docker compose up -d +``` -Navigate to **https://localhost:5380** and sign in: +### 4. Open the workspace -| Field | Value | -|----------|---------| -| Username | `admin` | -| Password | `admin` | +Navigate to **https://localhost:5380** and sign in with the admin credentials +printed by `setup.sh`. -> **Browser trust warning?** Both services use a self-signed TLS certificate by default. Click **Advanced → Proceed** to continue, then return to the workspace. See [Custom TLS certificates](README.md#custom-tls-certificates) to remove the warning permanently. +> **Browser trust warning?** The generated TLS certificates are self-signed. Click **Advanced → Proceed** to continue, then return to the workspace. See [Custom TLS certificates](README.md#custom-tls-certificates) to remove the warning permanently. --- diff --git a/portals/ai-workspace/README.md b/portals/ai-workspace/README.md index 04c1a11f54..c92836ed9d 100644 --- a/portals/ai-workspace/README.md +++ b/portals/ai-workspace/README.md @@ -11,7 +11,7 @@ The AI Workspace is a React/Vite SPA served by a **Go BFF (Backend-for-Frontend) | Get running locally in 5 minutes | [QUICKSTART.md](QUICKSTART.md) | | Production setup (Asgardeo IDP) | [production/README.md](production/README.md) | | Full runtime configuration reference | [configs/config-template.toml](configs/config-template.toml) | -| Platform API configuration reference | [configs/config-platform-api-template.toml](configs/config-platform-api-template.toml) | +| Platform API configuration reference | [../../platform-api/config/config-template.toml](../../platform-api/config/config-template.toml) (copied into the distribution zip as `configs/config-platform-api-template.toml`) | --- @@ -30,7 +30,7 @@ Controlled by `auth_mode` in `configs/config.toml` (whose shipped token also rea | Mode | When to use | |---|---| -| `basic` | Quickstart / air-gapped — credentials defined in `config-platform-api.toml` | +| `basic` | Quickstart / air-gapped — admin credentials generated by `setup.sh` (see `api-platform.env`) | | `oidc` | Production — delegates to an external OIDC-compliant IDP (e.g. Asgardeo) | See [production/README.md](production/README.md) for the full OIDC setup walkthrough. @@ -62,7 +62,8 @@ log_level = '{{ env "APIP_AIW_LOG_LEVEL" "info" }}' [oidc] # No default — an unset APIP_AIW_OIDC_CLIENT_SECRET fails startup rather than -# running with an empty credential. Keep the value in a git-ignored .env. +# running with an empty credential. Keep the value in a git-ignored env file +# (api-platform.env in the compose quickstart). client_secret = '{{ env "APIP_AIW_OIDC_CLIENT_SECRET" }}' # Preferred in production — the secret never enters the environment at all. @@ -86,7 +87,6 @@ All available options are documented in portals/ai-workspace/ ├── configs/ │ ├── config-template.toml # AI Workspace config reference -│ ├── config-platform-api-template.toml # Platform API config reference │ ├── config.toml # Active config (gitignored in prod) │ └── config-platform-api.toml # Active Platform API config ├── production/ @@ -265,19 +265,26 @@ client_secret = '{{ env "APIP_AIW_OIDC_CLIENT_SECRET" }}' ``` The secret's value is **not** written into the config file. Put it — along with the Platform API's -IDP endpoints, which the compose file substitutes into its `APIP_CP_AUTH_IDP_*` variables — in a -git-ignored `.env` next to `docker-compose.yaml`: +IDP settings — in `api-platform.env` next to `docker-compose.yaml` (git-ignored; generated by +`setup.sh` and loaded into both services via their `env_file` entries): ```bash -# portals/ai-workspace/.env — see .env.example +# portals/ai-workspace/api-platform.env — append below the setup.sh-generated keys APIP_AIW_OIDC_CLIENT_SECRET= # read by the token above -OIDC_JWKS_URL=https://idp.example.com/oauth2/jwks -OIDC_ISSUER=https://idp.example.com/oauth2/token -OIDC_AUDIENCE= # optional; omit to skip the aud check -# OIDC_ORG_ID_CLAIM=org_id # set only if your IDP names the org claim differently +APIP_CP_AUTH_IDP_ENABLED=true +APIP_CP_AUTH_JWT_ENABLED=false # required: mutually exclusive with the IDP +APIP_CP_AUTH_FILE_BASED_ENABLED=false # required: mutually exclusive with the IDP +APIP_CP_AUTH_IDP_JWKS_URL=https://idp.example.com/oauth2/jwks +APIP_CP_AUTH_IDP_ISSUER=https://idp.example.com/oauth2/token +APIP_CP_AUTH_IDP_AUDIENCE= # optional; omit to skip the aud check +# Set only if your IDP names the org claims differently (defaults: +# organization / org_name / org_handle): +# APIP_CP_AUTH_IDP_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME=org_id +# APIP_CP_AUTH_IDP_CLAIM_MAPPINGS_ORG_NAME_CLAIM_NAME=org_name +# APIP_CP_AUTH_IDP_CLAIM_MAPPINGS_ORG_HANDLE_CLAIM_NAME=org_handle ``` -Then **uncomment the IDP block on the `platform-api` service** and start the stack: +Then start the stack: ```bash docker compose up -d @@ -285,13 +292,14 @@ docker compose up -d In production, prefer mounting the secret as a file and referencing it with `[oidc] client_secret = '{{ file "/secrets/ai-workspace/oidc_client_secret" }}'` — the value then -never enters the environment at all. Leave `auth_mode = "basic"` (the default) to keep the -zero-config file-based quickstart (`admin` / `admin`). +never enters the environment at all. -> The Platform API and BFF auth modes are mutually exclusive: enabling the IDP while local JWT -> or file-based auth is also on is rejected at startup. The commented `platform-api` block already -> sets `APIP_CP_AUTH_JWT_ENABLED=false` and `APIP_CP_AUTH_FILE_BASED_ENABLED=false` — keep those -> uncommented too. +(Skipping this section and leaving `auth_mode = "basic"`, the default, keeps the file-based +quickstart instead — the admin user generated by `setup.sh` — with no IDP involved.) + +> The Platform API auth modes are mutually exclusive: enabling the IDP while local JWT +> or file-based auth is also on is rejected at startup — keep the +> `APIP_CP_AUTH_JWT_ENABLED=false` and `APIP_CP_AUTH_FILE_BASED_ENABLED=false` lines in `api-platform.env`. #### Option 2 (local `make bff-run`) @@ -354,9 +362,9 @@ failures, by symptom: | Symptom | Cause | Fix | |---|---|---| | `unauthorized_client` / *"not authorized to use the requested grant type"* | App registered as SPA, or Code/Refresh grant not enabled | Recreate as Standard-Based OIDC app; enable **Code** + **Refresh Token** (step 1) | -| Platform API exits at startup with *"auth.idp.enabled=true and auth.jwt.enabled=true are mutually exclusive"* | Local auth left on alongside the IDP | Compose: uncomment the full `platform-api` OIDC block (it sets `APIP_CP_AUTH_JWT_ENABLED=false` + `APIP_CP_AUTH_FILE_BASED_ENABLED=false`). Local: set `auth.jwt.enabled=false` and `auth.file_based.enabled=false` (step 3, Option 2) | +| Platform API exits at startup with *"auth.idp.enabled=true and auth.jwt.enabled=true are mutually exclusive"* | Local auth left on alongside the IDP | Compose: set `APIP_CP_AUTH_JWT_ENABLED=false` + `APIP_CP_AUTH_FILE_BASED_ENABLED=false` in `api-platform.env` (step 3, Option 1). Local: set `auth.jwt.enabled=false` and `auth.file_based.enabled=false` (step 3, Option 2) | | `502` + `dial tcp: lookup platform-api: no such host` | BFF run locally but `[platform_api] url` points at the compose hostname | Set `APIP_AIW_PLATFORM_API_URL=https://localhost:9243` (step 3, Option 2) | -| Proxied calls return `authentication_failed` | Platform API still on local JWT/file-based, validating the IDP token with the wrong validator | Switch it to the IDP — compose: uncomment the `platform-api` OIDC block; local: enable `[auth.idp]` (step 3, Option 2) | +| Proxied calls return `authentication_failed` | Platform API still on local JWT/file-based, validating the IDP token with the wrong validator | Switch it to the IDP — compose: set the `APIP_CP_AUTH_IDP_*` keys in `api-platform.env` (step 3, Option 1); local: enable `[auth.idp]` (step 3, Option 2) | | Proxied calls return `authentication_failed`, Platform API logs `token contains an invalid number of segments` | IDP is issuing **opaque** access tokens — the BFF forwards the access token and the Platform API can only validate a **JWT** via JWKS | Set **Access Token Type = JWT** on the app's Protocol tab (step 1) and re-login | | Login works, then proxied calls return `403` | Access token lacks `ap:*` scopes, or Platform API IDP/claim mapping wrong | Grant `ap:*` scopes to the user (step 2); check `[auth.idp]` issuer/JWKS/claim mappings | | User shows as a UUID and email is blank in the UI | Token carries no name/email claims — the BFF falls back to the `sub` (user UUID) | Release the `given_name` (or `name`/`preferred_username`) and `email` claims to the app and ensure the user has those attributes set; the `profile` and `email` scopes must be granted (both are in the default request) | @@ -427,61 +435,48 @@ Use the following commands after the stack is up: `npm run test:e2e` runs against `https://host.docker.internal:5380`, which maps back to your local quickstart stack from inside the Cypress container. The command adds an explicit `host-gateway` mapping so it also works on Linux Docker hosts. `npm run test:e2e:open` runs locally against `https://localhost:5380`. -The quickstart login used by the tests is: - -- Username: `admin` -- Password: `admin` +The tests default to `admin` / `admin`; pin the quickstart credentials to match +(`ADMIN_USERNAME=admin ADMIN_PASSWORD=admin ./setup.sh --force`) or export +`CYPRESS_ADMIN_USER` / `CYPRESS_ADMIN_PASSWORD` with the credentials `setup.sh` +generated. --- -## Custom TLS certificates (optional) +## Custom TLS certificates -Mount your own certificate to remove the browser trust warning. +`setup.sh` generates a single self-signed certificate/key pair shared by both services (its SAN +covers both compose hostnames). To remove the browser trust warning, replace it with a certificate +from your own CA — same file names, and the SAN must cover `localhost`, `host.docker.internal`, +`platform-api`, and `ai-workspace`: -1. Create a `certs/` directory next to `docker-compose.yaml`. -2. Place your certificate files there: - ``` - certs/ - ├── ai-workspace.crt - ├── ai-workspace.key - ├── platform-api.crt - └── platform-api.key - ``` -3. Uncomment the TLS volume lines in `docker-compose.yaml` under each service. -4. Restart the stack: `docker compose up -d` - ---- +``` +resources/certificates/ +├── cert.pem +└── key.pem +``` -## Production hardening (`APIP_DEMO_MODE`) +docker-compose mounts this directory read-only into both containers +(`/etc/platform-api/tls` and `/etc/ai-workspace/tls`). The same cert also serves as the CA bundle +the BFF trusts for the upstream platform-api hop, referenced by `[platform_api] ca_file` in +`configs/config.toml`. -The stack ships in **demo mode** so the quickstart is zero-config: file-based auth -(`admin` / `admin`) works out of the box and both services fall back to an auto-generated -self-signed TLS certificate. `APIP_DEMO_MODE` controls this — it **defaults to `true`**, and -only an explicit `false` (or `0`) opts into production-grade startup checks. +Then restart the stack: `docker compose up -d --force-recreate` (a plain `docker +compose up -d` won't recreate already-running containers, so they'd keep the +old certificates loaded) -A single `APIP_DEMO_MODE` drives the **whole stack**: `docker-compose.yaml` passes it to both -the `platform-api` and `ai-workspace` services, so set it once in your shell or `.env`: +--- -```bash -# portals/ai-workspace/.env -APIP_DEMO_MODE=false -``` +## Production hardening -When `APIP_DEMO_MODE=false`, startup is **stricter on both services** and will **fail fast** -rather than run insecurely: +There is **no demo mode**: every startup requirement is always enforced, on both +services, and a missing one **fails fast** with a message naming exactly what to +provide. The quickstart satisfies them with `setup.sh` (generated keys, +credentials, and self-signed certificates); for production, provide real values: -| Service | Demo mode (`true`, default) | Production (`false`) | +| Requirement | Quickstart (`setup.sh`) | Production | |---|---|---| -| **AI Workspace (BFF)** — auth | Basic / file-based auth allowed | Basic auth **rejected** — OIDC required (`auth_mode = "oidc"` + the `[oidc]` keys) | -| **AI Workspace (BFF)** — inbound TLS | Auto-generates a self-signed cert when none is mounted | Self-signed fallback **disabled** — a cert/key must be mounted (`tls_cert_file` / `tls_key_file`), or TLS turned off with `tls_enabled = false` when an ingress terminates it | -| **AI Workspace (BFF)** — upstream TLS | `PLATFORM_API_TLS_SKIP_VERIFY = true` accepts the Platform API's self-signed cert | Skip-verify **rejected** — trust the upstream cert with `platform_api_ca_file` (a PEM bundle) so verification stays on | -| **Platform API** — secrets | `APIP_CP_ENCRYPTION_KEY` and `APIP_CP_AUTH_JWT_SECRET_KEY` are **required** | Same — both keys required | - -So before flipping `APIP_DEMO_MODE=false`, make sure you have: - -1. **OIDC configured on both services** — follow [Testing with an IDP locally](#testing-with-an-idp-locally) (uncomment the OIDC blocks on both compose services and set the `OIDC_*` values). Basic auth is no longer a fallback. -2. **A real TLS certificate mounted** on the BFF (and the Platform API) — follow [Custom TLS certificates](#custom-tls-certificates-optional) above and uncomment the cert volume lines. The self-signed fallback is gone. -3. **A stable encryption key** for the Platform API — generate a value by running `openssl rand -hex 32` in a shell, then paste the resulting 64-hex string into your `.env` as `APIP_CP_ENCRYPTION_KEY=`. Do **not** put `APIP_CP_ENCRYPTION_KEY=$(openssl rand -hex 32)` in `.env` — `.env` files store the literal text and do not run command substitution. Otherwise encrypted secrets become unreadable after a restart. See [platform-api/README.md](../../platform-api/README.md). - -If any of these is missing, the corresponding service exits at startup with a message naming -exactly what to provide. +| **Platform API** — `APIP_CP_ENCRYPTION_KEY`, `APIP_CP_AUTH_JWT_SECRET_KEY` | Generated into `api-platform.env` | Manage as real secrets; prefer mounting files and referencing them with `{{ file "..." }}` in the config TOML | +| **Platform API** — admin credentials | Generated into `api-platform.env` (bcrypt hash); password printed once | Use OIDC (`auth.idp`) instead of file-based auth | +| **TLS certificates (both services)** | One self-signed pair in `resources/certificates/`, shared by both services | Certificates from your CA (same file names), or terminate TLS at an ingress and disable the listeners' TLS | +| **Upstream trust (BFF → Platform API)** | The generated shared cert is mounted as a CA bundle (`[platform_api] ca_file`) | Point `ca_file` at your CA bundle; never use `tls_skip_verify` in production | +| **Auth** | File-based (basic) auth with the generated admin user | OIDC — follow [Testing with an IDP locally](#testing-with-an-idp-locally) and [production/README.md](production/README.md) | diff --git a/portals/ai-workspace/bff/internal/config/config.go b/portals/ai-workspace/bff/internal/config/config.go index ad22fbfb01..7a7da9d43a 100644 --- a/portals/ai-workspace/bff/internal/config/config.go +++ b/portals/ai-workspace/bff/internal/config/config.go @@ -25,8 +25,8 @@ package config import ( "fmt" + "log/slog" "net/url" - "os" "strings" "time" ) @@ -61,11 +61,6 @@ type Config struct { AuthMode string // "basic" | "oidc" — informs the SPA which login UX to show OIDC OIDCConfig - // DemoMode mirrors Platform API's APIP_DEMO_MODE: defaults to true, and an - // explicit "false"/"0" opts into production-grade startup checks (no - // file-based/basic auth, no auto-generated self-signed TLS certificate). - DemoMode bool - // Runtime config surfaced to the SPA (window.__RUNTIME_CONFIG__) RuntimeConfig map[string]string } @@ -99,7 +94,6 @@ type TLSConfig struct { // service-mesh sidecar) terminates TLS and forwards plain HTTP to the BFF; no // certificate is then read, generated, or required. TerminateTLS bool - SelfSigned bool CertFile string KeyFile string } @@ -221,10 +215,6 @@ func Load(path string) (*Config, error) { // Parse typed values up front so a malformed one fails startup instead of // being silently replaced with the default. - selfSigned, err := s.getbool("tls.self_signed", true) - if err != nil { - return nil, err - } tlsEnabled, err := s.getbool("tls.enabled", true) if err != nil { return nil, err @@ -257,11 +247,10 @@ func Load(path string) (*Config, error) { LogFormat: strings.ToLower(s.get("log_format", "text")), TLS: TLSConfig{ TerminateTLS: tlsEnabled, - SelfSigned: selfSigned, - // Convention matches the container's mount path. buildTLS falls back to - // a self-signed cert when these files are absent. - CertFile: s.get("tls.cert_file", "/etc/ai-workspace/tls/tls.crt"), - KeyFile: s.get("tls.key_file", "/etc/ai-workspace/tls/tls.key"), + // Convention matches the container's mount path. A certificate pair is + // required there whenever TerminateTLS is on. + CertFile: s.get("tls.cert_file", "/etc/ai-workspace/tls/cert.pem"), + KeyFile: s.get("tls.key_file", "/etc/ai-workspace/tls/key.pem"), }, PlatformAPI: PlatformAPIConfig{ URL: strings.TrimRight(s.get("platform_api.url", ""), "/"), @@ -282,7 +271,6 @@ func Load(path string) (*Config, error) { }, CSRFHeader: s.get("csrf_header", "X-Requested-By"), AuthMode: authMode, - DemoMode: demoMode(), OIDC: OIDCConfig{ Enabled: authMode == "oidc" || oidcEnabled, Issuer: strings.TrimRight(s.get("oidc.authority", ""), "/"), @@ -334,11 +322,11 @@ func Load(path string) (*Config, error) { return nil, fmt.Errorf("[platform_api] ca_file / tls_skip_verify are set but [platform_api] url is http:// (no TLS on the upstream hop)") } } - // Skipping verification outside demo mode is a security downgrade; require an - // operator to reach it deliberately rather than inheriting it silently. - if u.Scheme == "https" && cfg.PlatformAPI.TLSSkipVerify && !cfg.DemoMode { - return nil, fmt.Errorf("[platform_api] tls_skip_verify = true is not allowed when demo mode is disabled; " + - "trust the upstream certificate with [platform_api] ca_file instead") + // Skipping verification is a security downgrade; say so loudly and point at + // the supported alternative. + if u.Scheme == "https" && cfg.PlatformAPI.TLSSkipVerify { + slog.Warn("[platform_api] tls_skip_verify = true — upstream certificate verification is DISABLED. " + + "Trust the upstream certificate with [platform_api] ca_file instead.") } if cfg.OIDC.Enabled { if cfg.OIDC.Issuer == "" || cfg.OIDC.ClientID == "" || cfg.OIDC.ClientSecret == "" || cfg.OIDC.RedirectURL == "" { @@ -355,25 +343,13 @@ func Load(path string) (*Config, error) { } } - // Outside demo mode, basic (file-based) auth is not allowed — it relies on the - // Platform API's built-in admin/admin credentials and is dev-only. - if !cfg.DemoMode && !cfg.OIDC.Enabled { - return nil, fmt.Errorf("basic (file-based) auth is not allowed when demo mode is disabled; " + + // Basic (file-based) auth is supported for quickstart deployments but is not + // recommended for production; point operators at OIDC. + if !cfg.OIDC.Enabled { + slog.Warn("basic (file-based) auth is enabled — this is not recommended for production; " + "configure OIDC (set auth_mode = \"oidc\" and [oidc] authority, client_id, client_secret, redirect_url)") } cfg.RuntimeConfig = buildRuntimeConfig(cfg, s) return cfg, nil } - -// demoMode reports whether APIP_DEMO_MODE is enabled. Defaults to true when the -// variable is unset; only an explicit "false"/"0" opts out. It is intentionally -// unprefixed: the same variable drives the Platform API, so one value governs the -// whole stack. -func demoMode() bool { - v := strings.ToLower(strings.TrimSpace(os.Getenv("APIP_DEMO_MODE"))) - if v == "" { - return true - } - return v == "true" || v == "1" -} diff --git a/portals/ai-workspace/bff/internal/config/shipped_config_test.go b/portals/ai-workspace/bff/internal/config/shipped_config_test.go index 64f15c09ee..5ed0f5d512 100644 --- a/portals/ai-workspace/bff/internal/config/shipped_config_test.go +++ b/portals/ai-workspace/bff/internal/config/shipped_config_test.go @@ -57,10 +57,16 @@ func TestShippedConfig_QuickstartLoadsWithNoEnv(t *testing.T) { if cfg.PlatformAPI.URL != "https://platform-api:9243" { t.Errorf("PlatformAPI.URL = %q, want the compose hostname", cfg.PlatformAPI.URL) } - // The Platform API generates its own certificate in demo mode, so the upstream hop - // cannot verify it. The https scheme still stands: the hop is encrypted. - if !cfg.PlatformAPI.TLSSkipVerify { - t.Error("PlatformAPI.TLSSkipVerify = false, want true — the quickstart upstream serves a self-signed certificate") + // The quickstart trusts the setup.sh-generated platform-api certificate via + // ca_file (mounted by docker-compose); verification stays on by default. + if cfg.PlatformAPI.TLSSkipVerify { + t.Error("PlatformAPI.TLSSkipVerify = true, want false — the quickstart trusts the upstream via ca_file, not by skipping verification") + } + // docker-compose no longer injects APIP_AIW_PLATFORM_API_CA_FILE — the default + // must be the path the compose file mounts the certificate at. The shared + // self-signed cert.pem is its own CA. + if cfg.PlatformAPI.CAFile != "/etc/ai-workspace/tls/cert.pem" { + t.Errorf("PlatformAPI.CAFile = %q, want the docker-compose mount path %q", cfg.PlatformAPI.CAFile, "/etc/ai-workspace/tls/cert.pem") } } diff --git a/portals/ai-workspace/bff/internal/server/composite_handlers.go b/portals/ai-workspace/bff/internal/server/composite_handlers.go index 601324bc11..f9f642c311 100644 --- a/portals/ai-workspace/bff/internal/server/composite_handlers.go +++ b/portals/ai-workspace/bff/internal/server/composite_handlers.go @@ -135,7 +135,7 @@ func (s *Server) deleteSecretAsync(jwt, handle, apiBase string) { func (s *Server) handleCreateWithSecretCompensation(w http.ResponseWriter, r *http.Request, resourcePath, apiBasePath string) { jwt, ok := s.tokenFromCookie(r) if !ok { - writeErrorJSON(w, http.StatusUnauthorized, "NOT_AUTHENTICATED", "not authenticated") + writeErrorJSON(w, http.StatusUnauthorized, "UNAUTHORIZED", "Invalid or expired credentials.") return } diff --git a/portals/ai-workspace/bff/internal/server/composite_handlers_test.go b/portals/ai-workspace/bff/internal/server/composite_handlers_test.go index 4fb630ad39..9719b1665e 100644 --- a/portals/ai-workspace/bff/internal/server/composite_handlers_test.go +++ b/portals/ai-workspace/bff/internal/server/composite_handlers_test.go @@ -295,7 +295,10 @@ func TestHandleCreateWithSecretCompensation_Unauthenticated(t *testing.T) { } var body map[string]string _ = json.NewDecoder(w.Body).Decode(&body) - if body["error"] != "not authenticated" { - t.Errorf("error = %q, want %q", body["error"], "not authenticated") + if body["code"] != "UNAUTHORIZED" { + t.Errorf("code = %q, want %q", body["code"], "UNAUTHORIZED") + } + if body["message"] != "Invalid or expired credentials." { + t.Errorf("message = %q, want %q", body["message"], "Invalid or expired credentials.") } } diff --git a/portals/ai-workspace/bff/internal/tlsutil/tls.go b/portals/ai-workspace/bff/internal/tlsutil/tls.go index 046302e421..d1ff42b1a9 100644 --- a/portals/ai-workspace/bff/internal/tlsutil/tls.go +++ b/portals/ai-workspace/bff/internal/tlsutil/tls.go @@ -14,60 +14,16 @@ * under the License. */ -// Package tlsutil provides the BFF listener's TLS certificate: a user-mounted -// cert/key when present, otherwise an in-memory self-signed certificate. This -// replaces the openssl step in the legacy entrypoint.sh. +// Package tlsutil provides the BFF listener's TLS certificate, loaded from a +// user-mounted cert/key pair. There is no self-signed fallback — generate a +// pair with the quickstart setup script (or your own tooling) and mount it. package tlsutil import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "math/big" - "net" - "time" ) // CertFromFiles loads a TLS certificate from PEM cert/key files on disk. func CertFromFiles(certFile, keyFile string) (tls.Certificate, error) { return tls.LoadX509KeyPair(certFile, keyFile) } - -// SelfSigned generates an in-memory self-signed certificate for CN=localhost. -// Browsers show a trust warning — mount a real cert to avoid it. -func SelfSigned() (tls.Certificate, error) { - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return tls.Certificate{}, err - } - - serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) - if err != nil { - return tls.Certificate{}, err - } - - tmpl := x509.Certificate{ - SerialNumber: serial, - Subject: pkix.Name{CommonName: "localhost"}, - NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().AddDate(10, 0, 0), - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - DNSNames: []string{"localhost"}, - IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, - } - - der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) - if err != nil { - return tls.Certificate{}, err - } - - return tls.Certificate{ - Certificate: [][]byte{der}, - PrivateKey: key, - }, nil -} diff --git a/portals/ai-workspace/bff/main.go b/portals/ai-workspace/bff/main.go index 5ff12ab85f..d32fc53584 100644 --- a/portals/ai-workspace/bff/main.go +++ b/portals/ai-workspace/bff/main.go @@ -56,12 +56,12 @@ func centerInBanner(s string) string { // the console, matching the gateway controller's startup banner style. It's // purely decorative — the structured "AI Workspace BFF started" slog.Info // line is the source of truth for log parsing. -func printStartedMarker(mode, url string) { +func printStartedMarker(url string) { rule := strings.Repeat("=", bannerWidth) fmt.Print("\n\n" + rule + "\n" + "\n" + - centerInBanner("AI Workspace Started mode="+mode) + "\n\n" + + centerInBanner("AI Workspace Started") + "\n\n" + centerInBanner("Visit "+url) + "\n" + "\n" + rule + "\n" + @@ -120,7 +120,7 @@ func main() { IdleTimeout: 120 * time.Second, } - tlsConfig, err := buildTLS(cfg.TLS, cfg.DemoMode) + tlsConfig, err := buildTLS(cfg.TLS) if err != nil { slog.Error("failed to set up TLS", "err", err) os.Exit(1) @@ -128,20 +128,15 @@ func main() { httpServer.TLSConfig = tlsConfig go func() { - mode := "PRODUCTION" - if cfg.DemoMode { - mode = "DEMO" - } url := portalURL(cfg.Addr, tlsConfig != nil) slog.Info("AI Workspace BFF started", "addr", cfg.Addr, "url", url, - "mode", mode, "auth_mode", cfg.AuthMode, "platform_api", cfg.PlatformAPI.URL, "oidc_enabled", cfg.OIDC.Enabled, ) - printStartedMarker(mode, url) + printStartedMarker(url) var serveErr error if tlsConfig != nil { serveErr = httpServer.ListenAndServeTLS("", "") @@ -167,20 +162,15 @@ func main() { } // buildTLS returns the listener TLS config, or nil for plain HTTP. When TLS is -// disabled no certificate is read, generated, or required. Otherwise the priority -// is: mounted cert/key files (when both exist), then in-memory self-signed. In -// demo mode a missing mounted cert is not fatal — it falls back to a self-signed -// cert; outside demo mode an operator-provided cert is required. -func buildTLS(c config.TLSConfig, demoMode bool) (*tls.Config, error) { +// disabled no certificate is read or required. Otherwise a mounted cert/key pair +// is required — there is no self-signed fallback; use the quickstart setup +// script (or your own tooling) to generate a pair and mount it. +func buildTLS(c config.TLSConfig) (*tls.Config, error) { if !c.TerminateTLS { // Plain HTTP is only safe when something upstream terminates TLS. - if !demoMode { - slog.Warn("TLS: disabled ([tls] enabled = false) while demo mode is disabled — " + - "serving plain HTTP. Terminate TLS at an ingress or service-mesh sidecar and " + - "never expose this listener directly to untrusted networks.") - } else { - slog.Info("TLS: disabled ([tls] enabled = false) — serving plain HTTP") - } + slog.Warn("TLS: disabled ([tls] enabled = false) — serving plain HTTP. " + + "Terminate TLS at an ingress or service-mesh sidecar and " + + "never expose this listener directly to untrusted networks.") return nil, nil } // A partial mount (exactly one of cert/key present) is a misconfiguration, not @@ -188,31 +178,17 @@ func buildTLS(c config.TLSConfig, demoMode bool) (*tls.Config, error) { if fileExists(c.CertFile) != fileExists(c.KeyFile) { return nil, fmt.Errorf("incomplete TLS mount: exactly one of cert (%q) and key (%q) is present", c.CertFile, c.KeyFile) } - if fileExists(c.CertFile) && fileExists(c.KeyFile) { - cert, err := tlsutil.CertFromFiles(c.CertFile, c.KeyFile) - if err != nil { - return nil, err - } - slog.Info("TLS: using mounted certificate", "cert", c.CertFile) - return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, nil - } - // No mounted cert. Auto-generating a self-signed certificate is a dev-only - // convenience — outside demo mode, require the operator to mount a real cert. - if !demoMode { - return nil, fmt.Errorf("disabling demo mode requires a mounted TLS certificate: "+ + if !fileExists(c.CertFile) { + return nil, fmt.Errorf("TLS is enabled but no certificate is mounted: "+ "set [tls] cert_file (%q) and key_file (%q) to existing files, "+ - "or set [tls] enabled = false to serve plain HTTP behind a TLS-terminating proxy. "+ - "Self-signed certificates are only auto-generated in demo mode", c.CertFile, c.KeyFile) + "or set [tls] enabled = false to serve plain HTTP behind a TLS-terminating proxy", c.CertFile, c.KeyFile) } - if c.SelfSigned { - cert, err := tlsutil.SelfSigned() - if err != nil { - return nil, err - } - slog.Warn("TLS: using in-memory self-signed certificate (browsers will warn)") - return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, nil + cert, err := tlsutil.CertFromFiles(c.CertFile, c.KeyFile) + if err != nil { + return nil, err } - return nil, nil + slog.Info("TLS: using mounted certificate", "cert", c.CertFile) + return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, nil } func fileExists(p string) bool { diff --git a/portals/ai-workspace/configs/config-platform-api.toml b/portals/ai-workspace/configs/config-platform-api.toml index 5115279ebe..449e8dd3f5 100644 --- a/portals/ai-workspace/configs/config-platform-api.toml +++ b/portals/ai-workspace/configs/config-platform-api.toml @@ -6,77 +6,41 @@ # in compliance with the License. You may obtain a copy of the # License at http://www.apache.org/licenses/LICENSE-2.0 # -------------------------------------------------------------------- -# -# Platform API configuration — quickstart (file-based auth) -# -# This file is read by the Platform API (Go binary). -# -# Default login: username = admin, password = admin -# Change the password hash before deploying to a shared environment. -# Generate a new hash: htpasswd -bnBC 12 "" | tr -d ':\n' -# -------------------------------------------------------------------- +# Platform API configuration — quickstart (file-based auth). See README.md. -# --------------------------------------------------------------------------- -# Server -# --------------------------------------------------------------------------- -log_level = "INFO" # DEBUG | INFO | WARN | ERROR -port = "9243" +log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' # DEBUG | INFO | WARN | ERROR +enable_scope_validation = '{{ env "APIP_CP_ENABLE_SCOPE_VALIDATION" "true" }}' -# Validate OAuth2 scopes on incoming requests. -enable_scope_validation = true +encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' # encryption_key = '{{ file "/secrets/platform-api/encryption_key" }}' -# --------------------------------------------------------------------------- -# Encryption -# --------------------------------------------------------------------------- -# Single 32-byte key (64 hex chars or base64) used for ALL at-rest encryption -# (secrets, subscription tokens, WebSub HMAC secrets) -encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' +[https] +enabled = '{{ env "APIP_CP_HTTPS_ENABLED" "true" }}' +port = '{{ env "APIP_CP_HTTPS_PORT" "9243" }}' +cert_dir = "/etc/platform-api/tls" -# --------------------------------------------------------------------------- -# Database -# --------------------------------------------------------------------------- [database] -driver = "sqlite3" # "sqlite3" or "postgres" +driver = '{{ env "APIP_CP_DATABASE_DRIVER" "sqlite3" }}' # "sqlite3" or "postgres" path = "/app/data/api_platform.db" # SQLite file path (ignored for postgres) -# --------------------------------------------------------------------------- -# Authentication -# --------------------------------------------------------------------------- -# JWT (local HMAC) — issues signed tokens after file-based login. [auth.jwt] -enabled = true -issuer = "platform-api" -secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' -skip_validation = false # verify JWT signature +enabled = '{{ env "APIP_CP_AUTH_JWT_ENABLED" "true" }}' +issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' +secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' # secret_key = '{{ file "/secrets/platform-api/jwt_secret" }}' +skip_validation = '{{ env "APIP_CP_AUTH_JWT_SKIP_VALIDATION" "false" }}' # verify JWT signature -# IDP (JWKS-based) — disabled in quickstart mode. -# Enable and configure when using Asgardeo, Keycloak, Auth0, etc. [auth.idp] -enabled = false +enabled = '{{ env "APIP_CP_AUTH_IDP_ENABLED" "false" }}' -# File-based auth — local username/password login. [auth.file_based] -enabled = true +enabled = '{{ env "APIP_CP_AUTH_FILE_BASED_ENABLED" "true" }}' [auth.file_based.organization] -id = "default" -display_name = "Default" -region = "us" +id = '{{ env "APIP_CP_AUTH_FILE_BASED_ORGANIZATION_ID" "default" }}' +display_name = '{{ env "APIP_CP_AUTH_FILE_BASED_ORGANIZATION_DISPLAY_NAME" "Default" }}' +region = '{{ env "APIP_CP_AUTH_FILE_BASED_ORGANIZATION_REGION" "us" }}' [[auth.file_based.users]] -username = "admin" -password_hash = "$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ." -scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:api_key:read ap:secret:manage" - -# --------------------------------------------------------------------------- -# TLS -# --------------------------------------------------------------------------- -[tls] -cert_dir = "/app/data/certs" - -# --------------------------------------------------------------------------- -# DevPortal integration — disable for standalone deployments -# --------------------------------------------------------------------------- -[default_devportal] -enabled = false +username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' +password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' +scopes = '{{ env "APIP_CP_ADMIN_SCOPES" "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:api_key:read ap:secret:manage" }}' diff --git a/portals/ai-workspace/configs/config-template.toml b/portals/ai-workspace/configs/config-template.toml index 2d39c5a1ce..01fec40b8e 100644 --- a/portals/ai-workspace/configs/config-template.toml +++ b/portals/ai-workspace/configs/config-template.toml @@ -116,10 +116,10 @@ static_dir = '{{ env "APIP_AIW_STATIC_DIR" "/app" }}' # Same-origin prefix the SPA calls; it is stripped before forwarding upstream. The # browser's API base URL is derived from this, so the browser only ever talks to the # app origin and never hits the platform-api certificate directly. -# proxy_prefix = '{{ env "APIP_AIW_PROXY_PREFIX" "/api/proxy" }}' +proxy_prefix = '{{ env "APIP_AIW_PROXY_PREFIX" "/api/proxy" }}' # Custom header required on state-mutating requests (CSRF protection). -# csrf_header = '{{ env "APIP_AIW_CSRF_HEADER" "X-Requested-By" }}' +csrf_header = '{{ env "APIP_AIW_CSRF_HEADER" "X-Requested-By" }}' log_level = '{{ env "APIP_AIW_LOG_LEVEL" "info" }}' # debug | info | warn | error log_format = '{{ env "APIP_AIW_LOG_FORMAT" "text" }}' # text | json @@ -140,8 +140,8 @@ login_path = '{{ env "APIP_AIW_PLATFORM_API_LOGIN_PATH" "/api/portal/v0.9/auth/l # --- Trust for the upstream's TLS certificate --- -# Accept the Platform API's self-signed certificate (dev/quickstart only). Rejected -# when APIP_DEMO_MODE=false — trust the certificate with ca_file instead. +# Accept the Platform API's self-signed certificate without verification (local +# development only) — prefer trusting the certificate with ca_file instead. tls_skip_verify = '{{ env "APIP_AIW_PLATFORM_API_TLS_SKIP_VERIFY" "false" }}' # PEM bundle to trust for the upstream's TLS certificate. Appended to the system @@ -159,13 +159,10 @@ ca_file = '{{ env "APIP_AIW_PLATFORM_API_CA_FILE" "" }}' # read, generated, or required. enabled = '{{ env "APIP_AIW_TLS_ENABLED" "true" }}' -# Use a self-signed certificate when no cert/key files are mounted. -# Ignored when enabled = false. Never reached when APIP_DEMO_MODE=false. -self_signed = '{{ env "APIP_AIW_TLS_SELF_SIGNED" "true" }}' - -# Paths to a mounted TLS certificate and key (used instead of self-signed). -cert_file = '{{ env "APIP_AIW_TLS_CERT_FILE" "/etc/ai-workspace/tls/tls.crt" }}' -key_file = '{{ env "APIP_AIW_TLS_KEY_FILE" "/etc/ai-workspace/tls/tls.key" }}' +# Paths to a mounted TLS certificate and key. REQUIRED when enabled = true — +# there is no self-signed fallback; setup.sh generates a pair for the quickstart. +cert_file = '{{ env "APIP_AIW_TLS_CERT_FILE" "/etc/ai-workspace/tls/cert.pem" }}' +key_file = '{{ env "APIP_AIW_TLS_KEY_FILE" "/etc/ai-workspace/tls/key.pem" }}' # --------------------------------------------------------------------------- @@ -277,11 +274,3 @@ role_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ROLE_CLAIM_NAME" # read from. Replaces (not extends) the defaults # /etc/ai-workspace and /secrets/ai-workspace. Shared by # every component in the platform. -# -# APIP_DEMO_MODE "true" (default) relaxes startup checks for quickstart: -# basic auth is allowed and a self-signed TLS certificate -# is generated when none is mounted. Set "false" for -# production, which then requires OIDC, plus either a -# mounted certificate or [tls] enabled = false above. -# The same variable drives the Platform API, so one value -# governs the whole stack. diff --git a/portals/ai-workspace/configs/config.toml b/portals/ai-workspace/configs/config.toml index 57e878af5d..61f738fa75 100644 --- a/portals/ai-workspace/configs/config.toml +++ b/portals/ai-workspace/configs/config.toml @@ -6,92 +6,29 @@ # in compliance with the License. You may obtain a copy of the # License at http://www.apache.org/licenses/LICENSE-2.0 # -------------------------------------------------------------------- -# -# AI Workspace configuration (quickstart) -# -# This file is the only source of configuration for the AI Workspace — both the BFF and -# the SPA, which is served the browser-safe subset of these keys at startup. The -# container reads it from its mount, /etc/ai-workspace/config.toml; `make bff-run` reads -# this same file (bff -config ../configs/config.toml). Keys are grouped into TOML tables -# ([platform_api], [oidc], ...). -# -# Each value below is written as an interpolation token that is resolved at startup: -# -# key = '{{ env "APIP_AIW_KEY" "default" }}' -# └── environment variable └── value used when the variable is unset -# -# So a deployment can set any of these keys from the environment without editing this -# file. The token is what reads the environment — a key written as a plain literal, or -# absent from the file, is not settable that way. By convention the variable is the -# key's table path uppercased with underscores: [platform_api] url is -# APIP_AIW_PLATFORM_API_URL. -# -# To source a value from a mounted file instead — the right choice for secrets — swap -# the token: -# -# key = '{{ file "/secrets/ai-workspace/key" }}' -# -# Never write a secret as a raw literal in this file. -# -# Only the quickstart keys are here. See configs/config-template.toml for the full -# set — TLS, session/cookie, claim mappings — with their defaults. -# -------------------------------------------------------------------- +# AI Workspace configuration (quickstart). See README.md → "Configuration". +# See configs/config-template.toml for the full set of available settings. -# UI auth mode: "basic" uses the Platform API's file-based login (quickstart); -# "oidc" delegates login to an external OIDC-compliant IDP (production). auth_mode = '{{ env "APIP_AIW_AUTH_MODE" "basic" }}' - -# Domain shown in the browser address bar (host:port or just host for port 80/443). domain = '{{ env "APIP_AIW_DOMAIN" "localhost:5380" }}' - -# Control-plane host — the externally reachable address gateways use to reach the -# Platform API. Shown in gateway setup instructions (keys.env, helm values). controlplane_host = '{{ env "APIP_AIW_CONTROLPLANE_HOST" "host.docker.internal:9243" }}' - -# Default region assigned to new organizations on first login. default_org_region = '{{ env "APIP_AIW_DEFAULT_ORG_REGION" "us" }}' - -# Gateway versions shown in the create-gateway version selector (JSON array string). platform_gateway_versions = '{{ env "APIP_AIW_PLATFORM_GATEWAY_VERSIONS" "[{\"version\":\"1.2\",\"latestVersion\":\"v1.2.0-M1\",\"channel\":\"STS\"}]" }}' -# Server. The defaults are the container's: the port it publishes, and the SPA baked -# into the image. `make bff-run` points the variables elsewhere to run the same config -# outside a container (:8081, ../dist). listen_addr = '{{ env "APIP_AIW_LISTEN_ADDR" ":5380" }}' static_dir = '{{ env "APIP_AIW_STATIC_DIR" "/app" }}' log_level = '{{ env "APIP_AIW_LOG_LEVEL" "info" }}' # debug | info | warn | error -# --------------------------------------------------------------------------- -# Upstream Platform API — the server-to-server hop the BFF proxies to. -# --------------------------------------------------------------------------- [platform_api] -# Base URL. In docker compose this is the compose hostname. REQUIRED. Its http/https -# scheme decides whether the upstream hop uses TLS. url = '{{ env "APIP_AIW_PLATFORM_API_URL" "https://platform-api:9243" }}' +ca_file = "/etc/ai-workspace/tls/cert.pem" +tls_skip_verify = '{{ env "APIP_AIW_PLATFORM_API_TLS_SKIP_VERIFY" "false" }}' -# Accept the Platform API's self-signed certificate. True for the quickstart, where the -# Platform API generates its own; rejected when APIP_DEMO_MODE=false — trust the -# certificate with [platform_api] ca_file instead (see config-template.toml). -tls_skip_verify = '{{ env "APIP_AIW_PLATFORM_API_TLS_SKIP_VERIFY" "true" }}' - - -# --------------------------------------------------------------------------- -# OIDC — used only when auth_mode = "oidc" above. -# -# The empty defaults keep basic mode starting with these variables unset; OIDC mode -# then rejects any that are still empty. The BFF is a confidential client, so the -# secret stays server-side — set it in .env (or swap the token for -# '{{ file "/secrets/ai-workspace/oidc_client_secret" }}'), never as a literal here. -# --------------------------------------------------------------------------- -[oidc] -authority = '{{ env "APIP_AIW_OIDC_AUTHORITY" "" }}' -client_id = '{{ env "APIP_AIW_OIDC_CLIENT_ID" "" }}' -client_secret = '{{ env "APIP_AIW_OIDC_CLIENT_SECRET" "" }}' -redirect_url = '{{ env "APIP_AIW_OIDC_REDIRECT_URL" "" }}' -post_logout_redirect_url = '{{ env "APIP_AIW_OIDC_POST_LOGOUT_REDIRECT_URL" "" }}' +[tls] -# Left empty, the full ap:* scope set is requested. -scope = '{{ env "APIP_AIW_OIDC_SCOPE" "" }}' +enabled = '{{ env "APIP_AIW_TLS_ENABLED" "true" }}' +cert_file = "/etc/ai-workspace/tls/cert.pem" +key_file = "/etc/ai-workspace/tls/key.pem" diff --git a/portals/ai-workspace/cypress/e2e/002-mcp-proxies/004-mcp-secret-management-update.cy.js b/portals/ai-workspace/cypress/e2e/002-mcp-proxies/004-mcp-secret-management-update.cy.js index 679f774907..fe46e596eb 100644 --- a/portals/ai-workspace/cypress/e2e/002-mcp-proxies/004-mcp-secret-management-update.cy.js +++ b/portals/ai-workspace/cypress/e2e/002-mcp-proxies/004-mcp-secret-management-update.cy.js @@ -71,6 +71,9 @@ describe('AI Workspace — MCP server secret management (update / policy-save fl cy.intercept('POST', '**/projects').as('setupProject'); cy.intercept('POST', '**/secrets').as('setupSecret'); cy.intercept('POST', /\/mcp-proxies(\?|$)/).as('setupServer'); + // Registered up front so it can't miss the client-side navigation to + // /mcp-proxy/:id right after creation (see usage below). + cy.intercept('GET', /\/mcp-proxies\/[^/?]+(\?|$)/).as('getServerDetails'); cy.contains('Projects', { timeout: 30000 }).should('be.visible').click(); cy.contains('button, a', /Create Project|Add New Project/, { timeout: 30000 }) @@ -133,7 +136,13 @@ describe('AI Workspace — MCP server secret management (update / policy-save fl createdServerId = pi.response.body?.id ?? ''; }); + // The Overview page re-fetches the server on mount and derives the initial + // policy list from it (ExternalServersOverview's mapPolicies effect), which + // itself calls GET **/policies*. Wait for that fetch to settle before + // switching tabs, so it can't race with the test's own '@getPolicies' wait + // below on 'Add Policies'. cy.location('pathname', { timeout: 30000 }).should('match', /\/mcp-proxy\/[^/]+$/); + cy.wait('@getServerDetails', { timeout: 20000 }); cy.contains('[role="tab"]', 'Policies', { timeout: 15000 }).click(); }); @@ -236,6 +245,7 @@ describe('AI Workspace — MCP server secret management (update / no-auth server cy.intercept('POST', '**/projects').as('setupProject2'); cy.intercept('POST', /\/mcp-proxies(\?|$)/).as('setupServer2'); + cy.intercept('GET', /\/mcp-proxies\/[^/?]+(\?|$)/).as('getServerDetails2'); cy.contains('Projects', { timeout: 30000 }).should('be.visible').click(); cy.contains('button, a', /Create Project|Add New Project/, { timeout: 30000 }) @@ -291,6 +301,7 @@ describe('AI Workspace — MCP server secret management (update / no-auth server }); cy.location('pathname', { timeout: 30000 }).should('match', /\/mcp-proxy\/[^/]+$/); + cy.wait('@getServerDetails2', { timeout: 20000 }); cy.contains('[role="tab"]', 'Policies', { timeout: 15000 }).click(); }); diff --git a/portals/ai-workspace/distribution/README.md b/portals/ai-workspace/distribution/README.md index b7e6307579..4bd7313901 100644 --- a/portals/ai-workspace/distribution/README.md +++ b/portals/ai-workspace/distribution/README.md @@ -8,6 +8,8 @@ A standalone distribution of the AI Workspace and Platform API, orchestrated wit wso2apip-ai-workspace-/ ├── README.md ├── docker-compose.yaml # AI Workspace + Platform API +├── scripts/ +│ └── setup.sh # One-time TLS + secrets provisioning ├── configs/ │ ├── config.toml # AI Workspace active configuration │ ├── config-template.toml # AI Workspace full configuration reference @@ -23,23 +25,27 @@ wso2apip-ai-workspace-/ - Docker Engine 24+ - Docker Compose v2 - -No other tools are required to run the stack. +- `openssl`, and either `htpasswd` or Docker (used by `setup.sh` to bcrypt-hash the admin password) ## Quick Start -Generate the two required secret keys and write them to a `.env` file alongside `docker-compose.yaml`: +Run the setup script once, from the distribution root, before the first start: ```bash -echo "AUTH_JWT_SECRET_KEY=$(openssl rand -hex 32)" >> .env -echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env +./scripts/setup.sh +docker compose up -d ``` -> **Important:** keep these values stable — changing them after first start invalidates all existing signed tokens and encrypted data. +`setup.sh` generates everything the stack needs — nothing is auto-generated at runtime: -```bash -docker compose up -d -``` +| Output | Contents | +|---|---| +| `api-platform.env` (git-ignored) | `APIP_CP_ENCRYPTION_KEY` (at-rest encryption), `APIP_CP_AUTH_JWT_SECRET_KEY` (signs login JWTs), `APIP_CP_ADMIN_USERNAME`, `APIP_CP_ADMIN_PASSWORD_HASH` (bcrypt) | +| `resources/certificates/cert.pem` + `key.pem` | Self-signed TLS pair shared by both services (SAN: `localhost`, `platform-api`, `ai-workspace`) | + +The admin password is generated and printed once by `setup.sh` — it is not stored anywhere; only its bcrypt hash lands in `api-platform.env`. Re-running `setup.sh` keeps existing files; pass `--force` to rotate keys and credentials, or `--certs-only` to (re)generate just the TLS pair. `ADMIN_USERNAME` / `ADMIN_PASSWORD` environment variables skip the interactive prompts (used by CI to pin known test credentials). + +For production, prefer mounting secret files and referencing them from the config TOMLs with `{{ file "..." }}` instead of `api-platform.env` — see [Configuration](#configuration) below. Verify both services are healthy: @@ -48,7 +54,7 @@ curl -fk https://localhost:9243/health # Platform API curl -fk https://localhost:5380/healthz # AI Workspace ``` -Open the AI Workspace in a browser at `https://localhost:5380` and log in with the default credentials: **admin / admin**. +Open the AI Workspace in a browser at `https://localhost:5380` and log in with the admin credentials printed by `setup.sh`. > **Browser trust warning?** Both services use a self-signed TLS certificate by default. Click **Advanced → Proceed** to continue. See [Custom TLS Certificates](#custom-tls-certificates) to remove the warning permanently. @@ -61,6 +67,12 @@ Open the AI Workspace in a browser at `https://localhost:5380` and log in with t ## Configuration +Edit `configs/config.toml` for AI Workspace settings and `configs/config-platform-api.toml` for Platform API settings. Both are read directly by the running containers — no rebuild required, just restart the affected service. + +Each config TOML writes its values as `'{{ env "..." }}'` tokens, so a key can be set from the environment without editing the file — the token names the variable, by convention the key uppercased and prefixed with `APIP_AIW_` (AI Workspace) or `APIP_CP_` (Platform API), e.g. `APIP_AIW_LOG_LEVEL`, `APIP_CP_DATABASE_HOST`. A key with no token is not settable from the environment: uncomment or add it in the TOML first. To source a value from a mounted file instead — the right choice for secrets — swap the token for `'{{ file "/secrets/..." }}'`. Never write a secret as a raw literal in either file. + +Environment overrides go in `api-platform.env` (git-ignored; loaded into both containers via `env_file`, format `raw`, since the bcrypt password hash contains `$`, which must not be treated as a compose interpolation variable). This is also where OIDC mode's `APIP_AIW_OIDC_CLIENT_SECRET` belongs — it's the only file compose passes into the containers, so a separate `.env` alongside it would never reach the app. + ### AI Workspace (`configs/config.toml`) | Setting | Description | Default | @@ -69,85 +81,66 @@ Open the AI Workspace in a browser at `https://localhost:5380` and log in with t | `auth_mode` | `basic` (file-based quickstart) or `oidc` (external IDP) | `basic` | | `controlplane_host` | Address gateways use to reach the Platform API | `host.docker.internal:9243` | | `platform_gateway_versions` | Gateway versions shown in the create-gateway selector | _(current release)_ | +| `[platform_api].url` | Base URL of the upstream Platform API hop | `https://platform-api:9243` | +| `[platform_api].ca_file` | PEM bundle trusted for the upstream's TLS cert (appended to system roots). Fixed to the mounted path — not env-overridable; edit the TOML if you change the volume mount in `docker-compose.yaml` | `/etc/ai-workspace/tls/cert.pem` | +| `[platform_api].tls_skip_verify` | Skip upstream cert verification — local dev only | `false` | +| `[tls].cert_file` / `key_file` | Listener certificate pair — required when `[tls].enabled` is `true`. Fixed to the mounted path, same as `ca_file` above | `/etc/ai-workspace/tls/{cert,key}.pem` | +| `[oidc].*` | Used only when `auth_mode = "oidc"` — see [OIDC](#oidc-production) below | _(empty)_ | ### Platform API (`configs/config-platform-api.toml`) | Setting | Description | Default | |---------|-------------|---------| | `log_level` | Log level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | `INFO` | -| `database.driver` | `sqlite3` or `postgres` | `sqlite3` | -| `auth.file_based.users` | Local user credentials (change the password hash before sharing) | `admin` / `admin` | +| `encryption_key` | Single 32-byte key (64 hex chars or base64) used for all at-rest encryption (secrets, subscription tokens, WebSub HMAC secrets). Generate with `openssl rand -hex 32` | _(from `setup.sh`)_ | +| `[database].driver` | `sqlite3` or `postgres` | `sqlite3` | +| `[auth.jwt].secret_key` | 32-byte HMAC key signing login JWTs | _(from `setup.sh`)_ | +| `[auth.idp]` | JWKS-based IDP auth — disabled in quickstart mode; enable for Asgardeo, Keycloak, Auth0, etc. | disabled | +| `[auth.file_based.users]` | Local user credentials (change the password hash before sharing) | admin, generated by `setup.sh` | +| `[https]` | Listener on `:9243`; `cert_dir` holds `cert.pem`/`key.pem` | `/etc/platform-api/tls` | See `configs/config-template.toml` and `configs/config-platform-api-template.toml` for a fully-commented reference of every available setting. -## Required Secrets - -Two secrets must be set before starting the stack. Pass them via environment variables or a `.env` file placed alongside `docker-compose.yaml`: - -| Variable | Purpose | Generate with | -|----------|---------|---------------| -| `AUTH_JWT_SECRET_KEY` | Signs login JWTs | `openssl rand -hex 32` | -| `ENCRYPTION_KEY` | Encrypts secrets, subscription tokens, and HMAC keys at rest | `openssl rand -hex 32` | - -Both must be **stable** — rotating them invalidates all existing signed tokens and encrypted data. - -## Demo Mode vs. Production - -The stack ships in **demo mode** (`APIP_DEMO_MODE=true` by default), which enables zero-config startup with file-based auth and self-signed TLS. Set `APIP_DEMO_MODE=false` in your `.env` to enable production-grade startup checks: - -| Check | Demo mode (default) | Production (`false`) | -|-------|--------------------|-----------------------| -| Authentication | File-based (`admin` / `admin`) allowed | OIDC required — basic auth rejected | -| Inbound TLS | Self-signed cert auto-generated | Real cert/key must be mounted | -| Upstream TLS | Skip-verify allowed | Skip-verify rejected — CA bundle required | - -When `APIP_DEMO_MODE=false`, any missing requirement causes the affected service to exit immediately with a message naming what to provide. - ## Authentication Modes ### File-based (default) -No setup required. The default `admin` / `admin` credentials are defined in `configs/config-platform-api.toml`. To change the password, generate a new bcrypt hash: +The admin user is generated by `setup.sh` (see [Quick Start](#quick-start)). To set your own password instead, generate a new bcrypt hash: ```bash -htpasswd -bnBC 12 "" NEW_PASSWORD | tr -d ':\n' +htpasswd -bnBC 10 "" NEW_PASSWORD | tr -d ':\n' ``` Replace the `password_hash` value in `configs/config-platform-api.toml` before starting. ### OIDC (production) -To delegate login to an external OIDC-compliant provider (Asgardeo, Keycloak, Auth0, etc.): +To delegate login to an external OIDC-compliant provider (Asgardeo, Keycloak, Auth0, etc.) instead of file-based auth, both services need to be reconfigured — the AI Workspace to send users to the IDP, and the Platform API to trust the tokens it issues. -1. Register a **confidential** OIDC application in your IDP with redirect URL `https:///api/auth/callback` (use `https://localhost:5380/api/auth/callback` for local development) and enable the **Authorization Code** and **Refresh Token** grants. -2. Uncomment the `OIDC` environment variable blocks on **both** services in `docker-compose.yaml`. -3. Add your IDP credentials to `.env`: +1. Register a **confidential** OIDC application in your IDP with redirect URL `https:///api/auth/callback` (use `https://localhost:5380/api/auth/callback` for local development), a post-logout redirect URL, and enable the **Authorization Code** and **Refresh Token** grants. +2. **AI Workspace** (`configs/config.toml`): set `auth_mode = "oidc"`. Every `[oidc]` key except `scope` defaults to empty and the server refuses to start in OIDC mode until each is set — either directly in the TOML or via its `APIP_AIW_OIDC_*` token in `api-platform.env`: -```bash -OIDC_ISSUER=https://idp.example.com/oauth2/token -OIDC_JWKS_URL=https://idp.example.com/oauth2/jwks -OIDC_CLIENT_ID= -OIDC_CLIENT_SECRET= -``` + ```bash + APIP_AIW_OIDC_AUTHORITY=https://idp.example.com + APIP_AIW_OIDC_CLIENT_ID= + APIP_AIW_OIDC_CLIENT_SECRET= + APIP_AIW_OIDC_REDIRECT_URL=https:///api/auth/callback + APIP_AIW_OIDC_POST_LOGOUT_REDIRECT_URL=https:///login + ``` -See the [WSO2 API Platform documentation](https://wso2.com/api-platform/docs/) (AI Workspace section) for a full OIDC setup walkthrough including Asgardeo scope registration. + Leaving `APIP_AIW_OIDC_SCOPE` unset requests the full `ap:*` scope set. -## Custom TLS Certificates +3. **Platform API** (`configs/config-platform-api.toml`): the `[auth.idp]` fields have no env-var tokens in the quickstart file, so edit the TOML directly — set `enabled = true` and fill in `jwks_url` and `issuer` for your IDP. Then set `[auth.file_based].enabled = false`: while file-based auth is enabled it takes priority, and the IDP is not used regardless of `[auth.idp]`. Align `[auth.idp.claim_mappings]` with `[oidc.claim_mappings]` in `configs/config.toml` — both services must read the same claims out of the same token. -Mount your own certificate to remove the browser trust warning: +See `configs/config-template.toml` and `configs/config-platform-api-template.toml` for the full, per-field reference, and the [WSO2 API Platform documentation](https://wso2.com/api-platform/docs/) (AI Workspace section) for a full OIDC setup walkthrough including Asgardeo scope registration. -1. Create a `certs/` directory next to `docker-compose.yaml` and place your PEM files there: +## Custom TLS Certificates - ``` - certs/ - ├── ai-workspace.crt # PEM certificate (or full chain) - ├── ai-workspace.key # PEM private key - ├── platform-api.crt - └── platform-api.key - ``` +`resources/certificates/` holds the TLS pair shared by both services — `cert.pem` (certificate or full chain) and `key.pem` (private key), generated by `setup.sh`. This one directory is mounted read-only into both containers at their `/etc//tls` path. To remove the browser trust warning, replace both files with a certificate from your own CA (same file names) whose SAN list covers all three hostnames (`localhost`, `platform-api`, `ai-workspace`), then restart: -2. Uncomment the TLS volume lines in `docker-compose.yaml` under each service. -3. Restart: `docker compose up -d` +```bash +docker compose up -d +``` ## Database @@ -160,7 +153,8 @@ host = "your-db-host" port = 5432 name = "platform_api" user = "platform_api" -# password via DATABASE_PASSWORD env var +password = '{{ file "/secrets/platform-api/postgres_password" }}' +ssl_mode = "disable" ``` The `resources/platform-api/db-scripts/` directory contains the schema scripts (`schema.postgres.sql`, `schema.sqlite.sql`, `schema.sqlserver.sql`). The Platform API applies the appropriate schema automatically on startup — no manual SQL execution is required. diff --git a/portals/ai-workspace/docker-compose.yaml b/portals/ai-workspace/docker-compose.yaml index ea147326c2..de0c628dd8 100644 --- a/portals/ai-workspace/docker-compose.yaml +++ b/portals/ai-workspace/docker-compose.yaml @@ -6,94 +6,23 @@ # in compliance with the License. You may obtain a copy of the # License at http://www.apache.org/licenses/LICENSE-2.0 # -------------------------------------------------------------------- -# -# AI Workspace + Platform API — standalone release compose file. -# -# Configuration: -# Edit configs/config.toml for AI Workspace settings. -# Edit configs/config-platform-api.toml for Platform API settings. -# -# Each config TOML writes its values as '{{ env "..." }}' tokens, so a key can be set -# from the environment without editing the file — the token names the variable, by -# convention the key uppercased and prefixed with APIP_AIW_ (AI Workspace) or APIP_CP_ -# (Platform API), e.g. APIP_AIW_LOG_LEVEL, APIP_CP_DATABASE_HOST. A key with no token -# is not settable from the environment: uncomment or add it in the TOML first. -# Secrets (REQUIRED — never hardcode them here or in the config TOML): -# Copy .env.example to .env (git-ignored) and fill it in: -# AUTH_JWT_SECRET_KEY=$(openssl rand -hex 32) # signs login JWTs -# ENCRYPTION_KEY=$(openssl rand -hex 32) # encrypts secrets -# APIP_AIW_OIDC_CLIENT_SECRET=... # only in OIDC mode -# -# Each service pulls .env into its container (env_file, below), and each config TOML -# references the values with {{ env "..." }} tokens. For production, prefer mounting -# a secret file and referencing it with {{ file "..." }} instead. -# -# Authentication: -# Out of the box the stack uses file-based auth (login admin / admin) — no -# configuration needed. To delegate to any OIDC provider, uncomment the IDP block -# on the Platform API below and set auth_mode = "oidc" plus the [oidc] keys in -# configs/config.toml. See README.md → "Testing with an IDP locally" (worked -# example: Asgardeo). -# -# Usage: -# docker compose --env-file keys.env up -d -# -# Ports: -# AI Workspace → https://localhost:5380 -# Platform API → https://localhost:9243 -# -# TLS certificates (optional — avoids browser trust warnings): -# Both services fall back to a self-signed certificate when no cert is mounted. -# To use your own certificate, uncomment the volume lines under each service -# and place your files in a certs/ directory next to this file: -# -# certs/ -# ai-workspace.crt ← PEM certificate (or full chain) -# ai-workspace.key ← PEM private key -# platform-api.crt ← PEM certificate (or full chain) -# platform-api.key ← PEM private key -# -------------------------------------------------------------------- +# AI Workspace + Platform API — standalone release compose file. See README.md. services: - # --------------------------------------------------------------------------- - # Platform API (Go HTTPS backend) - # --------------------------------------------------------------------------- platform-api: image: ghcr.io/wso2/api-platform/platform-api:0.11.0 container_name: platform-api restart: unless-stopped command: ["-config", "/etc/platform-api/config-platform-api.toml"] - environment: - - APIP_DEMO_MODE=${APIP_DEMO_MODE:-true} - - APIP_CP_ENCRYPTION_KEY=${APIP_CP_ENCRYPTION_KEY:-} - - APIP_CP_AUTH_JWT_SECRET_KEY=${APIP_CP_AUTH_JWT_SECRET_KEY:-} - # ── OIDC IDP — uncomment to validate tokens against any OIDC provider's - # JWKS instead of file-based auth. Set the OIDC_* values in .env. Claim - # overrides are optional (default: organization / org_name / org_handle). - # - APIP_CP_AUTH_IDP_ENABLED=true - # - APIP_CP_AUTH_JWT_ENABLED=false - # - APIP_CP_AUTH_FILE_BASED_ENABLED=false - # - APIP_CP_AUTH_IDP_JWKS_URL=${OIDC_JWKS_URL:-} - # - APIP_CP_AUTH_IDP_ISSUER=${OIDC_ISSUER:-} - # - APIP_CP_AUTH_IDP_AUDIENCE=${OIDC_AUDIENCE:-} - # - APIP_CP_AUTH_IDP_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME=${OIDC_ORG_ID_CLAIM:-} - # - APIP_CP_AUTH_IDP_CLAIM_MAPPINGS_ORG_NAME_CLAIM_NAME=${OIDC_ORG_NAME_CLAIM:-} - # - APIP_CP_AUTH_IDP_CLAIM_MAPPINGS_ORG_HANDLE_CLAIM_NAME=${OIDC_ORG_HANDLE_CLAIM:-} env_file: - # Supplies AUTH_JWT_SECRET_KEY / ENCRYPTION_KEY to the container, where the - # {{ env "..." }} tokens in config-platform-api.toml resolve them. A compose - # .env alone is not enough: it only substitutes ${...} in this file, it does - # not put the values inside the container. - - path: .env - required: false + - path: api-platform.env + required: true + format: raw volumes: - ./configs/config-platform-api.toml:/etc/platform-api/config-platform-api.toml:ro - platform-api-data:/app/data - # Optional: mount your own TLS certificate to remove the browser trust warning. - # The file names must match exactly (cert.pem / key.pem). - # - ./resources/certs/platform-api.crt:/app/data/certs/cert.pem:ro - # - ./resources/certs/platform-api.key:/app/data/certs/key.pem:ro + - ./resources/certificates:/etc/platform-api/tls:ro ports: - "9243:9243" healthcheck: @@ -103,13 +32,6 @@ services: start_period: 20s retries: 3 - # --------------------------------------------------------------------------- - # AI Workspace (React SPA served by the Go BFF) - # - # The BFF serves the SPA, proxies all browser→backend traffic same-origin, and - # owns authentication. Tokens live in the BFF session (HttpOnly cookie); the - # browser never holds a token. - # --------------------------------------------------------------------------- ai-workspace: image: ghcr.io/wso2/api-platform/ai-workspace:1.0.0-alpha2-SNAPSHOT container_name: ai-workspace @@ -117,36 +39,13 @@ services: depends_on: platform-api: condition: service_healthy - environment: - # These resolve the matching '{{ env "APIP_AIW_..." }}' tokens in - # configs/config.toml. Only deployment-specific values live here — everything - # else belongs in that file. - # - # The BFF talks to the Platform API over the internal compose network, whose - # certificate is self-signed. - - APIP_AIW_PLATFORM_API_URL=https://platform-api:9243 - - APIP_AIW_PLATFORM_API_TLS_SKIP_VERIFY=true - - APIP_DEMO_MODE=${APIP_DEMO_MODE:-true} - # ── OIDC — to delegate login to any OIDC provider, set auth_mode = "oidc" and - # the [oidc] keys in configs/config.toml, and keep the IDP block above in sync. - # The BFF is a confidential client: its secret stays server-side and is never - # sent to the browser. Put it in .env as APIP_AIW_OIDC_CLIENT_SECRET (injected - # below via env_file); config.toml references it with - # [oidc] - # client_secret = '{{ env "APIP_AIW_OIDC_CLIENT_SECRET" }}' - # In production prefer mounting a secret file and swapping that for - # client_secret = '{{ file "/secrets/ai-workspace/oidc_client_secret" }}' env_file: - # Supplies APIP_AIW_OIDC_CLIENT_SECRET to the container. Never hardcode the - # secret in the environment block above. - - path: .env - required: false + - path: api-platform.env + required: true + format: raw volumes: - ./configs/config.toml:/etc/ai-workspace/config.toml:ro - # Optional: mount your own TLS certificate to remove the browser trust warning. - # The file names must match exactly (tls.crt / tls.key). - # - ./certs/ai-workspace.crt:/etc/ai-workspace/tls/tls.crt:ro - # - ./certs/ai-workspace.key:/etc/ai-workspace/tls/tls.key:ro + - ./resources/certificates:/etc/ai-workspace/tls:ro ports: - "5380:5380" healthcheck: diff --git a/portals/ai-workspace/package.json b/portals/ai-workspace/package.json index eb44c0db31..8ebf2a39d8 100644 --- a/portals/ai-workspace/package.json +++ b/portals/ai-workspace/package.json @@ -8,7 +8,7 @@ "start": "vite", "build": "vite build", "preview": "vite preview", - "test:e2e": "CYPRESS_BASE_URL=${CYPRESS_BASE_URL:-https://host.docker.internal:5380} docker run --rm --add-host=host.docker.internal:host-gateway -v \"$(pwd):/e2e\" -w /e2e -e CYPRESS_BASE_URL cypress/included:13.17.0 cypress run --headless --browser electron --config-file cypress.config.js", + "test:e2e": "CYPRESS_BASE_URL=${CYPRESS_BASE_URL:-https://host.docker.internal:5380} CYPRESS_ADMIN_USER=${CYPRESS_ADMIN_USER:-admin} CYPRESS_ADMIN_PASSWORD=${CYPRESS_ADMIN_PASSWORD:-admin} docker run --rm --add-host=host.docker.internal:host-gateway -v \"$(pwd):/e2e\" -w /e2e -e CYPRESS_BASE_URL -e CYPRESS_ADMIN_USER -e CYPRESS_ADMIN_PASSWORD cypress/included:13.17.0 cypress run --headless --browser electron --config-file cypress.config.js", "test:e2e:local": "cypress run --headless --config-file cypress.config.js", "test:e2e:open": "CYPRESS_BASE_URL=${CYPRESS_BASE_URL:-https://localhost:5380} cypress open --e2e --config-file cypress.config.js" }, diff --git a/portals/ai-workspace/production/README.md b/portals/ai-workspace/production/README.md index 3220afc7b9..a13dc1d954 100644 --- a/portals/ai-workspace/production/README.md +++ b/portals/ai-workspace/production/README.md @@ -199,7 +199,7 @@ Mount the secret at that path, e.g. in `docker-compose.yaml`: Resolution fails closed: a missing or unreadable secret file aborts startup rather than yielding an empty credential. `{{ file }}` paths must live under `/etc/ai-workspace` or `/secrets/ai-workspace` (override with `APIP_CONFIG_FILE_SOURCE_ALLOWLIST`). For a simpler local setup, swap the token for -`'{{ env "APIP_AIW_OIDC_CLIENT_SECRET" }}'` and keep the value in a git-ignored `.env`. +`'{{ env "APIP_AIW_OIDC_CLIENT_SECRET" }}'` and keep the value in the git-ignored `api-platform.env`. > `[oidc] redirect_url` must exactly match the authorized redirect URL registered in the IDP > application (section 1.2). The BFF, not the browser, completes the code exchange. @@ -243,13 +243,14 @@ secret that already exists under its own name. For credentials, prefer a mounted --- -## 4. Disable demo mode (`APIP_DEMO_MODE=false`) +## 4. Production requirements -For a production deployment, set `APIP_DEMO_MODE=false` (a single var passed to **both** the -`platform-api` and `ai-workspace` services). This turns on fail-fast startup checks: basic / -file-based auth is rejected (the OIDC setup in sections 1–3 becomes mandatory), the BFF and -Platform API no longer auto-generate self-signed TLS certificates (you must mount your own), -and the Platform API requires a stable `APIP_CP_ENCRYPTION_KEY` and `APIP_CP_AUTH_JWT_SECRET_KEY`. +There is no demo mode: startup checks are always on for **both** the `platform-api` and +`ai-workspace` services and fail fast when a requirement is missing. For production, replace +the quickstart's `setup.sh` outputs with real values: use OIDC (sections 1–3) instead of the +generated file-based admin user, mount TLS certificates from your CA instead of the generated +self-signed pairs, and manage `APIP_CP_ENCRYPTION_KEY` / `APIP_CP_AUTH_JWT_SECRET_KEY` as +stable, real secrets (prefer `{{ file }}` tokens over environment variables). -See [Production hardening (`APIP_DEMO_MODE`)](../README.md#production-hardening-apip_demo_mode) -in the main README for the full checklist of what each service requires. +See [Production hardening](../README.md#production-hardening) in the main README for the +full checklist of what each service requires. diff --git a/portals/ai-workspace/setup.sh b/portals/ai-workspace/setup.sh new file mode 100755 index 0000000000..4d1eec6d43 --- /dev/null +++ b/portals/ai-workspace/setup.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the +# License at http://www.apache.org/licenses/LICENSE-2.0 +# -------------------------------------------------------------------- +# AI Workspace quickstart setup. See README.md → "Quick Start". +set -euo pipefail +cd "$(dirname "$0")" +# Distribution layout: scripts/setup.sh, one level below docker-compose.yaml. +[[ -f docker-compose.yaml ]] || cd .. + +ENV_FILE="api-platform.env" +CERTS_DIR="resources/certificates" +FORCE=false +CERTS_ONLY=false + +for arg in "$@"; do + case "$arg" in + --force) FORCE=true ;; + --certs-only) CERTS_ONLY=true ;; + -h|--help) + cat <<'EOF' +Usage: ./setup.sh [--force] [--certs-only] + + --force regenerate everything (rotates keys and credentials) + --certs-only generate only the TLS certificates (used by `make bff-run`) + +ADMIN_USERNAME / ADMIN_PASSWORD environment variables skip the interactive +prompts and pin the credentials (used by CI). See README.md → "Quick Start". +EOF + exit 0 + ;; + *) echo "unknown option: $arg (try --help)" >&2; exit 2 ;; + esac +done + +command -v openssl >/dev/null 2>&1 || { echo "error: openssl is required" >&2; exit 1; } + +log() { echo "[setup] $*"; } + +gen_cert() { + local san="$1" + if [[ "$FORCE" == false && -f "$CERTS_DIR/cert.pem" && -f "$CERTS_DIR/key.pem" ]]; then + log " - $CERTS_DIR/cert.pem already exists — keeping it" + return + fi + openssl req -x509 -newkey rsa:2048 -sha256 -days 365 -nodes \ + -keyout "$CERTS_DIR/key.pem" -out "$CERTS_DIR/cert.pem" \ + -subj "/O=WSO2 API Platform/CN=localhost" \ + -addext "subjectAltName=$san" >/dev/null 2>&1 + chmod 644 "$CERTS_DIR/key.pem" + log " - self-signed certificate generated at $CERTS_DIR/cert.pem" +} + +log "Provisioning TLS certificate ..." +mkdir -p "$CERTS_DIR" +# Self-signed, so this one cert also doubles as the CA bundle the BFF trusts +# for the upstream platform-api hop; SAN covers both compose hostnames. +gen_cert "DNS:localhost,DNS:platform-api,DNS:ai-workspace,DNS:host.docker.internal,IP:127.0.0.1" + +if [[ "$CERTS_ONLY" == true ]]; then + exit 0 +fi + +if [[ "$FORCE" == false && -f "$ENV_FILE" ]]; then + log "$ENV_FILE already exists — keeping it (rerun with --force to rotate keys and credentials)" + echo + log "Setup complete." + echo + echo " Next step:" + echo " docker compose up" + exit 0 +fi + +# bcrypt isn't in openssl; use htpasswd when available, else the httpd image. +bcrypt_hash() { + local password="$1" + if command -v htpasswd >/dev/null 2>&1; then + printf '%s' "$password" | htpasswd -niB -C 10 "" | cut -d: -f2 | tr -d '\r\n' + elif command -v docker >/dev/null 2>&1; then + printf '%s' "$password" | docker run --rm -i httpd:2.4-alpine htpasswd -niB -C 10 "" | cut -d: -f2 | tr -d '\r\n' + else + echo "error: need either htpasswd (apache2-utils / httpd-tools) or docker to bcrypt-hash the admin password" >&2 + exit 1 + fi +} + +GENERATED_PASSWORD="$(openssl rand -base64 24 | tr -d '/+=' | cut -c1-20)" + +if [[ -z "${ADMIN_USERNAME:-}" && -t 0 ]]; then + read -r -p "Admin username [admin]: " ADMIN_USERNAME +fi +ADMIN_USERNAME="${ADMIN_USERNAME:-admin}" + +if [[ -z "${ADMIN_PASSWORD:-}" && -t 0 ]]; then + read -r -s -p "Admin password [press Enter to generate one]: " ADMIN_PASSWORD + echo +fi +ADMIN_PASSWORD="${ADMIN_PASSWORD:-$GENERATED_PASSWORD}" + +log "Generating secrets into $ENV_FILE ..." +ENCRYPTION_KEY="$(openssl rand -hex 32)" +log " - APIP_CP_ENCRYPTION_KEY generated" +JWT_SECRET_KEY="$(openssl rand -hex 32)" +log " - APIP_CP_AUTH_JWT_SECRET_KEY generated" + +log "Provisioning admin credentials ..." +ADMIN_PASSWORD_HASH="$(bcrypt_hash "$ADMIN_PASSWORD")" +log " - APIP_CP_ADMIN_PASSWORD_HASH generated (bcrypt)" + +umask 177 +cat > "$ENV_FILE" <