From f7781288987bb3dca872649816d3ad9c86872113 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Wed, 15 Jul 2026 09:07:39 +0530 Subject: [PATCH 01/10] Refactor AI Workspace setup and configuration - Remove hardcoded encryption and JWT secret keys from the GitHub Actions workflow. - Introduce a setup script to generate required keys, credentials, and TLS certificates for the AI Workspace and Platform API. - Update Docker Compose files to utilize generated keys and certificates, ensuring no self-signed fallback is used. - Revise documentation to reflect changes in setup and configuration processes, emphasizing the use of generated credentials and certificates. - Remove references to demo mode, enforcing strict production requirements for all configurations. --- .github/workflows/ai-workspace-pr-check.yml | 12 +- distribution/all-in-one/docker-compose.yaml | 20 ++ docs/ai-workspace/configuration.md | 4 +- platform-api/README.md | 12 +- platform-api/config/config.go | 13 +- platform-api/config/config.toml | 6 +- platform-api/config/config_test.go | 7 +- .../internal/integration/harness_test.go | 2 - .../repository/api_deployments_test.go | 1 - platform-api/internal/server/server.go | 190 +++--------------- .../internal/server/server_tls_test.go | 65 +++++- portals/ai-workspace/.gitignore | 2 + portals/ai-workspace/Makefile | 3 + portals/ai-workspace/QUICKSTART.md | 27 ++- portals/ai-workspace/README.md | 79 +++----- .../bff/internal/config/config.go | 48 ++--- .../internal/config/shipped_config_test.go | 8 +- .../ai-workspace/bff/internal/tlsutil/tls.go | 50 +---- portals/ai-workspace/bff/main.go | 62 ++---- .../configs/config-platform-api-template.toml | 140 ++++++------- .../configs/config-platform-api.toml | 12 +- .../ai-workspace/configs/config-template.toml | 19 +- portals/ai-workspace/configs/config.toml | 22 +- portals/ai-workspace/docker-compose.yaml | 118 ++++++----- portals/ai-workspace/package.json | 2 +- portals/ai-workspace/production/README.md | 17 +- portals/ai-workspace/setup.sh | 138 +++++++++++++ .../distribution/docker-compose.yaml | 21 ++ .../docker-compose.platform-api.yaml | 20 ++ portals/developer-portal/docker-compose.yaml | 20 ++ .../docs/introduction/quick-start.md | 4 +- .../it/docker-compose.test.postgres.yaml | 20 ++ .../it/docker-compose.test.yaml | 20 ++ .../docker-compose.sqlite.yaml | 23 +++ .../docker-compose.sqlserver.yaml | 22 ++ tests/integration-e2e/docker-compose.yaml | 22 ++ 36 files changed, 701 insertions(+), 550 deletions(-) create mode 100755 portals/ai-workspace/setup.sh diff --git a/.github/workflows/ai-workspace-pr-check.yml b/.github/workflows/ai-workspace-pr-check.yml index 266effb2fb..4659e187c1 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,11 +55,18 @@ 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 keys.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: 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 # frontend), so this returns only when the API is ready to serve requests. - run: docker compose up -d --wait --wait-timeout 300 + run: docker compose --env-file keys.env up -d --wait --wait-timeout 300 working-directory: portals/ai-workspace - name: Verify API proxy is reachable via host.docker.internal 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/configuration.md b/docs/ai-workspace/configuration.md index 156915de94..a3823dffd1 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. @@ -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"`) diff --git a/platform-api/README.md b/platform-api/README.md index d5c1da1edc..ce3fa4c854 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -235,8 +235,7 @@ The prefix is stripped and the remainder mapped to a config key — e.g. `APIP_C `APIP_CP_DATABASE_HOST` → `database.host`. The variable names in the tables below are shown with the prefix. -Two variables are intentionally **not** prefixed: `APIP_DEMO_MODE` (a standalone runtime -flag) and the shared `APIP_CONFIG_FILE_SOURCE_ALLOWLIST`. The `{{ env "NAME" }}` interpolation +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 @@ -251,14 +250,15 @@ 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. --- #### Local JWT Mode (default) -The server signs and validates HMAC login tokens using `APIP_CP_AUTH_JWT_SECRET_KEY` — a 32-byte key (64 hex chars or base64). Set `APIP_CP_AUTH_JWT_SKIP_VALIDATION=true` only in local development environments where you do not have a token issuer available — all bearer values will be accepted without any signature check. +The server signs and validates HMAC login tokens using `APIP_CP_AUTH_JWT_SECRET_KEY` — a 32-byte key (64 hex chars or base64). `APIP_CP_AUTH_JWT_SKIP_VALIDATION` must remain `false`; the server refuses to start when signature validation is disabled. | Variable | Default | Description | |---|---|---------------------------------------------------------------------| @@ -417,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/platform-api/config/config.go b/platform-api/config/config.go index 748e72e102..6ea58f06ad 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -184,8 +184,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"` @@ -219,7 +218,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"` } @@ -623,6 +622,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 3cb71fc506..bb611d3689 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -101,8 +101,8 @@ scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_poli # 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. # # 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 @@ -140,7 +140,7 @@ scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_poli # --------------------------------------------------------------------------- # CORS # --------------------------------------------------------------------------- -# Required (non-wildcard) when APIP_DEMO_MODE=false. List the exact origins +# Optional; must not contain "*". List the exact origins # (scheme + host + port) that are allowed to make credentialed cross-origin # requests, e.g. the Developer Portal / Admin UI origins. # Override with the CORS_ALLOWED_ORIGINS env var (comma-separated). 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/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 51d1d3c06e..978d3e4e46 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 } @@ -359,7 +347,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() @@ -481,27 +468,23 @@ 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_ALLOWED_ORIGINS. + // CORS middleware); operators must opt in explicitly via CORS_ALLOWED_ORIGINS. A + // wildcard allowlist was already rejected by validateAuthConfig. corsOrigins := cfg.CORS.AllowedOrigins if len(corsOrigins) == 0 { slogger.Warn("CORS_ALLOWED_ORIGINS not set — cross-origin requests are disabled") - } else if slices.Contains(corsOrigins, "*") { - slogger.Warn("CORS_ALLOWED_ORIGINS contains \"*\" — allowing all origins without credentials") } chain = append(chain, gohttpkit.CORSMiddleware(gohttpkit.CORSOptions{ 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, @@ -560,37 +543,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 } @@ -665,110 +630,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}, @@ -811,12 +691,10 @@ func (s *Server) Start(httpCfg config.HTTPListener, httpsCfg config.HTTPSListene return fmt.Errorf("HTTP listener enabled but port is empty (HTTP_PORT)") } // 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) while APIP_DEMO_MODE=false: " + - "terminate TLS at an ingress or service-mesh sidecar and never expose this listener " + - "directly to untrusted networks.") - } + // 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, @@ -857,11 +735,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/.gitignore b/portals/ai-workspace/.gitignore index 0b45664748..5091f625e3 100644 --- a/portals/ai-workspace/.gitignore +++ b/portals/ai-workspace/.gitignore @@ -4,3 +4,5 @@ reports target .env .vscode +keys.env +certs/ diff --git a/portals/ai-workspace/Makefile b/portals/ai-workspace/Makefile index 3235321494..100bb5f94f 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=../certs/ai-workspace.crt \ + APIP_AIW_TLS_KEY_FILE=../certs/ai-workspace.key \ APIP_AIW_LISTEN_ADDR=$(BFF_DEV_ADDR) \ APIP_AIW_STATIC_DIR=../dist \ APIP_AIW_LOG_LEVEL=debug \ diff --git a/portals/ai-workspace/QUICKSTART.md b/portals/ai-workspace/QUICKSTART.md index f380a816dd..6574a45a4a 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 +./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 `keys.env`), and the +TLS certificates for both services (written to `certs/`). -Navigate to **https://localhost:5380** and sign in: +> **Save the printed admin username and password** — the password is shown only +> once and stored nowhere. Rerun `./setup.sh --force` to rotate it. -| Field | Value | -|----------|---------| -| Username | `admin` | -| Password | `admin` | +### 3. Start the stack -> **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. +```bash +docker compose --env-file keys.env up -d +``` + +### 4. Open the workspace + +Navigate to **https://localhost:5380** and sign in with the admin credentials +printed by `setup.sh`. + +> **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..98474d78b2 100644 --- a/portals/ai-workspace/README.md +++ b/portals/ai-workspace/README.md @@ -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 `keys.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. @@ -286,7 +286,7 @@ 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`). +file-based quickstart (the admin user generated by `setup.sh`). > 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 @@ -427,61 +427,42 @@ 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` - ---- - -## Custom TLS certificates (optional) - -Mount your own certificate to remove the browser trust warning. - -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` +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. --- -## Production hardening (`APIP_DEMO_MODE`) - -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. +## Custom TLS certificates -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`: +`setup.sh` generates self-signed certificates into `certs/`. To remove the +browser trust warning, replace them with certificates from your own CA — same +file names: -```bash -# portals/ai-workspace/.env -APIP_DEMO_MODE=false +``` +certs/ +├── ai-workspace.crt +├── ai-workspace.key +├── platform-api.crt +└── platform-api.key ``` -When `APIP_DEMO_MODE=false`, startup is **stricter on both services** and will **fail fast** -rather than run insecurely: +Then restart the stack: `docker compose --env-file keys.env up -d` -| Service | Demo mode (`true`, default) | Production (`false`) | -|---|---|---| -| **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: +## Production hardening -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). +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: -If any of these is missing, the corresponding service exits at startup with a message naming -exactly what to provide. +| Requirement | Quickstart (`setup.sh`) | Production | +|---|---|---| +| **Platform API** — `APIP_CP_ENCRYPTION_KEY`, `APIP_CP_AUTH_JWT_SECRET_KEY` | Generated into `keys.env` | Manage as real secrets; prefer mounting files and referencing them with `{{ file "..." }}` in the config TOML | +| **Platform API** — admin credentials | Generated into `keys.env` (bcrypt hash); password printed once | Use OIDC (`auth.idp`) instead of file-based auth | +| **TLS certificates (both services)** | Self-signed pairs in `certs/` | 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 platform-api 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..5db66784a7 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,9 +247,8 @@ 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. + // 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/tls.crt"), KeyFile: s.get("tls.key_file", "/etc/ai-workspace/tls/tls.key"), }, @@ -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..ea9e34f662 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,10 @@ 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") } } 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-template.toml b/portals/ai-workspace/configs/config-platform-api-template.toml index ba6fcd86a3..c9bcc778ed 100644 --- a/portals/ai-workspace/configs/config-platform-api-template.toml +++ b/portals/ai-workspace/configs/config-platform-api-template.toml @@ -44,7 +44,7 @@ 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,9 +53,9 @@ 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 @@ -76,17 +76,17 @@ driver = "sqlite3" # "sqlite3" or "postgres" 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" +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" # 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 # --------------------------------------------------------------------------- @@ -101,8 +101,8 @@ path = "/app/data/api_platform.db" # 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]; env var is AUTH_SKIP_PATHS (comma-separated). -# [auth] -# skip_paths = ["/health", "/metrics", "/api/portal/v0.9/auth/login"] +[auth] +skip_paths = ["/health", "/metrics", "/api/portal/v0.9/auth/login"] # JWT (local HMAC) — the default for self-hosted deployments. # Disable when delegating auth entirely to an external IDP. @@ -111,39 +111,38 @@ path = "/app/data/api_platform.db" [auth.jwt] enabled = true issuer = "platform-api" -# Signing key. Auto-generated (ephemeral — sessions reset on restart) when -# unset in demo mode; REQUIRED when APIP_DEMO_MODE=false. - secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' -# Skip signature verification. Defaults to true for the zero-config quickstart -# and is rejected outright when APIP_DEMO_MODE=false. NEVER set true in production. -# skip_validation = true +# 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. 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. [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 audiences; empty = skip audience check +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 audiences; 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" +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, and rejected outright when APIP_DEMO_MODE=false. +# setup; not recommended for production — prefer an IDP. # When enabled, IDP is NOT used for Platform API authentication. [auth.file_based] enabled = true @@ -155,22 +154,23 @@ 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 (AUTH_FILE_BASED_USERS) takes a JSON array instead. [[auth.file_based.users]] -username = "admin" -password_hash = "$2a$12$" +# The quickstart resolves these from keys.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 # [[auth.file_based.users]] -# username = "readonly" -# password_hash = "$2a$12$" -# scopes = "ap:organization:read ap:gateway:read ap:rest_api:read ap:llm_provider:read" +username = "readonly" +password_hash = "$2a$12$" +scopes = "ap:organization:read ap:gateway:read ap:rest_api:read ap:llm_provider:read" # --------------------------------------------------------------------------- # TLS @@ -180,22 +180,21 @@ scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:mana # 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 +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). +# Directory where the Platform API reads cert.pem and key.pem. The certificates +# must exist (or TLS must be disabled above) — the server never generates a +# self-signed pair. setup.sh generates one for the quickstart. cert_dir = "/app/data/certs" # --------------------------------------------------------------------------- # 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 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 unset to +# disable cross-origin access. Env var is CORS_ALLOWED_ORIGINS (comma-separated). +[cors] +allowed_origins = ["https://workspace.example.com", "https://devportal.example.com"] # --------------------------------------------------------------------------- # API Key @@ -243,38 +242,25 @@ enable_functionality_type_verification = false # 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 +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_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" +admin_role = "admin" +subscriber_role = "Internal/subscriber" +super_admin_role = "superAdmin" # --------------------------------------------------------------------------- # Environment-only settings (no config file key) # --------------------------------------------------------------------------- # -# 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. diff --git a/portals/ai-workspace/configs/config-platform-api.toml b/portals/ai-workspace/configs/config-platform-api.toml index 3e18658770..ff18d62a36 100644 --- a/portals/ai-workspace/configs/config-platform-api.toml +++ b/portals/ai-workspace/configs/config-platform-api.toml @@ -10,11 +10,11 @@ # Platform API configuration — quickstart (file-based auth) # # This file is read by the Platform API (Go binary). -# Environment variables always override values set here. # -# 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' +# The admin login credentials are generated by setup.sh and land in keys.env +# (APIP_CP_ADMIN_USERNAME / APIP_CP_ADMIN_PASSWORD_HASH); the tokens below read +# them from the environment. setup.sh prints the password once. +# To set your own: htpasswd -bnBC 10 "" | tr -d ':\n' # -------------------------------------------------------------------- # --------------------------------------------------------------------------- @@ -78,8 +78,8 @@ display_name = "Default" region = "us" [[auth.file_based.users]] -username = "admin" -password_hash = "$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ." +username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' +password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' 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..a294cbc0e7 100644 --- a/portals/ai-workspace/configs/config-template.toml +++ b/portals/ai-workspace/configs/config-template.toml @@ -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,11 +159,8 @@ 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). +# 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/tls.crt" }}' key_file = '{{ env "APIP_AIW_TLS_KEY_FILE" "/etc/ai-workspace/tls/tls.key" }}' @@ -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..a435c300a5 100644 --- a/portals/ai-workspace/configs/config.toml +++ b/portals/ai-workspace/configs/config.toml @@ -71,10 +71,24 @@ log_level = '{{ env "APIP_AIW_LOG_LEVEL" "info" }}' # debug | info | warn | # scheme decides whether the upstream hop uses TLS. url = '{{ env "APIP_AIW_PLATFORM_API_URL" "https://platform-api:9243" }}' -# 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" }}' +# PEM bundle to trust for the upstream's TLS certificate — appended to the system +# roots. docker-compose mounts the setup.sh-generated platform-api certificate here. +ca_file = '{{ env "APIP_AIW_PLATFORM_API_CA_FILE" "" }}' + +# Skip upstream certificate verification entirely. Last-resort escape hatch for +# local development only — prefer ca_file above. +tls_skip_verify = '{{ env "APIP_AIW_PLATFORM_API_TLS_SKIP_VERIFY" "false" }}' + + +# --------------------------------------------------------------------------- +# TLS on the AI Workspace listener. A certificate pair is REQUIRED when enabled — +# setup.sh generates one and docker-compose mounts it at the default paths below. +# --------------------------------------------------------------------------- +[tls] + +enabled = '{{ env "APIP_AIW_TLS_ENABLED" "true" }}' +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" }}' # --------------------------------------------------------------------------- diff --git a/portals/ai-workspace/docker-compose.yaml b/portals/ai-workspace/docker-compose.yaml index ea147326c2..6ffb48264c 100644 --- a/portals/ai-workspace/docker-compose.yaml +++ b/portals/ai-workspace/docker-compose.yaml @@ -18,40 +18,46 @@ # 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. +# Setup (REQUIRED — run once before first start): +# ./setup.sh +# +# It generates everything the stack requires — nothing is auto-generated at runtime: +# keys.env APIP_CP_ENCRYPTION_KEY encrypts secrets at rest +# APIP_CP_AUTH_JWT_SECRET_KEY signs login JWTs +# APIP_CP_ADMIN_USERNAME admin login username +# APIP_CP_ADMIN_PASSWORD_HASH bcrypt hash of the admin password +# certs/ TLS pairs for both services (self-signed; replace with your own +# certificates for production — same file names) +# +# The admin password is printed once by setup.sh and stored nowhere. +# For production, prefer mounting secret files and referencing them from the config +# TOML with {{ file "..." }} instead of keys.env. OIDC mode additionally needs +# APIP_AIW_OIDC_CLIENT_SECRET in .env (git-ignored). # # 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 +# Out of the box the stack uses file-based auth with the admin user generated by +# setup.sh. 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: +# ./setup.sh # 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 +# TLS certificates (REQUIRED — generated by setup.sh): +# 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 +# Swap in certificates from your own CA (same file names) to remove the +# browser trust warning. # -------------------------------------------------------------------- services: @@ -64,36 +70,40 @@ services: 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:-} + # ── OIDC IDP — uncomment the environment block 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). + # environment: + # - 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. + # Supplies the setup.sh-generated keys and admin credentials to the container, + # where the {{ env "..." }} tokens in config-platform-api.toml resolve them. + # The compose --env-file flag alone is not enough: it only substitutes ${...} + # in this file, it does not put the values inside the container. + # format: raw — the bcrypt password hash contains `$`, which must not be + # treated as a compose interpolation variable. + - path: keys.env + required: true + format: raw - path: .env required: false 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 + # TLS certificate presented on :9243 — generated by setup.sh; replace the + # files with your own to remove the browser trust warning. The in-container + # file names must match exactly (cert.pem / key.pem). + - ./certs/platform-api.crt:/app/data/certs/cert.pem:ro + - ./certs/platform-api.key:/app/data/certs/key.pem:ro ports: - "9243:9243" healthcheck: @@ -122,11 +132,10 @@ services: # 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. + # The BFF talks to the Platform API over the internal compose network and + # verifies its certificate against the setup.sh-generated cert mounted below. - APIP_AIW_PLATFORM_API_URL=https://platform-api:9243 - - APIP_AIW_PLATFORM_API_TLS_SKIP_VERIFY=true - - APIP_DEMO_MODE=${APIP_DEMO_MODE:-true} + - APIP_AIW_PLATFORM_API_CA_FILE=/etc/ai-workspace/tls/platform-api-ca.crt # ── 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 @@ -143,10 +152,13 @@ services: required: false 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 + # TLS certificate presented on :5380 — generated by setup.sh; replace the + # files with your own to remove the browser trust warning. The in-container + # 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 + # CA bundle the BFF trusts for the upstream platform-api hop. + - ./certs/platform-api.crt:/etc/ai-workspace/tls/platform-api-ca.crt: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..af7fc6e544 100644 --- a/portals/ai-workspace/production/README.md +++ b/portals/ai-workspace/production/README.md @@ -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..7fc0d031d9 --- /dev/null +++ b/portals/ai-workspace/setup.sh @@ -0,0 +1,138 @@ +#!/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. +# +# Generates everything the stack requires before first start — nothing is +# auto-generated at runtime anymore: +# +# keys.env APIP_CP_ENCRYPTION_KEY at-rest encryption key +# APIP_CP_AUTH_JWT_SECRET_KEY JWT signing key +# APIP_CP_ADMIN_USERNAME admin login username +# APIP_CP_ADMIN_PASSWORD_HASH bcrypt hash of the password +# certs/platform-api.crt|key TLS pair for the Platform API (SAN: localhost, platform-api) +# certs/ai-workspace.crt|key TLS pair for the AI Workspace (SAN: localhost, ai-workspace) +# +# The admin password is printed once below and is NOT stored anywhere. +# +# Usage: +# ./setup.sh generate anything that is missing +# ./setup.sh --force regenerate everything (rotates keys and credentials) +# ./setup.sh --certs-only generate only the TLS certificates (used by `make bff-run`) +# +# ADMIN_USERNAME / ADMIN_PASSWORD environment variables override the generated +# credentials (used by CI to pin known test credentials). +# +# Then start the stack: +# docker compose --env-file keys.env up -d +# -------------------------------------------------------------------- +set -euo pipefail +cd "$(dirname "$0")" + +ENV_FILE="keys.env" +CERTS_DIR="certs" +FORCE=false +CERTS_ONLY=false + +for arg in "$@"; do + case "$arg" in + --force) FORCE=true ;; + --certs-only) CERTS_ONLY=true ;; + -h|--help) + sed -n '11,35p' "$0" | sed 's/^# \{0,1\}//' + 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; } + +# --------------------------------------------------------------------------- +# TLS certificates +# --------------------------------------------------------------------------- +gen_cert() { + local name="$1" san="$2" + if [[ "$FORCE" == false && -f "$CERTS_DIR/$name.crt" && -f "$CERTS_DIR/$name.key" ]]; then + echo "✓ $CERTS_DIR/$name.crt already exists — keeping it" + return + fi + openssl req -x509 -newkey rsa:2048 -sha256 -days 365 -nodes \ + -keyout "$CERTS_DIR/$name.key" -out "$CERTS_DIR/$name.crt" \ + -subj "/O=WSO2 API Platform/CN=$name" \ + -addext "subjectAltName=$san" >/dev/null 2>&1 + chmod 600 "$CERTS_DIR/$name.key" + echo "✓ generated $CERTS_DIR/$name.crt / $name.key" +} + +mkdir -p "$CERTS_DIR" +gen_cert "platform-api" "DNS:localhost,DNS:platform-api,DNS:host.docker.internal,IP:127.0.0.1" +gen_cert "ai-workspace" "DNS:localhost,DNS:ai-workspace,DNS:host.docker.internal,IP:127.0.0.1" + +if [[ "$CERTS_ONLY" == true ]]; then + exit 0 +fi + +# --------------------------------------------------------------------------- +# keys.env — secrets and admin credentials +# --------------------------------------------------------------------------- +if [[ "$FORCE" == false && -f "$ENV_FILE" ]]; then + echo "✓ $ENV_FILE already exists — keeping it (rerun with --force to rotate keys and credentials)" + echo + echo "Start the stack with:" + echo " docker compose --env-file $ENV_FILE up -d" + exit 0 +fi + +# bcrypt is not in openssl; use htpasswd when available, otherwise the httpd image. +bcrypt_hash() { + local password="$1" + if command -v htpasswd >/dev/null 2>&1; then + htpasswd -nbB -C 10 "" "$password" | cut -d: -f2 | tr -d '\r\n' + elif command -v docker >/dev/null 2>&1; then + docker run --rm httpd:2.4-alpine htpasswd -nbB -C 10 "" "$password" | 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 +} + +ENCRYPTION_KEY="$(openssl rand -hex 32)" +JWT_SECRET_KEY="$(openssl rand -hex 32)" +ADMIN_USERNAME="${ADMIN_USERNAME:-admin-$(openssl rand -hex 2)}" +ADMIN_PASSWORD="${ADMIN_PASSWORD:-$(openssl rand -base64 24 | tr -d '/+=' | cut -c1-16)}" +ADMIN_PASSWORD_HASH="$(bcrypt_hash "$ADMIN_PASSWORD")" + +umask 177 +cat > "$ENV_FILE" < Date: Wed, 15 Jul 2026 09:50:51 +0530 Subject: [PATCH 02/10] Add Platform API configuration template and update Makefile - Introduce a new configuration template for the Platform API, detailing all configurable keys and their defaults. - Update the Makefile to copy the new configuration template into the distribution directory. - Revise README to reflect the new location of the Platform API configuration reference. --- .../config/config-template.toml | 228 +++++++++++++----- portals/ai-workspace/Makefile | 3 + portals/ai-workspace/README.md | 3 +- 3 files changed, 167 insertions(+), 67 deletions(-) rename portals/ai-workspace/configs/config-platform-api-template.toml => platform-api/config/config-template.toml (52%) diff --git a/portals/ai-workspace/configs/config-platform-api-template.toml b/platform-api/config/config-template.toml similarity index 52% rename from portals/ai-workspace/configs/config-platform-api-template.toml rename to platform-api/config/config-template.toml index c9bcc778ed..095fdc9696 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,17 +33,16 @@ # # 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 @@ -61,8 +62,10 @@ 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 `keys.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 `keys.env`. Files preferred: # encryption_key = '{{ file "/secrets/platform-api/encryption_key" }}' encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' @@ -72,15 +75,18 @@ encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' [database] driver = "sqlite3" # "sqlite3" or "postgres" -# SQLite — ignored when driver = "postgres". Env var is DATABASE_DB_PATH. +# 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 +# PostgreSQL — ignored when driver = "sqlite3"; set driver = "postgres" above to use. host = "localhost" port = 5432 name = "platform_api" user = "platform_api" -password = '{{ file "/secrets/platform-api/postgres_password" }}' +# 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 @@ -88,21 +94,37 @@ 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]; env var is AUTH_SKIP_PATHS (comma-separated). [auth] -skip_paths = ["/health", "/metrics", "/api/portal/v0.9/auth/login"] + +# 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. @@ -118,16 +140,17 @@ secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' 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 +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 audiences; empty = skip audience check +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" +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. @@ -158,7 +181,7 @@ 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 (AUTH_FILE_BASED_USERS) takes a JSON array instead. +# The env-var equivalent (APIP_CP_AUTH_FILE_BASED_USERS) takes a JSON array instead. [[auth.file_based.users]] # The quickstart resolves these from keys.env (generated by setup.sh). username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' @@ -166,35 +189,72 @@ 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$" -scopes = "ap:organization:read ap:gateway:read ap:rest_api:read ap:llm_provider:read" +# username = "readonly" +# password_hash = "$2a$12$" +# scopes = "ap:organization:read ap:gateway:read ap:rest_api:read ap:llm_provider:read" # --------------------------------------------------------------------------- -# TLS +# Listeners (HTTP + HTTPS) # --------------------------------------------------------------------------- -[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 +# The plain-HTTP and TLS listeners are independent — enable either or both, each +# 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. +# +# 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 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 -# Directory where the Platform API reads cert.pem and key.pem. The certificates -# must exist (or TLS must be disabled above) — the server never generates a -# self-signed pair. setup.sh generates one for the quickstart. -cert_dir = "/app/data/certs" +# --------------------------------------------------------------------------- +# Listener timeouts +# --------------------------------------------------------------------------- +# Bound the lifetime of a connection so a slow or idle peer cannot hold one open +# indefinitely (Slowloris). These apply to both listeners, which serve the same +# handler. Values are durations, e.g. "10s", "2m". +# +# 0 disables a timeout (net/http semantics). Disabling read or read_header +# removes the Slowloris protection — only do so behind a proxy that enforces its +# own bounds. Keep `write` generous: it bounds handler execution, and some +# handlers proxy slow upstreams (LLM completions, deployments). +# +# WebSocket routes are unaffected — the deadlines are cleared on upgrade. +# +# 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. Must never contain "*"; leave unset to -# disable cross-origin access. Env var is CORS_ALLOWED_ORIGINS (comma-separated). +# 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 = ["https://workspace.example.com", "https://devportal.example.com"] +allowed_origins = [] # e.g. ["https://workspace.example.com", "https://devportal.example.com"] # --------------------------------------------------------------------------- # API Key @@ -208,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 # --------------------------------------------------------------------------- @@ -236,19 +308,50 @@ enable_version_verification = false # Reject gateways that report an unexpected functionality type. enable_functionality_type_verification = false +# --------------------------------------------------------------------------- +# EventHub — multi-replica HA event delivery +# --------------------------------------------------------------------------- +# 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 + +# --------------------------------------------------------------------------- +# Webhook — control-plane webhook receiver +# --------------------------------------------------------------------------- +# 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 + # --------------------------------------------------------------------------- # DevPortal integration # --------------------------------------------------------------------------- -# Disable for standalone deployments that do not use a DevPortal. +# Enabled by default; disable for standalone deployments that do not use a +# DevPortal. name, identifier, api_url, hostname, and api_key are required +# when enabled = true. [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 +identifier = "default" # unique slug used internally +api_url = "http://localhost:3001" +hostname = "devportal.local" # public hostname shown in setup instructions api_key = "change-me" header_key_name = "x-wso2-api-key" -timeout = 10 # HTTP client timeout in seconds +timeout = 10 # HTTP client timeout in seconds # JWT claim name mappings for DevPortal token validation. role_claim_name = "roles" @@ -259,8 +362,3 @@ organization_claim_name = "organizationID" admin_role = "admin" subscriber_role = "Internal/subscriber" super_admin_role = "superAdmin" - -# --------------------------------------------------------------------------- -# Environment-only settings (no config file key) -# --------------------------------------------------------------------------- -# diff --git a/portals/ai-workspace/Makefile b/portals/ai-workspace/Makefile index 100bb5f94f..b65f5b673b 100644 --- a/portals/ai-workspace/Makefile +++ b/portals/ai-workspace/Makefile @@ -202,8 +202,11 @@ 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/*.sql $(DIST_DIR)/resources/platform-api/db-scripts/ + @cp ../../platform-api/config/config-template.toml $(DIST_DIR)/configs/config-platform-api-template.toml endif @cp docker-compose.yaml $(DIST_DIR)/docker-compose.yaml @sed -i.bak -E \ diff --git a/portals/ai-workspace/README.md b/portals/ai-workspace/README.md index 98474d78b2..82b0261494 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`) | --- @@ -86,7 +86,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/ From eaaebc48cb835c672914dbd08845df162a80afdd Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Wed, 15 Jul 2026 14:20:55 +0530 Subject: [PATCH 03/10] Update environment variable references and file structure for AI Workspace setup - Change references from `keys.env` to `api-platform.env` in various configuration files and documentation to reflect the new environment variable structure. - Update Docker Compose and setup scripts to utilize the new `api-platform.env` for managing secrets and credentials. - Revise paths for TLS certificates to ensure they are correctly referenced in the new directory structure. - Remove the obsolete `.env.example` file and update the `.gitignore` to include `api-platform.env` instead of `keys.env`. --- .github/workflows/ai-workspace-pr-check.yml | 4 +- .../authentication/asgardeo-setup.md | 2 +- docs/ai-workspace/authentication/oidc-auth.md | 2 +- docs/ai-workspace/configuration.md | 4 +- .../features/secrets-management.md | 13 ++- platform-api/config/config-template.toml | 8 +- portals/ai-workspace/.env.example | 35 ------ portals/ai-workspace/.gitignore | 4 +- portals/ai-workspace/Makefile | 13 ++- portals/ai-workspace/QUICKSTART.md | 6 +- portals/ai-workspace/README.md | 68 ++++++----- .../bff/internal/config/config.go | 4 +- .../internal/config/shipped_config_test.go | 6 + .../configs/config-platform-api.toml | 14 ++- .../ai-workspace/configs/config-template.toml | 4 +- portals/ai-workspace/configs/config.toml | 13 ++- portals/ai-workspace/docker-compose.yaml | 105 ++++++----------- portals/ai-workspace/production/README.md | 2 +- portals/ai-workspace/setup.sh | 107 ++++++++++++------ 19 files changed, 206 insertions(+), 208 deletions(-) delete mode 100644 portals/ai-workspace/.env.example diff --git a/.github/workflows/ai-workspace-pr-check.yml b/.github/workflows/ai-workspace-pr-check.yml index 4659e187c1..e5b74ed37c 100644 --- a/.github/workflows/ai-workspace-pr-check.yml +++ b/.github/workflows/ai-workspace-pr-check.yml @@ -56,7 +56,7 @@ jobs: run: echo "127.0.0.1 host.docker.internal" | sudo tee -a /etc/hosts - name: Generate quickstart keys, credentials and certificates - # setup.sh writes keys.env (encryption/JWT keys + admin credentials) and + # 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 @@ -66,7 +66,7 @@ jobs: # --wait blocks until all healthchecks pass. The ai-workspace healthcheck # now verifies the nginx→platform-api proxy route (not just the static # frontend), so this returns only when the API is ready to serve requests. - run: docker compose --env-file keys.env up -d --wait --wait-timeout 300 + run: docker compose up -d --wait --wait-timeout 300 working-directory: portals/ai-workspace - name: Verify API proxy is reachable via host.docker.internal 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 a3823dffd1..af9befdfa7 100644 --- a/docs/ai-workspace/configuration.md +++ b/docs/ai-workspace/configuration.md @@ -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] @@ -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/config/config-template.toml b/platform-api/config/config-template.toml index 095fdc9696..737b2ada40 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -25,7 +25,7 @@ # 1. Copy this file to config-platform-api.toml. # 2. Generate two 32-byte keys: openssl rand -hex 32 (one for APIP_CP_ENCRYPTION_KEY, # one for APIP_CP_AUTH_JWT_SECRET_KEY). -# 3. Put both in `keys.env` file. +# 3. Put both in `api-platform.env` file. # The encryption_key / secret_key fields below read them via {{ env "..." }} # tokens — never paste raw key values into this file. # 4. Set [auth.file_based] enabled = true and configure users. @@ -65,7 +65,7 @@ llm_template_definitions_path = "./resources/default-llm-provider-templates" # (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 `keys.env`. Files preferred: +# 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" }}' @@ -128,7 +128,7 @@ skip_paths = [ # JWT (local HMAC) — the default for self-hosted deployments. # Disable when delegating auth entirely to an external IDP. -# Resolved from APIP_CP_AUTH_JWT_SECRET_KEY in `keys.env` file; +# Resolved from APIP_CP_AUTH_JWT_SECRET_KEY in `api-platform.env` file; # secret files preferred: secret_key = '{{ file "/secrets/platform-api/jwt_secret" }}' [auth.jwt] enabled = true @@ -183,7 +183,7 @@ uuid = "99089a17-72e0-4dd8-a2f4-c8dfbb085295" # 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]] -# The quickstart resolves these from keys.env (generated by setup.sh). +# 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. 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 5091f625e3..97628a0377 100644 --- a/portals/ai-workspace/.gitignore +++ b/portals/ai-workspace/.gitignore @@ -4,5 +4,5 @@ reports target .env .vscode -keys.env -certs/ +api-platform.env +resources/certificates/ diff --git a/portals/ai-workspace/Makefile b/portals/ai-workspace/Makefile index b65f5b673b..c723e1b14d 100644 --- a/portals/ai-workspace/Makefile +++ b/portals/ai-workspace/Makefile @@ -60,8 +60,8 @@ bff-run: ## Run the BFF locally (proxies to PLATFORM_API_URL; pair with `make ru cd $(BFF_DIR) && \ APIP_AIW_PLATFORM_API_URL=$(PLATFORM_API_URL) \ APIP_AIW_PLATFORM_API_TLS_SKIP_VERIFY=true \ - APIP_AIW_TLS_CERT_FILE=../certs/ai-workspace.crt \ - APIP_AIW_TLS_KEY_FILE=../certs/ai-workspace.key \ + 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 \ @@ -186,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) @@ -208,6 +208,13 @@ else @cp ../../platform-api/internal/database/*.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 @sed -i.bak -E \ -e 's|^([[:space:]]*image:[[:space:]]*.*/ai-workspace):[^[:space:]]*|\1:$(DIST_VERSION)|' \ diff --git a/portals/ai-workspace/QUICKSTART.md b/portals/ai-workspace/QUICKSTART.md index 6574a45a4a..883c9a1d1b 100644 --- a/portals/ai-workspace/QUICKSTART.md +++ b/portals/ai-workspace/QUICKSTART.md @@ -29,8 +29,8 @@ cd wso2apip-ai-workspace-1.0.0-alpha ``` The script generates everything the stack requires: the encryption and JWT -signing keys and the admin login credentials (written to `keys.env`), and the -TLS certificates for both services (written to `certs/`). +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 `./setup.sh --force` to rotate it. @@ -38,7 +38,7 @@ TLS certificates for both services (written to `certs/`). ### 3. Start the stack ```bash -docker compose --env-file keys.env up -d +docker compose up -d ``` ### 4. Open the workspace diff --git a/portals/ai-workspace/README.md b/portals/ai-workspace/README.md index 82b0261494..12ba1117b5 100644 --- a/portals/ai-workspace/README.md +++ b/portals/ai-workspace/README.md @@ -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 — admin credentials generated by `setup.sh` (see `keys.env`) | +| `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. @@ -264,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 @@ -287,10 +295,9 @@ In production, prefer mounting the secret as a file and referencing it with never enters the environment at all. Leave `auth_mode = "basic"` (the default) to keep the file-based quickstart (the admin user generated by `setup.sh`). -> 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. +> 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`) @@ -353,9 +360,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) | @@ -435,19 +442,22 @@ generated. ## Custom TLS certificates -`setup.sh` generates self-signed certificates into `certs/`. To remove the -browser trust warning, replace them with certificates from your own CA — same -file names: +`setup.sh` generates one self-signed certificate into `resources/certificates/`, +shared by both services. To remove the browser trust warning, replace it with a +certificate from your own CA — same file names, and the SAN list must cover both +hostnames (`localhost`, `platform-api`, `ai-workspace`): ``` -certs/ -├── ai-workspace.crt -├── ai-workspace.key -├── platform-api.crt -└── platform-api.key +resources/certificates/ +├── cert.pem +└── key.pem ``` -Then restart the stack: `docker compose --env-file keys.env up -d` +docker-compose mounts this one directory read-only into both containers +(`/etc/platform-api/tls` and `/etc/ai-workspace/tls`), so replacing the two +files is all it takes. + +Then restart the stack: `docker compose up -d` --- @@ -460,8 +470,8 @@ credentials, and self-signed certificates); for production, provide real values: | Requirement | Quickstart (`setup.sh`) | Production | |---|---|---| -| **Platform API** — `APIP_CP_ENCRYPTION_KEY`, `APIP_CP_AUTH_JWT_SECRET_KEY` | Generated into `keys.env` | Manage as real secrets; prefer mounting files and referencing them with `{{ file "..." }}` in the config TOML | -| **Platform API** — admin credentials | Generated into `keys.env` (bcrypt hash); password printed once | Use OIDC (`auth.idp`) instead of file-based auth | -| **TLS certificates (both services)** | Self-signed pairs in `certs/` | 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 platform-api 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 | +| **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 5db66784a7..7a7da9d43a 100644 --- a/portals/ai-workspace/bff/internal/config/config.go +++ b/portals/ai-workspace/bff/internal/config/config.go @@ -249,8 +249,8 @@ func Load(path string) (*Config, error) { TerminateTLS: tlsEnabled, // 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/tls.crt"), - KeyFile: s.get("tls.key_file", "/etc/ai-workspace/tls/tls.key"), + 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", ""), "/"), 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 ea9e34f662..5ed0f5d512 100644 --- a/portals/ai-workspace/bff/internal/config/shipped_config_test.go +++ b/portals/ai-workspace/bff/internal/config/shipped_config_test.go @@ -62,6 +62,12 @@ func TestShippedConfig_QuickstartLoadsWithNoEnv(t *testing.T) { 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") + } } // `make bff-run` loads this same file and points the container-shaped defaults at the diff --git a/portals/ai-workspace/configs/config-platform-api.toml b/portals/ai-workspace/configs/config-platform-api.toml index ff18d62a36..91f135138b 100644 --- a/portals/ai-workspace/configs/config-platform-api.toml +++ b/portals/ai-workspace/configs/config-platform-api.toml @@ -11,7 +11,7 @@ # # This file is read by the Platform API (Go binary). # -# The admin login credentials are generated by setup.sh and land in keys.env +# The admin login credentials are generated by setup.sh and land in api-platform.env # (APIP_CP_ADMIN_USERNAME / APIP_CP_ADMIN_PASSWORD_HASH); the tokens below read # them from the environment. setup.sh prints the password once. # To set your own: htpasswd -bnBC 10 "" | tr -d ':\n' @@ -21,7 +21,6 @@ # Server # --------------------------------------------------------------------------- log_level = "INFO" # DEBUG | INFO | WARN | ERROR -port = "9243" # Validate OAuth2 scopes on incoming requests. enable_scope_validation = true @@ -83,10 +82,15 @@ password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' 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 +# Listeners (HTTP + HTTPS) # --------------------------------------------------------------------------- -[tls] -cert_dir = "/app/data/certs" +# The HTTPS listener serves on :9243. cert_dir holds the listener's certificate +# pair — the server reads cert.pem and key.pem from there. docker-compose mounts +# ./resources/certificates at this path. +[https] +enabled = true +port = "9243" +cert_dir = "/etc/platform-api/tls" # --------------------------------------------------------------------------- # DevPortal integration — disable for standalone deployments diff --git a/portals/ai-workspace/configs/config-template.toml b/portals/ai-workspace/configs/config-template.toml index a294cbc0e7..7c78b57de7 100644 --- a/portals/ai-workspace/configs/config-template.toml +++ b/portals/ai-workspace/configs/config-template.toml @@ -161,8 +161,8 @@ enabled = '{{ env "APIP_AIW_TLS_ENABLED" "true" }}' # 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/tls.crt" }}' -key_file = '{{ env "APIP_AIW_TLS_KEY_FILE" "/etc/ai-workspace/tls/tls.key" }}' +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" }}' # --------------------------------------------------------------------------- diff --git a/portals/ai-workspace/configs/config.toml b/portals/ai-workspace/configs/config.toml index a435c300a5..7ade20e4af 100644 --- a/portals/ai-workspace/configs/config.toml +++ b/portals/ai-workspace/configs/config.toml @@ -72,8 +72,10 @@ log_level = '{{ env "APIP_AIW_LOG_LEVEL" "info" }}' # debug | info | warn | url = '{{ env "APIP_AIW_PLATFORM_API_URL" "https://platform-api:9243" }}' # PEM bundle to trust for the upstream's TLS certificate — appended to the system -# roots. docker-compose mounts the setup.sh-generated platform-api certificate here. -ca_file = '{{ env "APIP_AIW_PLATFORM_API_CA_FILE" "" }}' +# roots. The default is the setup.sh-generated certificate shared by both services +# (self-signed, so it doubles as its own CA), at the path docker-compose mounts +# resources/certificates/. Ignored when tls_skip_verify below is true. +ca_file = '{{ env "APIP_AIW_PLATFORM_API_CA_FILE" "/etc/ai-workspace/tls/cert.pem" }}' # Skip upstream certificate verification entirely. Last-resort escape hatch for # local development only — prefer ca_file above. @@ -82,13 +84,14 @@ tls_skip_verify = '{{ env "APIP_AIW_PLATFORM_API_TLS_SKIP_VERIFY" "false" }}' # --------------------------------------------------------------------------- # TLS on the AI Workspace listener. A certificate pair is REQUIRED when enabled — -# setup.sh generates one and docker-compose mounts it at the default paths below. +# setup.sh generates one into resources/certificates/ and docker-compose mounts +# that directory at /etc/ai-workspace/tls, the default paths below. # --------------------------------------------------------------------------- [tls] enabled = '{{ env "APIP_AIW_TLS_ENABLED" "true" }}' -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" }}' +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" }}' # --------------------------------------------------------------------------- diff --git a/portals/ai-workspace/docker-compose.yaml b/portals/ai-workspace/docker-compose.yaml index 6ffb48264c..90b2780498 100644 --- a/portals/ai-workspace/docker-compose.yaml +++ b/portals/ai-workspace/docker-compose.yaml @@ -23,41 +23,41 @@ # ./setup.sh # # It generates everything the stack requires — nothing is auto-generated at runtime: -# keys.env APIP_CP_ENCRYPTION_KEY encrypts secrets at rest +# api-platform.env APIP_CP_ENCRYPTION_KEY encrypts secrets at rest # APIP_CP_AUTH_JWT_SECRET_KEY signs login JWTs # APIP_CP_ADMIN_USERNAME admin login username # APIP_CP_ADMIN_PASSWORD_HASH bcrypt hash of the admin password -# certs/ TLS pairs for both services (self-signed; replace with your own -# certificates for production — same file names) +# resources/certificates/ TLS pair shared by both services (self-signed; +# replace with your own certificates for production — same file names) # # The admin password is printed once by setup.sh and stored nowhere. # For production, prefer mounting secret files and referencing them from the config -# TOML with {{ file "..." }} instead of keys.env. OIDC mode additionally needs +# TOML with {{ file "..." }} instead of api-platform.env. OIDC mode additionally needs # APIP_AIW_OIDC_CLIENT_SECRET in .env (git-ignored). # # Authentication: # Out of the box the stack uses file-based auth with the admin user generated by -# setup.sh. 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). +# setup.sh. To delegate to any OIDC provider, set the APIP_CP_AUTH_IDP_* keys in +# api-platform.env (loaded into both services via env_file) and auth_mode = "oidc" +# plus the [oidc] keys in configs/config.toml. See README.md → "Testing with an IDP locally" +# (worked example: Asgardeo). # # Usage: # ./setup.sh -# docker compose --env-file keys.env up -d +# docker compose up -d # # Ports: # AI Workspace → https://localhost:5380 # Platform API → https://localhost:9243 # # TLS certificates (REQUIRED — generated by setup.sh): -# 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 -# Swap in certificates from your own CA (same file names) to remove the -# browser trust warning. +# resources/certificates/ +# cert.pem ← PEM certificate (or full chain), served by both services +# key.pem ← PEM private key +# This one directory is mounted read-only into both containers at their +# /etc//tls path. Swap in a certificate from your own CA (same file +# names) to remove the browser trust warning. The SAN list must cover both +# hostnames (localhost, platform-api, ai-workspace). # -------------------------------------------------------------------- services: @@ -70,40 +70,23 @@ services: container_name: platform-api restart: unless-stopped command: ["-config", "/etc/platform-api/config-platform-api.toml"] - # ── OIDC IDP — uncomment the environment block 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). - # environment: - # - 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 the setup.sh-generated keys and admin credentials to the container, # where the {{ env "..." }} tokens in config-platform-api.toml resolve them. - # The compose --env-file flag alone is not enough: it only substitutes ${...} - # in this file, it does not put the values inside the container. + # Optional overrides also go here — e.g. the APIP_CP_AUTH_IDP_* keys that + # switch auth to an OIDC provider (see README.md → "Testing with an IDP locally"). # format: raw — the bcrypt password hash contains `$`, which must not be # treated as a compose interpolation variable. - - path: keys.env + - path: api-platform.env required: true format: raw - - path: .env - required: false volumes: - ./configs/config-platform-api.toml:/etc/platform-api/config-platform-api.toml:ro - platform-api-data:/app/data # TLS certificate presented on :9243 — generated by setup.sh; replace the - # files with your own to remove the browser trust warning. The in-container - # file names must match exactly (cert.pem / key.pem). - - ./certs/platform-api.crt:/app/data/certs/cert.pem:ro - - ./certs/platform-api.key:/app/data/certs/key.pem:ro + # files with your own to remove the browser trust warning. The server reads + # cert.pem / key.pem from this directory ([https] cert_dir in the config TOML). + - ./resources/certificates:/etc/platform-api/tls:ro ports: - "9243:9243" healthcheck: @@ -127,38 +110,24 @@ 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 and - # verifies its certificate against the setup.sh-generated cert mounted below. - - APIP_AIW_PLATFORM_API_URL=https://platform-api:9243 - - APIP_AIW_PLATFORM_API_CA_FILE=/etc/ai-workspace/tls/platform-api-ca.crt - # ── 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 + # Everything the BFF needs is in configs/config.toml — its defaults are this + # compose deployment's values, and any key can be overridden by setting the + # matching '{{ env "APIP_AIW_..." }}' token's variable in api-platform.env + # (e.g. the OIDC keys, including the confidential APIP_AIW_OIDC_CLIENT_SECRET + # — see README.md). + # format: raw — values are passed through verbatim, never treated as compose + # interpolation variables. + - path: api-platform.env + required: true + format: raw volumes: - ./configs/config.toml:/etc/ai-workspace/config.toml:ro - # TLS certificate presented on :5380 — generated by setup.sh; replace the - # files with your own to remove the browser trust warning. The in-container - # 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 - # CA bundle the BFF trusts for the upstream platform-api hop. - - ./certs/platform-api.crt:/etc/ai-workspace/tls/platform-api-ca.crt:ro + # The same certificate directory: cert.pem / key.pem are the TLS pair + # presented on :5380, and cert.pem doubles as the CA bundle the BFF trusts + # for the upstream platform-api hop (self-signed, so it is its own CA). + # The paths are the cert_file / key_file / ca_file defaults in config.toml. + - ./resources/certificates:/etc/ai-workspace/tls:ro ports: - "5380:5380" healthcheck: diff --git a/portals/ai-workspace/production/README.md b/portals/ai-workspace/production/README.md index af7fc6e544..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. diff --git a/portals/ai-workspace/setup.sh b/portals/ai-workspace/setup.sh index 7fc0d031d9..9c1bc7148f 100755 --- a/portals/ai-workspace/setup.sh +++ b/portals/ai-workspace/setup.sh @@ -13,12 +13,12 @@ # Generates everything the stack requires before first start — nothing is # auto-generated at runtime anymore: # -# keys.env APIP_CP_ENCRYPTION_KEY at-rest encryption key +# api-platform.env APIP_CP_ENCRYPTION_KEY at-rest encryption key # APIP_CP_AUTH_JWT_SECRET_KEY JWT signing key # APIP_CP_ADMIN_USERNAME admin login username # APIP_CP_ADMIN_PASSWORD_HASH bcrypt hash of the password -# certs/platform-api.crt|key TLS pair for the Platform API (SAN: localhost, platform-api) -# certs/ai-workspace.crt|key TLS pair for the AI Workspace (SAN: localhost, ai-workspace) +# resources/certificates/cert.pem|key.pem TLS pair shared by both services +# (SAN: localhost, platform-api, ai-workspace) # # The admin password is printed once below and is NOT stored anywhere. # @@ -27,17 +27,23 @@ # ./setup.sh --force regenerate everything (rotates keys and credentials) # ./setup.sh --certs-only generate only the TLS certificates (used by `make bff-run`) # -# ADMIN_USERNAME / ADMIN_PASSWORD environment variables override the generated -# credentials (used by CI to pin known test credentials). +# When run interactively, setup.sh prompts for the admin username (default: +# admin) and password (default: a generated random string). +# +# ADMIN_USERNAME / ADMIN_PASSWORD environment variables skip the prompts and +# pin the credentials (used by CI to pin known test credentials). # # Then start the stack: -# docker compose --env-file keys.env up -d +# docker compose up -d # -------------------------------------------------------------------- set -euo pipefail cd "$(dirname "$0")" +# In the distribution this script lives in scripts/, one level below the +# docker-compose root; in the repo it sits next to docker-compose.yaml. +[[ -f docker-compose.yaml ]] || cd .. -ENV_FILE="keys.env" -CERTS_DIR="certs" +ENV_FILE="api-platform.env" +CERTS_DIR="resources/certificates" FORCE=false CERTS_ONLY=false @@ -55,60 +61,90 @@ done command -v openssl >/dev/null 2>&1 || { echo "error: openssl is required" >&2; exit 1; } +log() { echo "[setup] $*"; } + # --------------------------------------------------------------------------- # TLS certificates # --------------------------------------------------------------------------- gen_cert() { - local name="$1" san="$2" - if [[ "$FORCE" == false && -f "$CERTS_DIR/$name.crt" && -f "$CERTS_DIR/$name.key" ]]; then - echo "✓ $CERTS_DIR/$name.crt already exists — keeping it" + 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/$name.key" -out "$CERTS_DIR/$name.crt" \ - -subj "/O=WSO2 API Platform/CN=$name" \ + -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 600 "$CERTS_DIR/$name.key" - echo "✓ generated $CERTS_DIR/$name.crt / $name.key" + chmod 600 "$CERTS_DIR/key.pem" + log " - self-signed certificate generated at $CERTS_DIR/cert.pem" } +log "Provisioning TLS certificate ..." mkdir -p "$CERTS_DIR" -gen_cert "platform-api" "DNS:localhost,DNS:platform-api,DNS:host.docker.internal,IP:127.0.0.1" -gen_cert "ai-workspace" "DNS:localhost,DNS:ai-workspace,DNS:host.docker.internal,IP:127.0.0.1" +# One pair serves both services: the SAN list covers both compose hostnames, and +# because it is self-signed the same cert doubles as the CA bundle the BFF +# trusts for the upstream platform-api hop. The file names are the ones the +# platform-api container requires inside its cert dir (cert.pem / key.pem); +# docker-compose mounts this whole directory into both containers unchanged. +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 # --------------------------------------------------------------------------- -# keys.env — secrets and admin credentials +# api-platform.env — secrets and admin credentials # --------------------------------------------------------------------------- if [[ "$FORCE" == false && -f "$ENV_FILE" ]]; then - echo "✓ $ENV_FILE already exists — keeping it (rerun with --force to rotate keys and credentials)" + log "$ENV_FILE already exists — keeping it (rerun with --force to rotate keys and credentials)" echo - echo "Start the stack with:" - echo " docker compose --env-file $ENV_FILE up -d" + log "Setup complete." + echo + echo " Next step:" + echo " docker compose up" exit 0 fi # bcrypt is not in openssl; use htpasswd when available, otherwise the httpd image. +# The password is fed via stdin (-i) so it never appears in the process list. bcrypt_hash() { local password="$1" if command -v htpasswd >/dev/null 2>&1; then - htpasswd -nbB -C 10 "" "$password" | cut -d: -f2 | tr -d '\r\n' + printf '%s' "$password" | htpasswd -niB -C 10 "" | cut -d: -f2 | tr -d '\r\n' elif command -v docker >/dev/null 2>&1; then - docker run --rm httpd:2.4-alpine htpasswd -nbB -C 10 "" "$password" | cut -d: -f2 | tr -d '\r\n' + 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 } +# --------------------------------------------------------------------------- +# Admin credentials — from env vars, interactive prompts, or defaults +# --------------------------------------------------------------------------- +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)" -ADMIN_USERNAME="${ADMIN_USERNAME:-admin-$(openssl rand -hex 2)}" -ADMIN_PASSWORD="${ADMIN_PASSWORD:-$(openssl rand -base64 24 | tr -d '/+=' | cut -c1-16)}" +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" < Date: Wed, 15 Jul 2026 14:25:47 +0530 Subject: [PATCH 04/10] Update error handling in TestHandleCreateWithSecretCompensation for unauthenticated requests - Change error response validation to check for "code" and "message" fields instead of a single "error" field. - Ensure the test correctly verifies the new response structure for unauthenticated scenarios. --- .../bff/internal/server/composite_handlers_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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..0a77c321ff 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"] != "NOT_AUTHENTICATED" { + t.Errorf("code = %q, want %q", body["code"], "NOT_AUTHENTICATED") + } + if body["message"] != "not authenticated" { + t.Errorf("message = %q, want %q", body["message"], "not authenticated") } } From 5f0b63eb8e1ce2f4741753a7774be79a7a5fcbc2 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Wed, 15 Jul 2026 14:35:57 +0530 Subject: [PATCH 05/10] Update QUICKSTART.md to reflect new script path for setup - Changed the setup script reference from `./setup.sh` to `./scripts/setup.sh` in the AI Workspace quickstart guide. - Updated instructions for rotating the admin password to use the new script path. --- portals/ai-workspace/QUICKSTART.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/portals/ai-workspace/QUICKSTART.md b/portals/ai-workspace/QUICKSTART.md index 883c9a1d1b..fa49f508cc 100644 --- a/portals/ai-workspace/QUICKSTART.md +++ b/portals/ai-workspace/QUICKSTART.md @@ -25,7 +25,7 @@ unzip wso2apip-ai-workspace-1.0.0-alpha.zip ```bash cd wso2apip-ai-workspace-1.0.0-alpha -./setup.sh +./scripts/setup.sh ``` The script generates everything the stack requires: the encryption and JWT @@ -33,7 +33,7 @@ signing keys and the admin login credentials (written to `api-platform.env`), an 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 `./setup.sh --force` to rotate it. +> once and stored nowhere. Rerun `./scripts/setup.sh --force` to rotate it. ### 3. Start the stack From 6d9b9ed113d4ac6efb9a0d0f8f651d3bbcbf0cbb Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Wed, 15 Jul 2026 15:53:17 +0530 Subject: [PATCH 06/10] Enhance environment variable handling and JWT configuration - Added a step in the GitHub Actions workflow to export quickstart credentials as environment variables for subsequent job steps. - Updated JWT configuration in multiple TOML files to include `skip_validation` option, ensuring consistency across platform API, integration tests, and developer portal configurations. --- .github/workflows/ai-workspace-pr-check.yml | 9 +++++++++ platform-api/config/config.toml | 7 ++++--- .../it/configs/config-platform-api-it.toml | 7 ++++--- tests/integration-e2e/platform-api-config.toml | 7 ++++--- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ai-workspace-pr-check.yml b/.github/workflows/ai-workspace-pr-check.yml index e5b74ed37c..e727003177 100644 --- a/.github/workflows/ai-workspace-pr-check.yml +++ b/.github/workflows/ai-workspace-pr-check.yml @@ -62,6 +62,15 @@ jobs: 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/platform-api/config/config.toml b/platform-api/config/config.toml index bb611d3689..52d9e9ab11 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -55,9 +55,10 @@ driver = "sqlite3" # "sqlite3" or "postgres" # secret_key is a 32-byte key (64 hex chars or base64; openssl rand -hex 32) # secret_key = '{{ file "/secrets/platform-api/jwt_secret" }}' [auth.jwt] -enabled = true -issuer = "platform-api" -secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' +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. diff --git a/portals/developer-portal/it/configs/config-platform-api-it.toml b/portals/developer-portal/it/configs/config-platform-api-it.toml index fdd058207a..1a221507c1 100644 --- a/portals/developer-portal/it/configs/config-platform-api-it.toml +++ b/portals/developer-portal/it/configs/config-platform-api-it.toml @@ -28,9 +28,10 @@ driver = "sqlite3" path = "/app/data/platform-api-it.db" [auth.jwt] -enabled = true -issuer = "platform-api-it" -secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' +enabled = true +issuer = "platform-api-it" +secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' +skip_validation = false [auth.idp] enabled = false diff --git a/tests/integration-e2e/platform-api-config.toml b/tests/integration-e2e/platform-api-config.toml index 90d84559e0..04187bd5d7 100644 --- a/tests/integration-e2e/platform-api-config.toml +++ b/tests/integration-e2e/platform-api-config.toml @@ -15,9 +15,10 @@ password = '{{ env "APIP_CP_DATABASE_PASSWORD" "" }}' ssl_mode = '{{ env "APIP_CP_DATABASE_SSL_MODE" "disable" }}' [auth.jwt] -enabled = true -issuer = "platform-api" -secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' +enabled = true +issuer = "platform-api" +secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' +skip_validation = false [auth.file_based] enabled = true From aa7f162a3d6d15440905b13ef6e917458668675e Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Wed, 15 Jul 2026 16:57:13 +0530 Subject: [PATCH 07/10] Refactor configuration files and setup process - Removed deprecated DevPortal integration settings from `config-template.toml` and `config.toml`. - Updated `default_config.go` to set DevPortal integration as disabled by default. - Simplified `docker-compose.yaml` by removing unnecessary comments and clarifying service configurations. - Enhanced `setup.sh` to streamline the provisioning of TLS certificates and secrets, with improved documentation in `README.md`. - Adjusted AI Workspace and Platform API configuration files to reflect changes in environment variable handling and TLS settings. --- platform-api/config/config-template.toml | 26 ---- platform-api/config/config.toml | 78 ------------ platform-api/config/default_config.go | 2 +- .../configs/config-platform-api.toml | 54 +------- portals/ai-workspace/configs/config.toml | 90 +------------- portals/ai-workspace/distribution/README.md | 116 +++++++++--------- portals/ai-workspace/docker-compose.yaml | 84 +------------ portals/ai-workspace/setup.sh | 64 +++------- 8 files changed, 78 insertions(+), 436 deletions(-) diff --git a/platform-api/config/config-template.toml b/platform-api/config/config-template.toml index 737b2ada40..d3535ee3ba 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -336,29 +336,3 @@ 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 - -# --------------------------------------------------------------------------- -# DevPortal integration -# --------------------------------------------------------------------------- -# Enabled by default; disable for standalone deployments that do not use a -# DevPortal. name, identifier, api_url, hostname, and api_key are required -# when enabled = true. -[default_devportal] -enabled = false -name = "Default DevPortal" -identifier = "default" # unique slug used internally -api_url = "http://localhost:3001" -hostname = "devportal.local" # 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" diff --git a/platform-api/config/config.toml b/platform-api/config/config.toml index 52d9e9ab11..0b348c1ff3 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -39,14 +39,6 @@ encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' driver = "sqlite3" # "sqlite3" or "postgres" # path = "/app/data/api_platform.db" # SQLite file path (ignored for postgres) -# PostgreSQL — uncomment when driver = "postgres" -# host = "localhost" -# port = 5432 -# name = "platform_api" -# user = "platform_api" -# password = '{{ file "/secrets/platform-api/postgres_password" }}' -# ssl_mode = "disable" - # --------------------------------------------------------------------------- # Authentication # --------------------------------------------------------------------------- @@ -64,11 +56,6 @@ skip_validation = false # Enable and configure when using Asgardeo, Keycloak, Auth0, etc. [auth.idp] enabled = false -# jwks_url = "https://idp.example.com/oauth2/jwks" -# issuer = ["https://idp.example.com/oauth2/token"] -# 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. Override with AUTH_IDP_AUDIENCE. audience = [] # File-based auth — local username/password login. @@ -88,68 +75,3 @@ region = "us" 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" - -# --------------------------------------------------------------------------- -# 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). -# -# 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. -# -# https.cert_dir must contain cert.pem and key.pem when https.enabled = true. -# Certificates are always required — there is no self-signed fallback. -# -# 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" - -# --------------------------------------------------------------------------- -# Listener timeouts -# --------------------------------------------------------------------------- -# Bound the lifetime of a connection so a slow or idle peer cannot hold one open -# indefinitely (Slowloris). These apply to both listeners, which serve the same -# handler. Values are durations, e.g. "10s", "2m". -# -# 0 disables a timeout (net/http semantics). Disabling read or read_header -# removes the Slowloris protection — only do so behind a proxy that enforces its -# own bounds. Keep `write` generous: it bounds handler execution, and some -# handlers proxy slow upstreams (LLM completions, deployments). -# -# 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" - -# --------------------------------------------------------------------------- -# CORS -# --------------------------------------------------------------------------- -# Optional; must not contain "*". List the exact origins -# (scheme + host + port) that are allowed to make credentialed cross-origin -# requests, e.g. the Developer Portal / Admin UI origins. -# Override with the CORS_ALLOWED_ORIGINS env var (comma-separated). -# [cors] -# allowed_origins = ["https://devportal.example.com", "https://admin.example.com"] - -# --------------------------------------------------------------------------- -# DevPortal integration — disable for standalone deployments -# --------------------------------------------------------------------------- -[default_devportal] -enabled = false 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/portals/ai-workspace/configs/config-platform-api.toml b/portals/ai-workspace/configs/config-platform-api.toml index 91f135138b..706a9915b1 100644 --- a/portals/ai-workspace/configs/config-platform-api.toml +++ b/portals/ai-workspace/configs/config-platform-api.toml @@ -6,55 +6,18 @@ # 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). -# -# The admin login credentials are generated by setup.sh and land in api-platform.env -# (APIP_CP_ADMIN_USERNAME / APIP_CP_ADMIN_PASSWORD_HASH); the tokens below read -# them from the environment. setup.sh prints the password once. -# To set your own: htpasswd -bnBC 10 "" | tr -d ':\n' -# -------------------------------------------------------------------- +# Platform API configuration — quickstart (file-based auth). See README.md. -# --------------------------------------------------------------------------- -# Server -# --------------------------------------------------------------------------- log_level = "INFO" # DEBUG | INFO | WARN | ERROR - -# Validate OAuth2 scopes on incoming requests. enable_scope_validation = true -# --------------------------------------------------------------------------- -# Encryption -# --------------------------------------------------------------------------- -# 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. # encryption_key = '{{ file "/secrets/platform-api/encryption_key" }}' encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' -# --------------------------------------------------------------------------- -# Database -# --------------------------------------------------------------------------- [database] driver = "sqlite3" # "sqlite3" or "postgres" path = "/app/data/api_platform.db" # SQLite file path (ignored for postgres) -# PostgreSQL — uncomment when driver = "postgres" -# host = "localhost" -# port = 5432 -# name = "platform_api" -# user = "platform_api" -# password = '{{ file "/secrets/platform-api/postgres_password" }}' -# ssl_mode = "disable" - -# --------------------------------------------------------------------------- -# Authentication -# --------------------------------------------------------------------------- - -# JWT (local HMAC) — issues signed tokens after file-based login. -# secret_key is a 32-byte key (64 hex chars or base64; openssl rand -hex 32). # secret_key = '{{ file "/secrets/platform-api/jwt_secret" }}' [auth.jwt] enabled = true @@ -62,12 +25,9 @@ issuer = "platform-api" secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' 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 -# File-based auth — local username/password login. [auth.file_based] enabled = true @@ -81,19 +41,7 @@ username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' 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" -# --------------------------------------------------------------------------- -# Listeners (HTTP + HTTPS) -# --------------------------------------------------------------------------- -# The HTTPS listener serves on :9243. cert_dir holds the listener's certificate -# pair — the server reads cert.pem and key.pem from there. docker-compose mounts -# ./resources/certificates at this path. [https] enabled = true port = "9243" cert_dir = "/etc/platform-api/tls" - -# --------------------------------------------------------------------------- -# DevPortal integration — disable for standalone deployments -# --------------------------------------------------------------------------- -[default_devportal] -enabled = false diff --git a/portals/ai-workspace/configs/config.toml b/portals/ai-workspace/configs/config.toml index 7ade20e4af..61f738fa75 100644 --- a/portals/ai-workspace/configs/config.toml +++ b/portals/ai-workspace/configs/config.toml @@ -6,109 +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" }}' - -# PEM bundle to trust for the upstream's TLS certificate — appended to the system -# roots. The default is the setup.sh-generated certificate shared by both services -# (self-signed, so it doubles as its own CA), at the path docker-compose mounts -# resources/certificates/. Ignored when tls_skip_verify below is true. -ca_file = '{{ env "APIP_AIW_PLATFORM_API_CA_FILE" "/etc/ai-workspace/tls/cert.pem" }}' - -# Skip upstream certificate verification entirely. Last-resort escape hatch for -# local development only — prefer ca_file above. +ca_file = "/etc/ai-workspace/tls/cert.pem" tls_skip_verify = '{{ env "APIP_AIW_PLATFORM_API_TLS_SKIP_VERIFY" "false" }}' -# --------------------------------------------------------------------------- -# TLS on the AI Workspace listener. A certificate pair is REQUIRED when enabled — -# setup.sh generates one into resources/certificates/ and docker-compose mounts -# that directory at /etc/ai-workspace/tls, the default paths below. -# --------------------------------------------------------------------------- [tls] enabled = '{{ env "APIP_AIW_TLS_ENABLED" "true" }}' -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" }}' - - -# --------------------------------------------------------------------------- -# 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" "" }}' - -# Left empty, the full ap:* scope set is requested. -scope = '{{ env "APIP_AIW_OIDC_SCOPE" "" }}' +cert_file = "/etc/ai-workspace/tls/cert.pem" +key_file = "/etc/ai-workspace/tls/key.pem" 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 90b2780498..de0c628dd8 100644 --- a/portals/ai-workspace/docker-compose.yaml +++ b/portals/ai-workspace/docker-compose.yaml @@ -6,86 +6,22 @@ # 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. -# -# Setup (REQUIRED — run once before first start): -# ./setup.sh -# -# It generates everything the stack requires — nothing is auto-generated at runtime: -# api-platform.env APIP_CP_ENCRYPTION_KEY encrypts secrets at rest -# APIP_CP_AUTH_JWT_SECRET_KEY signs login JWTs -# APIP_CP_ADMIN_USERNAME admin login username -# APIP_CP_ADMIN_PASSWORD_HASH bcrypt hash of the admin password -# resources/certificates/ TLS pair shared by both services (self-signed; -# replace with your own certificates for production — same file names) -# -# The admin password is printed once by setup.sh and stored nowhere. -# For production, prefer mounting secret files and referencing them from the config -# TOML with {{ file "..." }} instead of api-platform.env. OIDC mode additionally needs -# APIP_AIW_OIDC_CLIENT_SECRET in .env (git-ignored). -# -# Authentication: -# Out of the box the stack uses file-based auth with the admin user generated by -# setup.sh. To delegate to any OIDC provider, set the APIP_CP_AUTH_IDP_* keys in -# api-platform.env (loaded into both services via env_file) and auth_mode = "oidc" -# plus the [oidc] keys in configs/config.toml. See README.md → "Testing with an IDP locally" -# (worked example: Asgardeo). -# -# Usage: -# ./setup.sh -# docker compose up -d -# -# Ports: -# AI Workspace → https://localhost:5380 -# Platform API → https://localhost:9243 -# -# TLS certificates (REQUIRED — generated by setup.sh): -# resources/certificates/ -# cert.pem ← PEM certificate (or full chain), served by both services -# key.pem ← PEM private key -# This one directory is mounted read-only into both containers at their -# /etc//tls path. Swap in a certificate from your own CA (same file -# names) to remove the browser trust warning. The SAN list must cover both -# hostnames (localhost, platform-api, ai-workspace). -# -------------------------------------------------------------------- +# 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"] env_file: - # Supplies the setup.sh-generated keys and admin credentials to the container, - # where the {{ env "..." }} tokens in config-platform-api.toml resolve them. - # Optional overrides also go here — e.g. the APIP_CP_AUTH_IDP_* keys that - # switch auth to an OIDC provider (see README.md → "Testing with an IDP locally"). - # format: raw — the bcrypt password hash contains `$`, which must not be - # treated as a compose interpolation variable. - 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 - # TLS certificate presented on :9243 — generated by setup.sh; replace the - # files with your own to remove the browser trust warning. The server reads - # cert.pem / key.pem from this directory ([https] cert_dir in the config TOML). - ./resources/certificates:/etc/platform-api/tls:ro ports: - "9243:9243" @@ -96,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 @@ -111,22 +40,11 @@ services: platform-api: condition: service_healthy env_file: - # Everything the BFF needs is in configs/config.toml — its defaults are this - # compose deployment's values, and any key can be overridden by setting the - # matching '{{ env "APIP_AIW_..." }}' token's variable in api-platform.env - # (e.g. the OIDC keys, including the confidential APIP_AIW_OIDC_CLIENT_SECRET - # — see README.md). - # format: raw — values are passed through verbatim, never treated as compose - # interpolation variables. - path: api-platform.env required: true format: raw volumes: - ./configs/config.toml:/etc/ai-workspace/config.toml:ro - # The same certificate directory: cert.pem / key.pem are the TLS pair - # presented on :5380, and cert.pem doubles as the CA bundle the BFF trusts - # for the upstream platform-api hop (self-signed, so it is its own CA). - # The paths are the cert_file / key_file / ca_file defaults in config.toml. - ./resources/certificates:/etc/ai-workspace/tls:ro ports: - "5380:5380" diff --git a/portals/ai-workspace/setup.sh b/portals/ai-workspace/setup.sh index 9c1bc7148f..4d1eec6d43 100755 --- a/portals/ai-workspace/setup.sh +++ b/portals/ai-workspace/setup.sh @@ -7,39 +7,10 @@ # 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. -# -# Generates everything the stack requires before first start — nothing is -# auto-generated at runtime anymore: -# -# api-platform.env APIP_CP_ENCRYPTION_KEY at-rest encryption key -# APIP_CP_AUTH_JWT_SECRET_KEY JWT signing key -# APIP_CP_ADMIN_USERNAME admin login username -# APIP_CP_ADMIN_PASSWORD_HASH bcrypt hash of the password -# resources/certificates/cert.pem|key.pem TLS pair shared by both services -# (SAN: localhost, platform-api, ai-workspace) -# -# The admin password is printed once below and is NOT stored anywhere. -# -# Usage: -# ./setup.sh generate anything that is missing -# ./setup.sh --force regenerate everything (rotates keys and credentials) -# ./setup.sh --certs-only generate only the TLS certificates (used by `make bff-run`) -# -# When run interactively, setup.sh prompts for the admin username (default: -# admin) and password (default: a generated random string). -# -# ADMIN_USERNAME / ADMIN_PASSWORD environment variables skip the prompts and -# pin the credentials (used by CI to pin known test credentials). -# -# Then start the stack: -# docker compose up -d -# -------------------------------------------------------------------- +# AI Workspace quickstart setup. See README.md → "Quick Start". set -euo pipefail cd "$(dirname "$0")" -# In the distribution this script lives in scripts/, one level below the -# docker-compose root; in the repo it sits next to docker-compose.yaml. +# Distribution layout: scripts/setup.sh, one level below docker-compose.yaml. [[ -f docker-compose.yaml ]] || cd .. ENV_FILE="api-platform.env" @@ -52,7 +23,15 @@ for arg in "$@"; do --force) FORCE=true ;; --certs-only) CERTS_ONLY=true ;; -h|--help) - sed -n '11,35p' "$0" | sed 's/^# \{0,1\}//' + 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 ;; @@ -63,9 +42,6 @@ command -v openssl >/dev/null 2>&1 || { echo "error: openssl is required" >&2; e log() { echo "[setup] $*"; } -# --------------------------------------------------------------------------- -# TLS certificates -# --------------------------------------------------------------------------- gen_cert() { local san="$1" if [[ "$FORCE" == false && -f "$CERTS_DIR/cert.pem" && -f "$CERTS_DIR/key.pem" ]]; then @@ -76,26 +52,20 @@ gen_cert() { -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 600 "$CERTS_DIR/key.pem" + chmod 644 "$CERTS_DIR/key.pem" log " - self-signed certificate generated at $CERTS_DIR/cert.pem" } log "Provisioning TLS certificate ..." mkdir -p "$CERTS_DIR" -# One pair serves both services: the SAN list covers both compose hostnames, and -# because it is self-signed the same cert doubles as the CA bundle the BFF -# trusts for the upstream platform-api hop. The file names are the ones the -# platform-api container requires inside its cert dir (cert.pem / key.pem); -# docker-compose mounts this whole directory into both containers unchanged. +# 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 -# --------------------------------------------------------------------------- -# api-platform.env — secrets and admin credentials -# --------------------------------------------------------------------------- if [[ "$FORCE" == false && -f "$ENV_FILE" ]]; then log "$ENV_FILE already exists — keeping it (rerun with --force to rotate keys and credentials)" echo @@ -106,8 +76,7 @@ if [[ "$FORCE" == false && -f "$ENV_FILE" ]]; then exit 0 fi -# bcrypt is not in openssl; use htpasswd when available, otherwise the httpd image. -# The password is fed via stdin (-i) so it never appears in the process list. +# 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 @@ -120,9 +89,6 @@ bcrypt_hash() { fi } -# --------------------------------------------------------------------------- -# Admin credentials — from env vars, interactive prompts, or defaults -# --------------------------------------------------------------------------- GENERATED_PASSWORD="$(openssl rand -base64 24 | tr -d '/+=' | cut -c1-20)" if [[ -z "${ADMIN_USERNAME:-}" && -t 0 ]]; then From 64d8b0d47bd1481043eb5e43735f4fe8402985a9 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Wed, 15 Jul 2026 17:52:30 +0530 Subject: [PATCH 08/10] Update configuration files and improve authentication error handling - Removed deprecated file-based authentication settings from `config.toml`. - Enhanced `README.md` to clarify production setup for OIDC client secrets and TLS certificate usage. - Updated error messages in `composite_handlers.go` and `composite_handlers_test.go` to provide clearer feedback for unauthorized access. - Modified environment variable handling in `config-platform-api.toml` and `config-template.toml` for improved flexibility. - Ensured TLS certificate permissions are set correctly in `docker-compose` files for integration tests. --- platform-api/config/config.toml | 14 -------- portals/ai-workspace/README.md | 21 +++++++----- .../bff/internal/server/composite_handlers.go | 2 +- .../server/composite_handlers_test.go | 8 ++--- .../configs/config-platform-api.toml | 34 +++++++++---------- .../ai-workspace/configs/config-template.toml | 4 +-- .../docker-compose.sqlite.yaml | 3 ++ .../docker-compose.sqlserver.yaml | 3 ++ tests/integration-e2e/docker-compose.yaml | 3 ++ 9 files changed, 45 insertions(+), 47 deletions(-) diff --git a/platform-api/config/config.toml b/platform-api/config/config.toml index 0b348c1ff3..d725dc0cd8 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -61,17 +61,3 @@ 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" -# uuid is the platform organization UUID. It is stored as idp_organization_ref_uuid -# and emitted as the `organization` claim in issued tokens. Leave unset to have one -# generated at startup, or pin it to keep the org stable across fresh databases. -# uuid = "99089a17-72e0-4dd8-a2f4-c8dfbb085295" - -[[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" diff --git a/portals/ai-workspace/README.md b/portals/ai-workspace/README.md index 12ba1117b5..918d9d321a 100644 --- a/portals/ai-workspace/README.md +++ b/portals/ai-workspace/README.md @@ -292,8 +292,10 @@ 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 -file-based quickstart (the admin user generated by `setup.sh`). +never enters the environment at all. + +(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 @@ -442,10 +444,10 @@ generated. ## Custom TLS certificates -`setup.sh` generates one self-signed certificate into `resources/certificates/`, -shared by both services. To remove the browser trust warning, replace it with a -certificate from your own CA — same file names, and the SAN list must cover both -hostnames (`localhost`, `platform-api`, `ai-workspace`): +`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`: ``` resources/certificates/ @@ -453,9 +455,10 @@ resources/certificates/ └── key.pem ``` -docker-compose mounts this one directory read-only into both containers -(`/etc/platform-api/tls` and `/etc/ai-workspace/tls`), so replacing the two -files is all it takes. +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`. Then restart the stack: `docker compose up -d` 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 0a77c321ff..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,10 +295,10 @@ func TestHandleCreateWithSecretCompensation_Unauthenticated(t *testing.T) { } var body map[string]string _ = json.NewDecoder(w.Body).Decode(&body) - if body["code"] != "NOT_AUTHENTICATED" { - t.Errorf("code = %q, want %q", body["code"], "NOT_AUTHENTICATED") + if body["code"] != "UNAUTHORIZED" { + t.Errorf("code = %q, want %q", body["code"], "UNAUTHORIZED") } - if body["message"] != "not authenticated" { - t.Errorf("message = %q, want %q", body["message"], "not authenticated") + 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/configs/config-platform-api.toml b/portals/ai-workspace/configs/config-platform-api.toml index 706a9915b1..bbf93fd3f0 100644 --- a/portals/ai-workspace/configs/config-platform-api.toml +++ b/portals/ai-workspace/configs/config-platform-api.toml @@ -8,40 +8,40 @@ # -------------------------------------------------------------------- # Platform API configuration — quickstart (file-based auth). See README.md. -log_level = "INFO" # DEBUG | INFO | WARN | ERROR -enable_scope_validation = true +log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' # DEBUG | INFO | WARN | ERROR +enable_scope_validation = '{{ env "APIP_CP_ENABLE_SCOPE_VALIDATION" "true" }}' # encryption_key = '{{ file "/secrets/platform-api/encryption_key" }}' 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] -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) # secret_key = '{{ file "/secrets/platform-api/jwt_secret" }}' [auth.jwt] -enabled = true -issuer = "platform-api" +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" }}' -skip_validation = false # verify JWT signature +skip_validation = '{{ env "APIP_CP_AUTH_JWT_SKIP_VALIDATION" "false" }}' # verify JWT signature [auth.idp] -enabled = false +enabled = '{{ env "APIP_CP_AUTH_IDP_ENABLED" "false" }}' [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 = '{{ env "APIP_CP_ADMIN_USERNAME" }}' password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' -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" - -[https] -enabled = true -port = "9243" -cert_dir = "/etc/platform-api/tls" +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 7c78b57de7..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 diff --git a/tests/integration-e2e/docker-compose.sqlite.yaml b/tests/integration-e2e/docker-compose.sqlite.yaml index 481de1d394..4940359795 100644 --- a/tests/integration-e2e/docker-compose.sqlite.yaml +++ b/tests/integration-e2e/docker-compose.sqlite.yaml @@ -14,6 +14,9 @@ services: -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" + # certgen runs as root; platform-api runs as uid 10001, so the key must be + # made world-readable or the server fails to start with "permission denied". + chmod 0644 /certs/key.pem volumes: - platform-api-certs:/certs networks: [e2e] diff --git a/tests/integration-e2e/docker-compose.sqlserver.yaml b/tests/integration-e2e/docker-compose.sqlserver.yaml index 5de4732442..3f385598b7 100644 --- a/tests/integration-e2e/docker-compose.sqlserver.yaml +++ b/tests/integration-e2e/docker-compose.sqlserver.yaml @@ -53,6 +53,9 @@ services: -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" + # certgen runs as root; platform-api runs as uid 10001, so the key must be + # made world-readable or the server fails to start with "permission denied". + chmod 0644 /certs/key.pem volumes: - platform-api-certs:/certs networks: [e2e] diff --git a/tests/integration-e2e/docker-compose.yaml b/tests/integration-e2e/docker-compose.yaml index c3ea5d2a26..6c1b43a171 100644 --- a/tests/integration-e2e/docker-compose.yaml +++ b/tests/integration-e2e/docker-compose.yaml @@ -54,6 +54,9 @@ services: -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" + # certgen runs as root; platform-api runs as uid 10001, so the key must be + # made world-readable or the server fails to start with "permission denied". + chmod 0644 /certs/key.pem volumes: - platform-api-certs:/certs networks: [e2e] From 6f032dc0dbd29b44160c6d4b5d4a2ee60ee731b9 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Wed, 15 Jul 2026 18:38:21 +0530 Subject: [PATCH 09/10] Add intercepts for server details in MCP secret management tests - Introduced new intercepts for GET requests to fetch server details in the Cypress tests for MCP secret management. - Ensured that the tests wait for the server details to be fetched before proceeding with tab navigation, preventing race conditions. - Updated test logic to improve reliability and accuracy in the server management workflow. --- .../004-mcp-secret-management-update.cy.js | 11 +++++++++++ 1 file changed, 11 insertions(+) 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(); }); From db7c8e30a2332013d3282c177059954e00a9e423 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Wed, 15 Jul 2026 18:47:59 +0530 Subject: [PATCH 10/10] Update README and docker-compose files for TLS certificate handling - Enhanced README.md to clarify the use of `--force-recreate` when restarting the Docker stack to ensure new certificates are loaded. - Modified `docker-compose` files for SQLite, SQL Server, and general integration tests to improve TLS certificate permissions by changing ownership and restricting access to the private key. - Added error handling with `set -e` to ensure the script exits on failure during certificate generation. --- portals/ai-workspace/README.md | 4 +++- tests/integration-e2e/docker-compose.sqlite.yaml | 6 ++++-- tests/integration-e2e/docker-compose.sqlserver.yaml | 6 ++++-- tests/integration-e2e/docker-compose.yaml | 6 ++++-- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/portals/ai-workspace/README.md b/portals/ai-workspace/README.md index 918d9d321a..c92836ed9d 100644 --- a/portals/ai-workspace/README.md +++ b/portals/ai-workspace/README.md @@ -460,7 +460,9 @@ docker-compose mounts this directory read-only into both containers the BFF trusts for the upstream platform-api hop, referenced by `[platform_api] ca_file` in `configs/config.toml`. -Then restart the stack: `docker compose up -d` +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) --- diff --git a/tests/integration-e2e/docker-compose.sqlite.yaml b/tests/integration-e2e/docker-compose.sqlite.yaml index 4940359795..b00ec5bf10 100644 --- a/tests/integration-e2e/docker-compose.sqlite.yaml +++ b/tests/integration-e2e/docker-compose.sqlite.yaml @@ -9,14 +9,16 @@ services: entrypoint: ["/bin/sh", "-c"] command: - | + set -e [ -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" # certgen runs as root; platform-api runs as uid 10001, so the key must be - # made world-readable or the server fails to start with "permission denied". - chmod 0644 /certs/key.pem + # owned by that uid, not made world-readable. + chown 10001 /certs/key.pem + chmod 0600 /certs/key.pem volumes: - platform-api-certs:/certs networks: [e2e] diff --git a/tests/integration-e2e/docker-compose.sqlserver.yaml b/tests/integration-e2e/docker-compose.sqlserver.yaml index 3f385598b7..2c83be0d9b 100644 --- a/tests/integration-e2e/docker-compose.sqlserver.yaml +++ b/tests/integration-e2e/docker-compose.sqlserver.yaml @@ -48,14 +48,16 @@ services: entrypoint: ["/bin/sh", "-c"] command: - | + set -e [ -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" # certgen runs as root; platform-api runs as uid 10001, so the key must be - # made world-readable or the server fails to start with "permission denied". - chmod 0644 /certs/key.pem + # owned by that uid, not made world-readable. + chown 10001 /certs/key.pem + chmod 0600 /certs/key.pem volumes: - platform-api-certs:/certs networks: [e2e] diff --git a/tests/integration-e2e/docker-compose.yaml b/tests/integration-e2e/docker-compose.yaml index 6c1b43a171..dabcd08227 100644 --- a/tests/integration-e2e/docker-compose.yaml +++ b/tests/integration-e2e/docker-compose.yaml @@ -49,14 +49,16 @@ services: entrypoint: ["/bin/sh", "-c"] command: - | + set -e [ -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" # certgen runs as root; platform-api runs as uid 10001, so the key must be - # made world-readable or the server fails to start with "permission denied". - chmod 0644 /certs/key.pem + # owned by that uid, not made world-readable. + chown 10001 /certs/key.pem + chmod 0600 /certs/key.pem volumes: - platform-api-certs:/certs networks: [e2e]