From 4fa30f9380092abfea5d2ff31f096ec5ec6c4ffc Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Mon, 20 Jul 2026 14:10:28 +0530 Subject: [PATCH 01/18] Refactor Platform API configuration and update documentation - Updated environment variable prefixes in README and config files to use `APIP_CP_` for consistency. - Refactored configuration structure to group server settings under a new `server` section. - Enhanced authentication configuration to select modes via `auth.mode`, supporting JWT, file-based, and IDP modes. - Updated tests to reflect changes in configuration structure and authentication handling. - Removed legacy variable names and clarified documentation for environment variable usage. - Adjusted default values and added environment variable fallbacks in the config template. --- platform-api/README.md | 53 +-- platform-api/cmd/main.go | 6 +- platform-api/config/config-template.toml | 317 ++++++++------- platform-api/config/config.go | 382 ++++++++++-------- platform-api/config/config.toml | 57 ++- platform-api/config/config_test.go | 155 ++++--- platform-api/config/default_config.go | 60 +-- platform-api/internal/apperror/catalog.go | 13 +- platform-api/internal/apperror/codes.go | 13 +- platform-api/internal/constants/constants.go | 9 +- platform-api/internal/handler/auth_login.go | 4 +- .../handler/llm_provider_integration_test.go | 35 +- platform-api/internal/server/server.go | 99 ++--- .../internal/server/server_tls_test.go | 23 +- platform-api/internal/service/llm.go | 25 -- platform-api/internal/service/llm_test.go | 31 -- platform-api/internal/service/mcp.go | 9 - platform-api/plugins/eventgateway/plugin.go | 11 +- .../eventgateway/service/webbroker_api.go | 9 - .../eventgateway/service/websub_api.go | 10 - .../configs/config-platform-api.toml | 46 --- .../ai-workspace/configs/config-template.toml | 276 ------------- .../configs/config-platform-api-template.toml | 106 ----- .../it/configs/config-platform-api-it.toml | 51 ++- portals/developer-portal/scripts/setup.sh | 48 +-- .../integration-e2e/platform-api-config.toml | 30 +- 26 files changed, 687 insertions(+), 1191 deletions(-) delete mode 100644 portals/ai-workspace/configs/config-platform-api.toml delete mode 100644 portals/ai-workspace/configs/config-template.toml delete mode 100644 portals/developer-portal/configs/config-platform-api-template.toml diff --git a/platform-api/README.md b/platform-api/README.md index 1cb53628c..a843abdd3 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -26,16 +26,16 @@ Platform API supports `sqlite3` (default), `postgres`, and `sqlserver`. ```bash # SQL Server example -export DATABASE_DRIVER=sqlserver -export DATABASE_HOST=sqlserver.example.internal -export DATABASE_PORT=1433 -export DATABASE_NAME=platform_api -export DATABASE_USER=sa -export DATABASE_PASSWORD='' -export DATABASE_SSL_MODE=disable +export APIP_CP_DATABASE_DRIVER=sqlserver +export APIP_CP_DATABASE_HOST=sqlserver.example.internal +export APIP_CP_DATABASE_PORT=1433 +export APIP_CP_DATABASE_NAME=platform_api +export APIP_CP_DATABASE_USER=sa +export APIP_CP_DATABASE_PASSWORD='' +export APIP_CP_DATABASE_SSL_MODE=disable cd platform-api -go run ./cmd/main.go +go run ./cmd/main.go -config config/config.toml ``` ### Step-by-Step Workflow @@ -277,17 +277,6 @@ go run ./cmd/main.go -config config/config.toml To skip signature checks during local development, set `skip_validation = true` under `[auth.jwt]` in the config file (demo mode only) and run the same command. -**Legacy variable names.** These unprefixed names are **no longer read** — environment -variables affect configuration only through `{{ env "…" }}` tokens (see above). Use the -current token variable name (or the config key directly) instead: - -| Old name | New name | -|---|---| -| `JWT_SECRET_KEY` | `APIP_CP_AUTH_JWT_SECRET_KEY` | -| `JWT_ISSUER` | `APIP_CP_AUTH_JWT_ISSUER` | -| `JWT_SKIP_VALIDATION` | `APIP_CP_AUTH_JWT_SKIP_VALIDATION` | -| `JWT_SKIP_PATHS` | `APIP_CP_AUTH_SKIP_PATHS` | - --- #### IDP Mode @@ -412,32 +401,6 @@ a missing/empty required env var, or a missing/disallowed/oversize file, aborts --- -### Other Settings - -| 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` (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 | -| `HTTP_PORT` | `9080` | Port for the plain-HTTP listener | -| `TIMEOUTS_READ_HEADER` | `10s` | Max time to read request headers, on both listeners (`0` disables) | -| `TIMEOUTS_READ` | `60s` | Max time to read the whole request, including the body (`0` disables) | -| `TIMEOUTS_WRITE` | `120s` | Max time for handler execution plus response write (`0` disables) | -| `TIMEOUTS_IDLE` | `120s` | Max time a keep-alive connection may sit unused (`0` disables) | -| `DEPLOYMENTS_MAX_PER_API_GATEWAY` | `20` | Maximum deployments per API per gateway | -| `DEPLOYMENTS_TRANSITIONAL_STATUS_ENABLED` | `false` | Show `DEPLOYING`/`UNDEPLOYING` status before gateway ack | -| `ARTIFACT_LIMITS_MAX_LLM_PROVIDERS_PER_ORG` | _unlimited_ | Max LLM providers per organization (`0` or unset = unlimited) | -| `ARTIFACT_LIMITS_MAX_LLM_PROXIES_PER_ORG` | _unlimited_ | Max LLM proxies per organization (`0` or unset = unlimited) | -| `ARTIFACT_LIMITS_MAX_MCP_PROXIES_PER_ORG` | _unlimited_ | Max MCP proxies per organization (`0` or unset = unlimited) | -| `ARTIFACT_LIMITS_MAX_WEBSUB_APIS_PER_ORG` | _unlimited_ | Max WebSub APIs per organization (`0` or unset = unlimited) | -| `ARTIFACT_LIMITS_MAX_WEBBROKER_APIS_PER_ORG` | _unlimited_ | Max WebBroker APIs per organization (`0` or unset = unlimited) | -| `GATEWAY_ENABLE_VERSION_VERIFICATION` | `false` | Reject gateway connections with mismatched versions | -| `API_KEY_HASHING_ALGORITHMS` | `sha256` | Comma-separated hash algorithms for API key storage | - -> The legacy `PORT`, `TLS_ENABLED`, and `TLS_CERT_DIR` env vars are still honored and map onto the HTTPS listener (`HTTPS_PORT`, `HTTPS_ENABLED`, `HTTPS_CERT_DIR`). - ## Documentation See [spec/](spec/) for product, architecture, design, and implementation documentation. diff --git a/platform-api/cmd/main.go b/platform-api/cmd/main.go index dc2ecc949..7f498bb03 100644 --- a/platform-api/cmd/main.go +++ b/platform-api/cmd/main.go @@ -50,9 +50,9 @@ func main() { } slogger.Info("Starting server", - "http_enabled", cfg.HTTP.Enabled, "http_port", cfg.HTTP.Port, - "https_enabled", cfg.HTTPS.Enabled, "https_port", cfg.HTTPS.Port) - if err := srv.Start(cfg.HTTP, cfg.HTTPS, cfg.Timeouts); err != nil { + "http_enabled", cfg.Listeners.HTTP.Enabled, "http_port", cfg.Listeners.HTTP.Port, + "https_enabled", cfg.Listeners.HTTPS.Enabled, "https_port", cfg.Listeners.HTTPS.Port) + if err := srv.Start(cfg.Listeners, cfg.Timeouts); err != nil { slogger.Error("Failed to start server", "error", err) os.Exit(1) } diff --git a/platform-api/config/config-template.toml b/platform-api/config/config-template.toml index d3535ee3b..1905efc3a 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -19,51 +19,64 @@ # 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. +# Precedence per key: an explicit {{ env "..." }} / {{ file "..." }} token in this +# file resolves its value from that source; a plain literal value in this file is +# used as-is; a key omitted from this file entirely falls back to its built-in +# default. Every non-secret key below is written as a {{ env "VAR" "default" }} +# token, so it can be overridden by setting VAR without editing this file — the +# quoted string after the var name is the fallback used when VAR is unset. The +# env var name inside a token is a free choice; APIP_CP_ is just this template's +# naming convention, not something the loader requires or enforces. # -# QUICK START (file-based auth mode — no external IDP needed): +# QUICK START (file auth mode — no external IDP needed): # 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 `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. +# 4. Set [platform_api.auth] mode = "file" and configure users. # 5. Run: docker compose up # # 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. A {{ env }} / {{ file }} -# token whose source is unset fails config load — only reference sources that exist. +# token whose source is unset — and has no fallback value — fails config load; only +# reference sources that exist. # -# OIDC mode: keep file_based disabled and set the [auth.idp] fields. +# OIDC mode: set [platform_api.auth] mode = "idp" and configure the [platform_api.auth.idp] fields. # -------------------------------------------------------------------- # --------------------------------------------------------------------------- -# Server +# All Platform API settings live under [platform_api] / [platform_api.*]. # --------------------------------------------------------------------------- -log_level = "INFO" # DEBUG | INFO | WARN | ERROR +[platform_api] -# Log output encoding. Use "json" when shipping logs to an aggregator. -log_format = "text" # text | json +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' # DEBUG | INFO | WARN | ERROR -# Scope-based authorization for API endpoints. When true, every request must -# carry the OAuth2 scope its endpoint declares. -# Set to false only to temporarily bypass during development. -enable_scope_validation = true +# Log output encoding. Use "json" when shipping logs to an aggregator. +log_format = '{{ env "APIP_CP_LOG_FORMAT" "text" }}' # text | json # 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 = '{{ env "APIP_CP_DB_SCHEMA_PATH" "./internal/database/schema.sql" }}' +openapi_spec_path = '{{ env "APIP_CP_OPENAPI_SPEC_PATH" "./resources/openapi.yaml" }}' +llm_template_definitions_path = '{{ env "APIP_CP_LLM_TEMPLATE_DEFINITIONS_PATH" "./resources/default-llm-provider-templates" }}' + +# Cap on the bytes read when fetching a remote OpenAPI spec by URL. <= 0 falls +# back to the built-in 5 MiB default. +openapi_spec_max_fetch_bytes = '{{ env "APIP_CP_OPENAPI_SPEC_MAX_FETCH_BYTES" "5242880" }}' # --------------------------------------------------------------------------- # Encryption # --------------------------------------------------------------------------- # Single 32-byte key (64 hex chars or base64) used for ALL at-rest encryption # (secrets, subscription tokens, WebSub HMAC secrets). -# REQUIRED — the server refuses to start without it. Generate with: +# REQUIRED — the server refuses to start without it; no fallback is given, so an +# unset APIP_CP_ENCRYPTION_KEY fails config load. Generate with: # openssl rand -hex 32 # Resolved from the APIP_CP_ENCRYPTION_KEY env var in `api-platform.env`. Files preferred: # encryption_key = '{{ file "/secrets/platform-api/encryption_key" }}' @@ -72,41 +85,56 @@ encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' # --------------------------------------------------------------------------- # Database # --------------------------------------------------------------------------- -[database] -driver = "sqlite3" # "sqlite3" or "postgres" +[platform_api.database] +driver = '{{ env "APIP_CP_DATABASE_DRIVER" "sqlite3" }}' # "sqlite3" | "postgres" | "postgresql" | "pgx" | "sqlserver" | "mssql" -# SQLite — ignored when driver = "postgres". Default: ./data/api_platform.db -path = "/app/data/api_platform.db" +# SQLite — ignored when driver = "postgres". +path = '{{ env "APIP_CP_DATABASE_PATH" "/app/data/api_platform.db" }}' -# PostgreSQL — ignored when driver = "sqlite3"; set driver = "postgres" above to use. -host = "localhost" -port = 5432 -name = "platform_api" -user = "platform_api" +# PostgreSQL/SQL Server — ignored when driver = "sqlite3"; host, port, name, and +# user are all required for every non-sqlite3 driver, or config load fails. +host = '{{ env "APIP_CP_DATABASE_HOST" "localhost" }}' +port = '{{ env "APIP_CP_DATABASE_PORT" "5432" }}' +name = '{{ env "APIP_CP_DATABASE_NAME" "platform_api" }}' +user = '{{ env "APIP_CP_DATABASE_USER" "platform_api" }}' # Resolve the password from a mounted secret file (preferred) or env var, e.g. # password = '{{ file "/secrets/platform-api/postgres_password" }}' -# The referenced file/env var must exist or config load fails. -password = "" -ssl_mode = "disable" # "disable" | "require" | "verify-ca" | "verify-full" +# The referenced file/env var must exist or config load fails; the fallback here +# is "" so a driver that needs no password (sqlite3) is unaffected. +password = '{{ env "APIP_CP_DATABASE_PASSWORD" "" }}' +ssl_mode = '{{ env "APIP_CP_DATABASE_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 = '{{ env "APIP_CP_DATABASE_MAX_OPEN_CONNS" "25" }}' # maximum open connections +max_idle_conns = '{{ env "APIP_CP_DATABASE_MAX_IDLE_CONNS" "10" }}' # maximum idle connections in the pool +conn_max_lifetime = '{{ env "APIP_CP_DATABASE_CONN_MAX_LIFETIME" "300" }}' # seconds before a connection is recycled # --------------------------------------------------------------------------- # Authentication # -# Exactly one of jwt / idp / file_based drives request authentication; enabling -# the IDP alongside a local mode is rejected at startup. +# auth.mode selects exactly one authentication mode: +# "jwt" — verify locally-signed HMAC JWTs ([platform_api.auth.jwt]); tokens +# are minted externally, e.g. by the Developer Portal using the +# shared secret. +# "file" — "jwt" plus local username/password login: the login endpoint +# authenticates users from [platform_api.auth.file] and issues HMAC JWTs. +# "idp" — validate tokens against an external IDP's JWKS ([platform_api.auth.idp]). +# Only the selected mode's section is read; the others are ignored. # --------------------------------------------------------------------------- -[auth] +[platform_api.auth] +mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' + +# Enforce per-endpoint OAuth2 scopes on validated tokens. When true, every +# request must carry the scope its endpoint declares. +# Set to false only to temporarily bypass authorization during development. +scope_validation = '{{ env "APIP_CP_AUTH_SCOPE_VALIDATION" "true" }}' # 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). +# This is a structured list, not a scalar, so it is edited directly rather than +# via a single env var token. skip_paths = [ "/health", "/metrics", @@ -126,64 +154,61 @@ skip_paths = [ "/api/internal/v0.9/webhook/events", ] -# JWT (local HMAC) — the default for self-hosted deployments. -# Disable when delegating auth entirely to an external IDP. +# JWT (local HMAC) — used in the "jwt" and "file" modes (file mode signs and +# verifies login tokens with the same secret). Signature validation is always on. # 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 -issuer = "platform-api" -# Signing key. REQUIRED — a 32-byte key (64 hex chars or base64); never generated. +[platform_api.auth.jwt] +issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' +# Signing key. REQUIRED — a 32-byte key (64 hex chars or base64); never generated, +# and has no fallback, so an unset APIP_CP_AUTH_JWT_SECRET_KEY fails config load. 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. -# jwks_url and issuer are required when enabled = true. -[auth.idp] -enabled = false -name = "asgardeo" # friendly name for logging -jwks_url = "https://accounts.example.com/oauth2/jwks" +# Lifetime of tokens issued by the file-mode login endpoint (Go duration syntax). +# Not used to validate externally-minted "jwt"-mode tokens — their expiry is +# whatever "exp" claim the issuer set. +token_ttl = '{{ env "APIP_CP_AUTH_JWT_TOKEN_TTL" "1h" }}' + +# IDP (JWKS-based) — used when mode = "idp" (Asgardeo, Keycloak, Auth0, etc.). +# jwks_url and issuer are required in that mode. +[platform_api.auth.idp] +name = '{{ env "APIP_CP_AUTH_IDP_NAME" "asgardeo" }}' # friendly name for logging +jwks_url = '{{ env "APIP_CP_AUTH_IDP_JWKS_URL" "https://accounts.example.com/oauth2/jwks" }}' issuer = ["https://accounts.example.com"] # list of accepted issuers audience = [] # accepted "aud" values; empty = skip audience check # Authorization mode: "scope" (default) checks the scope claim; # "role" checks a roles claim at claim_mappings.roles_claim_path. -validation_mode = "scope" -role_mappings_file = "" # path to a YAML file mapping IDP roles to platform scopes +validation_mode = '{{ env "APIP_CP_AUTH_IDP_VALIDATION_MODE" "scope" }}' +role_mappings = '{{ env "APIP_CP_AUTH_IDP_ROLE_MAPPINGS" "" }}' # path to a YAML file mapping IDP roles to platform scopes # JWT claim name mappings — configure to match your IDP's token structure. -[auth.idp.claim_mappings] -organization_claim_name = "organization" # claim carrying the org ID -org_name_claim_name = "org_name" # claim carrying the org display name -org_handle_claim_name = "org_handle" # claim carrying the org URL slug -user_id_claim_name = "sub" # claim used as the user's unique ID -username_claim_name = "username" -email_claim_name = "email" -scope_claim_name = "scope" # space-separated scope string -roles_claim_path = "" # JSON path to roles array, e.g. "realm_access.roles" - -# File-based auth — local username/password login. Ideal for initial / air-gapped -# setup; not recommended for production — prefer an IDP. -# When enabled, IDP is NOT used for Platform API authentication. -[auth.file_based] -enabled = true - -[auth.file_based.organization] -id = "default" # Required: organization handle (URL-safe slug) -display_name = "Default" -region = "us" +[platform_api.auth.idp.claim_mappings] +organization_claim_name = '{{ env "APIP_CP_AUTH_IDP_CLAIM_ORGANIZATION" "organization" }}' # claim carrying the org ID +org_name_claim_name = '{{ env "APIP_CP_AUTH_IDP_CLAIM_ORG_NAME" "org_name" }}' # claim carrying the org display name +org_handle_claim_name = '{{ env "APIP_CP_AUTH_IDP_CLAIM_ORG_HANDLE" "org_handle" }}' # claim carrying the org URL slug +user_id_claim_name = '{{ env "APIP_CP_AUTH_IDP_CLAIM_USER_ID" "sub" }}' # claim used as the user's unique ID +username_claim_name = '{{ env "APIP_CP_AUTH_IDP_CLAIM_USERNAME" "username" }}' +email_claim_name = '{{ env "APIP_CP_AUTH_IDP_CLAIM_EMAIL" "email" }}' +scope_claim_name = '{{ env "APIP_CP_AUTH_IDP_CLAIM_SCOPE" "scope" }}' # space-separated scope string +roles_claim_path = '{{ env "APIP_CP_AUTH_IDP_CLAIM_ROLES_PATH" "" }}' # JSON path to roles array, e.g. "realm_access.roles" + +# File auth — local username/password login, used when mode = "file". Ideal for +# initial / air-gapped setup; not recommended for production — prefer an IDP. +[platform_api.auth.file.organization] +id = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_ID" "default" }}' # Required: organization handle (URL-safe slug) +display_name = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_DISPLAY_NAME" "Default" }}' +region = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_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 = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_UUID" "99089a17-72e0-4dd8-a2f4-c8dfbb085295" }}' -# Add one [[auth.file_based.users]] block per user. +# Add one [[platform_api.auth.file.users]] block per user. # Generate a bcrypt hash with: htpasswd -bnBC 12 "" | tr -d ':\n' -# The env-var equivalent (APIP_CP_AUTH_FILE_BASED_USERS) takes a JSON array instead. -[[auth.file_based.users]] +[[platform_api.auth.file.users]] # The quickstart resolves these from api-platform.env (generated by setup.sh). +# Neither has a fallback — an unset var fails config load rather than starting +# with a blank or guessable credential. username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' # Full scope set — trim to restrict permissions for this user. @@ -191,38 +216,38 @@ scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:mana # 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]] +# [[platform_api.auth.file.users]] # username = "readonly" # password_hash = "$2a$12$" # scopes = "ap:organization:read ap:gateway:read ap:rest_api:read ap:llm_provider:read" # --------------------------------------------------------------------------- -# Listeners (HTTP + HTTPS) +# Server listeners (HTTP + HTTPS) # --------------------------------------------------------------------------- -# 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). +# The plain-HTTP and HTTPS 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 +# platform_api.server.https.tls.cert_file / key_file must point at a certificate +# pair when platform_api.server.https.enabled = true. Certificates are always +# required — there is no self-signed fallback. setup.sh generates a pair for +# the quickstart. +[platform_api.server.http] +enabled = '{{ env "APIP_CP_SERVER_HTTP_ENABLED" "false" }}' +port = '{{ env "APIP_CP_SERVER_HTTP_PORT" "9080" }}' + +[platform_api.server.https] +enabled = '{{ env "APIP_CP_SERVER_HTTPS_ENABLED" "true" }}' +port = '{{ env "APIP_CP_SERVER_HTTPS_PORT" "9243" }}' + +[platform_api.server.https.tls] +cert_file = '{{ env "APIP_CP_SERVER_HTTPS_TLS_CERT_FILE" "/app/data/certs/cert.pem" }}' # default: ./data/certs/cert.pem +key_file = '{{ env "APIP_CP_SERVER_HTTPS_TLS_KEY_FILE" "/app/data/certs/key.pem" }}' # default: ./data/certs/key.pem # --------------------------------------------------------------------------- # Listener timeouts @@ -238,13 +263,13 @@ cert_dir = "/app/data/certs" # default: ./data/certs # # 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" +# Override with APIP_CP_LISTENER_TIMEOUTS_READ_HEADER / APIP_CP_LISTENER_TIMEOUTS_READ / +# APIP_CP_LISTENER_TIMEOUTS_WRITE / APIP_CP_LISTENER_TIMEOUTS_IDLE. +[platform_api.listener_timeouts] +read_header = '{{ env "APIP_CP_LISTENER_TIMEOUTS_READ_HEADER" "10s" }}' +read = '{{ env "APIP_CP_LISTENER_TIMEOUTS_READ" "60s" }}' +write = '{{ env "APIP_CP_LISTENER_TIMEOUTS_WRITE" "120s" }}' +idle = '{{ env "APIP_CP_LISTENER_TIMEOUTS_IDLE" "120s" }}' # --------------------------------------------------------------------------- # CORS @@ -253,86 +278,76 @@ idle = "120s" # Portal and AI Workspace origins. Must never contain "*"; leave empty to # disable cross-origin access (the default). Env var is # APIP_CP_CORS_ALLOWED_ORIGINS (comma-separated). -[cors] -allowed_origins = [] # e.g. ["https://workspace.example.com", "https://devportal.example.com"] +[platform_api.cors] +allowed_origins = '{{ env "APIP_CP_CORS_ALLOWED_ORIGINS" "" }}' # e.g. "https://workspace.example.com,https://devportal.example.com" # --------------------------------------------------------------------------- # API Key # --------------------------------------------------------------------------- -[api_key] -# Hashing algorithms accepted for API key verification. +[platform_api.api_key] +# Hashing algorithms accepted for API key verification (comma-separated). # sha256 is the default; add "sha512" to accept both. -hashing_algorithms = ["sha256"] +hashing_algorithms = '{{ env "APIP_CP_API_KEY_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 +[platform_api.websocket] +max_connections = '{{ env "APIP_CP_WEBSOCKET_MAX_CONNECTIONS" "1000" }}' # global WebSocket connection limit +connection_timeout = '{{ env "APIP_CP_WEBSOCKET_CONNECTION_TIMEOUT" "30" }}' # seconds before an idle connection is closed +rate_limit_per_min = '{{ env "APIP_CP_WEBSOCKET_RATE_LIMIT_PER_MIN" "1000" }}' # maximum messages per minute per connection +max_connections_per_org = '{{ env "APIP_CP_WEBSOCKET_MAX_CONNECTIONS_PER_ORG" "3" }}' # maximum concurrent WebSocket connections per org +metrics_log_enabled = '{{ env "APIP_CP_WEBSOCKET_METRICS_LOG_ENABLED" "true" }}' # emit WebSocket metrics to the log +metrics_log_interval = '{{ env "APIP_CP_WEBSOCKET_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 +[platform_api.deployments] +max_per_api_gateway = '{{ env "APIP_CP_DEPLOYMENTS_MAX_PER_API_GATEWAY" "20" }}' # maximum API deployments per gateway +transitional_status_enabled = '{{ env "APIP_CP_DEPLOYMENTS_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 +timeout_enabled = '{{ env "APIP_CP_DEPLOYMENTS_TIMEOUT_ENABLED" "true" }}' +timeout_interval = '{{ env "APIP_CP_DEPLOYMENTS_TIMEOUT_INTERVAL" "20" }}' # seconds between timeout check sweeps +timeout_duration = '{{ env "APIP_CP_DEPLOYMENTS_TIMEOUT_DURATION" "60" }}' # seconds before a stuck deployment is timed out # --------------------------------------------------------------------------- # Gateway # --------------------------------------------------------------------------- -[gateway] +[platform_api.gateway] # Reject gateway registrations whose runtime version does not match the expected range. -enable_version_verification = false +enable_version_verification = '{{ env "APIP_CP_GATEWAY_ENABLE_VERSION_VERIFICATION" "false" }}' # Reject gateways that report an unexpected functionality type. -enable_functionality_type_verification = false +enable_functionality_type_verification = '{{ env "APIP_CP_GATEWAY_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 +[platform_api.event_hub] +poll_interval = '{{ env "APIP_CP_EVENT_HUB_POLL_INTERVAL" "3s" }}' # how often each replica polls for new events +cleanup_interval = '{{ env "APIP_CP_EVENT_HUB_CLEANUP_INTERVAL" "10m" }}' # how often delivered events are purged +retention_period = '{{ env "APIP_CP_EVENT_HUB_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 +[platform_api.webhook] +enabled = '{{ env "APIP_CP_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 = "" +# — the referenced env var/file must exist or config load fails. The "" fallback +# below is only ever read while enabled = false. +secret = '{{ env "APIP_CP_WEBHOOK_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 = "" +private_key_path = '{{ env "APIP_CP_WEBHOOK_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 +gateway_type = '{{ env "APIP_CP_WEBHOOK_GATEWAY_TYPE" "wso2/api-platform" }}' +signature_tolerance = '{{ env "APIP_CP_WEBHOOK_SIGNATURE_TOLERANCE" "5m" }}' # max age of a signed request (replay protection) +max_body_size = '{{ env "APIP_CP_WEBHOOK_MAX_BODY_SIZE" "1048576" }}' # request body cap in bytes (1 MiB) +signature_header = '{{ env "APIP_CP_WEBHOOK_SIGNATURE_HEADER" "X-Devportal-Signature" }}' # header carrying the "t=...,v1=..." signature diff --git a/platform-api/config/config.go b/platform-api/config/config.go index 18d19e4b1..e0f61ab1e 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -24,6 +24,7 @@ import ( "fmt" "log/slog" "reflect" + "strings" "sync" "time" @@ -66,8 +67,8 @@ type FileBasedOrg struct { } // FileBased holds configuration for local username/password authentication. +// Active when Auth.Mode is AuthModeFile. type FileBased struct { - Enabled bool `koanf:"enabled"` Organization FileBasedOrg `koanf:"organization"` Users FileBasedUsers `koanf:"users"` } @@ -84,30 +85,44 @@ type Server struct { EncryptionKey string `koanf:"encryption_key"` - Database Database `koanf:"database"` - Auth Auth `koanf:"auth"` - WebSocket WebSocket `koanf:"websocket"` - DefaultDevPortal DefaultDevPortal `koanf:"default_devportal"` - Deployments Deployments `koanf:"deployments"` - ArtifactLimits ArtifactLimits `koanf:"artifact_limits"` - HTTP HTTPListener `koanf:"http"` - HTTPS HTTPSListener `koanf:"https"` - Timeouts Timeouts `koanf:"timeouts"` - CORS CORS `koanf:"cors"` - APIKey APIKey `koanf:"api_key"` - Gateway Gateway `koanf:"gateway"` - EventHub EventHub `koanf:"event_hub"` - Webhook Webhook `koanf:"webhook"` - - EnableScopeValidation bool `koanf:"enable_scope_validation"` -} + Database Database `koanf:"database"` + Auth Auth `koanf:"auth"` + WebSocket WebSocket `koanf:"websocket"` + Deployments Deployments `koanf:"deployments"` + Listeners ServerListeners `koanf:"server"` + Timeouts Timeouts `koanf:"listener_timeouts"` + CORS CORS `koanf:"cors"` + APIKey APIKey `koanf:"api_key"` + Gateway Gateway `koanf:"gateway"` + EventHub EventHub `koanf:"event_hub"` + Webhook Webhook `koanf:"webhook"` +} + +// Authentication modes selectable via auth.mode. Exactly one mode is active; +// modeling the choice as a single discriminator (rather than per-mode enabled +// flags) makes conflicting configurations inexpressible. +const ( + // AuthModeJWT verifies locally-signed HMAC JWTs (auth.jwt.secret_key). Tokens + // are minted externally, e.g. by the Developer Portal using the shared secret. + AuthModeJWT = "jwt" + // AuthModeFile is AuthModeJWT plus local username/password login: the login + // endpoint authenticates users from auth.file and issues HMAC JWTs. + AuthModeFile = "file" + // AuthModeIDP validates tokens against an external IDP's JWKS (auth.idp). + AuthModeIDP = "idp" +) // Auth groups all authentication-related configuration. type Auth struct { - SkipPaths []string `koanf:"skip_paths"` - IDP IDP `koanf:"idp"` - JWT JWT `koanf:"jwt"` - FileBased FileBased `koanf:"file_based"` + // Mode selects the active authentication mode: "jwt", "file", or "idp". + Mode string `koanf:"mode"` + // ScopeValidation enforces per-endpoint OAuth2 scopes on validated tokens. + // Disable only to temporarily bypass authorization during development. + ScopeValidation bool `koanf:"scope_validation"` + SkipPaths []string `koanf:"skip_paths"` + IDP IDP `koanf:"idp"` + JWT JWT `koanf:"jwt"` + File FileBased `koanf:"file"` } // IDPClaimMappings holds JWT claim name mappings for an IDP. @@ -122,16 +137,16 @@ type IDPClaimMappings struct { RolesClaimPath string `koanf:"roles_claim_path"` } -// IDP holds configuration for JWKS-based identity providers. +// IDP holds configuration for JWKS-based identity providers. Active when +// Auth.Mode is AuthModeIDP. type IDP struct { - Enabled bool `koanf:"enabled"` - Name string `koanf:"name"` - JWKSUrl string `koanf:"jwks_url"` - Issuer []string `koanf:"issuer"` - Audience []string `koanf:"audience"` - ValidationMode string `koanf:"validation_mode"` - RoleMappingsFile string `koanf:"role_mappings_file"` - ClaimMappings IDPClaimMappings `koanf:"claim_mappings"` + Name string `koanf:"name"` + JWKSUrl string `koanf:"jwks_url"` + Issuer []string `koanf:"issuer"` + Audience []string `koanf:"audience"` + ValidationMode string `koanf:"validation_mode"` + RoleMappings string `koanf:"role_mappings"` + ClaimMappings IDPClaimMappings `koanf:"claim_mappings"` } // EventHub holds EventHub-specific configuration for multi-replica HA event delivery. @@ -169,27 +184,36 @@ type Gateway struct { EnableFunctionalityTypeVerification bool `koanf:"enable_functionality_type_verification"` } -// HTTPListener and HTTPSListener model the two independent listeners, following -// the gateway router's http/https split (see RouterConfig.HTTPSEnabled / -// HTTPSPort in gateway-controller). Each is enabled independently and bound to -// its own port, so a deployment can serve plain HTTP internally, HTTPS -// externally, or both at once (e.g. to migrate clients between the two without -// downtime). +// ServerListeners models the two independent listeners under the [server] +// section. Each is enabled independently and bound to its own port, so a +// deployment can serve plain HTTP internally, HTTPS externally, or both at +// once (e.g. to migrate clients between the two without downtime). +type ServerListeners struct { + HTTP HTTPListener `koanf:"http"` + HTTPS HTTPSListener `koanf:"https"` +} // HTTPListener configures the plain-HTTP listener. Enable it only when a trusted // upstream (ingress, service-mesh sidecar) terminates TLS, or for internal // cluster traffic; never expose it directly to untrusted networks. type HTTPListener struct { - Enabled bool `koanf:"enabled"` - Port string `koanf:"port"` + Enabled bool `koanf:"enabled"` + Port int `koanf:"port"` } -// HTTPSListener configures the TLS listener. CertDir must contain cert.pem and -// key.pem when Enabled is true; there is no self-signed fallback. +// HTTPSListener configures the TLS listener. TLS.CertFile and TLS.KeyFile must +// point at a certificate pair when Enabled is true; there is no self-signed +// fallback. type HTTPSListener struct { - Enabled bool `koanf:"enabled"` - Port string `koanf:"port"` - CertDir string `koanf:"cert_dir"` + Enabled bool `koanf:"enabled"` + Port int `koanf:"port"` + TLS ListenerTLS `koanf:"tls"` +} + +// ListenerTLS holds the certificate material for the HTTPS listener. +type ListenerTLS struct { + CertFile string `koanf:"cert_file"` + KeyFile string `koanf:"key_file"` } // Timeouts bounds the lifetime of a connection on both listeners, so a slow or @@ -224,12 +248,13 @@ type CORS struct { AllowedOrigins []string `koanf:"allowed_origins"` } -// JWT holds configuration for local HMAC JWT authentication. +// JWT holds configuration for local HMAC JWT authentication. Active when +// Auth.Mode is AuthModeJWT or AuthModeFile (file mode issues and verifies the +// same locally-signed tokens). Signature validation is always on. type JWT struct { - Enabled bool `koanf:"enabled"` - SecretKey string `koanf:"secret_key"` - Issuer string `koanf:"issuer"` - SkipValidation bool `koanf:"skip_validation"` + SecretKey string `koanf:"secret_key"` + Issuer string `koanf:"issuer"` + TokenTTL time.Duration `koanf:"token_ttl"` } // WebSocket holds WebSocket-specific configuration. @@ -259,25 +284,6 @@ type Database struct { ConnMaxLifetime int `koanf:"conn_max_lifetime"` } -// DefaultDevPortal holds default DevPortal configuration for new organizations. -type DefaultDevPortal struct { - Enabled bool `koanf:"enabled"` - Name string `koanf:"name"` - Identifier string `koanf:"identifier"` - APIUrl string `koanf:"api_url"` - Hostname string `koanf:"hostname"` - APIKey string `koanf:"api_key"` - HeaderKeyName string `koanf:"header_key_name"` - Timeout int `koanf:"timeout"` - - RoleClaimName string `koanf:"role_claim_name"` - GroupsClaimName string `koanf:"groups_claim_name"` - OrganizationClaimName string `koanf:"organization_claim_name"` - AdminRole string `koanf:"admin_role"` - SubscriberRole string `koanf:"subscriber_role"` - SuperAdminRole string `koanf:"super_admin_role"` -} - // Deployments holds deployment-specific configuration. type Deployments struct { MaxPerAPIGateway int `koanf:"max_per_api_gateway"` @@ -287,24 +293,6 @@ type Deployments struct { TimeoutDuration int `koanf:"timeout_duration"` } -// ArtifactLimits holds the maximum number of each artifact kind an organization -// may create. Each limit is optional: a value <= 0 (the default) means unlimited, -// so organizations may create as many artifacts of that kind as they want. -type ArtifactLimits struct { - MaxLLMProvidersPerOrg int `koanf:"max_llm_providers_per_org"` - MaxLLMProxiesPerOrg int `koanf:"max_llm_proxies_per_org"` - MaxMCPProxiesPerOrg int `koanf:"max_mcp_proxies_per_org"` - MaxWebSubAPIsPerOrg int `koanf:"max_websub_apis_per_org"` - MaxWebBrokerAPIsPerOrg int `koanf:"max_webbroker_apis_per_org"` -} - -// LimitReached reports whether an organization currently holding currentCount -// artifacts of some kind has reached its configured limit. A limit <= 0 means -// unlimited, in which case this always returns false. -func LimitReached(currentCount, limit int) bool { - return limit > 0 && currentCount >= limit -} - // APIKey holds API key-specific configuration. type APIKey struct { HashingAlgorithms []string `koanf:"hashing_algorithms"` @@ -335,11 +323,6 @@ func GetConfig() *Server { return settingInstance } -// EnvPrefix namespaces the environment variables that override configuration keys. -// The prefix is stripped before the remainder is mapped to a koanf key (see envToKoanfKey), -// e.g. APIP_CP_LOG_LEVEL -> log_level, APIP_CP_DATABASE_HOST -> database.host. -const EnvPrefix = "APIP_CP_" - // defaultFileSourceAllowlist is the platform-api's default set of directories that a // {{ file "..." }} config-interpolation token may read from. It can be overridden via // the shared APIP_CONFIG_FILE_SOURCE_ALLOWLIST env var (see configinterpolate.ResolveAllowlist). @@ -348,6 +331,12 @@ var defaultFileSourceAllowlist = []string{ "/secrets/platform-api", } +// platformAPIConfigKey is the top-level TOML table that all Platform API +// settings live under (e.g. [platform_api], [platform_api.database]). This +// namespacing lets a Platform API config file coexist with sibling services' +// sections in a shared deployment config. +const platformAPIConfigKey = "platform_api" + // LoadConfig loads configuration with priority: config file > defaults. // configPath may be empty — when omitted only env vars and defaults are used. func LoadConfig(configPath string) (*Server, error) { @@ -369,7 +358,7 @@ func LoadConfig(configPath string) (*Server, error) { return nil, err } - if err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{ + if err := k.UnmarshalWithConf(platformAPIConfigKey, cfg, koanf.UnmarshalConf{ DecoderConfig: &mapstructure.DecoderConfig{ TagName: "koanf", WeaklyTypedInput: true, @@ -389,10 +378,10 @@ func LoadConfig(configPath string) (*Server, error) { // format as the rest of the application, instead of slog's default handler. slog.SetDefault(logger.NewLogger(logger.Config{Level: cfg.LogLevel, Format: cfg.LogFormat})) - if err := validateTimeoutsConfig(&cfg.Timeouts); err != nil { + if err := validateLoggingConfig(cfg.LogLevel, cfg.LogFormat); err != nil { return nil, err } - if err := validateDefaultDevPortalConfig(&cfg.DefaultDevPortal); err != nil { + if err := validateTimeoutsConfig(&cfg.Timeouts); err != nil { return nil, err } if err := validateDeploymentsConfig(&cfg.Deployments); err != nil { @@ -401,22 +390,22 @@ func LoadConfig(configPath string) (*Server, error) { if err := validateEventHubConfig(&cfg.EventHub); err != nil { return nil, err } - if err := validateIDPConfig(&cfg.Auth.IDP); err != nil { + if err := validateAuthConfig(&cfg.Auth); err != nil { return nil, err } if err := validateWebhookConfig(&cfg.Webhook); err != nil { return nil, err } - if err := validateFileBasedConfig(&cfg.Auth.FileBased); err != nil { + if err := validateEncryptionKey(cfg.EncryptionKey); err != nil { return nil, err } - if err := validateAuthModeExclusivity(&cfg.Auth); err != nil { + if err := validateDatabaseConfig(&cfg.Database); err != nil { return nil, err } - if err := validateJWTConfig(&cfg.Auth.JWT, cfg.Auth.FileBased.Enabled); err != nil { + if err := validateListenersConfig(&cfg.Listeners); err != nil { return nil, err } - if err := validateEncryptionKey(cfg.EncryptionKey); err != nil { + if err := validateCORSConfig(&cfg.CORS); err != nil { return nil, err } @@ -465,8 +454,8 @@ func valid32ByteKey(keyStr string) bool { return false } -// fileBasedUsersDecodeHook handles decoding AUTH_FILE_BASED_USERS from a JSON string -// (env var format) in addition to the native TOML array-of-tables format. +// fileBasedUsersDecodeHook handles decoding auth.file.users from a JSON string +// (e.g. a {{ env }} token) in addition to the native TOML array-of-tables format. func fileBasedUsersDecodeHook() mapstructure.DecodeHookFuncType { return func(f reflect.Type, t reflect.Type, data interface{}) (interface{}, error) { if t != reflect.TypeOf(FileBasedUsers{}) { @@ -481,7 +470,7 @@ func fileBasedUsersDecodeHook() mapstructure.DecodeHookFuncType { } var users FileBasedUsers if err := json.Unmarshal([]byte(s), &users); err != nil { - return nil, fmt.Errorf("failed to parse AUTH_FILE_BASED_USERS as JSON: %w", err) + return nil, fmt.Errorf("failed to parse auth.file.users as JSON: %w", err) } return users, nil } @@ -495,10 +484,10 @@ func validateTimeoutsConfig(cfg *Timeouts) error { name string value time.Duration }{ - {"timeouts.read_header (TIMEOUTS_READ_HEADER)", cfg.ReadHeader}, - {"timeouts.read (TIMEOUTS_READ)", cfg.Read}, - {"timeouts.write (TIMEOUTS_WRITE)", cfg.Write}, - {"timeouts.idle (TIMEOUTS_IDLE)", cfg.Idle}, + {"listener_timeouts.read_header", cfg.ReadHeader}, + {"listener_timeouts.read", cfg.Read}, + {"listener_timeouts.write", cfg.Write}, + {"listener_timeouts.idle", cfg.Idle}, } { if f.value < 0 { return fmt.Errorf("%s must not be negative (got %s); use 0 to disable the timeout", f.name, f.value) @@ -506,70 +495,48 @@ func validateTimeoutsConfig(cfg *Timeouts) error { } if cfg.Read > 0 && cfg.ReadHeader > cfg.Read { return fmt.Errorf( - "timeouts.read_header (%s) must not exceed timeouts.read (%s): the header deadline would never be reached", + "listener_timeouts.read_header (%s) must not exceed listener_timeouts.read (%s): the header deadline would never be reached", cfg.ReadHeader, cfg.Read, ) } return nil } -func validateDefaultDevPortalConfig(cfg *DefaultDevPortal) error { - if !cfg.Enabled { - return nil - } - if cfg.Name == "" { - return fmt.Errorf("default DevPortal is enabled but name is not configured") - } - if cfg.Identifier == "" { - return fmt.Errorf("default DevPortal is enabled but identifier is not configured") - } - if cfg.APIUrl == "" { - return fmt.Errorf("default DevPortal is enabled but api_url is not configured") - } - if cfg.Hostname == "" { - return fmt.Errorf("default DevPortal is enabled but hostname is not configured") - } - if cfg.APIKey == "" { - return fmt.Errorf("default DevPortal is enabled but api_key is not configured") - } - if cfg.HeaderKeyName == "" { - return fmt.Errorf("default DevPortal header_key_name is not configured") - } - return nil -} - -// validateAuthModeExclusivity enforces that IDP (JWKS) auth is not enabled -// alongside the local auth modes. When an IDP is configured every token must be -// validated against its JWKS; leaving local HMAC auth on would let the server -// silently validate (file-based) or accept (local JWT) tokens with the local -// secret instead, shadowing the IDP. So enabling the IDP requires consciously -// turning the local modes off. -func validateAuthModeExclusivity(auth *Auth) error { - if !auth.IDP.Enabled { - return nil - } - if auth.JWT.Enabled { - return fmt.Errorf("auth.idp.enabled=true and auth.jwt.enabled=true are mutually exclusive: " + - "set auth.jwt.enabled=false to delegate authentication to the IDP (tokens are validated against auth.idp.jwks_url)") - } - if auth.FileBased.Enabled { - return fmt.Errorf("auth.idp.enabled=true and auth.file_based.enabled=true are mutually exclusive: " + - "set auth.file_based.enabled=false to delegate authentication to the IDP (tokens are validated against auth.idp.jwks_url)") +// validateAuthConfig validates the selected auth mode and the section that mode +// activates. Modes are mutually exclusive by construction: auth.mode is a single +// discriminator, so conflicting-mode configurations are inexpressible and only +// the active mode's section is validated. +func validateAuthConfig(auth *Auth) error { + switch auth.Mode { + case AuthModeJWT: + return validateJWTConfig(&auth.JWT) + case AuthModeFile: + if err := validateJWTConfig(&auth.JWT); err != nil { + return err + } + // TokenTTL only matters in file mode: the login endpoint mints tokens + // itself here, whereas in plain "jwt" mode tokens are minted externally + // and their expiry is whatever "exp" claim the issuer set. + if auth.JWT.TokenTTL <= 0 { + return fmt.Errorf("Auth.JWT.TokenTTL must be a positive duration when auth.mode is %q "+ + "(set auth.jwt.token_ttl, e.g. \"8h\")", AuthModeFile) + } + return validateFileBasedConfig(&auth.File) + case AuthModeIDP: + return validateIDPConfig(&auth.IDP) + default: + return fmt.Errorf("auth.mode must be %q, %q, or %q (got %q)", AuthModeJWT, AuthModeFile, AuthModeIDP, auth.Mode) } - return nil } // validateJWTConfig verifies the local HMAC JWT secret. The same secret signs and -// verifies the login tokens issued in file-based mode, so it is required whenever -// either JWT auth or file-based auth is enabled. The secret is never generated: a -// missing or malformed key fails startup. -func validateJWTConfig(jwt *JWT, fileBasedEnabled bool) error { - if !jwt.Enabled && !fileBasedEnabled { - return nil - } +// verifies the login tokens issued in file mode, so it is required in both the +// "jwt" and "file" auth modes. The secret is never generated: a missing or +// malformed key fails startup. +func validateJWTConfig(jwt *JWT) error { if jwt.SecretKey == "" { - return fmt.Errorf("Auth.JWT.SecretKey is required when JWT or file-based authentication is enabled " + - "(set auth.jwt.secret_key in config via {{ env }}/{{ file }})") + return fmt.Errorf("Auth.JWT.SecretKey is required when auth.mode is %q or %q "+ + "(set auth.jwt.secret_key in config via {{ env }}/{{ file }})", AuthModeJWT, AuthModeFile) } if !valid32ByteKey(jwt.SecretKey) { return fmt.Errorf("invalid Auth.JWT.SecretKey: must be 64 hex characters or base64 decoding to 32 bytes") @@ -591,15 +558,91 @@ func validateEncryptionKey(key string) error { return nil } -func validateIDPConfig(idp *IDP) error { - if !idp.Enabled { +// validateLoggingConfig rejects a log_level/log_format typo at startup instead +// of silently falling back to logger.NewLogger's default (info/json), which +// would leave an operator's requested verbosity or encoding silently ignored. +func validateLoggingConfig(level, format string) error { + switch strings.ToUpper(level) { + case "DEBUG", "INFO", "WARN", "WARNING", "ERROR": + default: + return fmt.Errorf("log_level must be one of \"DEBUG\", \"INFO\", \"WARN\", or \"ERROR\" (got %q)", level) + } + switch strings.ToLower(format) { + case "text", "json": + default: + return fmt.Errorf("log_format must be \"text\" or \"json\" (got %q)", format) + } + return nil +} + +// validateDatabaseConfig fails closed when the selected driver's required +// connection fields are missing, rather than surfacing an opaque driver-level +// connection error only once the server tries to open the database. +func validateDatabaseConfig(cfg *Database) error { + driver := strings.ToLower(cfg.Driver) + switch driver { + case "sqlite3", "postgres", "postgresql", "pgx", "sqlserver", "mssql": + default: + return fmt.Errorf("database.driver must be one of \"sqlite3\", \"postgres\", \"postgresql\", \"pgx\", "+ + "\"sqlserver\", or \"mssql\" (got %q)", cfg.Driver) + } + if driver == "sqlite3" { return nil } + if cfg.Host == "" { + return fmt.Errorf("database.host is required when database.driver is %q", cfg.Driver) + } + if cfg.Port <= 0 || cfg.Port > 65535 { + return fmt.Errorf("database.port must be between 1 and 65535 when database.driver is %q (got %d)", cfg.Driver, cfg.Port) + } + if cfg.Name == "" { + return fmt.Errorf("database.name is required when database.driver is %q", cfg.Driver) + } + if cfg.User == "" { + return fmt.Errorf("database.user is required when database.driver is %q", cfg.Driver) + } + switch cfg.SSLMode { + case "", "disable", "require", "verify-ca", "verify-full": + default: + return fmt.Errorf("database.ssl_mode must be \"disable\", \"require\", \"verify-ca\", or \"verify-full\" (got %q)", cfg.SSLMode) + } + return nil +} + +// validateListenersConfig rejects an out-of-range port on either listener and +// a port collision when both listeners are enabled, rather than failing at +// bind time with a generic "address already in use" error. +func validateListenersConfig(l *ServerListeners) error { + if l.HTTP.Enabled && (l.HTTP.Port <= 0 || l.HTTP.Port > 65535) { + return fmt.Errorf("server.http.port must be between 1 and 65535 (got %d)", l.HTTP.Port) + } + if l.HTTPS.Enabled && (l.HTTPS.Port <= 0 || l.HTTPS.Port > 65535) { + return fmt.Errorf("server.https.port must be between 1 and 65535 (got %d)", l.HTTPS.Port) + } + if l.HTTP.Enabled && l.HTTPS.Enabled && l.HTTP.Port == l.HTTPS.Port { + return fmt.Errorf("server.http.port and server.https.port must differ when both listeners are enabled (both are %d)", l.HTTP.Port) + } + return nil +} + +// validateCORSConfig rejects a wildcard origin: CORS.AllowedOrigins is used +// for credentialed cross-origin requests, and wildcard origins cannot be +// combined with credentials without opening a cross-tenant exploit path. +func validateCORSConfig(c *CORS) error { + for _, o := range c.AllowedOrigins { + if o == "*" { + return fmt.Errorf("cors.allowed_origins must not contain \"*\" (wildcard origins cannot be combined with credentialed requests)") + } + } + return nil +} + +func validateIDPConfig(idp *IDP) error { if idp.JWKSUrl == "" { - return fmt.Errorf("auth.idp.enabled=true requires auth.idp.jwks_url to be configured") + return fmt.Errorf("auth.mode=%q requires auth.idp.jwks_url to be configured", AuthModeIDP) } if len(idp.Issuer) == 0 { - return fmt.Errorf("auth.idp.enabled=true requires auth.idp.issuer to be configured") + return fmt.Errorf("auth.mode=%q requires auth.idp.issuer to be configured", AuthModeIDP) } switch idp.ValidationMode { case "scope", "role": @@ -613,24 +656,21 @@ func validateIDPConfig(idp *IDP) error { } func validateFileBasedConfig(cfg *FileBased) error { - if !cfg.Enabled { - return nil - } if cfg.Organization.ID == "" { - return fmt.Errorf("auth.file_based.enabled=true requires auth.file_based.organization.id to be configured") + return fmt.Errorf("auth.mode=%q requires auth.file.organization.id to be configured", AuthModeFile) } if cfg.Organization.DisplayName == "" { - return fmt.Errorf("auth.file_based.enabled=true requires auth.file_based.organization.display_name to be configured") + return fmt.Errorf("auth.mode=%q requires auth.file.organization.display_name to be configured", AuthModeFile) } if len(cfg.Users) == 0 { - return fmt.Errorf("auth.file_based.enabled=true requires at least one user in auth.file_based.users") + return fmt.Errorf("auth.mode=%q requires at least one user in auth.file.users", AuthModeFile) } 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) + return fmt.Errorf("auth.file.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 fmt.Errorf("auth.file.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 375178d21..3fba560fa 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -7,23 +7,26 @@ # License at http://www.apache.org/licenses/LICENSE-2.0 # -------------------------------------------------------------------- # -# Platform API configuration — quickstart (file-based auth) +# Platform API configuration — local development # -# This file is read by the Platform API (Go binary). +# This file is read by the Platform API (Go binary): +# go run ./cmd/main.go -config config/config.toml # -# 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' +# Auth mode is "jwt" (locally-signed HMAC tokens, no local users). To use +# username/password login instead, set [auth] mode = "file" and add +# [auth.file.organization] / [[auth.file.users]] blocks — see +# config-template.toml for the full reference. # -------------------------------------------------------------------- # --------------------------------------------------------------------------- -# Server +# All Platform API settings live under [platform_api] / [platform_api.*]. # --------------------------------------------------------------------------- -log_level = "INFO" # DEBUG | INFO | WARN | ERROR -port = "9243" +[platform_api] -# Validate OAuth2 scopes on incoming requests. -enable_scope_validation = true +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' # DEBUG | INFO | WARN | ERROR # --------------------------------------------------------------------------- # Encryption @@ -35,27 +38,21 @@ 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) +[platform_api.database] +driver = '{{ env "APIP_CP_DATABASE_DRIVER" "sqlite3" }}' # "sqlite3" or "postgres" +path = '{{ env "APIP_CP_DATABASE_PATH" "./data/api_platform.db" }}' # SQLite file path (ignored for postgres) # --------------------------------------------------------------------------- # Authentication # --------------------------------------------------------------------------- - -# JWT (local HMAC) — issues signed tokens after file-based login. -[auth.jwt] -enabled = true -issuer = "platform-api" -secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' -skip_validation = false - -# IDP (JWKS-based) — disabled in quickstart mode. -# Enable and configure when using Asgardeo, Keycloak, Auth0, etc. -[auth.idp] -enabled = false -audience = [] - -# File-based auth — local username/password login. -[auth.file_based] -enabled = false +# mode selects exactly one auth mode: "jwt" | "file" | "idp". +# "jwt" verifies locally-signed HMAC JWTs; tokens are minted externally +# (e.g. by the Developer Portal using the shared secret below). +[platform_api.auth] +mode = '{{ env "APIP_CP_AUTH_MODE" "jwt" }}' +scope_validation = '{{ env "APIP_CP_AUTH_SCOPE_VALIDATION" "true" }}' # validate OAuth2 scopes on incoming requests + +# JWT (local HMAC) — used in the "jwt" and "file" modes. +[platform_api.auth.jwt] +issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' +secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' diff --git a/platform-api/config/config_test.go b/platform-api/config/config_test.go index 635960df7..d3491ec17 100644 --- a/platform-api/config/config_test.go +++ b/platform-api/config/config_test.go @@ -39,10 +39,10 @@ const ( // interpolation. Environment variables reach config ONLY through these tokens now // (there is no direct env-key override), so tests must go through a config file. const validKeysBase = ` +[platform_api] encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' -[auth.jwt] -enabled = true +[platform_api.auth.jwt] secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' ` @@ -77,8 +77,7 @@ func TestLoadConfig_MissingEncryptionKey_Errors(t *testing.T) { // Encryption key omitted entirely; the JWT secret resolves so the JWT check passes first. _, err := loadTOML(t, ` -[auth.jwt] -enabled = true +[platform_api.auth.jwt] secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' `) require.Error(t, err) @@ -90,25 +89,23 @@ func TestLoadConfig_InvalidEncryptionKey_Errors(t *testing.T) { t.Setenv("APIP_CP_AUTH_JWT_SECRET_KEY", validJWTKey) _, err := loadTOML(t, ` +[platform_api] encryption_key = "not-a-valid-32-byte-key" -[auth.jwt] -enabled = true +[platform_api.auth.jwt] secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' `) require.Error(t, err) assert.Contains(t, err.Error(), "invalid EncryptionKey") } -// The JWT secret is required (JWT auth is enabled) and never generated. +// The JWT secret is required (default auth mode is "jwt") and never generated. func TestLoadConfig_MissingJWTSecretKey_Errors(t *testing.T) { t.Setenv("APIP_CP_ENCRYPTION_KEY", validInlineKey) _, err := loadTOML(t, ` +[platform_api] encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' - -[auth.jwt] -enabled = true `) require.Error(t, err) assert.Contains(t, err.Error(), "Auth.JWT.SecretKey is required") @@ -119,10 +116,10 @@ func TestLoadConfig_InvalidJWTSecretKey_Errors(t *testing.T) { t.Setenv("APIP_CP_ENCRYPTION_KEY", validInlineKey) _, err := loadTOML(t, ` +[platform_api] encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' -[auth.jwt] -enabled = true +[platform_api.auth.jwt] secret_key = "not-a-valid-32-byte-key" `) require.Error(t, err) @@ -151,41 +148,76 @@ func init() { } } -// validateAuthModeExclusivity: IDP (JWKS) auth must not be enabled alongside the -// local JWT or file-based modes — the server must fail fast so operators turn the -// local modes off consciously and all tokens are validated against the IDP JWKS. -func TestValidateAuthModeExclusivity(t *testing.T) { +// validateAuthConfig: auth.mode is a single discriminator, so exactly one mode +// is active and only that mode's section is validated. Unknown modes fail fast. +func TestValidateAuthConfig(t *testing.T) { tests := []struct { name string auth Auth - wantErr bool + wantErr string }{ { - name: "idp disabled — local modes allowed", - auth: Auth{IDP: IDP{Enabled: false}, JWT: JWT{Enabled: true}, FileBased: FileBased{Enabled: true}}, - wantErr: false, + name: "jwt mode with valid secret", + auth: Auth{Mode: AuthModeJWT, JWT: JWT{SecretKey: validJWTKey}}, + }, + { + name: "jwt mode without secret", + auth: Auth{Mode: AuthModeJWT}, + wantErr: "Auth.JWT.SecretKey is required", + }, + { + name: "file mode without token_ttl", + auth: Auth{Mode: AuthModeFile, JWT: JWT{SecretKey: validJWTKey}}, + wantErr: "Auth.JWT.TokenTTL must be a positive duration", + }, + { + name: "file mode requires org and users", + auth: Auth{Mode: AuthModeFile, JWT: JWT{SecretKey: validJWTKey, TokenTTL: time.Hour}}, + // Default org fields are empty in a zero-value Auth — users check fires + // after the org checks. + wantErr: "auth.file.organization.id", + }, + { + name: "file mode fully configured", + auth: Auth{ + Mode: AuthModeFile, + JWT: JWT{SecretKey: validJWTKey, TokenTTL: time.Hour}, + File: FileBased{ + Organization: FileBasedOrg{ID: "default", DisplayName: "Default"}, + Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash"}}, + }, + }, + }, + { + name: "idp mode requires jwks_url", + auth: Auth{Mode: AuthModeIDP, IDP: IDP{ValidationMode: "scope"}}, + wantErr: "auth.idp.jwks_url", }, { - name: "idp only", - auth: Auth{IDP: IDP{Enabled: true}, JWT: JWT{Enabled: false}, FileBased: FileBased{Enabled: false}}, - wantErr: false, + name: "idp mode fully configured", + auth: Auth{Mode: AuthModeIDP, IDP: IDP{ + JWKSUrl: "https://idp.example.com/jwks", + Issuer: []string{"https://idp.example.com"}, + ValidationMode: "scope", + }}, }, { - name: "idp and jwt both enabled", - auth: Auth{IDP: IDP{Enabled: true}, JWT: JWT{Enabled: true}, FileBased: FileBased{Enabled: false}}, - wantErr: true, + name: "unknown mode rejected", + auth: Auth{Mode: "basic"}, + wantErr: "auth.mode must be", }, { - name: "idp and file_based both enabled", - auth: Auth{IDP: IDP{Enabled: true}, JWT: JWT{Enabled: false}, FileBased: FileBased{Enabled: true}}, - wantErr: true, + name: "empty mode rejected", + auth: Auth{}, + wantErr: "auth.mode must be", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validateAuthModeExclusivity(&tt.auth) - if tt.wantErr { - assert.Error(t, err) + err := validateAuthConfig(&tt.auth) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) } else { assert.NoError(t, err) } @@ -199,37 +231,40 @@ func TestValidateAuthModeExclusivity(t *testing.T) { func TestLoadConfig_HTTPSEnabled_DefaultsToTrue(t *testing.T) { cfg, err := loadWithKeys(t, "") require.NoError(t, err) - assert.True(t, cfg.HTTPS.Enabled, "https.enabled must default to true when unset") - assert.Equal(t, "9243", cfg.HTTPS.Port, "https.port must default to 9243") - assert.False(t, cfg.HTTP.Enabled, "http.enabled must default to false when unset") + assert.True(t, cfg.Listeners.HTTPS.Enabled, "server.https.enabled must default to true when unset") + assert.Equal(t, 9243, cfg.Listeners.HTTPS.Port, "server.https.port must default to 9243") + assert.False(t, cfg.Listeners.HTTP.Enabled, "server.http.enabled must default to false when unset") + assert.Equal(t, "./data/certs/cert.pem", cfg.Listeners.HTTPS.TLS.CertFile) + assert.Equal(t, "./data/certs/key.pem", cfg.Listeners.HTTPS.TLS.KeyFile) } // A {{ env }} token feeding a bool field must survive koanf's weakly-typed decode, // so an operator can disable the TLS listener by pointing the field at an env var. func TestLoadConfig_HTTPSEnabled_TokenDisables(t *testing.T) { - t.Setenv("APIP_CP_HTTPS_ENABLED", "false") + t.Setenv("APIP_CP_SERVER_HTTPS_ENABLED", "false") cfg, err := loadWithKeys(t, ` -[https] -enabled = '{{ env "APIP_CP_HTTPS_ENABLED" }}' +[platform_api.server.https] +enabled = '{{ env "APIP_CP_SERVER_HTTPS_ENABLED" }}' `) require.NoError(t, err) - assert.False(t, cfg.HTTPS.Enabled, "https.enabled from a {{ env }} token must decode to false") + assert.False(t, cfg.Listeners.HTTPS.Enabled, "server.https.enabled from a {{ env }} token must decode to false") } -// The plain-HTTP listener can be enabled independently on its own port via tokens. +// The plain-HTTP listener can be enabled independently on its own port via tokens; +// a numeric string from an env var must decode into the int port field. func TestLoadConfig_HTTPListener_TokenEnables(t *testing.T) { - t.Setenv("APIP_CP_HTTP_ENABLED", "true") - t.Setenv("APIP_CP_HTTP_PORT", "9080") + t.Setenv("APIP_CP_SERVER_HTTP_ENABLED", "true") + t.Setenv("APIP_CP_SERVER_HTTP_PORT", "9080") cfg, err := loadWithKeys(t, ` -[http] -enabled = '{{ env "APIP_CP_HTTP_ENABLED" }}' -port = '{{ env "APIP_CP_HTTP_PORT" }}' +[platform_api.server.http] +enabled = '{{ env "APIP_CP_SERVER_HTTP_ENABLED" }}' +port = '{{ env "APIP_CP_SERVER_HTTP_PORT" }}' `) require.NoError(t, err) - assert.True(t, cfg.HTTP.Enabled, "http.enabled from a {{ env }} token must decode to true") - assert.Equal(t, "9080", cfg.HTTP.Port) + assert.True(t, cfg.Listeners.HTTP.Enabled, "server.http.enabled from a {{ env }} token must decode to true") + assert.Equal(t, 9080, cfg.Listeners.HTTP.Port) } // Listener timeouts must be finite by default, so a deployment that never sets @@ -245,17 +280,17 @@ func TestLoadConfig_Timeouts_DefaultToFiniteValues(t *testing.T) { // Duration strings resolved from {{ env }} tokens must decode into time.Duration fields. func TestLoadConfig_Timeouts_TokenOverride(t *testing.T) { - t.Setenv("APIP_CP_TIMEOUTS_READ_HEADER", "5s") - t.Setenv("APIP_CP_TIMEOUTS_READ", "30s") - t.Setenv("APIP_CP_TIMEOUTS_WRITE", "2m") - t.Setenv("APIP_CP_TIMEOUTS_IDLE", "90s") + t.Setenv("APIP_CP_LISTENER_TIMEOUTS_READ_HEADER", "5s") + t.Setenv("APIP_CP_LISTENER_TIMEOUTS_READ", "30s") + t.Setenv("APIP_CP_LISTENER_TIMEOUTS_WRITE", "2m") + t.Setenv("APIP_CP_LISTENER_TIMEOUTS_IDLE", "90s") cfg, err := loadWithKeys(t, ` -[timeouts] -read_header = '{{ env "APIP_CP_TIMEOUTS_READ_HEADER" }}' -read = '{{ env "APIP_CP_TIMEOUTS_READ" }}' -write = '{{ env "APIP_CP_TIMEOUTS_WRITE" }}' -idle = '{{ env "APIP_CP_TIMEOUTS_IDLE" }}' +[platform_api.listener_timeouts] +read_header = '{{ env "APIP_CP_LISTENER_TIMEOUTS_READ_HEADER" }}' +read = '{{ env "APIP_CP_LISTENER_TIMEOUTS_READ" }}' +write = '{{ env "APIP_CP_LISTENER_TIMEOUTS_WRITE" }}' +idle = '{{ env "APIP_CP_LISTENER_TIMEOUTS_IDLE" }}' `) require.NoError(t, err) assert.Equal(t, 5*time.Second, cfg.Timeouts.ReadHeader) @@ -268,11 +303,11 @@ idle = '{{ env "APIP_CP_TIMEOUTS_IDLE" }}' // than being silently replaced by the default. func TestLoadConfig_Timeouts_ZeroDisablesTimeout(t *testing.T) { cfg, err := loadWithKeys(t, ` -[timeouts] +[platform_api.listener_timeouts] write = "0s" `) require.NoError(t, err) - assert.Zero(t, cfg.Timeouts.Write, "timeouts.write = 0 must disable the write timeout") + assert.Zero(t, cfg.Timeouts.Write, "listener_timeouts.write = 0 must disable the write timeout") } // A negative duration would expire immediately and break every request; a @@ -281,7 +316,7 @@ write = "0s" func TestLoadConfig_Timeouts_RejectsInvalidValues(t *testing.T) { t.Run("negative", func(t *testing.T) { _, err := loadWithKeys(t, ` -[timeouts] +[platform_api.listener_timeouts] read = "-1s" `) require.Error(t, err) @@ -290,7 +325,7 @@ read = "-1s" t.Run("read_header exceeds read", func(t *testing.T) { _, err := loadWithKeys(t, ` -[timeouts] +[platform_api.listener_timeouts] read_header = "30s" read = "10s" `) diff --git a/platform-api/config/default_config.go b/platform-api/config/default_config.go index 755260671..a21aea3db 100644 --- a/platform-api/config/default_config.go +++ b/platform-api/config/default_config.go @@ -31,7 +31,6 @@ func defaultConfig() *Server { DBSchemaPath: "./internal/database/schema.sql", OpenAPISpecPath: "./resources/openapi.yaml", LLMTemplateDefinitionsPath: "./resources/default-llm-provider-templates", - EnableScopeValidation: true, Database: Database{ Driver: "sqlite3", Path: "./data/api_platform.db", @@ -40,6 +39,10 @@ func defaultConfig() *Server { ConnMaxLifetime: 300, }, Auth: Auth{ + // Default mode verifies locally-signed HMAC JWTs; the quickstart config + // selects "file" to add username/password login on top of it. + Mode: AuthModeJWT, + ScopeValidation: true, // SkipPaths bypasses JWT/IDP auth middleware. Paths below the health/metrics // probes are internal gateway routes authenticated via gateway token instead. SkipPaths: []string{ @@ -61,12 +64,10 @@ func defaultConfig() *Server { "/api/internal/" + constants.APIVersion + "/webhook/events", }, JWT: JWT{ - Enabled: true, - Issuer: "platform-api", - SkipValidation: true, + Issuer: "platform-api", + TokenTTL: time.Hour, }, IDP: IDP{ - Enabled: false, ValidationMode: "scope", ClaimMappings: IDPClaimMappings{ OrganizationClaimName: "organization", @@ -78,8 +79,7 @@ func defaultConfig() *Server { ScopeClaimName: "scope", }, }, - FileBased: FileBased{ - Enabled: false, + File: FileBased{ Organization: FileBasedOrg{ ID: "default", DisplayName: "Default", @@ -104,45 +104,29 @@ func defaultConfig() *Server { MetricsLogEnabled: true, MetricsLogInterval: 10, }, - DefaultDevPortal: DefaultDevPortal{ - Enabled: false, - Name: "Default DevPortal", - Identifier: "default", - APIUrl: "http://localhost:3001", - Hostname: "devportal.local", - APIKey: "default-api-key", - HeaderKeyName: "x-wso2-api-key", - Timeout: 10, - RoleClaimName: "roles", - GroupsClaimName: "groups", - OrganizationClaimName: "organizationID", - AdminRole: "admin", - SubscriberRole: "Internal/subscriber", - SuperAdminRole: "superAdmin", - }, Deployments: Deployments{ MaxPerAPIGateway: 20, TimeoutEnabled: true, TimeoutInterval: 20, TimeoutDuration: 60, }, - // ArtifactLimits are unlimited by default: every limit is left at its - // zero value, which LimitReached treats as "no limit". Operators can cap - // a specific artifact kind per organization by setting a positive value - // (config file key artifact_limits.max_* or env ARTIFACT_LIMITS_MAX_*). - ArtifactLimits: ArtifactLimits{}, // By default the HTTPS listener serves on 9243 and the plain-HTTP listener // is off — preserving the historical single-TLS-port behavior. Enable the - // HTTP listener (and/or move ports) via the [http] / [https] config or the - // HTTP_* / HTTPS_* env vars. - HTTP: HTTPListener{ - Enabled: false, - Port: "9080", - }, - HTTPS: HTTPSListener{ - Enabled: true, - Port: "9243", - CertDir: "./data/certs", + // HTTP listener (and/or move ports) via the [server.http] / [server.https] + // config sections. + Listeners: ServerListeners{ + HTTP: HTTPListener{ + Enabled: false, + Port: 9080, + }, + HTTPS: HTTPSListener{ + Enabled: true, + Port: 9243, + TLS: ListenerTLS{ + CertFile: "./data/certs/cert.pem", + KeyFile: "./data/certs/key.pem", + }, + }, }, // Finite by default so a slow or idle peer cannot hold a connection open // indefinitely. Write is the loosest of the four because some handlers diff --git a/platform-api/internal/apperror/catalog.go b/platform-api/internal/apperror/catalog.go index 5d2838478..48aed5d05 100644 --- a/platform-api/internal/apperror/catalog.go +++ b/platform-api/internal/apperror/catalog.go @@ -77,14 +77,12 @@ var ( LLMProviderNotFound = def(CodeLLMProviderNotFound, http.StatusNotFound, "The specified LLM provider could not be found.") LLMProviderRefNotFound = def(CodeLLMProviderRefNotFound, http.StatusBadRequest, "The referenced LLM provider could not be found.") LLMProviderExists = def(CodeLLMProviderExists, http.StatusConflict, "An LLM provider with this ID already exists.") - LLMProviderLimitReached = def(CodeLLMProviderLimitReached, http.StatusConflict, "LLM provider limit reached for the organization.") LLMProviderAPIKeyNotFound = def(CodeLLMProviderAPIKeyNotFound, http.StatusNotFound, "The specified API key could not be found.") LLMProviderAPIKeyForbidden = def(CodeLLMProviderAPIKeyForbidden, http.StatusForbidden, "You do not have permission to access this API key.") LLMProviderDeploymentValidationFailed = def(CodeLLMProviderDeploymentValidationFailed, http.StatusBadRequest, "%s") LLMProxyNotFound = def(CodeLLMProxyNotFound, http.StatusNotFound, "The specified LLM proxy could not be found.") LLMProxyExists = def(CodeLLMProxyExists, http.StatusConflict, "An LLM proxy with this ID already exists.") - LLMProxyLimitReached = def(CodeLLMProxyLimitReached, http.StatusConflict, "LLM proxy limit reached for the organization.") LLMProxyAPIKeyNotFound = def(CodeLLMProxyAPIKeyNotFound, http.StatusNotFound, "The specified API key could not be found.") LLMProxyAPIKeyForbidden = def(CodeLLMProxyAPIKeyForbidden, http.StatusForbidden, "You do not have permission to access this API key.") LLMProxyDeploymentValidationFailed = def(CodeLLMProxyDeploymentValidationFailed, http.StatusBadRequest, "%s") @@ -131,7 +129,6 @@ var ( var ( MCPProxyNotFound = def(CodeMCPProxyNotFound, http.StatusNotFound, "The specified MCP proxy could not be found.") MCPProxyExists = def(CodeMCPProxyExists, http.StatusConflict, "An MCP proxy with this ID already exists.") - MCPProxyLimitReached = def(CodeMCPProxyLimitReached, http.StatusConflict, "MCP proxy limit reached for the organization.") MCPProxyDeploymentValidationFailed = def(CodeMCPProxyDeploymentValidationFailed, http.StatusBadRequest, "%s") ) @@ -190,12 +187,10 @@ var ( // WebSub / WebBroker API entries. var ( - WebSubAPINotFound = def(CodeWebSubAPINotFound, http.StatusNotFound, "The specified WebSub API could not be found.") - WebSubAPIExists = def(CodeWebSubAPIExists, http.StatusConflict, "A WebSub API with this ID already exists.") - WebSubAPILimitReached = def(CodeWebSubAPILimitReached, http.StatusConflict, "WebSub API limit reached for the organization.") - WebBrokerAPINotFound = def(CodeWebBrokerAPINotFound, http.StatusNotFound, "The specified WebBroker API could not be found.") - WebBrokerAPIExists = def(CodeWebBrokerAPIExists, http.StatusConflict, "A WebBroker API with this ID already exists.") - WebBrokerAPILimitReached = def(CodeWebBrokerAPILimitReached, http.StatusConflict, "WebBroker API limit reached for the organization.") + WebSubAPINotFound = def(CodeWebSubAPINotFound, http.StatusNotFound, "The specified WebSub API could not be found.") + WebSubAPIExists = def(CodeWebSubAPIExists, http.StatusConflict, "A WebSub API with this ID already exists.") + WebBrokerAPINotFound = def(CodeWebBrokerAPINotFound, http.StatusNotFound, "The specified WebBroker API could not be found.") + WebBrokerAPIExists = def(CodeWebBrokerAPIExists, http.StatusConflict, "A WebBroker API with this ID already exists.") ) // HMAC secret entries. The 32-character minimum is a fixed, publicly diff --git a/platform-api/internal/apperror/codes.go b/platform-api/internal/apperror/codes.go index fcf757fd0..84296474f 100644 --- a/platform-api/internal/apperror/codes.go +++ b/platform-api/internal/apperror/codes.go @@ -39,12 +39,10 @@ const ( const ( CodeLLMProviderNotFound = "LLM_PROVIDER_NOT_FOUND" CodeLLMProviderExists = "LLM_PROVIDER_EXISTS" - CodeLLMProviderLimitReached = "LLM_PROVIDER_LIMIT_REACHED" CodeLLMProviderAPIKeyNotFound = "LLM_PROVIDER_API_KEY_NOT_FOUND" CodeLLMProviderDeploymentValidationFailed = "LLM_PROVIDER_DEPLOYMENT_VALIDATION_FAILED" CodeLLMProxyNotFound = "LLM_PROXY_NOT_FOUND" CodeLLMProxyExists = "LLM_PROXY_EXISTS" - CodeLLMProxyLimitReached = "LLM_PROXY_LIMIT_REACHED" CodeLLMProxyAPIKeyNotFound = "LLM_PROXY_API_KEY_NOT_FOUND" CodeLLMProviderRefNotFound = "LLM_PROVIDER_REF_NOT_FOUND" CodeLLMProxyDeploymentValidationFailed = "LLM_PROXY_DEPLOYMENT_VALIDATION_FAILED" @@ -103,7 +101,6 @@ const ( const ( CodeMCPProxyNotFound = "MCP_PROXY_NOT_FOUND" CodeMCPProxyExists = "MCP_PROXY_EXISTS" - CodeMCPProxyLimitReached = "MCP_PROXY_LIMIT_REACHED" CodeMCPProxyDeploymentValidationFailed = "MCP_PROXY_DEPLOYMENT_VALIDATION_FAILED" ) @@ -181,12 +178,10 @@ const ( // WebSub and WebBroker API domain codes, raised by the event-gateway plugin // and by the two gateway-internal artifact lookups. const ( - CodeWebSubAPINotFound = "WEBSUB_API_NOT_FOUND" - CodeWebSubAPIExists = "WEBSUB_API_EXISTS" - CodeWebSubAPILimitReached = "WEBSUB_API_LIMIT_REACHED" - CodeWebBrokerAPINotFound = "WEBBROKER_API_NOT_FOUND" - CodeWebBrokerAPIExists = "WEBBROKER_API_EXISTS" - CodeWebBrokerAPILimitReached = "WEBBROKER_API_LIMIT_REACHED" + CodeWebSubAPINotFound = "WEBSUB_API_NOT_FOUND" + CodeWebSubAPIExists = "WEBSUB_API_EXISTS" + CodeWebBrokerAPINotFound = "WEBBROKER_API_NOT_FOUND" + CodeWebBrokerAPIExists = "WEBBROKER_API_EXISTS" ) // HMAC secret domain codes (WebSub subscriber callback signing secrets). diff --git a/platform-api/internal/constants/constants.go b/platform-api/internal/constants/constants.go index 390154f21..7aff27343 100644 --- a/platform-api/internal/constants/constants.go +++ b/platform-api/internal/constants/constants.go @@ -137,11 +137,6 @@ const ( DeploymentLimitBuffer = 100 ) -// Per-organization artifact creation limits are no longer hardcoded here. They are -// configured via config.ArtifactLimits (config file keys artifact_limits.max_* or -// env vars ARTIFACT_LIMITS_MAX_*) and default to unlimited. Enforcement uses -// config.LimitReached, which treats a limit <= 0 as "no limit". - // Gateway artifact apiVersion (the `apiVersion:` field on deployment artifacts). // GatewayApiVersionV1Alpha1 is the legacy value for gateways < 1.2.0 — use it only // in down-convert paths (gatewaytranslator) that must produce artifacts @@ -162,8 +157,8 @@ const ( // Custom Policy ManagedBy constants const ( - PolicyManagedByOrganization = "organization" - PolicyManagedByWSO2 = "wso2" + PolicyManagedByOrganization = "organization" + PolicyManagedByWSO2 = "wso2" PolicyManagedByLegacyCustomer = "customer" ) diff --git a/platform-api/internal/handler/auth_login.go b/platform-api/internal/handler/auth_login.go index aab14a85e..2d19d6f10 100644 --- a/platform-api/internal/handler/auth_login.go +++ b/platform-api/internal/handler/auth_login.go @@ -66,7 +66,7 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) error { return apperror.ValidationFailed.New("username and password are required") } - fileBasedAuth := &h.cfg.Auth.FileBased + fileBasedAuth := &h.cfg.Auth.File var matched *config.FileBasedUser for i := range fileBasedAuth.Users { if fileBasedAuth.Users[i].Username == req.Username { @@ -86,7 +86,7 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) error { return apperror.Unauthorized.New().WithLogMessage("login failed: password mismatch") } - expiry := time.Now().Add(8 * time.Hour) + expiry := time.Now().Add(h.cfg.Auth.JWT.TokenTTL) claims := jwt.MapClaims{ "sub": matched.Username, "username": matched.Username, diff --git a/platform-api/internal/handler/llm_provider_integration_test.go b/platform-api/internal/handler/llm_provider_integration_test.go index 789eb62cb..e7b359600 100644 --- a/platform-api/internal/handler/llm_provider_integration_test.go +++ b/platform-api/internal/handler/llm_provider_integration_test.go @@ -44,9 +44,8 @@ const provOrg = "org-prov-it-001" // setupLLMProviderEnv builds the real route -> handler -> service -> repository // stack over an in-memory SQLite DB, seeded with the shipped built-in templates -// and a test organization. maxProviders <= 0 means unlimited (see -// config.ArtifactLimits), letting individual tests exercise the limit-reached path. -func setupLLMProviderEnv(t *testing.T, maxProviders int) (http.Handler, func()) { +// and a test organization. +func setupLLMProviderEnv(t *testing.T) (http.Handler, func()) { t.Helper() dbPath := filepath.Join(t.TempDir(), "test.db") @@ -84,7 +83,7 @@ func setupLLMProviderEnv(t *testing.T, maxProviders int) (http.Handler, func()) } identityService := service.NewIdentityService(repository.NewUserIdentityMappingRepo(db)) - cfg := &config.Server{ArtifactLimits: config.ArtifactLimits{MaxLLMProvidersPerOrg: maxProviders}} + cfg := &config.Server{} providerService := service.NewLLMProviderService( providerRepo, templateRepo, orgRepo, seeder, @@ -146,7 +145,7 @@ func validProviderBody(idSuffix string) string { // ---- Auth ------------------------------------------------------------- func TestLLMProviderHTTP_CreateRequiresOrg_401(t *testing.T) { - r, cleanup := setupLLMProviderEnv(t, 0) + r, cleanup := setupLLMProviderEnv(t) defer cleanup() w := doProviderJSON(t, r, http.MethodPost, provBase, validProviderBody(""), false) @@ -165,7 +164,7 @@ func TestLLMProviderHTTP_CreateRequiresOrg_401(t *testing.T) { // ---- Create: validation errors ----------------------------------------- func TestLLMProviderHTTP_Create_InvalidBody_400(t *testing.T) { - r, cleanup := setupLLMProviderEnv(t, 0) + r, cleanup := setupLLMProviderEnv(t) defer cleanup() w := doProviderJSON(t, r, http.MethodPost, provBase, `not-json`, true) @@ -175,7 +174,7 @@ func TestLLMProviderHTTP_Create_InvalidBody_400(t *testing.T) { } func TestLLMProviderHTTP_Create_MissingRequiredFields_400(t *testing.T) { - r, cleanup := setupLLMProviderEnv(t, 0) + r, cleanup := setupLLMProviderEnv(t) defer cleanup() // Missing displayName/version/template entirely. @@ -190,7 +189,7 @@ func TestLLMProviderHTTP_Create_MissingRequiredFields_400(t *testing.T) { } func TestLLMProviderHTTP_Create_UnknownTemplate_400(t *testing.T) { - r, cleanup := setupLLMProviderEnv(t, 0) + r, cleanup := setupLLMProviderEnv(t) defer cleanup() body := `{ @@ -209,7 +208,7 @@ func TestLLMProviderHTTP_Create_UnknownTemplate_400(t *testing.T) { // ---- Create: conflicts -------------------------------------------------- func TestLLMProviderHTTP_Create_DuplicateHandle_409(t *testing.T) { - r, cleanup := setupLLMProviderEnv(t, 0) + r, cleanup := setupLLMProviderEnv(t) defer cleanup() body := `{ @@ -234,24 +233,10 @@ func TestLLMProviderHTTP_Create_DuplicateHandle_409(t *testing.T) { } } -func TestLLMProviderHTTP_Create_LimitReached_409(t *testing.T) { - r, cleanup := setupLLMProviderEnv(t, 1) // only one provider allowed for this org - defer cleanup() - - if w := doProviderJSON(t, r, http.MethodPost, provBase, validProviderBody("A"), true); w.Code != http.StatusCreated { - t.Fatalf("first create: expected 201, got %d: %s", w.Code, w.Body.String()) - } - - w := doProviderJSON(t, r, http.MethodPost, provBase, validProviderBody("B"), true) - if w.Code != http.StatusConflict { - t.Fatalf("limit reached: expected 409, got %d: %s", w.Code, w.Body.String()) - } -} - // ---- Create: secret placeholder validation ------------------------------ func TestLLMProviderHTTP_Create_MissingSecretRef_400(t *testing.T) { - r, cleanup := setupLLMProviderEnv(t, 0) + r, cleanup := setupLLMProviderEnv(t) defer cleanup() body := `{ @@ -275,7 +260,7 @@ func TestLLMProviderHTTP_Create_MissingSecretRef_400(t *testing.T) { // ---- Create: happy path (sanity check for the error tests above) ------- func TestLLMProviderHTTP_Create_Success(t *testing.T) { - r, cleanup := setupLLMProviderEnv(t, 0) + r, cleanup := setupLLMProviderEnv(t) defer cleanup() w := doProviderJSON(t, r, http.MethodPost, provBase, validProviderBody(""), true) diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index 39b49fb5a..edd41d0d2 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -28,6 +28,7 @@ import ( "os/signal" "path/filepath" "slices" + "strconv" "strings" "syscall" "time" @@ -66,25 +67,20 @@ type Server struct { logger *slog.Logger } -// validateAuthConfig enforces auth requirements at startup. All checks are -// unconditional: there is no relaxed/demo mode. -func validateAuthConfig(cfg *config.Server) error { - 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 (auth.jwt.skip_validation=true / APIP_CP_AUTH_JWT_SKIP_VALIDATION=true); set it to false") - } +// validateServerConfig enforces request-security requirements at startup that +// are not covered by config-load validation. All checks are unconditional: +// there is no relaxed/demo mode. +func validateServerConfig(cfg *config.Server) error { 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 fmt.Errorf("cors.allowed_origins must not contain \"*\"; list explicit origins, or leave it empty to disable cross-origin access") } return nil } // StartPlatformAPIServer creates a new server instance with all dependencies initialized func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, error) { - if err := validateAuthConfig(cfg); err != nil { - slogger.Error("Invalid auth configuration for production mode", "error", err) + if err := validateServerConfig(cfg); err != nil { + slogger.Error("Invalid server configuration", "error", err) return nil, err } @@ -133,8 +129,8 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, userIdentityMappingRepo := repository.NewUserIdentityMappingRepo(db) userOrgMappingRepo := repository.NewUserOrganizationMappingRepo(db) - // Seed the file-based organization on startup if file-based auth mode is enabled. - if cfg.Auth.FileBased.Enabled { + // Seed the file-based organization on startup if file auth mode is selected. + if cfg.Auth.Mode == config.AuthModeFile { if err := seedFileBasedOrg(cfg, orgRepo, slogger); err != nil { return nil, fmt.Errorf("failed to seed file-based organization: %w", err) } @@ -368,7 +364,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, return nil, err } - if !cfg.EnableScopeValidation { + if !cfg.Auth.ScopeValidation { slogger.Warn("scope validation is disabled — all authenticated requests will be allowed regardless of scope") } @@ -486,8 +482,8 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, AllowCredentials: true, })) - if cfg.Auth.FileBased.Enabled { - slogger.Info("Auth mode: file-based (HMAC-signed JWT)") + if cfg.Auth.Mode == config.AuthModeFile { + slogger.Info("Auth mode: file (local users, HMAC-signed JWT)") 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, @@ -522,7 +518,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, // values are already in the context when scope checks run. chain = append(chain, middleware.ScopeEnforcer(scopeRegistry, middleware.ScopeEnforcerConfig{ ValidationMode: cfg.Auth.IDP.ValidationMode, - Enabled: cfg.EnableScopeValidation, + Enabled: cfg.Auth.ScopeValidation, })) slogger.Info("WebSocket manager initialized", @@ -548,12 +544,11 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, } // buildAuthenticator constructs an Authenticator from the server configuration. -// Only called when file-based auth is disabled. +// Only called when the auth mode is "jwt" or "idp" (file mode wires its own +// local-JWT middleware). func buildAuthenticator(cfg *config.Server, slogger *slog.Logger, roleScopeMap map[string][]string) (middleware.Authenticator, error) { - if !cfg.Auth.IDP.Enabled { - // validateAuthConfig already rejected skip_validation=true, so signatures - // are always verified here. - slogger.Info("JWT mode: HMAC signature validation enabled") + if cfg.Auth.Mode != config.AuthModeIDP { + slogger.Info("Auth mode: jwt (HMAC signature validation enabled)") return middleware.NewJWTAuthenticator( middleware.LocalJWTAuthMiddleware(middleware.AuthConfig{ SecretKey: cfg.Auth.JWT.SecretKey, @@ -618,18 +613,18 @@ func buildAuthenticator(cfg *config.Server, slogger *slog.Logger, roleScopeMap m // Returns nil when role mode is not active or no mapping file is configured, // which causes IDP role names to be used as-is as scope values (passthrough). func loadRoleScopeMap(cfg *config.Server, registry *middleware.ScopeRegistry, slogger *slog.Logger) (map[string][]string, error) { - if !cfg.Auth.IDP.Enabled || cfg.Auth.IDP.ValidationMode != "role" || cfg.Auth.IDP.RoleMappingsFile == "" { + if cfg.Auth.Mode != config.AuthModeIDP || cfg.Auth.IDP.ValidationMode != "role" || cfg.Auth.IDP.RoleMappings == "" { return nil, nil } - m, err := middleware.LoadRoleScopeMap(cfg.Auth.IDP.RoleMappingsFile) + m, err := middleware.LoadRoleScopeMap(cfg.Auth.IDP.RoleMappings) if err != nil { return nil, fmt.Errorf("failed to load role mappings file: %w", err) } if err := middleware.ValidateRoleScopeMap(m, registry); err != nil { return nil, fmt.Errorf("invalid roles.yaml: %w", err) } - slogger.Info("Loaded role-to-scope mapping", "path", cfg.Auth.IDP.RoleMappingsFile, "roles", len(m)) + slogger.Info("Loaded role-to-scope mapping", "path", cfg.Auth.IDP.RoleMappings, "roles", len(m)) return m, nil } @@ -639,20 +634,22 @@ func loadRoleScopeMap(cfg *config.Server, registry *middleware.ScopeRegistry, sl // 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") + certFile := httpsCfg.TLS.CertFile + keyFile := httpsCfg.TLS.KeyFile + if certFile == "" || keyFile == "" { + return nil, fmt.Errorf("HTTPS listener enabled but server.https.tls.cert_file / server.https.tls.key_file is not configured") + } - cert, err := tls.LoadX509KeyPair(certPath, keyPath) + cert, err := tls.LoadX509KeyPair(certFile, keyFile) 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, + "failed to load TLS certificates (cert %q / key %q): %w. "+ + "Mount a certificate pair and point server.https.tls.cert_file / key_file at it, "+ + "or set server.https.enabled=false to serve plain HTTP behind a TLS-terminating proxy", + certFile, keyFile, err, ) } - s.logger.Info("Using mounted certificates", "certDir", certDir) + s.logger.Info("Using mounted certificates", "certFile", certFile, "keyFile", keyFile) return &tls.Config{ Certificates: []tls.Certificate{cert}, @@ -667,21 +664,23 @@ func (s *Server) buildTLSConfig(httpsCfg config.HTTPSListener) (*tls.Config, err // // timeouts bounds connection lifetime on both listeners so a slow or idle peer // cannot hold one open indefinitely (Slowloris). It is validated at config load. -func (s *Server) Start(httpCfg config.HTTPListener, httpsCfg config.HTTPSListener, timeouts config.Timeouts) error { +func (s *Server) Start(listeners config.ServerListeners, timeouts config.Timeouts) error { + httpCfg := listeners.HTTP + httpsCfg := listeners.HTTPS if !httpCfg.Enabled && !httpsCfg.Enabled { s.logger.Error("No listeners enabled") - return fmt.Errorf("no listeners enabled: set http.enabled=true and/or https.enabled=true in config") + return fmt.Errorf("no listeners enabled: set server.http.enabled=true and/or server.https.enabled=true in config") } // Preflight: validate listener configuration and build the TLS config before // starting any listener, background job, or goroutine - if httpCfg.Enabled && httpCfg.Port == "" { - return fmt.Errorf("HTTP listener enabled but http.port is empty") + if httpCfg.Enabled && (httpCfg.Port <= 0 || httpCfg.Port > 65535) { + return fmt.Errorf("HTTP listener enabled but server.http.port is invalid (got %d)", httpCfg.Port) } var tlsConfig *tls.Config if httpsCfg.Enabled { - if httpsCfg.Port == "" { - return fmt.Errorf("HTTPS listener enabled but https.port is empty") + if httpsCfg.Port <= 0 || httpsCfg.Port > 65535 { + return fmt.Errorf("HTTPS listener enabled but server.https.port is invalid (got %d)", httpsCfg.Port) } var err error if tlsConfig, err = s.buildTLSConfig(httpsCfg); err != nil { @@ -709,11 +708,12 @@ func (s *Server) Start(httpCfg config.HTTPListener, httpsCfg config.HTTPSListene if httpCfg.Enabled { // Plain HTTP is only safe when something upstream terminates TLS, or for // 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.") + s.logger.Warn("Plain-HTTP listener is enabled (server.http.enabled=true); " + + "terminate TLS at an ingress or service-mesh sidecar and never expose this listener " + + "directly to untrusted networks.") + httpPort := strconv.Itoa(httpCfg.Port) httpServer := &http.Server{ - Addr: ":" + httpCfg.Port, + Addr: ":" + httpPort, Handler: s.handler, ReadHeaderTimeout: timeouts.ReadHeader, ReadTimeout: timeouts.Read, @@ -721,7 +721,7 @@ func (s *Server) Start(httpCfg config.HTTPListener, httpsCfg config.HTTPSListene IdleTimeout: timeouts.Idle, } httpServers = append(httpServers, httpServer) - s.logger.Info("Starting HTTP listener", "address", "http://localhost:"+httpCfg.Port) + s.logger.Info("Starting HTTP listener", "address", "http://localhost:"+httpPort) go func() { errCh <- httpServer.ListenAndServe() }() @@ -729,8 +729,9 @@ func (s *Server) Start(httpCfg config.HTTPListener, httpsCfg config.HTTPSListene // TLS listener. if httpsCfg.Enabled { + httpsPort := strconv.Itoa(httpsCfg.Port) httpsServer := &http.Server{ - Addr: ":" + httpsCfg.Port, + Addr: ":" + httpsPort, Handler: s.handler, TLSConfig: tlsConfig, ReadHeaderTimeout: timeouts.ReadHeader, @@ -739,7 +740,7 @@ func (s *Server) Start(httpCfg config.HTTPListener, httpsCfg config.HTTPSListene IdleTimeout: timeouts.Idle, } httpServers = append(httpServers, httpsServer) - s.logger.Info("Starting HTTPS listener", "address", "https://localhost:"+httpsCfg.Port) + s.logger.Info("Starting HTTPS listener", "address", "https://localhost:"+httpsPort) go func() { errCh <- httpsServer.ListenAndServeTLS("", "") }() @@ -800,7 +801,7 @@ func (s *Server) GetMux() *http.ServeMux { // is stored back into cfg (Organization.UUID) so the login handler issues tokens // whose `organization` claim matches the value the organization resolver looks up. func seedFileBasedOrg(cfg *config.Server, orgRepo repository.OrganizationRepository, slogger *slog.Logger) error { - ba := &cfg.Auth.FileBased + ba := &cfg.Auth.File existing, err := orgRepo.GetOrganizationByHandle(ba.Organization.ID) if err != nil { diff --git a/platform-api/internal/server/server_tls_test.go b/platform-api/internal/server/server_tls_test.go index c555a058c..3982d5fe1 100644 --- a/platform-api/internal/server/server_tls_test.go +++ b/platform-api/internal/server/server_tls_test.go @@ -41,21 +41,40 @@ func testServer() *Server { // 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) { + missingDir := filepath.Join(t.TempDir(), "does-not-exist") _, err := testServer().buildTLSConfig(config.HTTPSListener{ Enabled: true, - CertDir: filepath.Join(t.TempDir(), "does-not-exist"), + TLS: config.ListenerTLS{ + CertFile: filepath.Join(missingDir, "cert.pem"), + KeyFile: filepath.Join(missingDir, "key.pem"), + }, }) if err == nil { t.Fatal("expected an error when the HTTPS listener has no certificates") } } +// HTTPS listener with unset certificate paths: rejected before any file access. +func TestBuildTLSConfig_UnsetCertPaths_Errors(t *testing.T) { + _, err := testServer().buildTLSConfig(config.HTTPSListener{Enabled: true}) + if err == nil { + t.Fatal("expected an error when cert_file / key_file are not configured") + } +} + // 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}) + tlsConfig, err := testServer().buildTLSConfig(config.HTTPSListener{ + Enabled: true, + Port: 9243, + TLS: config.ListenerTLS{ + CertFile: filepath.Join(certDir, "cert.pem"), + KeyFile: filepath.Join(certDir, "key.pem"), + }, + }) if err != nil { t.Fatalf("expected mounted certificates to load, got %v", err) } diff --git a/platform-api/internal/service/llm.go b/platform-api/internal/service/llm.go index 7a7f02374..0e6905e41 100644 --- a/platform-api/internal/service/llm.go +++ b/platform-api/internal/service/llm.go @@ -892,14 +892,6 @@ func (s *LLMProviderService) Create(orgUUID, createdBy string, req *api.LLMProvi } } - providerCount, err := s.repo.Count(orgUUID) - if err != nil { - return nil, fmt.Errorf("failed to count providers: %w", err) - } - if err := validateLLMResourceLimit(providerCount, s.cfg.ArtifactLimits.MaxLLMProvidersPerOrg, apperror.LLMProviderLimitReached.New()); err != nil { - return nil, err - } - openapiSpec := utils.ValueOrEmpty(req.Openapi) if openapiSpec == "" { openapiSpec = resolveTemplateOpenAPISpec(context.Background(), tpl, openAPISpecFetchLimit(s.cfg), s.slogger) @@ -1383,14 +1375,6 @@ func (s *LLMProxyService) Create(orgUUID, createdBy string, req *api.LLMProxy) ( } } - proxyCount, err := s.repo.Count(orgUUID) - if err != nil { - return nil, fmt.Errorf("failed to count proxies: %w", err) - } - if err := validateLLMResourceLimit(proxyCount, s.cfg.ArtifactLimits.MaxLLMProxiesPerOrg, apperror.LLMProxyLimitReached.New()); err != nil { - return nil, err - } - // Resolve any associated gateways up-front so they can be persisted within the // same transaction as the proxy create. associatedGateways, err := resolveAssociatedGateways(s.gatewayRepo, orgUUID, req.AssociatedGateways) @@ -1874,15 +1858,6 @@ func preserveUpstreamAuthCredential(existing, updated *model.UpstreamAuth) *mode return updated } -// validateLLMResourceLimit returns limitErr when the org has reached maxAllowed. -// A maxAllowed <= 0 means unlimited (see config.LimitReached), so it never errors. -func validateLLMResourceLimit(currentCount int, maxAllowed int, limitErr error) error { - if config.LimitReached(currentCount, maxAllowed) { - return limitErr - } - return nil -} - func mapExtractionIdentifierAPI(in *api.ExtractionIdentifier) *model.ExtractionIdentifier { if in == nil { return nil diff --git a/platform-api/internal/service/llm_test.go b/platform-api/internal/service/llm_test.go index 6657ce85a..e292748ed 100644 --- a/platform-api/internal/service/llm_test.go +++ b/platform-api/internal/service/llm_test.go @@ -355,37 +355,6 @@ func TestMapProxyModelToAPI_DoesNotExposeProviderAuthValue(t *testing.T) { } } -func TestValidateLLMResourceLimit(t *testing.T) { - t.Run("below limit should pass", func(t *testing.T) { - err := validateLLMResourceLimit(4, 5, apperror.LLMProviderLimitReached.New()) - if err != nil { - t.Fatalf("expected no error below limit, got: %v", err) - } - }) - - t.Run("at limit should fail", func(t *testing.T) { - err := validateLLMResourceLimit(5, 5, apperror.LLMProviderLimitReached.New()) - if !apperror.LLMProviderLimitReached.Is(err) { - t.Fatalf("expected ErrLLMProviderLimitReached, got: %v", err) - } - }) - - t.Run("above limit should fail", func(t *testing.T) { - err := validateLLMResourceLimit(6, 5, apperror.LLMProxyLimitReached.New()) - if !apperror.LLMProxyLimitReached.Is(err) { - t.Fatalf("expected ErrLLMProxyLimitReached, got: %v", err) - } - }) - - t.Run("unlimited (limit <= 0) should always pass", func(t *testing.T) { - for _, limit := range []int{0, -1} { - if err := validateLLMResourceLimit(1_000_000, limit, apperror.LLMProviderLimitReached.New()); err != nil { - t.Fatalf("expected no error for unlimited (limit=%d), got: %v", limit, err) - } - } - }) -} - func TestGenerateLLMProviderDeploymentYAML_WithSecurityAPIKeyPolicy(t *testing.T) { trueValue := true diff --git a/platform-api/internal/service/mcp.go b/platform-api/internal/service/mcp.go index 141e34cc9..e8a8e2b35 100644 --- a/platform-api/internal/service/mcp.go +++ b/platform-api/internal/service/mcp.go @@ -152,15 +152,6 @@ func (s *MCPProxyService) Create(orgUUID, createdBy string, req *api.MCPProxy) ( } req.Id = &handle - // Enforce the per-organization MCP proxy limit (unlimited when not configured). - proxyCount, err := s.repo.Count(orgUUID) - if err != nil { - return nil, fmt.Errorf("failed to count existing MCP proxies: %w", err) - } - if config.LimitReached(proxyCount, s.cfg.ArtifactLimits.MaxMCPProxiesPerOrg) { - return nil, apperror.MCPProxyLimitReached.New() - } - // Validate {{ secret "..." }} placeholders anywhere in the request — the // gateway-controller's template engine resolves placeholders generically // across the whole artifact (policies included), not just upstream.auth, diff --git a/platform-api/plugins/eventgateway/plugin.go b/platform-api/plugins/eventgateway/plugin.go index fdde3af64..03a57bcb8 100644 --- a/platform-api/plugins/eventgateway/plugin.go +++ b/platform-api/plugins/eventgateway/plugin.go @@ -278,19 +278,10 @@ func (p *EventGatewayPlugin) EnrichSubscription(orgID string, sub *api.Organizat if err != nil { return err } - // A limit <= 0 means unlimited: report only usage, leaving Limit/Remaining - // unset so consumers treat the WebSub API quota as uncapped. - quota := &api.OrganizationQuota{Used: count} - if limit := p.cfg.ArtifactLimits.MaxWebSubAPIsPerOrg; limit > 0 { - quota.Limit = intPtr(limit) - quota.Remaining = intPtr(max(limit-count, 0)) - } - sub.Quotas.WebsubApis = quota + sub.Quotas.WebsubApis = &api.OrganizationQuota{Used: count} return nil } -func intPtr(v int) *int { return &v } - // selectSchema returns the DDL appropriate for the current database driver. func (p *EventGatewayPlugin) selectSchema(db *database.DB) string { driver := strings.ToLower(db.Driver()) diff --git a/platform-api/plugins/eventgateway/service/webbroker_api.go b/platform-api/plugins/eventgateway/service/webbroker_api.go index c43d1be2f..0cad990ab 100644 --- a/platform-api/plugins/eventgateway/service/webbroker_api.go +++ b/platform-api/plugins/eventgateway/service/webbroker_api.go @@ -126,15 +126,6 @@ func (s *WebBrokerAPIService) Create(orgUUID, createdBy string, req *api.WebBrok return nil, apperror.WebBrokerAPIExists.New() } - // Enforce the per-organization WebBroker API limit (unlimited when not configured). - count, err := s.repo.Count(orgUUID) - if err != nil { - return nil, fmt.Errorf("failed to count existing WebBroker APIs: %w", err) - } - if config.LimitReached(count, s.cfg.ArtifactLimits.MaxWebBrokerAPIsPerOrg) { - return nil, apperror.WebBrokerAPILimitReached.New() - } - transport := []string{"http", "https"} if req.Transport != nil && len(*req.Transport) > 0 { transport = make([]string, 0, len(*req.Transport)) diff --git a/platform-api/plugins/eventgateway/service/websub_api.go b/platform-api/plugins/eventgateway/service/websub_api.go index 205e5dfdf..21cd1accd 100644 --- a/platform-api/plugins/eventgateway/service/websub_api.go +++ b/platform-api/plugins/eventgateway/service/websub_api.go @@ -89,7 +89,6 @@ func (s *WebSubAPIService) toWebSubAPI(m *model.WebSubAPI) (*api.WebSubAPI, erro return resp, nil } - // Create creates a new WebSub API func (s *WebSubAPIService) Create(orgUUID, createdBy string, req *api.WebSubAPI) (*api.WebSubAPI, error) { if req == nil { @@ -127,15 +126,6 @@ func (s *WebSubAPIService) Create(orgUUID, createdBy string, req *api.WebSubAPI) return nil, apperror.WebSubAPIExists.New() } - // Enforce the per-organization WebSub API limit (unlimited when not configured). - count, err := s.repo.Count(orgUUID) - if err != nil { - return nil, fmt.Errorf("failed to count existing WebSub APIs: %w", err) - } - if config.LimitReached(count, s.cfg.ArtifactLimits.MaxWebSubAPIsPerOrg) { - return nil, apperror.WebSubAPILimitReached.New() - } - transport := []string{"http", "https"} if req.Transport != nil && len(*req.Transport) > 0 { transport = make([]string, 0, len(*req.Transport)) diff --git a/portals/ai-workspace/configs/config-platform-api.toml b/portals/ai-workspace/configs/config-platform-api.toml deleted file mode 100644 index 449e8dd3f..000000000 --- a/portals/ai-workspace/configs/config-platform-api.toml +++ /dev/null @@ -1,46 +0,0 @@ -# -------------------------------------------------------------------- -# 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 -# -------------------------------------------------------------------- -# Platform API configuration — quickstart (file-based auth). See README.md. - -log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' # DEBUG | INFO | WARN | ERROR -enable_scope_validation = '{{ env "APIP_CP_ENABLE_SCOPE_VALIDATION" "true" }}' - -encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' # encryption_key = '{{ file "/secrets/platform-api/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 = '{{ env "APIP_CP_DATABASE_DRIVER" "sqlite3" }}' # "sqlite3" or "postgres" -path = "/app/data/api_platform.db" # SQLite file path (ignored for postgres) - - -[auth.jwt] -enabled = '{{ env "APIP_CP_AUTH_JWT_ENABLED" "true" }}' -issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' -secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' # secret_key = '{{ file "/secrets/platform-api/jwt_secret" }}' -skip_validation = '{{ env "APIP_CP_AUTH_JWT_SKIP_VALIDATION" "false" }}' # verify JWT signature - -[auth.idp] -enabled = '{{ env "APIP_CP_AUTH_IDP_ENABLED" "false" }}' - -[auth.file_based] -enabled = '{{ env "APIP_CP_AUTH_FILE_BASED_ENABLED" "true" }}' - -[auth.file_based.organization] -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 = '{{ 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 deleted file mode 100644 index ff4d8470f..000000000 --- a/portals/ai-workspace/configs/config-template.toml +++ /dev/null @@ -1,276 +0,0 @@ -# -------------------------------------------------------------------- -# 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 configuration template -# -# Copy this file to config.toml and edit the values for your deployment. -# This file is read by the AI Workspace container at startup, and is the only source of -# configuration. -# -# HOW VALUES ARE RESOLVED -# Each value is an interpolation token, resolved once at startup: -# -# key = '{{ env "APIP_AIW_KEY" "default" }}' -# └── environment variable └── value used when the variable is unset -# -# So a key written this way can be set by its environment variable — no edit to this -# file required. The token is what reads the environment: there is no implicit override, -# so a key written as a plain literal (key = "value"), or left commented out below, -# ignores the variable entirely. Uncomment the key first to make it settable that way. -# -# By convention the variable is the key's table path, uppercased, dots as underscores, -# prefixed with APIP_AIW_: -# -# [platform_api] url -> APIP_AIW_PLATFORM_API_URL -# [oidc] client_id -> APIP_AIW_OIDC_CLIENT_ID -# domain (no table) -> APIP_AIW_DOMAIN -# -# To source a value from a mounted file instead of the environment — the right -# choice for secrets — swap the token: -# -# key = '{{ file "/secrets/ai-workspace/key" }}' -# -# A {{ file }} token is required and fails closed: if the file is missing, or lives -# outside the allowed source directories (/etc/ai-workspace and /secrets/ai-workspace -# by default; override with APIP_CONFIG_FILE_SOURCE_ALLOWLIST), the server refuses to -# start rather than run with an empty value. An {{ env }} token with no default -# behaves the same way. -# -# Never write a secret as a raw literal in this file, and never hardcode one in -# docker-compose.yaml. -# -# KEY ORDER -# Within every table, keys are ordered so the ones that decide whether the rest apply -# come first: -# -# 1. the gate — enabled, or the mode that selects this table (auth_mode) -# 2. required identity/connection keys (url, authority, client_id, client_secret) -# 3. optional behaviour, in the order you would tune it -# 4. TLS/trust and other hardening keys last -# -# Keep new keys in that order rather than appending to the end of a table. -# -# QUICK START (basic-auth mode): -# 1. Copy this file to config.toml. -# 2. Set auth_mode = "basic" (uses the users defined in config-platform-api.toml). -# 3. Run: docker compose up -# -# OIDC mode: set auth_mode = "oidc" and fill in the [oidc] table below. -# -------------------------------------------------------------------- - -# ==================================================================== -# Deployment identity -# -# These keys are browser-safe: they are the subset served to the SPA as -# window.__RUNTIME_CONFIG__. Never put a credential among them. -# ==================================================================== - -# Authentication mode for the UI: "basic" (local users) or "oidc" (external IDP). -# Choosing "oidc" enables the [oidc] table below. -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 host:port that deployed gateways use to reach the Platform -# API. Shown in gateway setup instructions (keys.env, helm values). Must be the -# externally reachable address, not a relative/proxy path. -controlplane_host = '{{ env "APIP_AIW_CONTROLPLANE_HOST" "localhost:9243" }}' - -# Default region assigned to new organizations on first login. -default_org_region = '{{ env "APIP_AIW_DEFAULT_ORG_REGION" "us" }}' - -# Gateway versions offered in the create-gateway version selector (JSON array string). -# Each entry: version (helm chart minor), latestVersion (image/chart tag shown to the -# user and used in the helm --version flag), channel ("STS" | "LTS"). The first entry -# is the default selection. -platform_gateway_versions = '{{ env "APIP_AIW_PLATFORM_GATEWAY_VERSIONS" "[{\"version\":\"1.2\",\"latestVersion\":\"v1.2.0-alpha2\",\"channel\":\"STS\"}]" }}' - -# Verbose logging in the browser console. -debug = '{{ env "APIP_AIW_DEBUG" "false" }}' - - -# ==================================================================== -# Server -# -# Everything from here down configures the AI Workspace server itself. These values -# stay server-side and are never sent to the browser (the [oidc] claim names being the -# one exception — the SPA needs them to display user/org identity). -# -# All keys are optional: the value in each token's default position is the built-in -# default. -# ==================================================================== - -# Address the server listens on (host:port). The container publishes this port. -listen_addr = '{{ env "APIP_AIW_LISTEN_ADDR" ":5380" }}' - -# Directory holding the built SPA (index.html + assets). -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" }}' - -# Custom header required on state-mutating requests (CSRF protection). -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 - - -# --------------------------------------------------------------------------- -# Upstream Platform API — the server-to-server hop the BFF proxies to. -# --------------------------------------------------------------------------- -[platform_api] - -# Base URL used to reach the Platform API. REQUIRED. In docker compose this is the -# compose hostname; running locally, use localhost. Its http/https scheme decides -# whether the upstream hop uses TLS. -url = '{{ env "APIP_AIW_PLATFORM_API_URL" "https://platform-api:9243" }}' - -# File-based login path on the Platform API (basic-auth mode). -login_path = '{{ env "APIP_AIW_PLATFORM_API_LOGIN_PATH" "/api/portal/v0.9/auth/login" }}' - -# --- Trust for the upstream's TLS certificate --- - -# 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 -# roots, so public CAs keep working. -ca_file = '{{ env "APIP_AIW_PLATFORM_API_CA_FILE" "" }}' - - -# --------------------------------------------------------------------------- -# TLS on the AI Workspace listener. -# --------------------------------------------------------------------------- -[tls] - -# Terminate TLS on this listener. Set false only when a trusted upstream (ingress -# controller, service-mesh sidecar) terminates TLS for you — no certificate is then -# read, generated, or required. -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/cert.pem" }}' -key_file = '{{ env "APIP_AIW_TLS_KEY_FILE" "/etc/ai-workspace/tls/key.pem" }}' - - -# --------------------------------------------------------------------------- -# Server-side session lifetime. -# --------------------------------------------------------------------------- -[session] - -# Session store. Only "memory" is supported today ("redis" is future). -store = '{{ env "APIP_AIW_SESSION_STORE" "memory" }}' - -# Sliding idle window (Go duration, e.g. "30m"). Reserved — not enforced yet. -idle_timeout = '{{ env "APIP_AIW_SESSION_IDLE_TIMEOUT" "30m" }}' - -# Hard cap on session lifetime regardless of activity/refresh (Go duration). -absolute_ttl = '{{ env "APIP_AIW_SESSION_ABSOLUTE_TTL" "8h" }}' - - -# --------------------------------------------------------------------------- -# Session cookie attributes. -# --------------------------------------------------------------------------- -[cookie] - -name = '{{ env "APIP_AIW_COOKIE_NAME" "_ai_workspace_session" }}' - -# Mark the session cookie Secure (HTTPS-only). Set false only for local HTTP. -secure = '{{ env "APIP_AIW_COOKIE_SECURE" "true" }}' - -# SameSite policy: "lax" | "strict" | "none". -samesite = '{{ env "APIP_AIW_COOKIE_SAMESITE" "lax" }}' - - -# --------------------------------------------------------------------------- -# OIDC — set auth_mode = "oidc" above to enable. -# -# The BFF is a confidential client: it performs the whole handshake, and the client -# credentials never reach the browser. -# --------------------------------------------------------------------------- -[oidc] - -# Force the OIDC client on independently of auth_mode. Setting auth_mode = "oidc" -# already enables it, so this is rarely needed. -enabled = '{{ env "APIP_AIW_OIDC_ENABLED" "false" }}' - -# Issuer URL — endpoints are auto-discovered from /.well-known/openid-configuration. -authority = '{{ env "APIP_AIW_OIDC_AUTHORITY" "https://accounts.example.com" }}' - -# Client ID registered in your IDP. -client_id = '{{ env "APIP_AIW_OIDC_CLIENT_ID" "your-client-id" }}' - -# The confidential-client secret. Required in OIDC mode, and deliberately has no -# default: an unset variable fails startup rather than running with an empty credential. -# Prefer the {{ file }} form in production — the value then never enters the -# environment at all. -client_secret = '{{ env "APIP_AIW_OIDC_CLIENT_SECRET" }}' - -# The callback the IDP redirects to after login — a server-side callback, not an SPA -# route. Must equal the redirect URI registered on the IDP application. -redirect_url = '{{ env "APIP_AIW_OIDC_REDIRECT_URL" "https://localhost:5380/api/auth/callback" }}' - -# Absolute URL the IDP redirects to after logout (must be pre-registered there). -post_logout_redirect_url = '{{ env "APIP_AIW_OIDC_POST_LOGOUT_REDIRECT_URL" "https://localhost:5380/login" }}' - -# Scopes requested at login (space-separated). Leave APIP_AIW_OIDC_SCOPE unset to -# request the built-in full ap:* set (recommended). Override only to request a -# narrower set — and keep offline_access in any override, or token refresh breaks. -scope = '{{ env "APIP_AIW_OIDC_SCOPE" "" }}' - - -# --------------------------------------------------------------------------- -# JWT claim name mappings — which token claim carries each user/org field. -# -# This table mirrors [auth.idp.claim_mappings] in config-platform-api.toml key for key, -# and the two must agree: both services read the same claims out of the same token. -# The variables line up one-to-one as well: -# -# APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME (AI Workspace) -# APIP_CP_AUTH_IDP_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME (Platform API) -# -# Override only if your IDP uses claim names other than the defaults shown. -# -# Must be the last table under [oidc]: in TOML, a sub-table header ends the parent -# table's key section, so any plain [oidc] key placed below it would land here instead. -# --------------------------------------------------------------------------- -[oidc.claim_mappings] - -organization_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME" "org_id" }}' # claim carrying the org ID -org_name_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ORG_NAME_CLAIM_NAME" "org_name" }}' # org display name -org_handle_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ORG_HANDLE_CLAIM_NAME" "org_handle" }}' # org URL slug -username_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_USERNAME_CLAIM_NAME" "username" }}' -email_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_EMAIL_CLAIM_NAME" "email" }}' -scope_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_SCOPE_CLAIM_NAME" "scope" }}' # space-separated scope string -role_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ROLE_CLAIM_NAME" "platform_role" }}' - - -# ==================================================================== -# Settings with no config key -# -# The path to this file cannot be a key in it: the server reads its mount, -# /etc/ai-workspace/config.toml, unless -config names another path -# (`bff -config ../configs/config.toml`, as `make bff-run` does). -# -# The variables below are read straight from the environment by the server itself, not -# through a token, because each is needed before (or independently of) the config file. -# They are not config keys, so they have no entry above. -# ==================================================================== -# -# APIP_CONFIG_FILE_SOURCE_ALLOWLIST -# Comma-separated directories a {{ file "..." }} token may -# read from. Replaces (not extends) the defaults -# /etc/ai-workspace and /secrets/ai-workspace. Shared by -# every component in the platform. diff --git a/portals/developer-portal/configs/config-platform-api-template.toml b/portals/developer-portal/configs/config-platform-api-template.toml deleted file mode 100644 index 9cf0bb1cd..000000000 --- a/portals/developer-portal/configs/config-platform-api-template.toml +++ /dev/null @@ -1,106 +0,0 @@ -# -------------------------------------------------------------------- -# 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 -# -------------------------------------------------------------------- -# -# Platform API configuration for the Developer Portal. -# -# This file defines the local users who can log in to the Developer Portal -# via the username/password login form. Credentials are validated by the -# Platform API; the Developer Portal never sees the raw passwords. -# -# Admin credentials: -# ./setup.sh generates APIP_CP_ADMIN_USERNAME / APIP_CP_ADMIN_PASSWORD_HASH into -# api-platform.env (prompting for a username/password, or randomly generating -# one) and the {{ env "..." }} tokens below resolve them. Setting up manually -# instead: generate a bcrypt hash with -# htpasswd -bnBC 12 "" | tr -d ':\n' and put both values in -# `keys.env` file — never hardcode raw values here or as literals in -# docker-compose.yaml. -# -# Secrets: -# APIP_CP_AUTH_JWT_SECRET_KEY (signs login JWTs; the Developer Portal verifies them with -# the same value via APIP_DP_PLATFORMAPI_JWTSECRET) and APIP_CP_ENCRYPTION_KEY (encrypts -# data at rest) are both REQUIRED 32-byte keys and are never auto-generated — the -# Platform API fails to start if either is missing or malformed. Generate each -# with: openssl rand -hex 32. Put the values in `keys.env` file and -# let the {{ env "..." }} tokens below resolve them — never hardcode raw values -# here or as literals in docker-compose.yaml. Files are preferred in production -# ({{ file "..." }}). -# -------------------------------------------------------------------- - -# --------------------------------------------------------------------------- -# Server -# --------------------------------------------------------------------------- -log_level = "INFO" # DEBUG | INFO | WARN | ERROR - -# --------------------------------------------------------------------------- -# Encryption -# --------------------------------------------------------------------------- -# 32-byte key (64 hex chars or base64) for all at-rest encryption. REQUIRED. -# Resolved from the APIP_CP_ENCRYPTION_KEY env var in `keys.env` file. Files preferred: -# encryption_key = '{{ file "/secrets/platform-api/encryption_key" }}' -encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' - -# --------------------------------------------------------------------------- -# HTTPS -# --------------------------------------------------------------------------- -[https] -enabled = true -port = "9243" -# Directory containing cert.pem and key.pem — must match the volume mount in -# docker-compose.yaml. There is no self-signed fallback. -cert_dir = "/etc/platform-api/tls" - -# --------------------------------------------------------------------------- -# Database -# --------------------------------------------------------------------------- -[database] -driver = "sqlite3" -path = "/app/data/platform-api-devportal.db" - -# --------------------------------------------------------------------------- -# Authentication -# --------------------------------------------------------------------------- -[auth.jwt] -enabled = true -issuer = "platform-api" -# 32-byte key that signs login JWTs, shared with the Developer Portal -# (APIP_DP_PLATFORMAPI_JWTSECRET must match). Resolved from APIP_CP_AUTH_JWT_SECRET_KEY -# from `keys.env` file; files preferred: secret_key = '{{ file "/secrets/platform-api/jwt_secret" }}' -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 - -[auth.idp] -enabled = false - -[auth.file_based] -enabled = true - -[auth.file_based.organization] -id = "default" # Required: organization handle (URL-safe slug) -display_name = "Default" -region = "us" - -# Admin user — username/password resolved from APIP_CP_ADMIN_USERNAME / -# APIP_CP_ADMIN_PASSWORD_HASH (see "Admin credentials" note above). -# Scopes use the dp:* namespace understood by the Developer Portal REST API. -# Add dp:*_manage scopes to grant full access to every resource area. -[[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: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:git:read ap:api_key:read dp:org_read dp:org_write dp:org_manage dp:org_delete dp:org_content_read dp:org_content_write dp:org_content_manage dp:org_content_delete dp:api_read dp:api_write dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:mcp_create dp:mcp_read dp:mcp_update dp:mcp_delete dp:mcp_manage dp:mcp_content_create dp:mcp_content_read dp:mcp_content_update dp:mcp_content_delete dp:mcp_content_manage dp:mcp_key_create dp:mcp_key_read dp:mcp_key_update dp:mcp_key_revoke dp:mcp_key_manage dp:api_key_read dp:api_key_write dp:api_key_manage dp:api_key_revoke dp:api_flow_read dp:api_flow_write dp:api_flow_manage dp:api_flow_delete dp:api_workflow_read dp:api_workflow_create dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:app_read dp:app_write dp:app_manage dp:app_delete dp:app_key_write dp:app_key_manage dp:app_key_revoke dp:app_key_mapping_read dp:app_key_mapping_write dp:app_key_mapping_manage dp:subscription_read dp:subscription_write dp:subscription_manage dp:subscription_delete dp:sub_plan_read dp:sub_plan_write dp:sub_plan_manage dp:sub_plan_delete dp:idp_read dp:idp_write dp:idp_manage dp:idp_delete dp:view_read dp:view_write dp:view_manage dp:view_delete dp:km_read dp:km_write dp:km_manage dp:km_delete dp:label_read dp:label_write dp:label_manage dp:label_delete dp:provider_read dp:provider_write dp:provider_manage dp:provider_delete dp:event_read dp:delivery_manage dp:utility_write dp:utility_manage dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dev" - -# --------------------------------------------------------------------------- -# DevPortal integration — disabled (devportal calls us, not the other way) -# --------------------------------------------------------------------------- -[default_devportal] -enabled = false 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 48c52818c..3e1af2437 100644 --- a/portals/developer-portal/it/configs/config-platform-api-it.toml +++ b/portals/developer-portal/it/configs/config-platform-api-it.toml @@ -18,34 +18,37 @@ # Regenerate a password hash: htpasswd -bnBC 10 "" | tr -d ':\n' # -------------------------------------------------------------------- -log_level = "INFO" -port = "9243" +[platform_api] +log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' -[database] -driver = "sqlite3" -path = "/app/data/platform-api-it.db" +[platform_api.server.https] +enabled = '{{ env "APIP_CP_SERVER_HTTPS_ENABLED" "true" }}' +port = '{{ env "APIP_CP_SERVER_HTTPS_PORT" "9243" }}' -[auth.jwt] -enabled = true -issuer = "platform-api-it" -secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' -skip_validation = false +[platform_api.server.https.tls] +cert_file = '{{ env "APIP_CP_SERVER_HTTPS_TLS_CERT_FILE" "/app/data/certs/cert.pem" }}' +key_file = '{{ env "APIP_CP_SERVER_HTTPS_TLS_KEY_FILE" "/app/data/certs/key.pem" }}' -[auth.idp] -enabled = false +[platform_api.database] +driver = '{{ env "APIP_CP_DATABASE_DRIVER" "sqlite3" }}' +path = '{{ env "APIP_CP_DATABASE_PATH" "/app/data/platform-api-it.db" }}' -[auth.file_based] -enabled = true +[platform_api.auth] +mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' -[auth.file_based.organization] -id = "default" -display_name = "Default" -region = "us" +[platform_api.auth.jwt] +issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api-it" }}' +secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' + +[platform_api.auth.file.organization] +id = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_ID" "default" }}' +display_name = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_DISPLAY_NAME" "Default" }}' +region = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_REGION" "us" }}' # Full access — organization management plus every resource area. -[[auth.file_based.users]] +[[platform_api.auth.file.users]] username = "admin" password_hash = "$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ." scopes = "dp:org_read dp:org_create dp:org_update dp:org_manage dp:org_delete dp:org_content_read dp:org_content_write dp:org_content_manage dp:org_content_delete dp:api_read dp:api_create dp:api_update dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:mcp_read dp:mcp_create dp:mcp_update dp:mcp_manage dp:mcp_delete dp:mcp_content_read dp:mcp_content_create dp:mcp_content_update dp:mcp_content_manage dp:mcp_content_delete dp:api_key_create dp:api_key_read dp:api_key_update dp:api_key_manage dp:api_key_revoke dp:mcp_key_create dp:mcp_key_read dp:mcp_key_update dp:mcp_key_manage dp:mcp_key_revoke dp:api_workflow_create dp:api_workflow_read dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:app_create dp:app_read dp:app_update dp:app_manage dp:app_delete dp:app_key_create dp:app_key_manage dp:app_key_revoke dp:app_key_mapping_read dp:app_key_mapping_write dp:app_key_mapping_manage dp:subscription_create dp:subscription_read dp:subscription_update dp:subscription_manage dp:subscription_delete dp:sub_plan_create dp:sub_plan_read dp:sub_plan_update dp:sub_plan_manage dp:sub_plan_delete dp:km_create dp:km_read dp:km_update dp:km_manage dp:km_delete dp:view_create dp:view_read dp:view_update dp:view_manage dp:view_delete dp:label_create dp:label_read dp:label_update dp:label_manage dp:label_delete dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dp:event_read dp:delivery_manage" @@ -53,20 +56,14 @@ scopes = "dp:org_read dp:org_create dp:org_update dp:org_manage dp:org_de # Content/config manager — APIs, MCP servers, key managers, subscription # plans, views/labels, webhook subscribers, API workflows. No org management, # no application ownership (that's the developer's). -[[auth.file_based.users]] +[[platform_api.auth.file.users]] username = "publisher" password_hash = "$2y$10$BN9I5oPs34clNmhlO0CX0uDMKMnh9xkczGGmuLiInXSe/KOF5wqFW" scopes = "dp:org_read dp:api_read dp:api_create dp:api_update dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:mcp_read dp:mcp_create dp:mcp_update dp:mcp_manage dp:mcp_delete dp:mcp_content_read dp:mcp_content_create dp:mcp_content_update dp:mcp_content_manage dp:mcp_content_delete dp:api_key_create dp:api_key_read dp:api_key_update dp:api_key_manage dp:api_key_revoke dp:mcp_key_create dp:mcp_key_read dp:mcp_key_update dp:mcp_key_manage dp:mcp_key_revoke dp:api_workflow_create dp:api_workflow_read dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:sub_plan_create dp:sub_plan_read dp:sub_plan_update dp:sub_plan_manage dp:sub_plan_delete dp:km_create dp:km_read dp:km_update dp:km_manage dp:km_delete dp:view_create dp:view_read dp:view_update dp:view_manage dp:view_delete dp:label_create dp:label_read dp:label_update dp:label_manage dp:label_delete dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dp:event_read" # Portal end-user — read APIs/MCP servers, own applications, subscriptions, # and API keys. No org, key-manager, view/label, or webhook-subscriber management. -[[auth.file_based.users]] +[[platform_api.auth.file.users]] username = "developer" password_hash = "$2y$10$jX3o2E5jF4i3EOgoyJ0k.uegbDYmsmFNDfIxnvcZgTNJifAPjgKKK" scopes = "dp:org_read dp:api_read dp:api_content_read dp:mcp_read dp:mcp_content_read dp:api_key_create dp:api_key_read dp:api_key_update dp:api_key_manage dp:api_key_revoke dp:app_create dp:app_read dp:app_update dp:app_manage dp:app_delete dp:app_key_create dp:app_key_manage dp:app_key_revoke dp:app_key_mapping_read dp:app_key_mapping_write dp:app_key_mapping_manage dp:subscription_create dp:subscription_read dp:subscription_update dp:subscription_manage dp:subscription_delete dp:sub_plan_read dp:view_read dp:label_read" - -[tls] -cert_dir = "/app/data/certs" - -[default_devportal] -enabled = false diff --git a/portals/developer-portal/scripts/setup.sh b/portals/developer-portal/scripts/setup.sh index 9a3d27a96..e7381900c 100755 --- a/portals/developer-portal/scripts/setup.sh +++ b/portals/developer-portal/scripts/setup.sh @@ -167,43 +167,39 @@ else # Generated by ./setup.sh — every secret below is a {{ env "..." }} token # resolved from api-platform.env. Not tracked in git (see .gitignore). -log_level = "INFO" # DEBUG | INFO | WARN | ERROR +[platform_api] +log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' # DEBUG | INFO | WARN | ERROR encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' -[https] -enabled = true -port = "9243" -cert_dir = "/etc/platform-api/tls" +[platform_api.server.https] +enabled = '{{ env "APIP_CP_SERVER_HTTPS_ENABLED" "true" }}' +port = '{{ env "APIP_CP_SERVER_HTTPS_PORT" "9243" }}' -[database] -driver = "sqlite3" -path = "/app/data/platform-api-devportal.db" +[platform_api.server.https.tls] +cert_file = '{{ env "APIP_CP_SERVER_HTTPS_TLS_CERT_FILE" "/etc/platform-api/tls/cert.pem" }}' +key_file = '{{ env "APIP_CP_SERVER_HTTPS_TLS_KEY_FILE" "/etc/platform-api/tls/key.pem" }}' -[auth.jwt] -enabled = true -issuer = "platform-api" -secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' -skip_validation = false +[platform_api.database] +driver = '{{ env "APIP_CP_DATABASE_DRIVER" "sqlite3" }}' +path = '{{ env "APIP_CP_DATABASE_PATH" "/app/data/platform-api-devportal.db" }}' -[auth.idp] -enabled = false +[platform_api.auth] +mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' -[auth.file_based] -enabled = true +[platform_api.auth.jwt] +issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' +secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' -[auth.file_based.organization] -id = "default" -display_name = "Default" -region = "us" +[platform_api.auth.file.organization] +id = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_ID" "default" }}' +display_name = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_DISPLAY_NAME" "Default" }}' +region = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_REGION" "us" }}' -[[auth.file_based.users]] +[[platform_api.auth.file.users]] username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' scopes = "$ADMIN_SCOPES" - -[default_devportal] -enabled = false EOF log " - $PLATFORM_API_CONFIG generated" fi @@ -237,7 +233,7 @@ else # `format: raw`, which passes file content through byte-for-byte with no # ${VAR} interpolation, so a literal bcrypt hash ("$2y$12$...") survives # into the container as-is. Escaping "$" as "$$" here would corrupt it. - # Read by config-platform-api.toml's [[auth.file_based.users]] entry — + # Read by config-platform-api.toml's [[platform_api.auth.file.users]] entry — # scopes lives there as a plain literal, not in this env file. set_env_var "APIP_CP_ADMIN_USERNAME" "$ADMIN_USERNAME" set_env_var "APIP_CP_ADMIN_PASSWORD_HASH" "$ADMIN_HASH" diff --git a/tests/integration-e2e/platform-api-config.toml b/tests/integration-e2e/platform-api-config.toml index 04187bd5d..a9185950d 100644 --- a/tests/integration-e2e/platform-api-config.toml +++ b/tests/integration-e2e/platform-api-config.toml @@ -2,9 +2,11 @@ # Database settings come from environment variables (see docker-compose.yaml); # this file supplies file-based auth so the scenario can log in (admin/admin). +[platform_api] +log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' -[database] +[platform_api.database] driver = '{{ env "APIP_CP_DATABASE_DRIVER" "sqlite3" }}' path = '{{ env "APIP_CP_DATABASE_PATH" "/app/data/platform.db" }}' host = '{{ env "APIP_CP_DATABASE_HOST" "" }}' @@ -14,28 +16,26 @@ user = '{{ env "APIP_CP_DATABASE_USER" "" }}' 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" }}' -skip_validation = false +[platform_api.auth] +mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' -[auth.file_based] -enabled = true +[platform_api.auth.jwt] +issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' +secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' -[auth.file_based.organization] -id = "default" -display_name = "Default" -region = "us" -uuid = "99089a17-72e0-4dd8-a2f4-c8dfbb085295" +[platform_api.auth.file.organization] +id = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_ID" "default" }}' +display_name = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_DISPLAY_NAME" "Default" }}' +region = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_REGION" "us" }}' +uuid = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_UUID" "99089a17-72e0-4dd8-a2f4-c8dfbb085295" }}' # Default login: admin / admin (bcrypt hash of "admin", reused from the shipped sample config). -[[auth.file_based.users]] +[[platform_api.auth.file.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 dp:org_manage dp:api_manage dp:sub_plan_manage dp:app_manage dp:subscription_manage dp:api_key_manage dp:webhook_subscriber_manage" -[webhook] +[platform_api.webhook] enabled = '{{ env "APIP_CP_WEBHOOK_ENABLED" "false" }}' secret = '{{ env "APIP_CP_WEBHOOK_SECRET" "" }}' private_key_path = '{{ env "APIP_CP_WEBHOOK_PRIVATE_KEY_PATH" "" }}' From 7ea5df45ae873d5eaba6f0ac7d51d0dc5535821f Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Mon, 20 Jul 2026 15:09:16 +0530 Subject: [PATCH 02/18] Update Platform API authentication modes and configuration documentation - Changed default authentication mode from "jwt" to "external_token" in configuration files and documentation. - Updated README and config-template.toml to reflect the new authentication structure and usage of environment variables. - Enhanced comments in config files to clarify the purpose of each authentication mode. - Adjusted test cases to align with the new authentication mode and configuration changes. - Ensured consistency across various configuration files and documentation regarding authentication settings. --- platform-api/README.md | 224 +++++--------- platform-api/config/config-template.toml | 29 +- platform-api/config/config.go | 45 +-- platform-api/config/config.toml | 44 ++- platform-api/config/config_test.go | 10 +- platform-api/config/default_config.go | 2 +- platform-api/internal/server/server.go | 4 +- portals/ai-workspace/Makefile | 11 +- portals/ai-workspace/README.md | 5 +- .../ai-workspace/configs/config-template.toml | 276 ++++++++++++++++++ portals/ai-workspace/docker-compose.yaml | 13 +- portals/ai-workspace/production/README.md | 24 +- portals/developer-portal/.gitignore | 1 - portals/developer-portal/Makefile | 11 +- portals/developer-portal/README.md | 6 +- .../docker-compose.platform-api.yaml | 17 +- portals/developer-portal/docker-compose.yaml | 13 +- portals/developer-portal/scripts/setup.sh | 62 ---- 18 files changed, 503 insertions(+), 294 deletions(-) create mode 100644 portals/ai-workspace/configs/config-template.toml diff --git a/platform-api/README.md b/platform-api/README.md index a843abdd3..a5af40e5c 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -6,7 +6,7 @@ Backend service that powers the API Platform portals, gateways, and automation f ### Prerequisites -Before using the Platform API, obtain a bearer token for authentication. In local JWT mode (default) you can generate a token using the configured `APIP_CP_AUTH_JWT_SECRET_KEY`. In IDP mode, obtain a token from your identity provider. +Before using the Platform API, obtain a bearer token for authentication. In `file` or `external_token` auth mode you can generate a token using the HMAC key configured at `platform_api.auth.jwt.secret_key`. In `idp` mode, obtain a token from your identity provider. See [Configuration](#configuration) below. ### Build and Run @@ -22,18 +22,21 @@ go run ./cmd/main.go ### Database Configuration -Platform API supports `sqlite3` (default), `postgres`, and `sqlserver`. +Platform API supports `sqlite3` (default), `postgres`, and `sqlserver`. Configure the driver +under `[platform_api.database]` in your config file, e.g. for SQL Server: -```bash -# SQL Server example -export APIP_CP_DATABASE_DRIVER=sqlserver -export APIP_CP_DATABASE_HOST=sqlserver.example.internal -export APIP_CP_DATABASE_PORT=1433 -export APIP_CP_DATABASE_NAME=platform_api -export APIP_CP_DATABASE_USER=sa -export APIP_CP_DATABASE_PASSWORD='' -export APIP_CP_DATABASE_SSL_MODE=disable +```toml +[platform_api.database] +driver = "sqlserver" +host = "sqlserver.example.internal" +port = "1433" +name = "platform_api" +user = "sa" +password = '{{ env "DB_PASSWORD" }}' # or '{{ file "/secrets/platform-api/db_password" }}' +ssl_mode = "disable" +``` +```bash cd platform-api go run ./cmd/main.go -config config/config.toml ``` @@ -230,109 +233,60 @@ The connected gateway will receive a deployment event via WebSocket: ## Configuration Configuration is read from a TOML config file (`-config `), layered over built-in -defaults. **Environment variables do not override config keys directly.** The only way an -environment variable affects a setting is through an explicit `{{ env "NAME" }}` interpolation -token placed in the config file, which is resolved at load time via `os.LookupEnv`; a field -with no token always takes its literal TOML value (or the built-in default). See "Providing -secrets via the config file" below. - -One variable is intentionally **not** prefixed: the shared `APIP_CONFIG_FILE_SOURCE_ALLOWLIST`. The `{{ env "NAME" }}` interpolation -tokens in the config file read the literal name via `os.LookupEnv` (independent of the koanf -prefix mechanism); the samples use the same `APIP_CP_`-prefixed names for one consistent -namespace — e.g. `{{ env "APIP_CP_ENCRYPTION_KEY" }}` (see "Providing secrets via the config -file" below). - -### Authentication - -Two authentication modes are supported. Exactly one should be active at a time. - -``` -APIP_CP_AUTH_IDP_ENABLED=false (default) → Local JWT mode (HMAC signature verification) -APIP_CP_AUTH_IDP_ENABLED=true → IDP mode (JWKS-based verification) -``` - -> `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. - ---- +defaults. **There are no fixed, prescriptive environment variable names** — a key omitted from +the file simply falls back to its built-in default, a literal value in the file is used as-is, +and the only way an environment variable (or a mounted file) affects a setting is by writing an +explicit interpolation token as that key's value: -#### Local JWT Mode (default) - -The server signs and validates HMAC login tokens using the key that `auth.jwt.secret_key` resolves to — a 32-byte value (64 hex chars or base64). This key is **required** at startup whenever local JWT or file-based auth is enabled and is never generated; a missing or malformed value fails startup. The sample config reads it from `{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}`. - -`auth.jwt.skip_validation` is a development-only switch that accepts bearer values without a signature check. It is honored **only in demo mode** — startup is rejected if it is `true` while `APIP_DEMO_MODE=false` — and it does **not** remove the required secret key above. - -| Variable | Default | Description | -|---|---|---| -| `APIP_CP_AUTH_JWT_SECRET_KEY` | _(empty)_ | HMAC key for signing/verifying login JWTs — 32-byte value (64 hex or base64; `openssl rand -hex 32`). **Required** whenever local JWT or file-based auth is enabled. | -| `APIP_CP_AUTH_JWT_ISSUER` | `platform-api` | Expected `iss` claim value | -| `APIP_CP_AUTH_JWT_SKIP_VALIDATION` | `false` | Skip signature verification — **development only**, honored solely in demo mode | - -Run locally. The config file supplies the `{{ env }}` tokens, so export the referenced variable and pass `-config`: -```bash -export APIP_CP_AUTH_JWT_SECRET_KEY="$(openssl rand -hex 32)" -go run ./cmd/main.go -config config/config.toml +```toml +some_key = '{{ env "ANY_VAR_NAME" "optional-default" }}' # from an env var, with a fallback +some_key = '{{ env "ANY_VAR_NAME" }}' # from an env var, no fallback — unset fails config load +some_key = '{{ file "/secrets/platform-api/some-file" }}' # from a mounted file (preferred for secrets) ``` -To skip signature checks during local development, set `skip_validation = true` under `[auth.jwt]` in the config file (demo mode only) and run the same command. - ---- +The name inside the token (`ANY_VAR_NAME`) is a free choice — it's read via `os.LookupEnv` at +load time and isn't tied to any specific naming scheme. [`config/config-template.toml`](config/config-template.toml) +is the authoritative reference: it lists every key the binary reads, each already wrapped in an +`{{ env }}` token using the `APIP_CP_*` naming convention as one consistent example — copy it and +edit the values, or replace the tokens with plain literals. `{{ file }}` reads are restricted to +an allowlisted directory (default `/etc/platform-api`, `/secrets/platform-api`) and fail closed: +a missing/empty required source, or a missing/disallowed/oversize file, aborts startup. -#### IDP Mode +### Key sections -Tokens are validated against any standards-compliant identity provider (Thunder, Asgardeo, Keycloak, Azure AD, Okta, etc.) using its JWKS endpoint. Set `APIP_CP_AUTH_IDP_ENABLED=true` and supply at minimum `APIP_CP_AUTH_IDP_JWKS_URL` and `APIP_CP_AUTH_IDP_ISSUER`. +All settings live under `[platform_api]` / `[platform_api.*]`. The main sections: -| Variable | Default | Description | -|---|---|---| -| `APIP_CP_AUTH_IDP_ENABLED` | `false` | Set to `true` to activate IDP mode | -| `APIP_CP_AUTH_IDP_NAME` | _(empty)_ | Optional label shown in startup logs (e.g. `thunder`, `asgardeo`) | -| `APIP_CP_AUTH_IDP_JWKS_URL` | _(required)_ | IDP's JWKS endpoint for public key retrieval | -| `APIP_CP_AUTH_IDP_ISSUER` | _(required)_ | Accepted JWT issuer | -| `APIP_CP_AUTH_IDP_AUDIENCE` | _(empty)_ | Accepted JWT audience. When set, the token's `aud` claim must contain this value; empty skips the check | -| `APIP_CP_AUTH_IDP_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME` | `organization` | JWT claim holding the org UUID for the active session | -| `APIP_CP_AUTH_IDP_CLAIM_MAPPINGS_ORG_NAME_CLAIM_NAME` | `org_name` | JWT claim for the org display name | -| `APIP_CP_AUTH_IDP_CLAIM_MAPPINGS_ORG_HANDLE_CLAIM_NAME` | `org_handle` | JWT claim for the org URL-safe handle | -| `APIP_CP_AUTH_IDP_CLAIM_MAPPINGS_USER_ID_CLAIM_NAME` | `sub` | JWT claim used as the canonical user identifier | -| `APIP_CP_AUTH_IDP_CLAIM_MAPPINGS_USERNAME_CLAIM_NAME` | `username` | JWT claim for the human-readable username | -| `APIP_CP_AUTH_IDP_CLAIM_MAPPINGS_EMAIL_CLAIM_NAME` | `email` | JWT claim for the user's email address | -| `APIP_CP_AUTH_IDP_CLAIM_MAPPINGS_SCOPE_CLAIM_NAME` | `scope` | JWT claim carrying granted OAuth2 scopes | -| `APIP_CP_AUTH_IDP_VALIDATION_MODE` | `scope` | Authorization mode: `scope` (validate scope claim directly) or `role` (expand IDP roles to platform roles) | -| `APIP_CP_AUTH_IDP_CLAIM_MAPPINGS_ROLES_CLAIM_PATH` | _(empty)_ | Dot-notation path to the roles claim (e.g. `realm_access.roles`). Required when `APIP_CP_AUTH_IDP_VALIDATION_MODE=role` | -| `APIP_CP_AUTH_IDP_ROLE_MAPPINGS` | _(empty)_ | Comma-separated `idp-role=platform-role` pairs (e.g. `PLATFORM_ADMIN=admin,PLATFORM_DEV=developer`). When empty, IDP role values are used as-is | - -**Example — Asgardeo:** -```bash -export APIP_CP_AUTH_IDP_ENABLED=true -export APIP_CP_AUTH_IDP_NAME=asgardeo -export APIP_CP_AUTH_IDP_JWKS_URL=https://api.asgardeo.io/t//oauth2/jwks -export APIP_CP_AUTH_IDP_ISSUER=https://api.asgardeo.io/t//oauth2/token -export APIP_CP_AUTH_IDP_AUDIENCE= -export APIP_CP_AUTH_IDP_ORGANIZATION_CLAIM_NAME=organizationId -export APIP_CP_AUTH_IDP_VALIDATION_MODE=scope -export APIP_CP_AUTH_IDP_ROLES_CLAIM_PATH=scope -``` - ---- - -#### Skip Paths +| Section | Purpose | +|---|---| +| `[platform_api]` | `log_level`, `log_format`, resource paths, `encryption_key` (**required** — at-rest AES-256 key, 32 bytes as hex or base64, never auto-generated) | +| `[platform_api.database]` | `driver` (`sqlite3` / `postgres` / `sqlserver`), connection fields, pool sizing | +| `[platform_api.auth]` | `mode` — one of `external_token`, `file`, or `idp`; `scope_validation`; `skip_paths` | +| `[platform_api.auth.jwt]` | HMAC login token settings: `issuer`, `secret_key` (**required**), `token_ttl` | +| `[platform_api.auth.idp]` / `[platform_api.auth.idp.claim_mappings]` | JWKS endpoint, issuer/audience, validation mode, and JWT claim-name mappings for `idp` mode | +| `[platform_api.auth.file.organization]` / `[[platform_api.auth.file.users]]` | Local org + username/password/scope entries for `file` mode | +| `[platform_api.server.http]` / `[platform_api.server.https]` / `[platform_api.server.https.tls]` | Listener enablement, ports, and TLS cert/key paths (certificates are always required for HTTPS — no self-signed fallback) | +| `[platform_api.listener_timeouts]` | Read/write/idle timeouts | +| `[platform_api.cors]` | `allowed_origins` for credentialed cross-origin requests | +| `[platform_api.websocket]` | Gateway WebSocket connection limits and rate limiting | +| `[platform_api.deployments]` | Deployment caps and stuck-deployment timeout handling | +| `[platform_api.gateway]` | Gateway registration verification toggles | +| `[platform_api.event_hub]` | Multi-replica event delivery polling/retention | +| `[platform_api.webhook]` | Developer Portal webhook receiver: `enabled`, `secret` (required when enabled), signature/body limits | -Path prefixes listed here bypass authentication entirely. Used for internal gateway traffic and health checks. +#### Authentication modes -| Variable | Default | -|---|---| -| `APIP_CP_AUTH_SKIP_PATHS` | `/health,/metrics,/api/internal/v1/ws/gateways/connect,...` | +`platform_api.auth.mode` selects exactly one mode; only that mode's section is read: -To extend the default list: -```bash -export APIP_CP_AUTH_SKIP_PATHS="/health,/metrics,/api/internal/v1/ws/gateways/connect,/my-custom-path" -``` +- **`external_token`** — verify locally-signed HMAC JWTs (`[platform_api.auth.jwt]`); tokens are minted externally (e.g. by the Developer Portal) using the shared `secret_key`. +- **`file`** — `external_token` plus local username/password login: the login endpoint authenticates against `[platform_api.auth.file]` and issues HMAC JWTs signed with the same `[platform_api.auth.jwt]` secret. Used by the AI Workspace and Developer Portal quickstarts. +- **`idp`** — validate tokens against an external IDP's JWKS endpoint (Thunder, Asgardeo, Keycloak, Azure AD, Okta, etc.) via `[platform_api.auth.idp]`; `jwks_url` and `issuer` are required. ---- +`platform_api.auth.skip_paths` is a structured list (not a scalar), so it's edited directly in +the file rather than through a single token; setting it replaces the built-in default list. -### Role-Based Access Control (RBAC) +#### Role-Based Access Control (RBAC) -Per-route scope checks are enforced when `APIP_CP_ENABLE_SCOPE_VALIDATION=true`. Five built-in platform roles exist: +Per-route scope checks are enforced when `platform_api.auth.scope_validation = true`. Five built-in platform roles exist: | Role | Persona | Access level | |---|---|---| @@ -342,47 +296,16 @@ Per-route scope checks are enforced when `APIP_CP_ENABLE_SCOPE_VALIDATION=true`. | `operator` | CI/CD service account | Deploy and undeploy operations only; cannot create resources or manage credentials | | `viewer` | Auditor | Read-only access to all resources | -| Variable | Default | Description | -|---|---|---| -| `APIP_CP_ENABLE_SCOPE_VALIDATION` | `false` | Set to `true` to enforce per-route scope/role checks | - -In **local JWT mode**, scopes are read directly from the `scope` claim in the token. -In **IDP mode with `APIP_CP_AUTH_IDP_VALIDATION_MODE=scope`**, scopes are read from the claim named by `APIP_CP_AUTH_IDP_SCOPE_CLAIM_NAME`. -In **IDP mode with `APIP_CP_AUTH_IDP_VALIDATION_MODE=role`**, IDP roles are resolved from `APIP_CP_AUTH_IDP_ROLES_CLAIM_PATH`, mapped via `APIP_CP_AUTH_IDP_ROLE_MAPPINGS`, and matched against the required roles for each route. - ---- - -### Database - -| Variable | Default | Description | -|---|---|---| -| `APIP_CP_DATABASE_DRIVER` | `sqlite3` | `sqlite3` or `postgres` | -| `APIP_CP_DATABASE_DB_PATH` | `./data/api_platform.db` | SQLite file path (ignored for Postgres) | -| `APIP_CP_DATABASE_HOST` | `localhost` | Postgres host | -| `APIP_CP_DATABASE_PORT` | `5432` | Postgres port | -| `APIP_CP_DATABASE_NAME` | `platform_api` | Postgres database name | -| `APIP_CP_DATABASE_USER` | _(empty)_ | Postgres username | -| `APIP_CP_DATABASE_PASSWORD` | _(empty)_ | Postgres password | -| `APIP_CP_DATABASE_SSL_MODE` | `disable` | Postgres SSL mode (`disable`, `require`, `verify-full`) | -| `APIP_CP_DATABASE_EXECUTE_SCHEMA_DDL` | `true` | Set to `false` when the DB user lacks DDL privileges | - ---- +In **`external_token`/`file` mode**, scopes are read directly from the `scope` claim in the token. +In **`idp` mode**, scopes or roles are read from the claim(s) named in `[platform_api.auth.idp.claim_mappings]`, +per `validation_mode` (`scope` reads the scope claim directly; `role` expands IDP roles from +`roles_claim_path` via `role_mappings`). -### Encryption +### Providing secrets via the config file -`APIP_CP_ENCRYPTION_KEY` protects all at-rest encryption (secrets, subscription tokens, WebSub HMAC secrets). It is **never auto-generated** — the operator must provide it. - -| Variable | Default | Description | -|---|---|---| -| `APIP_CP_ENCRYPTION_KEY` | _(empty)_ | **Required.** 32-byte AES-256 key as 64 hex chars or base64 (32 bytes). Generate with `openssl rand -hex 32`. Startup fails if missing or malformed. | - -#### Providing secrets via the config file (preferred over raw values) - -When the Platform API is configured from a TOML file, do **not** write raw key values into -it and do **not** hardcode them as literal env vars in a compose file. Reference each secret -(`APIP_CP_ENCRYPTION_KEY`, `APIP_CP_AUTH_JWT_SECRET_KEY`, `APIP_CP_DATABASE_PASSWORD`, -`APIP_CP_WEBHOOK_SECRET`, …) with an interpolation token that is resolved at startup, -preferring a mounted file: +Never write raw secret values into the config file, and never hardcode them as literals in a +compose file. Reference each secret (`encryption_key`, `auth.jwt.secret_key`, `database.password`, +`webhook.secret`, …) with an interpolation token, preferring a mounted file over an env var: ```toml encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' # from an env var @@ -391,13 +314,18 @@ encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' # from an env v ``` For the `{{ env }}` form, supply the value from a git-ignored env file rather than the shell or -the compose file — the samples keep secrets in `keys.env` and start the stack with -`docker compose --env-file keys.env up`, which the compose forwards into the container -via an `environment:` `${APIP_CP_…}` passthrough (never an `env_file:` block or a hardcoded value). - -`{{ file }}` reads are restricted to an allowlist (`/etc/platform-api`, `/secrets/platform-api`; -override with the shared `APIP_CONFIG_FILE_SOURCE_ALLOWLIST` env var). Resolution fails closed: -a missing/empty required env var, or a missing/disallowed/oversize file, aborts startup. +a hardcoded literal in the compose file — the samples keep secrets in `api-platform.env` and +mount it into the container via an `env_file:` entry (`format: raw`, since a bcrypt hash can +contain `$`, which must not be treated as compose interpolation): + +```yaml +services: + platform-api: + env_file: + - path: api-platform.env + required: true + format: raw +``` --- diff --git a/platform-api/config/config-template.toml b/platform-api/config/config-template.toml index 1905efc3a..a8a2a0046 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -113,12 +113,15 @@ conn_max_lifetime = '{{ env "APIP_CP_DATABASE_CONN_MAX_LIFETIME" "300" }}' # s # Authentication # # auth.mode selects exactly one authentication mode: -# "jwt" — verify locally-signed HMAC JWTs ([platform_api.auth.jwt]); tokens -# are minted externally, e.g. by the Developer Portal using the -# shared secret. -# "file" — "jwt" plus local username/password login: the login endpoint -# authenticates users from [platform_api.auth.file] and issues HMAC JWTs. -# "idp" — validate tokens against an external IDP's JWKS ([platform_api.auth.idp]). +# "external_token" — verify locally-signed HMAC JWTs ([platform_api.auth.jwt]); +# tokens are minted externally, e.g. by the Developer +# Portal using the shared secret. +# "file" — "external_token" plus local username/password login: the +# login endpoint authenticates users from +# [platform_api.auth.file] and issues HMAC JWTs signed +# with the same [platform_api.auth.jwt] secret. +# "idp" — validate tokens against an external IDP's JWKS +# ([platform_api.auth.idp]). # Only the selected mode's section is read; the others are ignored. # --------------------------------------------------------------------------- [platform_api.auth] @@ -154,18 +157,20 @@ skip_paths = [ "/api/internal/v0.9/webhook/events", ] -# JWT (local HMAC) — used in the "jwt" and "file" modes (file mode signs and -# verifies login tokens with the same secret). Signature validation is always on. -# Resolved from APIP_CP_AUTH_JWT_SECRET_KEY in `api-platform.env` file; -# secret files preferred: secret_key = '{{ file "/secrets/platform-api/jwt_secret" }}' +# JWT (local HMAC) — used in the "external_token" and "file" modes: +# "external_token" mode only verifies externally-minted tokens with this key; +# "file" mode also signs the tokens its login endpoint issues with it. +# Signature validation is always on. Resolved from APIP_CP_AUTH_JWT_SECRET_KEY +# in `api-platform.env` file; secret files preferred: +# secret_key = '{{ file "/secrets/platform-api/jwt_secret" }}' [platform_api.auth.jwt] issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' # Signing key. REQUIRED — a 32-byte key (64 hex chars or base64); never generated, # and has no fallback, so an unset APIP_CP_AUTH_JWT_SECRET_KEY fails config load. secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' # Lifetime of tokens issued by the file-mode login endpoint (Go duration syntax). -# Not used to validate externally-minted "jwt"-mode tokens — their expiry is -# whatever "exp" claim the issuer set. +# Not used to validate externally-minted "external_token"-mode tokens — their +# expiry is whatever "exp" claim the issuer set. token_ttl = '{{ env "APIP_CP_AUTH_JWT_TOKEN_TTL" "1h" }}' # IDP (JWKS-based) — used when mode = "idp" (Asgardeo, Keycloak, Auth0, etc.). diff --git a/platform-api/config/config.go b/platform-api/config/config.go index e0f61ab1e..58856d386 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -102,11 +102,12 @@ type Server struct { // modeling the choice as a single discriminator (rather than per-mode enabled // flags) makes conflicting configurations inexpressible. const ( - // AuthModeJWT verifies locally-signed HMAC JWTs (auth.jwt.secret_key). Tokens - // are minted externally, e.g. by the Developer Portal using the shared secret. - AuthModeJWT = "jwt" - // AuthModeFile is AuthModeJWT plus local username/password login: the login - // endpoint authenticates users from auth.file and issues HMAC JWTs. + // AuthModeExternalToken verifies locally-signed HMAC JWTs (auth.jwt.secret_key) + // that are minted externally, e.g. by the Developer Portal using the shared secret. + AuthModeExternalToken = "external_token" + // AuthModeFile is AuthModeExternalToken plus local username/password login: the + // login endpoint authenticates users from auth.file and issues HMAC JWTs signed + // with auth.jwt.secret_key. AuthModeFile = "file" // AuthModeIDP validates tokens against an external IDP's JWKS (auth.idp). AuthModeIDP = "idp" @@ -114,15 +115,18 @@ const ( // Auth groups all authentication-related configuration. type Auth struct { - // Mode selects the active authentication mode: "jwt", "file", or "idp". + // Mode selects the active authentication mode: "external_token", "file", or "idp". Mode string `koanf:"mode"` // ScopeValidation enforces per-endpoint OAuth2 scopes on validated tokens. // Disable only to temporarily bypass authorization during development. - ScopeValidation bool `koanf:"scope_validation"` - SkipPaths []string `koanf:"skip_paths"` - IDP IDP `koanf:"idp"` - JWT JWT `koanf:"jwt"` - File FileBased `koanf:"file"` + ScopeValidation bool `koanf:"scope_validation"` + SkipPaths []string `koanf:"skip_paths"` + IDP IDP `koanf:"idp"` + // JWT is shared by two modes — "external_token" mode only verifies + // externally-minted tokens with it, "file" mode both signs and verifies + // with it. + JWT JWT `koanf:"jwt"` + File FileBased `koanf:"file"` } // IDPClaimMappings holds JWT claim name mappings for an IDP. @@ -249,8 +253,9 @@ type CORS struct { } // JWT holds configuration for local HMAC JWT authentication. Active when -// Auth.Mode is AuthModeJWT or AuthModeFile (file mode issues and verifies the -// same locally-signed tokens). Signature validation is always on. +// Auth.Mode is AuthModeExternalToken (verify-only, externally-minted tokens) +// or AuthModeFile (file mode also issues these tokens). Signature validation +// is always on. type JWT struct { SecretKey string `koanf:"secret_key"` Issuer string `koanf:"issuer"` @@ -508,15 +513,15 @@ func validateTimeoutsConfig(cfg *Timeouts) error { // the active mode's section is validated. func validateAuthConfig(auth *Auth) error { switch auth.Mode { - case AuthModeJWT: + case AuthModeExternalToken: return validateJWTConfig(&auth.JWT) case AuthModeFile: if err := validateJWTConfig(&auth.JWT); err != nil { return err } // TokenTTL only matters in file mode: the login endpoint mints tokens - // itself here, whereas in plain "jwt" mode tokens are minted externally - // and their expiry is whatever "exp" claim the issuer set. + // itself here, whereas in plain "external_token" mode tokens are minted + // externally and their expiry is whatever "exp" claim the issuer set. if auth.JWT.TokenTTL <= 0 { return fmt.Errorf("Auth.JWT.TokenTTL must be a positive duration when auth.mode is %q "+ "(set auth.jwt.token_ttl, e.g. \"8h\")", AuthModeFile) @@ -525,18 +530,18 @@ func validateAuthConfig(auth *Auth) error { case AuthModeIDP: return validateIDPConfig(&auth.IDP) default: - return fmt.Errorf("auth.mode must be %q, %q, or %q (got %q)", AuthModeJWT, AuthModeFile, AuthModeIDP, auth.Mode) + return fmt.Errorf("auth.mode must be %q, %q, or %q (got %q)", AuthModeExternalToken, AuthModeFile, AuthModeIDP, auth.Mode) } } // validateJWTConfig verifies the local HMAC JWT secret. The same secret signs and // verifies the login tokens issued in file mode, so it is required in both the -// "jwt" and "file" auth modes. The secret is never generated: a missing or -// malformed key fails startup. +// "external_token" and "file" auth modes. The secret is never generated: a +// missing or malformed key fails startup. func validateJWTConfig(jwt *JWT) error { if jwt.SecretKey == "" { return fmt.Errorf("Auth.JWT.SecretKey is required when auth.mode is %q or %q "+ - "(set auth.jwt.secret_key in config via {{ env }}/{{ file }})", AuthModeJWT, AuthModeFile) + "(set auth.jwt.secret_key in config via {{ env }}/{{ file }})", AuthModeExternalToken, AuthModeFile) } if !valid32ByteKey(jwt.SecretKey) { return fmt.Errorf("invalid Auth.JWT.SecretKey: must be 64 hex characters or base64 decoding to 32 bytes") diff --git a/platform-api/config/config.toml b/platform-api/config/config.toml index 3fba560fa..4516db886 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -12,10 +12,14 @@ # This file is read by the Platform API (Go binary): # go run ./cmd/main.go -config config/config.toml # -# Auth mode is "jwt" (locally-signed HMAC tokens, no local users). To use -# username/password login instead, set [auth] mode = "file" and add -# [auth.file.organization] / [[auth.file.users]] blocks — see -# config-template.toml for the full reference. +# Auth mode is "file" (username/password login, backed by the organization/ +# user block below) — the same mode the AI Workspace and Developer Portal +# quickstarts use, so this config works out of the box with either without +# any env vars set. Set APIP_CP_ADMIN_USERNAME / APIP_CP_ADMIN_PASSWORD_HASH +# to pick your own login credentials (generate a hash with: +# htpasswd -bnBC 12 "" | tr -d ':\n'), or set [platform_api.auth] +# mode = "external_token" for locally-signed HMAC tokens with no local users +# — see config-template.toml for the full reference. # -------------------------------------------------------------------- # --------------------------------------------------------------------------- @@ -45,14 +49,36 @@ path = '{{ env "APIP_CP_DATABASE_PATH" "./data/api_platform.db" }}' # SQLite # --------------------------------------------------------------------------- # Authentication # --------------------------------------------------------------------------- -# mode selects exactly one auth mode: "jwt" | "file" | "idp". -# "jwt" verifies locally-signed HMAC JWTs; tokens are minted externally -# (e.g. by the Developer Portal using the shared secret below). +# mode selects exactly one auth mode: "external_token" | "file" | "idp". +# "file" adds username/password login (below) on top of locally-signed HMAC +# JWTs — the mode used by the AI Workspace and Developer Portal quickstarts. [platform_api.auth] -mode = '{{ env "APIP_CP_AUTH_MODE" "jwt" }}' +mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' scope_validation = '{{ env "APIP_CP_AUTH_SCOPE_VALIDATION" "true" }}' # validate OAuth2 scopes on incoming requests -# JWT (local HMAC) — used in the "jwt" and "file" modes. +# JWT (local HMAC) — used in the "external_token" and "file" modes: +# "external_token" mode only verifies externally-minted tokens with this key; +# "file" mode also signs the tokens it issues with it. [platform_api.auth.jwt] issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' + +# File auth — local username/password login, used in "file" mode. Falls back +# to the same admin/admin-hash pair as the built-in default (defaultConfig() +# in config/default_config.go) so this file boots standalone; override via +# env vars for a real deployment. +# +# This is the one Platform API config shared by every quickstart (the AI +# Workspace and Developer Portal docker-compose.yaml files both mount this +# file directly — neither keeps its own copy) so the admin user's scopes +# cover both the ap:* namespace (AI Workspace / platform-admin operations) +# and the dp:* namespace (Developer Portal operations). +[platform_api.auth.file.organization] +id = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_ID" "default" }}' +display_name = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_DISPLAY_NAME" "Default" }}' +region = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_REGION" "us" }}' + +[[platform_api.auth.file.users]] +username = '{{ env "APIP_CP_ADMIN_USERNAME" "admin" }}' +password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" "$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ." }}' +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: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:git:read ap:api_key:read ap:secret:manage dp:org_read dp:org_create dp:org_update dp:org_manage dp:org_delete dp:org_content_read dp:org_content_write dp:org_content_manage dp:org_content_delete dp:api_read dp:api_create dp:api_update dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:mcp_read dp:mcp_create dp:mcp_update dp:mcp_manage dp:mcp_delete dp:mcp_content_read dp:mcp_content_create dp:mcp_content_update dp:mcp_content_manage dp:mcp_content_delete dp:api_key_create dp:api_key_read dp:api_key_update dp:api_key_manage dp:api_key_revoke dp:mcp_key_create dp:mcp_key_read dp:mcp_key_update dp:mcp_key_manage dp:mcp_key_revoke dp:api_workflow_create dp:api_workflow_read dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:app_create dp:app_read dp:app_update dp:app_manage dp:app_delete dp:app_key_create dp:app_key_manage dp:app_key_revoke dp:app_key_mapping_read dp:app_key_mapping_write dp:app_key_mapping_manage dp:subscription_create dp:subscription_read dp:subscription_update dp:subscription_manage dp:subscription_delete dp:sub_plan_create dp:sub_plan_read dp:sub_plan_update dp:sub_plan_manage dp:sub_plan_delete dp:km_create dp:km_read dp:km_update dp:km_manage dp:km_delete dp:view_create dp:view_read dp:view_update dp:view_manage dp:view_delete dp:label_create dp:label_read dp:label_update dp:label_manage dp:label_delete dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dp:event_read dp:delivery_manage" }}' diff --git a/platform-api/config/config_test.go b/platform-api/config/config_test.go index d3491ec17..67a991de2 100644 --- a/platform-api/config/config_test.go +++ b/platform-api/config/config_test.go @@ -99,7 +99,7 @@ secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' assert.Contains(t, err.Error(), "invalid EncryptionKey") } -// The JWT secret is required (default auth mode is "jwt") and never generated. +// The JWT secret is required (default auth mode is "external_token") and never generated. func TestLoadConfig_MissingJWTSecretKey_Errors(t *testing.T) { t.Setenv("APIP_CP_ENCRYPTION_KEY", validInlineKey) @@ -157,12 +157,12 @@ func TestValidateAuthConfig(t *testing.T) { wantErr string }{ { - name: "jwt mode with valid secret", - auth: Auth{Mode: AuthModeJWT, JWT: JWT{SecretKey: validJWTKey}}, + name: "external_token mode with valid secret", + auth: Auth{Mode: AuthModeExternalToken, JWT: JWT{SecretKey: validJWTKey}}, }, { - name: "jwt mode without secret", - auth: Auth{Mode: AuthModeJWT}, + name: "external_token mode without secret", + auth: Auth{Mode: AuthModeExternalToken}, wantErr: "Auth.JWT.SecretKey is required", }, { diff --git a/platform-api/config/default_config.go b/platform-api/config/default_config.go index a21aea3db..ba3464f10 100644 --- a/platform-api/config/default_config.go +++ b/platform-api/config/default_config.go @@ -41,7 +41,7 @@ func defaultConfig() *Server { Auth: Auth{ // Default mode verifies locally-signed HMAC JWTs; the quickstart config // selects "file" to add username/password login on top of it. - Mode: AuthModeJWT, + Mode: AuthModeExternalToken, ScopeValidation: true, // SkipPaths bypasses JWT/IDP auth middleware. Paths below the health/metrics // probes are internal gateway routes authenticated via gateway token instead. diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index edd41d0d2..a460a5f7b 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -544,8 +544,8 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, } // buildAuthenticator constructs an Authenticator from the server configuration. -// Only called when the auth mode is "jwt" or "idp" (file mode wires its own -// local-JWT middleware). +// Only called when the auth mode is "external_token" or "idp" (file mode wires +// its own local-JWT middleware). func buildAuthenticator(cfg *config.Server, slogger *slog.Logger, roleScopeMap map[string][]string) (middleware.Authenticator, error) { if cfg.Auth.Mode != config.AuthModeIDP { slogger.Info("Auth mode: jwt (HMAC signature validation enabled)") diff --git a/portals/ai-workspace/Makefile b/portals/ai-workspace/Makefile index c2b16f45c..6125e0154 100644 --- a/portals/ai-workspace/Makefile +++ b/portals/ai-workspace/Makefile @@ -215,9 +215,17 @@ ifeq ($(PLATFORM_API_FROM_TAG),true) 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 + @git -C ../.. show "$(PLATFORM_API_TAG):platform-api/config/config.toml" \ + > $(DIST_DIR)/configs/config-platform-api.toml else @cp ../../platform-api/internal/database/schema.*.sql $(DIST_DIR)/resources/platform-api/db-scripts/ @cp ../../platform-api/config/config-template.toml $(DIST_DIR)/configs/config-platform-api-template.toml + # The source docker-compose.yaml mounts ../../platform-api/config/config.toml + # directly (the monorepo's single source-of-truth Platform API config, no + # per-portal copy) — the standalone zip has no platform-api/ sibling, so bake + # a real copy into its own configs/ dir; the sed below repoints the compose + # file at it. + @cp ../../platform-api/config/config.toml $(DIST_DIR)/configs/config-platform-api.toml endif @printf '%s\n' \ '# Generated secrets — never commit these.' \ @@ -226,7 +234,8 @@ endif > $(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 's#\.\./\.\./platform-api/config/config\.toml#./configs/config-platform-api.toml#' \ + docker-compose.yaml > $(DIST_DIR)/docker-compose.yaml @cp distribution/README.md $(DIST_DIR)/README.md @sed -i.bak -E \ -e 's|^([[:space:]]*image:[[:space:]]*.*/ai-workspace):[^[:space:]]*|\1:$(DIST_VERSION)|' \ diff --git a/portals/ai-workspace/README.md b/portals/ai-workspace/README.md index c92836ed9..c36aeecbf 100644 --- a/portals/ai-workspace/README.md +++ b/portals/ai-workspace/README.md @@ -87,8 +87,9 @@ All available options are documented in portals/ai-workspace/ ├── configs/ │ ├── config-template.toml # AI Workspace config reference -│ ├── config.toml # Active config (gitignored in prod) -│ └── config-platform-api.toml # Active Platform API config +│ └── config.toml # Active config (gitignored in prod) +│ # docker-compose.yaml mounts ../../platform-api/config/config.toml +│ # directly for the Platform API sidecar — no per-portal copy here. ├── production/ │ └── README.md # Production setup guide (Asgardeo) ├── bff/ # Go BFF — serves SPA, proxy, auth diff --git a/portals/ai-workspace/configs/config-template.toml b/portals/ai-workspace/configs/config-template.toml new file mode 100644 index 000000000..ff4d8470f --- /dev/null +++ b/portals/ai-workspace/configs/config-template.toml @@ -0,0 +1,276 @@ +# -------------------------------------------------------------------- +# 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 configuration template +# +# Copy this file to config.toml and edit the values for your deployment. +# This file is read by the AI Workspace container at startup, and is the only source of +# configuration. +# +# HOW VALUES ARE RESOLVED +# Each value is an interpolation token, resolved once at startup: +# +# key = '{{ env "APIP_AIW_KEY" "default" }}' +# └── environment variable └── value used when the variable is unset +# +# So a key written this way can be set by its environment variable — no edit to this +# file required. The token is what reads the environment: there is no implicit override, +# so a key written as a plain literal (key = "value"), or left commented out below, +# ignores the variable entirely. Uncomment the key first to make it settable that way. +# +# By convention the variable is the key's table path, uppercased, dots as underscores, +# prefixed with APIP_AIW_: +# +# [platform_api] url -> APIP_AIW_PLATFORM_API_URL +# [oidc] client_id -> APIP_AIW_OIDC_CLIENT_ID +# domain (no table) -> APIP_AIW_DOMAIN +# +# To source a value from a mounted file instead of the environment — the right +# choice for secrets — swap the token: +# +# key = '{{ file "/secrets/ai-workspace/key" }}' +# +# A {{ file }} token is required and fails closed: if the file is missing, or lives +# outside the allowed source directories (/etc/ai-workspace and /secrets/ai-workspace +# by default; override with APIP_CONFIG_FILE_SOURCE_ALLOWLIST), the server refuses to +# start rather than run with an empty value. An {{ env }} token with no default +# behaves the same way. +# +# Never write a secret as a raw literal in this file, and never hardcode one in +# docker-compose.yaml. +# +# KEY ORDER +# Within every table, keys are ordered so the ones that decide whether the rest apply +# come first: +# +# 1. the gate — enabled, or the mode that selects this table (auth_mode) +# 2. required identity/connection keys (url, authority, client_id, client_secret) +# 3. optional behaviour, in the order you would tune it +# 4. TLS/trust and other hardening keys last +# +# Keep new keys in that order rather than appending to the end of a table. +# +# QUICK START (basic-auth mode): +# 1. Copy this file to config.toml. +# 2. Set auth_mode = "basic" (uses the users defined in config-platform-api.toml). +# 3. Run: docker compose up +# +# OIDC mode: set auth_mode = "oidc" and fill in the [oidc] table below. +# -------------------------------------------------------------------- + +# ==================================================================== +# Deployment identity +# +# These keys are browser-safe: they are the subset served to the SPA as +# window.__RUNTIME_CONFIG__. Never put a credential among them. +# ==================================================================== + +# Authentication mode for the UI: "basic" (local users) or "oidc" (external IDP). +# Choosing "oidc" enables the [oidc] table below. +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 host:port that deployed gateways use to reach the Platform +# API. Shown in gateway setup instructions (keys.env, helm values). Must be the +# externally reachable address, not a relative/proxy path. +controlplane_host = '{{ env "APIP_AIW_CONTROLPLANE_HOST" "localhost:9243" }}' + +# Default region assigned to new organizations on first login. +default_org_region = '{{ env "APIP_AIW_DEFAULT_ORG_REGION" "us" }}' + +# Gateway versions offered in the create-gateway version selector (JSON array string). +# Each entry: version (helm chart minor), latestVersion (image/chart tag shown to the +# user and used in the helm --version flag), channel ("STS" | "LTS"). The first entry +# is the default selection. +platform_gateway_versions = '{{ env "APIP_AIW_PLATFORM_GATEWAY_VERSIONS" "[{\"version\":\"1.2\",\"latestVersion\":\"v1.2.0-alpha2\",\"channel\":\"STS\"}]" }}' + +# Verbose logging in the browser console. +debug = '{{ env "APIP_AIW_DEBUG" "false" }}' + + +# ==================================================================== +# Server +# +# Everything from here down configures the AI Workspace server itself. These values +# stay server-side and are never sent to the browser (the [oidc] claim names being the +# one exception — the SPA needs them to display user/org identity). +# +# All keys are optional: the value in each token's default position is the built-in +# default. +# ==================================================================== + +# Address the server listens on (host:port). The container publishes this port. +listen_addr = '{{ env "APIP_AIW_LISTEN_ADDR" ":5380" }}' + +# Directory holding the built SPA (index.html + assets). +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" }}' + +# Custom header required on state-mutating requests (CSRF protection). +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 + + +# --------------------------------------------------------------------------- +# Upstream Platform API — the server-to-server hop the BFF proxies to. +# --------------------------------------------------------------------------- +[platform_api] + +# Base URL used to reach the Platform API. REQUIRED. In docker compose this is the +# compose hostname; running locally, use localhost. Its http/https scheme decides +# whether the upstream hop uses TLS. +url = '{{ env "APIP_AIW_PLATFORM_API_URL" "https://platform-api:9243" }}' + +# File-based login path on the Platform API (basic-auth mode). +login_path = '{{ env "APIP_AIW_PLATFORM_API_LOGIN_PATH" "/api/portal/v0.9/auth/login" }}' + +# --- Trust for the upstream's TLS certificate --- + +# 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 +# roots, so public CAs keep working. +ca_file = '{{ env "APIP_AIW_PLATFORM_API_CA_FILE" "" }}' + + +# --------------------------------------------------------------------------- +# TLS on the AI Workspace listener. +# --------------------------------------------------------------------------- +[tls] + +# Terminate TLS on this listener. Set false only when a trusted upstream (ingress +# controller, service-mesh sidecar) terminates TLS for you — no certificate is then +# read, generated, or required. +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/cert.pem" }}' +key_file = '{{ env "APIP_AIW_TLS_KEY_FILE" "/etc/ai-workspace/tls/key.pem" }}' + + +# --------------------------------------------------------------------------- +# Server-side session lifetime. +# --------------------------------------------------------------------------- +[session] + +# Session store. Only "memory" is supported today ("redis" is future). +store = '{{ env "APIP_AIW_SESSION_STORE" "memory" }}' + +# Sliding idle window (Go duration, e.g. "30m"). Reserved — not enforced yet. +idle_timeout = '{{ env "APIP_AIW_SESSION_IDLE_TIMEOUT" "30m" }}' + +# Hard cap on session lifetime regardless of activity/refresh (Go duration). +absolute_ttl = '{{ env "APIP_AIW_SESSION_ABSOLUTE_TTL" "8h" }}' + + +# --------------------------------------------------------------------------- +# Session cookie attributes. +# --------------------------------------------------------------------------- +[cookie] + +name = '{{ env "APIP_AIW_COOKIE_NAME" "_ai_workspace_session" }}' + +# Mark the session cookie Secure (HTTPS-only). Set false only for local HTTP. +secure = '{{ env "APIP_AIW_COOKIE_SECURE" "true" }}' + +# SameSite policy: "lax" | "strict" | "none". +samesite = '{{ env "APIP_AIW_COOKIE_SAMESITE" "lax" }}' + + +# --------------------------------------------------------------------------- +# OIDC — set auth_mode = "oidc" above to enable. +# +# The BFF is a confidential client: it performs the whole handshake, and the client +# credentials never reach the browser. +# --------------------------------------------------------------------------- +[oidc] + +# Force the OIDC client on independently of auth_mode. Setting auth_mode = "oidc" +# already enables it, so this is rarely needed. +enabled = '{{ env "APIP_AIW_OIDC_ENABLED" "false" }}' + +# Issuer URL — endpoints are auto-discovered from /.well-known/openid-configuration. +authority = '{{ env "APIP_AIW_OIDC_AUTHORITY" "https://accounts.example.com" }}' + +# Client ID registered in your IDP. +client_id = '{{ env "APIP_AIW_OIDC_CLIENT_ID" "your-client-id" }}' + +# The confidential-client secret. Required in OIDC mode, and deliberately has no +# default: an unset variable fails startup rather than running with an empty credential. +# Prefer the {{ file }} form in production — the value then never enters the +# environment at all. +client_secret = '{{ env "APIP_AIW_OIDC_CLIENT_SECRET" }}' + +# The callback the IDP redirects to after login — a server-side callback, not an SPA +# route. Must equal the redirect URI registered on the IDP application. +redirect_url = '{{ env "APIP_AIW_OIDC_REDIRECT_URL" "https://localhost:5380/api/auth/callback" }}' + +# Absolute URL the IDP redirects to after logout (must be pre-registered there). +post_logout_redirect_url = '{{ env "APIP_AIW_OIDC_POST_LOGOUT_REDIRECT_URL" "https://localhost:5380/login" }}' + +# Scopes requested at login (space-separated). Leave APIP_AIW_OIDC_SCOPE unset to +# request the built-in full ap:* set (recommended). Override only to request a +# narrower set — and keep offline_access in any override, or token refresh breaks. +scope = '{{ env "APIP_AIW_OIDC_SCOPE" "" }}' + + +# --------------------------------------------------------------------------- +# JWT claim name mappings — which token claim carries each user/org field. +# +# This table mirrors [auth.idp.claim_mappings] in config-platform-api.toml key for key, +# and the two must agree: both services read the same claims out of the same token. +# The variables line up one-to-one as well: +# +# APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME (AI Workspace) +# APIP_CP_AUTH_IDP_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME (Platform API) +# +# Override only if your IDP uses claim names other than the defaults shown. +# +# Must be the last table under [oidc]: in TOML, a sub-table header ends the parent +# table's key section, so any plain [oidc] key placed below it would land here instead. +# --------------------------------------------------------------------------- +[oidc.claim_mappings] + +organization_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME" "org_id" }}' # claim carrying the org ID +org_name_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ORG_NAME_CLAIM_NAME" "org_name" }}' # org display name +org_handle_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ORG_HANDLE_CLAIM_NAME" "org_handle" }}' # org URL slug +username_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_USERNAME_CLAIM_NAME" "username" }}' +email_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_EMAIL_CLAIM_NAME" "email" }}' +scope_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_SCOPE_CLAIM_NAME" "scope" }}' # space-separated scope string +role_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ROLE_CLAIM_NAME" "platform_role" }}' + + +# ==================================================================== +# Settings with no config key +# +# The path to this file cannot be a key in it: the server reads its mount, +# /etc/ai-workspace/config.toml, unless -config names another path +# (`bff -config ../configs/config.toml`, as `make bff-run` does). +# +# The variables below are read straight from the environment by the server itself, not +# through a token, because each is needed before (or independently of) the config file. +# They are not config keys, so they have no entry above. +# ==================================================================== +# +# APIP_CONFIG_FILE_SOURCE_ALLOWLIST +# Comma-separated directories a {{ file "..." }} token may +# read from. Replaces (not extends) the defaults +# /etc/ai-workspace and /secrets/ai-workspace. Shared by +# every component in the platform. diff --git a/portals/ai-workspace/docker-compose.yaml b/portals/ai-workspace/docker-compose.yaml index de0c628dd..b165d834f 100644 --- a/portals/ai-workspace/docker-compose.yaml +++ b/portals/ai-workspace/docker-compose.yaml @@ -14,15 +14,22 @@ services: 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"] + # Mounts the single source-of-truth config shipped with the Platform API + # itself (../../platform-api/config/config.toml) rather than keeping a + # separate copy under this portal's configs/ — every portal's compose + # file mounts the same file. + command: ["-config", "/etc/platform-api/config.toml"] env_file: - path: api-platform.env required: true format: raw volumes: - - ./configs/config-platform-api.toml:/etc/platform-api/config-platform-api.toml:ro + - ../../platform-api/config/config.toml:/etc/platform-api/config.toml:ro - platform-api-data:/app/data - - ./resources/certificates:/etc/platform-api/tls:ro + # Certs land under /app/data/certs to match the binary's compiled + # default cert_file/key_file paths (./data/certs/{cert,key}.pem, + # resolved against WORKDIR /app) — no cert path override needed. + - ./resources/certificates:/app/data/certs:ro ports: - "9243:9243" healthcheck: diff --git a/portals/ai-workspace/production/README.md b/portals/ai-workspace/production/README.md index 3e3899c5d..0087476a8 100644 --- a/portals/ai-workspace/production/README.md +++ b/portals/ai-workspace/production/README.md @@ -83,35 +83,31 @@ In each sub-organization: ## 2. Platform API Configuration -The Platform API reads its configuration from `config-platform-api.toml` (mounted at -`/etc/platform-api/config.toml` in the container). Open `configs/config-platform-api.toml` -and update the `[auth.idp]` section for production: +The Platform API reads its configuration from `../../platform-api/config/config.toml` +(mounted at `/etc/platform-api/config.toml` in the container — every portal's +docker-compose.yaml mounts this one file directly, no per-portal copy). Open it +and update the `[platform_api.auth]` section for production: > **Note:** Asgardeo uses `org_id` as the JWT claim for the organization UUID. The Platform > API defaults to `organization`, so the claim name overrides below are required. ```toml -# Disable local JWT auth when delegating entirely to an external IDP. -[auth.jwt] -enabled = false +# Select "idp" mode to delegate entirely to an external IDP. +[platform_api.auth] +mode = "idp" -# Enable JWKS-based IDP authentication. -[auth.idp] -enabled = true +# JWKS-based IDP authentication. +[platform_api.auth.idp] name = "asgardeo" jwks_url = "https://api.asgardeo.io/t//oauth2/jwks" issuer = ["https://api.asgardeo.io/t//oauth2/token"] audience = [""] # Client ID from Asgardeo Protocol tab # Asgardeo-specific claim name overrides. -[auth.idp.claim_mappings] +[platform_api.auth.idp.claim_mappings] organization_claim_name = "org_id" org_name_claim_name = "org_name" org_handle_claim_name = "org_handle" - -# Disable file-based auth in production. -[auth.file_based] -enabled = false ``` Optional overrides (defaults shown): diff --git a/portals/developer-portal/.gitignore b/portals/developer-portal/.gitignore index ed729ddad..65a8b06e2 100644 --- a/portals/developer-portal/.gitignore +++ b/portals/developer-portal/.gitignore @@ -99,7 +99,6 @@ resources/mock .env api-platform.env -configs/config-platform-api.toml # ./setup.sh output: bind-mounted TLS cert resources/certificates/ diff --git a/portals/developer-portal/Makefile b/portals/developer-portal/Makefile index 3c566b079..7538cff3d 100644 --- a/portals/developer-portal/Makefile +++ b/portals/developer-portal/Makefile @@ -147,9 +147,14 @@ dist: clean-dist ## Build standalone developer portal distribution zip @cp -R samples/mcps $(DIST_DIR)/resources/samples/ @cp configs/config.toml $(DIST_DIR)/configs/config.toml @cp configs/config-template.toml $(DIST_DIR)/configs/config-template.toml - @cp configs/config-platform-api-template.toml $(DIST_DIR)/configs/config-platform-api.toml - @cp configs/config-platform-api-template.toml $(DIST_DIR)/configs/config-platform-api-template.toml - @cp docker-compose.yaml $(DIST_DIR)/docker-compose.yaml + # The source docker-compose.yaml mounts ../../platform-api/config/config.toml + # directly (the monorepo's single source-of-truth Platform API config, no + # per-portal copy) — the standalone zip has no platform-api/ sibling, so bake + # a real copy into its own configs/ dir and repoint the compose file at it. + @cp ../../platform-api/config/config.toml $(DIST_DIR)/configs/config-platform-api.toml + @cp ../../platform-api/config/config-template.toml $(DIST_DIR)/configs/config-platform-api-template.toml + @sed 's#\.\./\.\./platform-api/config/config\.toml#./configs/config-platform-api.toml#' \ + docker-compose.yaml > $(DIST_DIR)/docker-compose.yaml @cp distribution/README.md $(DIST_DIR)/README.md @mkdir -p $(DIST_DIR)/scripts @cp scripts/setup.sh $(DIST_DIR)/scripts/setup.sh diff --git a/portals/developer-portal/README.md b/portals/developer-portal/README.md index 28e98bb3d..f8356a9f9 100644 --- a/portals/developer-portal/README.md +++ b/portals/developer-portal/README.md @@ -31,7 +31,7 @@ The fastest way to get the portal running — no local Node install required. Re docker compose up ``` -`./scripts/setup.sh` is a one-time step: it generates devportal's and the Platform API's encryption/JWT secrets, a self-signed TLS certificate, and an admin user into `api-platform.env` (git-ignored) and `configs/config-platform-api.toml` (also git-ignored — copy `configs/config-platform-api-template.toml` instead for a static, no-dependencies starting point). It prompts for an admin username/password interactively, or generates a random password if you press Enter; set `ADMIN_USERNAME`/`ADMIN_PASSWORD` env vars to skip the prompts (e.g. in CI). Safe to re-run — it only fills in what's missing and never overwrites an existing value; to build devportal from source instead of using the published image, run `docker compose up --build`. +`./scripts/setup.sh` is a one-time step: it generates devportal's and the Platform API's encryption/JWT secrets, a self-signed TLS certificate, and an admin user into `api-platform.env` (git-ignored). It prompts for an admin username/password interactively, or generates a random password if you press Enter; set `ADMIN_USERNAME`/`ADMIN_PASSWORD` env vars to skip the prompts (e.g. in CI). Safe to re-run — it only fills in what's missing and never overwrites an existing value; to build devportal from source instead of using the published image, run `docker compose up --build`. Then open **https://localhost:3000/default/views/default** and log in with the admin credentials `./scripts/setup.sh` printed. @@ -229,10 +229,10 @@ The full annotated list of settings is in [`configs/config-template.toml`](confi ### Local auth -For quick exploration without an IdP, the portal delegates credential validation to a Platform API sidecar. Users, bcrypt-hashed passwords, and `dp:*` scopes are defined in `configs/config-platform-api.toml` (copy from `configs/config-platform-api-template.toml`): +For quick exploration without an IdP, the portal delegates credential validation to a Platform API sidecar. `docker-compose.yaml` mounts the Platform API's own [`../../platform-api/config/config.toml`](../../platform-api/config/config.toml) directly — there is no per-portal copy. Users, bcrypt-hashed passwords, and `dp:*` scopes are defined there, under `[[platform_api.auth.file.users]]`: ```toml -[[auth.file_based.users]] +[[platform_api.auth.file.users]] username = "admin" password_hash = "$2y$10$..." # bcrypt hash — generate with: htpasswd -bnBC 12 "" | tr -d ':\n' scopes = "dp:org_manage dp:api_manage ..." diff --git a/portals/developer-portal/docker-compose.platform-api.yaml b/portals/developer-portal/docker-compose.platform-api.yaml index 2ddf5bb58..6db5dc214 100644 --- a/portals/developer-portal/docker-compose.platform-api.yaml +++ b/portals/developer-portal/docker-compose.platform-api.yaml @@ -19,8 +19,8 @@ # Platform API only — for local development with npm start # # Quick start: -# 1. Run ./scripts/setup.sh once — generates the required secrets into api-platform.env -# (also read directly by `npm start` below) and configs/config-platform-api.toml. +# 1. Run ./scripts/setup.sh once — generates the required secrets into +# api-platform.env (also read directly by `npm start` below). # 2. docker compose -f docker-compose.platform-api.yaml up -d # 3. npm start # 4. Open http://localhost:3000 @@ -31,15 +31,22 @@ services: image: ghcr.io/wso2/api-platform/platform-api:0.12.0 container_name: platform-api restart: unless-stopped - command: ["-config", "/etc/platform-api/config-platform-api.toml"] + # Mounts the single source-of-truth config shipped with the Platform API + # itself (../../platform-api/config/config.toml) rather than keeping a + # separate copy under this portal's configs/ — every portal's compose + # file mounts the same file. + command: ["-config", "/etc/platform-api/config.toml"] env_file: - path: api-platform.env required: true format: raw volumes: - - ./configs/config-platform-api.toml:/etc/platform-api/config-platform-api.toml:ro + - ../../platform-api/config/config.toml:/etc/platform-api/config.toml:ro - platform-api-data:/app/data - - ./resources/certificates:/etc/platform-api/tls:ro + # Certs land under /app/data/certs to match the binary's compiled + # default cert_file/key_file paths (./data/certs/{cert,key}.pem, + # resolved against WORKDIR /app) — no cert path override needed. + - ./resources/certificates:/app/data/certs:ro ports: - "9243:9243" healthcheck: diff --git a/portals/developer-portal/docker-compose.yaml b/portals/developer-portal/docker-compose.yaml index daa5bcbcd..2c54eb15c 100644 --- a/portals/developer-portal/docker-compose.yaml +++ b/portals/developer-portal/docker-compose.yaml @@ -13,15 +13,22 @@ services: image: ghcr.io/wso2/api-platform/platform-api:0.12.0 container_name: platform-api restart: unless-stopped - command: ["-config", "/etc/platform-api/config-platform-api.toml"] + # Mounts the single source-of-truth config shipped with the Platform API + # itself (../../platform-api/config/config.toml) rather than keeping a + # separate copy under this portal's configs/ — every portal's compose + # file mounts the same file. + command: ["-config", "/etc/platform-api/config.toml"] env_file: - path: api-platform.env required: true format: raw volumes: - - ./configs/config-platform-api.toml:/etc/platform-api/config-platform-api.toml:ro + - ../../platform-api/config/config.toml:/etc/platform-api/config.toml:ro - platform-api-data:/app/data - - ./resources/certificates:/etc/platform-api/tls:ro + # Certs land under /app/data/certs to match the binary's compiled + # default cert_file/key_file paths (./data/certs/{cert,key}.pem, + # resolved against WORKDIR /app) — no cert path override needed. + - ./resources/certificates:/app/data/certs:ro ports: - "9243:9243" healthcheck: diff --git a/portals/developer-portal/scripts/setup.sh b/portals/developer-portal/scripts/setup.sh index e7381900c..feae3947f 100755 --- a/portals/developer-portal/scripts/setup.sh +++ b/portals/developer-portal/scripts/setup.sh @@ -70,7 +70,6 @@ cd "$ROOT_DIR" ENV_FILE="$ROOT_DIR/api-platform.env" DEVPORTAL_CERT_DIR="$ROOT_DIR/resources/certificates" -PLATFORM_API_CONFIG="$ROOT_DIR/configs/config-platform-api.toml" # Bind-mounted into a container running as a non-root UID: 644 (not 600) so the # container user can read a file owned by the host user. Local single-user @@ -143,67 +142,6 @@ fi JWT_SECRET_KEY="$(get_env_var APIP_CP_AUTH_JWT_SECRET_KEY)" set_env_var "APIP_DP_PLATFORMAPI_JWTSECRET" "$JWT_SECRET_KEY" -# Full-access scopes for the seeded admin user — ap:* (platform-admin) plus every -# dp:*_manage scope so it can manage every Developer Portal resource area. A plain -# literal in config-platform-api.toml (never templated), since it carries no secret. -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: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:git:read ap:api_key:read dp:org_read dp:org_write dp:org_manage dp:org_delete dp:org_content_read dp:org_content_write dp:org_content_manage dp:org_content_delete dp:api_read dp:api_write dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:mcp_create dp:mcp_read dp:mcp_update dp:mcp_delete dp:mcp_manage dp:mcp_content_create dp:mcp_content_read dp:mcp_content_update dp:mcp_content_delete dp:mcp_content_manage dp:mcp_key_create dp:mcp_key_read dp:mcp_key_update dp:mcp_key_revoke dp:mcp_key_manage dp:api_key_read dp:api_key_write dp:api_key_manage dp:api_key_revoke dp:api_flow_read dp:api_flow_write dp:api_flow_manage dp:api_flow_delete dp:api_workflow_read dp:api_workflow_create dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:app_read dp:app_write dp:app_manage dp:app_delete dp:app_key_write dp:app_key_manage dp:app_key_revoke dp:app_key_mapping_read dp:app_key_mapping_write dp:app_key_mapping_manage dp:subscription_read dp:subscription_write dp:subscription_manage dp:subscription_delete dp:sub_plan_read dp:sub_plan_write dp:sub_plan_manage dp:sub_plan_delete dp:idp_read dp:idp_write dp:idp_manage dp:idp_delete dp:view_read dp:view_write dp:view_manage dp:view_delete dp:km_read dp:km_write dp:km_manage dp:km_delete dp:label_read dp:label_write dp:label_manage dp:label_delete dp:provider_read dp:provider_write dp:provider_manage dp:provider_delete dp:event_read dp:delivery_manage dp:utility_write dp:utility_manage dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dev" - -log "Provisioning configs/config-platform-api.toml ..." -if [ -f "$PLATFORM_API_CONFIG" ]; then - log " - $PLATFORM_API_CONFIG already exists, leaving as-is" -else - mkdir -p "$ROOT_DIR/configs" - # Wired to api-platform.env via {{ env "..." }} tokens (never hardcode a - # secret here) — this is the file docker-compose.yaml bind-mounts into the - # platform-api container. It's gitignored: for a static, no-dependencies - # starting point instead (e.g. running platform-api directly, without - # ./setup.sh), copy configs/config-platform-api-template.toml instead. - # Unquoted heredoc: $ADMIN_SCOPES is interpolated by bash below, while every - # {{ env "..." }} token is left untouched for platform-api's own config - # loader to resolve at container startup (no literal "$" appears in this - # file otherwise, so nothing else gets touched by the expansion). - cat > "$PLATFORM_API_CONFIG" </dev/null; then From b05d761a31121282c0921b6abb69cf6ccf278421 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Mon, 20 Jul 2026 15:33:16 +0530 Subject: [PATCH 03/18] Update go.work.sum and modify test configuration format in harness_test.go - Added new dependencies for aws/smithy-go, coreos/go-oidc/v3, prometheus/otlptranslator, and google.golang.org/genproto. - Changed the configuration format in harness_test.go to reflect the new structure for platform API settings, ensuring proper organization of encryption and authentication parameters. --- go.work.sum | 5 +++++ platform-api/internal/integration/harness_test.go | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/go.work.sum b/go.work.sum index 528b6225c..b21ef1d58 100644 --- a/go.work.sum +++ b/go.work.sum @@ -2210,6 +2210,7 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.33.17/go.mod h1:cQnB8CUnxbMU82JvlqjK github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= @@ -2388,6 +2389,7 @@ github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc github.com/coreos/go-etcd v2.0.0+incompatible h1:bXhRBIXoTm9BYHS3gE0TtQuyNZyeEMux2sDi4oo5YOo= github.com/coreos/go-oidc v2.3.0+incompatible h1:+5vEsrgprdLjjQ9FzIKAzQz1wwPD+83hQRfUIPh7rO0= github.com/coreos/go-oidc v2.3.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e h1:Wf6HqHfScWJN9/ZjdUKyjop4mf3Qdd+1TvvltAvM3m8= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -3122,6 +3124,7 @@ github.com/knadh/koanf/parsers/yaml v0.1.0/go.mod h1:cvbUDC7AL23pImuQP0oRw/hPucc github.com/knadh/koanf/providers/env v1.0.0/go.mod h1:mzFyRZueYhb37oPmC1HAv/oGEEuyvJDA98r3XAa8Gak= github.com/knadh/koanf/providers/file v1.1.2/go.mod h1:/faSBcv2mxPVjFrXck95qeoyoZ5myJ6uxN8OOVNJJCI= github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo= +github.com/knqyf263/go-plugin v0.9.0/go.mod h1:2z5lCO1/pez6qGo8CvCxSlBFSEat4MEp1DrnA+f7w8Q= github.com/knz/go-libedit v1.10.1 h1:0pHpWtx9vcvC0xGZqEQlQdfSQs7WRlAjuPvk3fOZDCo= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= @@ -3425,6 +3428,7 @@ github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB8 github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA= +github.com/prometheus/otlptranslator v0.0.2/go.mod h1:P8AwMgdD7XEr6QRUJ2QWLpiAZTgTE2UYgjlu3svompI= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= @@ -4890,6 +4894,7 @@ google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUE google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:VUhTRKeHn9wwcdrk73nvdC9gF178Tzhmt/qyaFcPLSo= google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4/go.mod h1:qbZzneIOXSq+KFAFut9krLfRLZiFLzZL5u2t8SV83EE= google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= +google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE= google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b h1:kqShdsddZrS6q+DGBCA73CzHsKDu5vW4qw78tFnbVvY= google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:gw1DtiPCt5uh/HV9STVEeaO00S5ATsJiJ2LsZV8lcDI= google.golang.org/genproto/googleapis/api v0.0.0-20230525234020-1aefcd67740a/go.mod h1:ts19tUU+Z0ZShN1y3aPyq2+O3d5FUNNgT6FtOzmrNn8= diff --git a/platform-api/internal/integration/harness_test.go b/platform-api/internal/integration/harness_test.go index 9599d9fbf..fd398ef4f 100644 --- a/platform-api/internal/integration/harness_test.go +++ b/platform-api/internal/integration/harness_test.go @@ -50,7 +50,7 @@ func TestMain(m *testing.M) { if err != nil { panic(fmt.Sprintf("integration harness: create temp config: %v", err)) } - if _, err := fmt.Fprintf(f, "encryption_key = %q\n\n[auth.jwt]\nenabled = true\nsecret_key = %q\n", testKey, testKey); err != nil { + if _, err := fmt.Fprintf(f, "[platform_api]\nencryption_key = %q\n\n[platform_api.auth.jwt]\nenabled = true\nsecret_key = %q\n", testKey, testKey); err != nil { panic(fmt.Sprintf("integration harness: write temp config: %v", err)) } _ = f.Close() From ca3bd731f420df112a689bbe7a8f6e22c4747cf9 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Mon, 20 Jul 2026 15:42:27 +0530 Subject: [PATCH 04/18] Enhance Platform API README and configuration documentation - Added detailed explanations for the local-development configuration in README.md, including usage of `config/config.toml` and authentication modes. - Streamlined comments in `config/config.toml` to clarify the purpose of various settings, particularly around authentication and database configuration. - Ensured consistency in documentation to support quickstart setups for AI Workspace and Developer Portal. --- platform-api/README.md | 11 ++++++ platform-api/config/config.toml | 65 ++------------------------------- 2 files changed, 15 insertions(+), 61 deletions(-) diff --git a/platform-api/README.md b/platform-api/README.md index a5af40e5c..34a6a171e 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -20,6 +20,17 @@ cd platform-api go run ./cmd/main.go ``` +`config/config.toml` is the local-development config, used with `platform_api.auth.mode = "file"` +(username/password login backed by the organization/user block in that file) — the same mode the +AI Workspace and Developer Portal quickstarts use, so it works out of the box with either, with no +env vars set. It's the one Platform API config shared by every quickstart (both docker-compose +setups mount it directly), so its admin user's scopes cover both the `ap:*` (AI Workspace / +platform-admin) and `dp:*` (Developer Portal) namespaces. Set `APIP_CP_ADMIN_USERNAME` / +`APIP_CP_ADMIN_PASSWORD_HASH` to pick your own login credentials (generate a hash with +`htpasswd -bnBC 12 "" | tr -d ':\n'`), or set `platform_api.auth.mode = "external_token"` +for locally-signed HMAC tokens with no local users — see +[`config/config-template.toml`](config/config-template.toml) for the full reference. + ### Database Configuration Platform API supports `sqlite3` (default), `postgres`, and `sqlserver`. Configure the driver diff --git a/platform-api/config/config.toml b/platform-api/config/config.toml index 4516db886..83ccc1a6f 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -1,78 +1,21 @@ -# -------------------------------------------------------------------- -# 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 -# -------------------------------------------------------------------- -# -# Platform API configuration — local development -# -# This file is read by the Platform API (Go binary): -# go run ./cmd/main.go -config config/config.toml -# -# Auth mode is "file" (username/password login, backed by the organization/ -# user block below) — the same mode the AI Workspace and Developer Portal -# quickstarts use, so this config works out of the box with either without -# any env vars set. Set APIP_CP_ADMIN_USERNAME / APIP_CP_ADMIN_PASSWORD_HASH -# to pick your own login credentials (generate a hash with: -# htpasswd -bnBC 12 "" | tr -d ':\n'), or set [platform_api.auth] -# mode = "external_token" for locally-signed HMAC tokens with no local users -# — see config-template.toml for the full reference. -# -------------------------------------------------------------------- - -# --------------------------------------------------------------------------- -# All Platform API settings live under [platform_api] / [platform_api.*]. -# --------------------------------------------------------------------------- [platform_api] -# --------------------------------------------------------------------------- -# Logging -# --------------------------------------------------------------------------- -log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' # DEBUG | INFO | WARN | ERROR +log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' -# --------------------------------------------------------------------------- -# Encryption -# --------------------------------------------------------------------------- -# Single 32-byte key (64 hex chars or base64) used for ALL at-rest encryption -# (secrets, subscription tokens, WebSub HMAC secrets) encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' -# --------------------------------------------------------------------------- -# Database -# --------------------------------------------------------------------------- [platform_api.database] -driver = '{{ env "APIP_CP_DATABASE_DRIVER" "sqlite3" }}' # "sqlite3" or "postgres" -path = '{{ env "APIP_CP_DATABASE_PATH" "./data/api_platform.db" }}' # SQLite file path (ignored for postgres) +driver = '{{ env "APIP_CP_DATABASE_DRIVER" "sqlite3" }}' +path = '{{ env "APIP_CP_DATABASE_PATH" "./data/api_platform.db" }}' -# --------------------------------------------------------------------------- -# Authentication -# --------------------------------------------------------------------------- -# mode selects exactly one auth mode: "external_token" | "file" | "idp". -# "file" adds username/password login (below) on top of locally-signed HMAC -# JWTs — the mode used by the AI Workspace and Developer Portal quickstarts. [platform_api.auth] mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' -scope_validation = '{{ env "APIP_CP_AUTH_SCOPE_VALIDATION" "true" }}' # validate OAuth2 scopes on incoming requests +scope_validation = '{{ env "APIP_CP_AUTH_SCOPE_VALIDATION" "true" }}' -# JWT (local HMAC) — used in the "external_token" and "file" modes: -# "external_token" mode only verifies externally-minted tokens with this key; -# "file" mode also signs the tokens it issues with it. [platform_api.auth.jwt] issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' -# File auth — local username/password login, used in "file" mode. Falls back -# to the same admin/admin-hash pair as the built-in default (defaultConfig() -# in config/default_config.go) so this file boots standalone; override via -# env vars for a real deployment. -# -# This is the one Platform API config shared by every quickstart (the AI -# Workspace and Developer Portal docker-compose.yaml files both mount this -# file directly — neither keeps its own copy) so the admin user's scopes -# cover both the ap:* namespace (AI Workspace / platform-admin operations) -# and the dp:* namespace (Developer Portal operations). [platform_api.auth.file.organization] id = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_ID" "default" }}' display_name = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_DISPLAY_NAME" "Default" }}' From 0f954c489390537e651d297a932913d327d9fbb9 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Mon, 20 Jul 2026 16:49:20 +0530 Subject: [PATCH 05/18] Refactor AI Workspace configuration structure and update documentation - Consolidated all AI Workspace settings under a single top-level `[ai_workspace]` table to align with the Platform API's configuration structure, preventing key collisions in shared config files. - Updated related documentation in `configuration.md`, `asgardeo-setup.md`, and `oidc-auth.md` to reflect the new namespacing and clarify the usage of environment variables. - Adjusted test cases to ensure compatibility with the new configuration format, including changes in the expected paths for OIDC and platform API settings. - Enhanced comments in `config-template.toml` and `README.md` to provide clearer guidance on configuration options and their defaults. --- .../authentication/asgardeo-setup.md | 17 +-- docs/ai-workspace/authentication/oidc-auth.md | 30 ++--- docs/ai-workspace/configuration.md | 114 +++++++++--------- portals/ai-workspace/README.md | 34 +++--- .../bff/internal/config/config_test.go | 61 ++++++---- .../bff/internal/config/settings.go | 32 ++++- .../internal/config/shipped_config_test.go | 20 +-- .../ai-workspace/configs/config-template.toml | 53 ++++---- portals/ai-workspace/configs/config.toml | 8 +- portals/ai-workspace/distribution/README.md | 48 ++++---- portals/ai-workspace/production/README.md | 38 +++--- 11 files changed, 249 insertions(+), 206 deletions(-) diff --git a/docs/ai-workspace/authentication/asgardeo-setup.md b/docs/ai-workspace/authentication/asgardeo-setup.md index 31b4cd222..d1593e294 100644 --- a/docs/ai-workspace/authentication/asgardeo-setup.md +++ b/docs/ai-workspace/authentication/asgardeo-setup.md @@ -145,15 +145,16 @@ enabled = false Update `configs/config.toml`: ```toml +[ai_workspace] domain = "" auth_mode = "oidc" controlplane_host = "" default_org_region = "us" -[platform_api] +[ai_workspace.platform_api] url = "https://" -[oidc] +[ai_workspace.oidc] authority = "https://api.asgardeo.io/t//oauth2/token" client_id = "" @@ -165,10 +166,10 @@ post_logout_redirect_url = "https:///login" # 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" }}' -# Mirrors [auth.idp.claim_mappings] in config-platform-api.toml — the two must agree. -# Must stay the last table under [oidc]: plain [oidc] keys placed below this header -# would land in [oidc.claim_mappings] instead. -[oidc.claim_mappings] +# Mirrors [platform_api.auth.idp.claim_mappings] in config-platform-api.toml — the two must +# agree. Must stay the last table under [ai_workspace.oidc]: plain [ai_workspace.oidc] keys +# placed below this header would land in [ai_workspace.oidc.claim_mappings] instead. +[ai_workspace.oidc.claim_mappings] organization_claim_name = "org_id" org_name_claim_name = "org_name" org_handle_claim_name = "org_handle" @@ -178,7 +179,7 @@ The redirect URLs and the client secret are BFF settings and never reach the bro redirect URLs are ordinary `config.toml` keys; the secret is referenced with an interpolation token so the raw value never lands in the file. -> `[oidc] redirect_url` must exactly match the authorized redirect URL registered in section 2. +> `[ai_workspace.oidc] redirect_url` must exactly match the authorized redirect URL registered in section 2. > A missing client secret fails startup — see [Configuration → Secrets](../configuration.md#secrets). --- @@ -196,5 +197,5 @@ Asgardeo token The claim names must be consistent across all three places: - Asgardeo token mapper output -- `[oidc.claim_mappings]` in `config.toml` +- `[ai_workspace.oidc.claim_mappings]` in `config.toml` - `*_claim_name` in Platform API `[auth.idp.claim_mappings]` diff --git a/docs/ai-workspace/authentication/oidc-auth.md b/docs/ai-workspace/authentication/oidc-auth.md index 62cea56b2..dd9d6fbe2 100644 --- a/docs/ai-workspace/authentication/oidc-auth.md +++ b/docs/ai-workspace/authentication/oidc-auth.md @@ -22,15 +22,16 @@ Tested IDPs: [Asgardeo](asgardeo-setup.md), Keycloak, Auth0, Okta. ### AI Workspace (`configs/config.toml`) ```toml +[ai_workspace] domain = "app.example.com" auth_mode = "oidc" controlplane_host = "api.example.com" -[platform_api] +[ai_workspace.platform_api] # The upstream the BFF proxies to (an origin — the API paths are appended by the proxy). url = "https://api.example.com" -[oidc] +[ai_workspace.oidc] # IDP issuer URL — the discovery doc is fetched from {authority}/.well-known/openid-configuration authority = "https://idp.example.com/realms/my-realm" @@ -46,11 +47,12 @@ post_logout_redirect_url = "https:///login" client_secret = '{{ file "/secrets/ai-workspace/oidc_client_secret" }}' # JWT claim names for organization identity. This table mirrors -# [auth.idp.claim_mappings] in the Platform API config (below) key for key — both -# services read the same claims out of the same token, so the two must agree. -# Must stay the last table under [oidc]: plain [oidc] keys placed below this -# header would land in [oidc.claim_mappings] instead. -[oidc.claim_mappings] +# [platform_api.auth.idp.claim_mappings] in the Platform API config (below) key for +# key — both services read the same claims out of the same token, so the two must +# agree. Must stay the last table under [ai_workspace.oidc]: plain [ai_workspace.oidc] +# keys placed below this header would land in [ai_workspace.oidc.claim_mappings] +# instead. +[ai_workspace.oidc.claim_mappings] organization_claim_name = "org_id" org_name_claim_name = "org_name" org_handle_claim_name = "org_handle" @@ -65,7 +67,7 @@ the value in the git-ignored `api-platform.env`. The key must carry one token or 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). -`[oidc] redirect_url` (the BFF callback `/api/auth/callback`) and `post_logout_redirect_url` +`[ai_workspace.oidc] redirect_url` (the BFF callback `/api/auth/callback`) and `post_logout_redirect_url` must be registered as allowed redirect URIs in your IDP application. The redirect is **not** the SPA `/signin` route — the BFF, not the browser, completes the code exchange. @@ -82,10 +84,10 @@ enabled = true name = "my-idp" jwks_url = "https://idp.example.com/realms/my-realm/protocol/openid-connect/certs" issuer = ["https://idp.example.com/realms/my-realm"] -audience = ["ai-workspace"] # must match [oidc] client_id +audience = ["ai-workspace"] # must match [ai_workspace.oidc] client_id # Map IDP-specific claim names to Platform API's expected fields -# These must match the [oidc.claim_mappings] values in config.toml above +# These must match the [ai_workspace.oidc.claim_mappings] values in config.toml above [auth.idp.claim_mappings] organization_claim_name = "org_id" org_name_claim_name = "org_name" @@ -139,16 +141,16 @@ You must register the `ap:*` scopes as an API resource in your IDP and grant the **Users see a blank screen or redirect loop after login** - Verify `domain` in `config.toml` matches the actual host:port in the browser. - Verify the redirect URI `https:///api/auth/callback` (the BFF callback) is registered - in the IDP and matches `[oidc] redirect_url`. + in the IDP and matches `[ai_workspace.oidc] redirect_url`. **Token endpoint rejects the BFF with `unauthorized_client` / "not authorized to use the requested grant type"** - The app is registered as a public/SPA client. Re-register it as a **confidential** client - (authorization-code + refresh-token grants, PKCE) and set `[oidc] client_secret`. + (authorization-code + refresh-token grants, PKCE) and set `[ai_workspace.oidc] client_secret`. **Platform API returns 401** - Check that `jwks_url` and `issuer` in Platform API config match the IDP's discovery doc values. -- Check that `audience` matches the `[oidc] client_id` of the confidential application. -- Ensure `organization_claim_name` matches on both sides — `[auth.idp.claim_mappings]` in the Platform API and `[oidc.claim_mappings]` in AI Workspace. +- Check that `audience` matches the `[ai_workspace.oidc] client_id` of the confidential application. +- Ensure `organization_claim_name` matches on both sides — `[platform_api.auth.idp.claim_mappings]` in the Platform API and `[ai_workspace.oidc.claim_mappings]` in AI Workspace. **"Organization not found" error** - The `org_id` claim in the token does not match any organization in Platform API's database. diff --git a/docs/ai-workspace/configuration.md b/docs/ai-workspace/configuration.md index af9befdfa..786da6675 100644 --- a/docs/ai-workspace/configuration.md +++ b/docs/ai-workspace/configuration.md @@ -2,19 +2,19 @@ AI Workspace is configured through a `config.toml` file mounted into the container at `/etc/ai-workspace/config.toml`. -Keys are grouped into TOML tables (`[platform_api]`, `[tls]`, `[session]`, `[cookie]`, `[oidc]`); deployment-identity keys such as `domain` and `auth_mode` sit at the top level. +All AI Workspace settings live under a single top-level `[ai_workspace]` table — the same namespacing convention the Platform API uses for its own `[platform_api]` table — so one `config.toml` can hold both services' sections side by side without their keys colliding. Keys are grouped into TOML tables (`[ai_workspace.platform_api]`, `[ai_workspace.tls]`, `[ai_workspace.session]`, `[ai_workspace.cookie]`, `[ai_workspace.oidc]`); deployment-identity keys such as `domain` and `auth_mode` sit directly under `[ai_workspace]`. The file is the **only** source of configuration. Each value in it is written as an interpolation token that is resolved once at startup, so where the value comes from is visible in place: ```toml -[oidc] +[ai_workspace.oidc] client_id = '{{ env "APIP_AIW_OIDC_CLIENT_ID" "default" }}' # ^ environment variable ^ value used when the variable is unset ``` A key written this way can be set from the environment without editing the file. That token is the *only* thing that lets an environment variable reach a config key — there is no implicit override, so a key written as a plain literal (`key = "value"`), or absent from the file, ignores the variable entirely. Add the key with a token to make it settable that way. -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. +By convention the variable a token names is the key's path **under `[ai_workspace]`** (the `ai_workspace` segment itself is not part of the name) — table and key, uppercased, dots as underscores — prefixed with **`APIP_AIW_`**: `[ai_workspace.oidc] client_id` → `APIP_AIW_OIDC_CLIENT_ID`, `[ai_workspace.platform_api] url` → `APIP_AIW_PLATFORM_API_URL`, and `[ai_workspace] 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). 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). @@ -27,14 +27,14 @@ Never write a secret as a literal in `config.toml`, and never hardcode one in `d **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] +[ai_workspace.oidc] client_secret = '{{ env "APIP_AIW_OIDC_CLIENT_SECRET" }}' ``` **Mounted secret file (preferred in production)** — swap the token so the value never enters the environment at all: ```toml -[oidc] +[ai_workspace.oidc] client_secret = '{{ file "/secrets/ai-workspace/oidc_client_secret" }}' ``` @@ -42,89 +42,94 @@ Both forms fail closed: if the variable is unset, or the file is missing or outs ## All Configuration Keys -The "Env var" column is the variable each key's shipped `{{ env }}` token names — set it and the key picks the value up. It works only while the key is present (uncommented) in `config.toml`. +Every key's `{{ env }}` token and default value are written inline in +[`configs/config-template.toml`](../../portals/ai-workspace/configs/config-template.toml) — that +file is the single source of truth for both, so they are not duplicated here. What follows is a +map of what each table is for. -### Top level (deployment identity) +### `[ai_workspace]` (deployment identity) -| Key | Env var | Default | Description | -|-----|-------------|---------|-------------| -| `domain` | `APIP_AIW_DOMAIN` | `localhost:5380` | Host (and optional port) shown in the browser address bar. | -| `auth_mode` | `APIP_AIW_AUTH_MODE` | `basic` | Authentication mode. `"basic"` for file-based local auth; `"oidc"` for external IDP. | -| `controlplane_host` | `APIP_AIW_CONTROLPLANE_HOST` | `localhost:9243` | Externally reachable `host:port` that deployed gateways use to reach the Platform API. Shown in gateway setup instructions. Must be an absolute address, not a relative path. | -| `default_org_region` | `APIP_AIW_DEFAULT_ORG_REGION` | `us` | Default region label assigned to new organizations on first login. | -| `log_level` | `APIP_AIW_LOG_LEVEL` | `info` | `debug` \| `info` \| `warn` \| `error`. | +| Key | Description | +|-----|-------------| +| `domain` | Host (and optional port) shown in the browser address bar. | +| `auth_mode` | Authentication mode. `"basic"` for file-based local auth; `"oidc"` for external IDP. | +| `controlplane_host` | Externally reachable `host:port` that deployed gateways use to reach the Platform API. Shown in gateway setup instructions. Must be an absolute address, not a relative path. | +| `default_org_region` | Default region label assigned to new organizations on first login. | +| `log_level` | `debug` \| `info` \| `warn` \| `error`. | -### `[platform_api]` — the upstream hop +### `[ai_workspace.platform_api]` — the upstream hop -| 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` | 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`. | +| Key | Description | +|-----|-------------| +| `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` | Skip upstream certificate verification entirely (local development only) — prefer `ca_file`. | +| `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"`) +### `[ai_workspace.oidc]` (only required when `auth_mode = "oidc"`) -| Key | Env var | Default | Description | -|-----|-------------|---------|-------------| -| `authority` | `APIP_AIW_OIDC_AUTHORITY` | — | OIDC issuer URL. Endpoints (authorization, token, JWKS, etc.) are auto-discovered from `{authority}/.well-known/openid-configuration`. | -| `client_id` | `APIP_AIW_OIDC_CLIENT_ID` | — | Client ID of the AI Workspace confidential application registered in your IDP. | -| `client_secret` | `APIP_AIW_OIDC_CLIENT_SECRET` | — | Confidential-client secret, held only by the BFF and never sent to the browser. Set it via the env var, or from a mounted file with a `{{ file }}` token — see [Secrets](#secrets). | -| `redirect_url` | `APIP_AIW_OIDC_REDIRECT_URL` | — | The BFF callback, e.g. `https:///api/auth/callback`. | -| `post_logout_redirect_url` | `APIP_AIW_OIDC_POST_LOGOUT_REDIRECT_URL` | — | Post-logout URL, e.g. `https:///login`. Must be an absolute, pre-registered URL. | +| Key | Description | +|-----|-------------| +| `authority` | OIDC issuer URL. Endpoints (authorization, token, JWKS, etc.) are auto-discovered from `{authority}/.well-known/openid-configuration`. | +| `client_id` | Client ID of the AI Workspace confidential application registered in your IDP. | +| `client_secret` | Confidential-client secret, held only by the BFF and never sent to the browser. Referenced from an env var or a mounted file — see [Secrets](#secrets). | +| `redirect_url` | The BFF callback, e.g. `https:///api/auth/callback`. | +| `post_logout_redirect_url` | Post-logout URL, e.g. `https:///login`. Must be an absolute, pre-registered URL. | -### `[oidc.claim_mappings]` — which token claim carries each field +### `[ai_workspace.oidc.claim_mappings]` — which token claim carries each field -This table mirrors the Platform API's `[auth.idp.claim_mappings]` key for key, and the two must agree: both services read the same claims out of the same token. The variables line up one-to-one too — `APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME` against `APIP_CP_AUTH_IDP_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME`. +This table mirrors the Platform API's `[platform_api.auth.idp.claim_mappings]` key for key, and the two must agree: both services read the same claims out of the same token. -| Key | Env var | Default | Description | -|-----|-------------|---------|-------------| -| `organization_claim_name` | `APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME` | `org_id` | Claim carrying the organization UUID. | -| `org_name_claim_name` | `APIP_AIW_OIDC_CLAIM_MAPPINGS_ORG_NAME_CLAIM_NAME` | `org_name` | Claim carrying the human-readable organization name. | -| `org_handle_claim_name` | `APIP_AIW_OIDC_CLAIM_MAPPINGS_ORG_HANDLE_CLAIM_NAME` | `org_handle` | Claim carrying the organization handle (slug). | -| `username_claim_name` | `APIP_AIW_OIDC_CLAIM_MAPPINGS_USERNAME_CLAIM_NAME` | `username` | Claim carrying the display name. | -| `email_claim_name` | `APIP_AIW_OIDC_CLAIM_MAPPINGS_EMAIL_CLAIM_NAME` | `email` | Claim carrying the email address. | -| `scope_claim_name` | `APIP_AIW_OIDC_CLAIM_MAPPINGS_SCOPE_CLAIM_NAME` | `scope` | Claim carrying the space-separated scope string. | -| `role_claim_name` | `APIP_AIW_OIDC_CLAIM_MAPPINGS_ROLE_CLAIM_NAME` | `platform_role` | Claim carrying the platform role. Server-side only — not published to the browser. | +| Key | Description | +|-----|-------------| +| `organization_claim_name` | Claim carrying the organization UUID. | +| `org_name_claim_name` | Claim carrying the human-readable organization name. | +| `org_handle_claim_name` | Claim carrying the organization handle (slug). | +| `username_claim_name` | Claim carrying the display name. | +| `email_claim_name` | Claim carrying the email address. | +| `scope_claim_name` | Claim carrying the space-separated scope string. | +| `role_claim_name` | Claim carrying the platform role. Server-side only — not published to the browser. | -`[oidc.claim_mappings]` must be the **last** table under `[oidc]`: in TOML a sub-table header ends the parent table's key section, so a plain `[oidc]` key written below it would land in the sub-table instead. +`[ai_workspace.oidc.claim_mappings]` must be the **last** table under `[ai_workspace.oidc]`: in TOML a sub-table header ends the parent table's key section, so a plain `[ai_workspace.oidc]` key written below it would land in the sub-table instead. -`[oidc] redirect_url` and `post_logout_redirect_url` must be registered as authorized redirect +`[ai_workspace.oidc] redirect_url` and `post_logout_redirect_url` must be registered as authorized redirect URLs in your IDP application. The sign-in redirect is the **BFF callback** `/api/auth/callback` (the BFF, not the browser, completes the code exchange) — not a `/signin` route. -The remaining tables (`[tls]`, `[session]`, `[cookie]`) and the top-level listener keys are documented inline in +The remaining tables (`[ai_workspace.tls]`, `[ai_workspace.session]`, `[ai_workspace.cookie]`) and the `[ai_workspace]` listener keys are documented inline in [`configs/config-template.toml`](../../portals/ai-workspace/configs/config-template.toml). ## Minimal Quick-Start Config (basic auth) ```toml +[ai_workspace] domain = "localhost:8080" auth_mode = "basic" controlplane_host = "localhost:9243" -[platform_api] +[ai_workspace.platform_api] url = "https://localhost:9243" ``` ## Minimal Production Config (OIDC) ```toml +[ai_workspace] domain = "app.example.com" auth_mode = "oidc" controlplane_host = "api.example.com" default_org_region = "us" -[platform_api] +[ai_workspace.platform_api] url = "https://api.example.com" -[oidc] +[ai_workspace.oidc] authority = "https://api.asgardeo.io/t//oauth2/token" client_id = "" client_secret = '{{ file "/secrets/ai-workspace/oidc_client_secret" }}' redirect_url = "https://app.example.com/api/auth/callback" -# Mirrors [auth.idp.claim_mappings] in the Platform API config — the two must agree. -[oidc.claim_mappings] +# Mirrors [platform_api.auth.idp.claim_mappings] in the Platform API config — the two must agree. +[ai_workspace.oidc.claim_mappings] organization_claim_name = "org_id" org_name_claim_name = "org_name" org_handle_claim_name = "org_handle" @@ -165,19 +170,12 @@ secret_key = '{{ file "/secrets/platform-api/jwt_secret" }}' # from a file ( ``` 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: - -| Key referenced by the token | Description | -|--------------------------|-----------------------------------------------------------------------------------| -| `APIP_CP_AUTH_JWT_SECRET_KEY` | JWT signing key (required when `auth.jwt.enabled = true`) | -| `APIP_CP_ENCRYPTION_KEY` | 32-byte key (64 hex / base64) — encrypts secrets & subscription tokens (required) | -| `APIP_CP_DATABASE_PASSWORD` | Database password | +Or mount a secret file and use `{{ file }}`. The variable each token names, and which keys +require one, are documented inline in `config-platform-api-template.toml`. ## Environment Variable Override -A key can be overridden from the environment only when its value carries an `{{ env }}` token naming the variable — there is no implicit environment overlay, so a literal or absent key ignores the environment entirely. Every key in the shipped `config-template.toml` carries such a token, conventionally named `APIP_AIW_` + the uppercased dotted key path (`[oidc] authority` → `APIP_AIW_OIDC_AUTHORITY`). This is useful in container orchestration environments (Kubernetes `env:` blocks, Docker Compose `environment:` sections) where file mounts are less convenient. +A key can be overridden from the environment only when its value carries an `{{ env }}` token naming the variable — there is no implicit environment overlay, so a literal or absent key ignores the environment entirely. Every key in the shipped `config-template.toml` carries such a token, conventionally named `APIP_AIW_` + the uppercased dotted key path under `[ai_workspace]`. This is useful in container orchestration environments (Kubernetes `env:` blocks, Docker Compose `environment:` sections) where file mounts are less convenient. Example — override just the authority for a staging environment: @@ -188,6 +186,6 @@ docker run \ ghcr.io/wso2/api-platform/ai-workspace: ``` -A browser-safe key keeps the **same name everywhere** — in `config.toml` (`domain`), as an environment override (`APIP_AIW_DOMAIN`), in Vite's `import.meta.env` at build time, and in the `window.__RUNTIME_CONFIG__` payload the BFF serves to the SPA. (Vite's `envPrefix` is configured with an explicit allowlist of these browser-safe `APIP_AIW_*` names — mirroring the BFF allowlist — so secrets sharing the namespace never reach the bundle; the legacy `VITE_*` names are gone and setting one has no effect.) +A browser-safe key keeps the **same name everywhere** — in `config.toml`, as an environment override, in Vite's `import.meta.env` at build time, and in the `window.__RUNTIME_CONFIG__` payload the BFF serves to the SPA. (Vite's `envPrefix` is configured with an explicit allowlist of these browser-safe names — mirroring the BFF allowlist — so secrets sharing the namespace never reach the bundle.) Only keys on the BFF's browser-safe allowlist (`bff/internal/config/runtime_config.go`) are ever emitted to the page — server-side settings and the OIDC client credentials are not. diff --git a/portals/ai-workspace/README.md b/portals/ai-workspace/README.md index c36aeecbf..5262255ab 100644 --- a/portals/ai-workspace/README.md +++ b/portals/ai-workspace/README.md @@ -57,10 +57,11 @@ The shipped `config.toml` writes each key as an `{{ env }}` token, so a deployme them from the environment without editing the file: ```toml +[ai_workspace] # The token names the variable; the second argument is the value used when it is unset. log_level = '{{ env "APIP_AIW_LOG_LEVEL" "info" }}' -[oidc] +[ai_workspace.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 file # (api-platform.env in the compose quickstart). @@ -71,10 +72,14 @@ client_secret = '{{ file "/secrets/ai-workspace/oidc_client_secret" }}' ``` The token is what reads the environment, so setting `APIP_AIW_LOG_LEVEL` does nothing unless -`log_level` is present in the file with its token. Keys are grouped into TOML tables -(`[platform_api]`, `[tls]`, `[session]`, `[cookie]`, `[oidc]`), and by convention a token names the -key's table path uppercased with underscores (`[oidc] client_id` → `APIP_AIW_OIDC_CLIENT_ID`) — but -a token may name any variable. +`log_level` is present in the file with its token. All AI Workspace settings live under +`[ai_workspace]` — the same namespacing convention the Platform API uses for its own +`[platform_api]` table, so a shared config.toml can hold both services' sections without their +keys colliding. Keys are grouped into TOML tables under it +(`[ai_workspace.platform_api]`, `[ai_workspace.tls]`, `[ai_workspace.session]`, +`[ai_workspace.cookie]`, `[ai_workspace.oidc]`), and by convention a token names the key's path +under `[ai_workspace]` uppercased with underscores (`[ai_workspace.oidc] client_id` → +`APIP_AIW_OIDC_CLIENT_ID`) — but a token may name any variable. All available options are documented in [configs/config-template.toml](configs/config-template.toml). @@ -164,7 +169,7 @@ make bff-run # serves /api/* on https://localhost:8081, proxies to th The Vite dev server proxies `/api` and `/runtime-config.js` to the BFF, so the browser talks only to the app origin (same topology as production). Set `PLATFORM_API_URL` if the Platform API is not on `https://localhost:9243` — `make bff-run` forwards it to the BFF as -`APIP_AIW_PLATFORM_API_URL`, the variable the `[platform_api] url` token names, so the two +`APIP_AIW_PLATFORM_API_URL`, the variable the `[ai_workspace.platform_api] url` token names, so the two names are the same setting. Ensure all three services are running before accessing the application. @@ -252,9 +257,10 @@ stays out of the file, referenced there by an interpolation token: ```toml # portals/ai-workspace/configs/config.toml +[ai_workspace] auth_mode = "oidc" -[oidc] +[ai_workspace.oidc] authority = "https://idp.example.com/oauth2/token" # Asgardeo: https://api.asgardeo.io/t/acme/oauth2/token client_id = "" redirect_url = "https://localhost:5380/api/auth/callback" @@ -292,7 +298,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 +`[ai_workspace.oidc] client_secret = '{{ file "/secrets/ai-workspace/oidc_client_secret" }}'` — the value then never enters the environment at all. (Skipping this section and leaving `auth_mode = "basic"`, the default, keeps the file-based @@ -333,11 +339,11 @@ enabled = false # required: mutually exclusive with the IDP Then export the BFF settings and start it. `make bff-run` runs the BFF against [`configs/config.toml`](configs/config.toml) — the same file the container mounts, passed with `-config` — whose keys are all `{{ env }}` tokens naming the variables below, so exporting one -sets its key (`APIP_AIW_OIDC_CLIENT_ID` → `[oidc] client_id`). A key absent from that file is not +sets its key (`APIP_AIW_OIDC_CLIENT_ID` → `[ai_workspace.oidc] client_id`). A key absent from that file is not settable this way; add it there first. Point the BFF at the locally published Platform API port — the `platform-api` compose hostname does **not** resolve outside the compose network: -> `[oidc] authority` is Asgardeo's **token base** — the BFF appends +> `[ai_workspace.oidc] authority` is Asgardeo's **token base** — the BFF appends > `/.well-known/openid-configuration` itself, so do **not** include the discovery suffix. ```bash @@ -364,12 +370,12 @@ failures, by symptom: |---|---|---| | `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: 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) | +| `502` + `dial tcp: lookup platform-api: no such host` | BFF run locally but `[ai_workspace.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: 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) | -| Logged out as soon as the access token expires; never silently refreshed | IDP returned no refresh token — `offline_access` scope missing from the request, or not permitted for the app | Keep `offline_access` in `[oidc] scope` (it's in the default); allow it for the app in the IDP (step 1) | +| Logged out as soon as the access token expires; never silently refreshed | IDP returned no refresh token — `offline_access` scope missing from the request, or not permitted for the app | Keep `offline_access` in `[ai_workspace.oidc] scope` (it's in the default); allow it for the app in the IDP (step 1) | | Refresh fails minutes after login | **Refresh Token** grant not enabled on the app | Enable it on the Protocol tab (step 1) | ## Session lifetime & token refresh @@ -458,7 +464,7 @@ resources/certificates/ 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 +the BFF trusts for the upstream platform-api hop, referenced by `[ai_workspace.platform_api] ca_file` in `configs/config.toml`. Then restart the stack: `docker compose up -d --force-recreate` (a plain `docker @@ -479,5 +485,5 @@ credentials, and self-signed certificates); for production, provide real values: | **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 | +| **Upstream trust (BFF → Platform API)** | The generated shared cert is mounted as a CA bundle (`[ai_workspace.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_test.go b/portals/ai-workspace/bff/internal/config/config_test.go index 06be844bc..54ed1f2fe 100644 --- a/portals/ai-workspace/bff/internal/config/config_test.go +++ b/portals/ai-workspace/bff/internal/config/config_test.go @@ -37,9 +37,10 @@ func writeConfig(t *testing.T, body string) string { // A literal config.toml value is used as written. func TestLoad_ConfigFileValue(t *testing.T) { cfgPath := writeConfig(t, ` +[ai_workspace] log_level = "warn" -[platform_api] +[ai_workspace.platform_api] url = "https://platform-api:9243" `) @@ -59,10 +60,11 @@ url = "https://platform-api:9243" // supplies the variable's value, and its default applies when the variable is unset. func TestLoad_EnvTokenSuppliesValueAndDefault(t *testing.T) { cfgPath := writeConfig(t, ` +[ai_workspace] log_level = '{{ env "APIP_AIW_LOG_LEVEL" "info" }}' log_format = '{{ env "APIP_AIW_LOG_FORMAT" "text" }}' -[platform_api] +[ai_workspace.platform_api] url = "https://platform-api:9243" `) t.Setenv("APIP_AIW_LOG_LEVEL", "debug") // named by the token @@ -85,9 +87,10 @@ url = "https://platform-api:9243" // pulls a value in from the environment. func TestLoad_EnvVarWithoutTokenIsIgnored(t *testing.T) { cfgPath := writeConfig(t, ` +[ai_workspace] log_level = "warn" -[platform_api] +[ai_workspace.platform_api] url = "https://platform-api:9243" `) t.Setenv("APIP_AIW_LOG_LEVEL", "debug") @@ -106,12 +109,13 @@ url = "https://platform-api:9243" // variable — the APIP_AIW_ prefix is a convention, not a requirement. func TestLoad_InterpolatesEnvToken(t *testing.T) { cfgPath := writeConfig(t, ` +[ai_workspace] auth_mode = "oidc" -[platform_api] +[ai_workspace.platform_api] url = "https://platform-api:9243" -[oidc] +[ai_workspace.oidc] authority = "https://idp.example.com" client_id = "client-id" client_secret = '{{ env "CUSTOM_SECRET_VAR" }}' @@ -137,12 +141,13 @@ func TestLoad_InterpolatesFileToken(t *testing.T) { t.Setenv("APIP_CONFIG_FILE_SOURCE_ALLOWLIST", secretDir) cfgPath := writeConfig(t, ` +[ai_workspace] auth_mode = "oidc" -[platform_api] +[ai_workspace.platform_api] url = "https://platform-api:9243" -[oidc] +[ai_workspace.oidc] authority = "https://idp.example.com" client_id = "client-id" client_secret = '{{ file "`+filepath.Join(secretDir, "oidc_client_secret")+`" }}' @@ -169,10 +174,10 @@ func TestLoad_FileTokenOutsideAllowlist_Errors(t *testing.T) { t.Setenv("APIP_CONFIG_FILE_SOURCE_ALLOWLIST", t.TempDir()) // a different directory cfgPath := writeConfig(t, ` -[platform_api] +[ai_workspace.platform_api] url = "https://platform-api:9243" -[oidc] +[ai_workspace.oidc] client_secret = '{{ file "`+outside+`" }}' `) @@ -185,10 +190,10 @@ client_secret = '{{ file "`+outside+`" }}' // yield an empty secret. func TestLoad_MissingEnvToken_Errors(t *testing.T) { cfgPath := writeConfig(t, ` -[platform_api] +[ai_workspace.platform_api] url = "https://platform-api:9243" -[oidc] +[ai_workspace.oidc] client_secret = '{{ env "CUSTOM_SECRET_VAR" }}' `) t.Setenv("CUSTOM_SECRET_VAR", "") @@ -204,7 +209,7 @@ client_secret = '{{ env "CUSTOM_SECRET_VAR" }}' // The upstream URL is mandatory — the BFF has nothing to proxy to without it. func TestLoad_MissingPlatformAPIURL_Errors(t *testing.T) { - cfgPath := writeConfig(t, `log_level = "info"`) + cfgPath := writeConfig(t, "[ai_workspace]\nlog_level = \"info\"") _, err := Load(cfgPath) if err == nil { @@ -219,13 +224,14 @@ func TestLoad_MissingPlatformAPIURL_Errors(t *testing.T) { // and OIDC client credentials must never appear in it. func TestLoad_RuntimeConfigExcludesServerSideKeys(t *testing.T) { cfgPath := writeConfig(t, ` +[ai_workspace] auth_mode = "oidc" domain = "localhost:5380" -[platform_api] +[ai_workspace.platform_api] url = "https://platform-api:9243" -[oidc] +[ai_workspace.oidc] authority = "https://idp.example.com" client_id = "client-id" client_secret = "s3cr3t" @@ -257,9 +263,10 @@ redirect_url = "https://localhost:5380/api/auth/callback" // browser. func TestLoad_BrowserSafeKeyUsesSameName(t *testing.T) { cfgPath := writeConfig(t, ` +[ai_workspace] moesif_web_url = "https://moesif.example.com" -[platform_api] +[ai_workspace.platform_api] url = "https://platform-api:9243" `) @@ -276,9 +283,10 @@ url = "https://platform-api:9243" // under that same name, exactly as if it had been written as a literal. func TestLoad_BrowserSafeKeyFromEnvToken(t *testing.T) { cfgPath := writeConfig(t, ` +[ai_workspace] domain = '{{ env "APIP_AIW_DOMAIN" "localhost:5380" }}' -[platform_api] +[ai_workspace.platform_api] url = "https://platform-api:9243" `) t.Setenv("APIP_AIW_DOMAIN", "app.example.com") @@ -296,13 +304,13 @@ url = "https://platform-api:9243" // string, but a plain literal is naturally typed. Both forms must reach the same value. func TestLoad_BareTOMLScalars(t *testing.T) { cfgPath := writeConfig(t, ` -[platform_api] +[ai_workspace.platform_api] url = "https://platform-api:9243" -[cookie] +[ai_workspace.cookie] secure = false -[session] +[ai_workspace.session] absolute_ttl = "2h" `) @@ -322,15 +330,16 @@ absolute_ttl = "2h" // distinct dotted paths, so [tls] enabled and [oidc] enabled are independent. func TestLoad_SameKeyInDifferentTables(t *testing.T) { cfgPath := writeConfig(t, ` +[ai_workspace] auth_mode = "oidc" -[platform_api] +[ai_workspace.platform_api] url = "https://platform-api:9243" -[tls] +[ai_workspace.tls] enabled = false -[oidc] +[ai_workspace.oidc] enabled = true authority = "https://idp.example.com" client_id = "client-id" @@ -356,10 +365,10 @@ redirect_url = "https://localhost:5380/api/auth/callback" // src/config.env.ts looks up. func TestLoad_ClaimMappingsMirrorPlatformAPI(t *testing.T) { cfgPath := writeConfig(t, ` -[platform_api] +[ai_workspace.platform_api] url = "https://platform-api:9243" -[oidc.claim_mappings] +[ai_workspace.oidc.claim_mappings] organization_claim_name = "org_uuid" username_claim_name = "given_name" role_claim_name = "roles" @@ -390,10 +399,10 @@ role_claim_name = "roles" // A malformed boolean must fail startup rather than fall back to the default. func TestLoad_InvalidBool_Errors(t *testing.T) { cfgPath := writeConfig(t, ` -[platform_api] +[ai_workspace.platform_api] url = "https://platform-api:9243" -[cookie] +[ai_workspace.cookie] secure = "maybe" `) diff --git a/portals/ai-workspace/bff/internal/config/settings.go b/portals/ai-workspace/bff/internal/config/settings.go index 1a8b1527e..2c7970f18 100644 --- a/portals/ai-workspace/bff/internal/config/settings.go +++ b/portals/ai-workspace/bff/internal/config/settings.go @@ -40,6 +40,15 @@ import ( // The prefix also namespaces the runtime config the SPA reads (see runtimeKey). const EnvPrefix = "APIP_AIW_" +// aiWorkspaceConfigKey is the top-level TOML table all AI Workspace settings live +// under (e.g. [ai_workspace], [ai_workspace.platform_api]). It mirrors the Platform +// API's platformAPIConfigKey: this namespacing lets an AI Workspace config file +// coexist with sibling services' sections ([platform_api], ...) in a shared +// deployment config, the same file convention as the Platform API's [platform_api] +// table. Every key below this cut (log_level, platform_api.url, oidc.*, ...) is +// resolved relative to [ai_workspace], not the file root. +const aiWorkspaceConfigKey = "ai_workspace" + // defaultFileSourceAllowlist is the AI Workspace's default set of directories a // {{ file "..." }} token may read from. Overridable via the shared // APIP_CONFIG_FILE_SOURCE_ALLOWLIST env var (see configinterpolate.ResolveAllowlist). @@ -48,10 +57,12 @@ var defaultFileSourceAllowlist = []string{ "/secrets/ai-workspace", } -// settings is the fully-resolved configuration: the config.toml values with every -// {{ env }} / {{ file }} token expanded, flattened to dotted paths. A key is its -// table path joined with dots — [platform_api] url becomes "platform_api.url" — and a -// key outside any table keeps its bare name ("domain"). +// settings is the fully-resolved configuration: the config.toml values under +// [ai_workspace], with every {{ env }} / {{ file }} token expanded and flattened to +// dotted paths relative to that table. A key is its path under [ai_workspace] joined +// with dots — [ai_workspace.platform_api] url becomes "platform_api.url" — and a key +// directly under [ai_workspace] keeps its bare name ("domain"). Sibling top-level +// tables belonging to other services (e.g. [platform_api]) are ignored. type settings map[string]string // loadSettings reads config.toml and expands its interpolation tokens. config.toml is @@ -90,10 +101,21 @@ func loadSettings(tomlPath string) (settings, error) { } s := settings{} - flatten(s, "", expanded) + flatten(s, "", cut(expanded, aiWorkspaceConfigKey)) return s, nil } +// cut returns the subtree of tree rooted at key, or an empty tree when key is +// absent (a missing [ai_workspace] table simply leaves every key on its default, +// same as a missing config file). A key present but not a table is also treated as +// absent — flatten only ever descends into map[string]any nodes. +func cut(tree map[string]any, key string) map[string]any { + if sub, ok := tree[key].(map[string]any); ok { + return sub + } + return map[string]any{} +} + // parseTOML decodes the config file with the stdlib subset parser (see toml.go). // A missing file yields an empty tree rather than an error, leaving every key on // its default — Load then fails on the required ones. 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 5ed0f5d51..16513a259 100644 --- a/portals/ai-workspace/bff/internal/config/shipped_config_test.go +++ b/portals/ai-workspace/bff/internal/config/shipped_config_test.go @@ -30,8 +30,8 @@ var ( ) // The shipped config.toml must load with nothing set in the environment — that is the -// quickstart: `docker compose up` with no .env beyond the two secrets. Its [oidc] keys -// default to empty, which must leave OIDC off rather than fail startup. +// quickstart: `docker compose up` with no .env beyond the two secrets. It ships without +// an [ai_workspace.oidc] table at all, which must leave OIDC off rather than fail startup. func TestShippedConfig_QuickstartLoadsWithNoEnv(t *testing.T) { cfg, err := Load(quickstartConfig) if err != nil { @@ -41,10 +41,10 @@ func TestShippedConfig_QuickstartLoadsWithNoEnv(t *testing.T) { if cfg.AuthMode != "basic" { t.Errorf("AuthMode = %q, want %q", cfg.AuthMode, "basic") } - // The empty [oidc] defaults must not switch the OIDC client on, or Load would - // demand an authority/client_id/client_secret the quickstart has no use for. + // The missing [ai_workspace.oidc] table must not switch the OIDC client on, or Load + // would demand an authority/client_id/client_secret the quickstart has no use for. if cfg.OIDC.Enabled { - t.Error("OIDC.Enabled = true, want false — the empty [oidc] defaults must leave basic mode intact") + t.Error("OIDC.Enabled = true, want false — the empty [ai_workspace.oidc] defaults must leave basic mode intact") } // The defaults in the file are the container's: the port it publishes and the SPA // baked into the image. @@ -101,8 +101,10 @@ func TestShippedConfig_MakeBffRunOverrides(t *testing.T) { } } -// The [oidc] tokens in the shipped config are what let `make bff-run` — and a compose -// deployment — turn on OIDC from the environment alone, without editing the file. +// configs/config.toml is deliberately minimal and ships without an [ai_workspace.oidc] +// table — the quickstart only ever runs in basic-auth mode. configs/config-template.toml +// is the file a real deployment copies from, and its [ai_workspace.oidc] tokens are what +// let OIDC be turned on from the environment alone, without further edits to the file. func TestShippedConfig_OIDCFromEnvAlone(t *testing.T) { t.Setenv("APIP_AIW_AUTH_MODE", "oidc") t.Setenv("APIP_AIW_OIDC_AUTHORITY", "https://idp.example.com") @@ -110,9 +112,9 @@ func TestShippedConfig_OIDCFromEnvAlone(t *testing.T) { t.Setenv("APIP_AIW_OIDC_CLIENT_SECRET", "s3cr3t") t.Setenv("APIP_AIW_OIDC_REDIRECT_URL", "https://localhost:5380/api/auth/callback") - cfg, err := Load(quickstartConfig) + cfg, err := Load(templateConfig) if err != nil { - t.Fatalf("Load(configs/config.toml) error = %v — the [oidc] tokens must make OIDC settable from the environment", err) + t.Fatalf("Load(configs/config-template.toml) error = %v — the [ai_workspace.oidc] tokens must make OIDC settable from the environment", err) } if !cfg.OIDC.Enabled { t.Fatal("OIDC.Enabled = false, want true — auth_mode = oidc must enable the client") diff --git a/portals/ai-workspace/configs/config-template.toml b/portals/ai-workspace/configs/config-template.toml index ff4d8470f..de6b70b26 100644 --- a/portals/ai-workspace/configs/config-template.toml +++ b/portals/ai-workspace/configs/config-template.toml @@ -13,6 +13,11 @@ # This file is read by the AI Workspace container at startup, and is the only source of # configuration. # +# All AI Workspace settings live under [ai_workspace] / [ai_workspace.*]. This +# namespacing mirrors the Platform API's own [platform_api] / [platform_api.*] table +# and lets one config.toml hold both services' sections side by side in a shared +# deployment config without their tables colliding. +# # HOW VALUES ARE RESOLVED # Each value is an interpolation token, resolved once at startup: # @@ -24,12 +29,13 @@ # so a key written as a plain literal (key = "value"), or left commented out below, # ignores the variable entirely. Uncomment the key first to make it settable that way. # -# By convention the variable is the key's table path, uppercased, dots as underscores, +# By convention the variable is the key's table path under [ai_workspace] (not +# including the [ai_workspace] segment itself), uppercased, dots as underscores, # prefixed with APIP_AIW_: # -# [platform_api] url -> APIP_AIW_PLATFORM_API_URL -# [oidc] client_id -> APIP_AIW_OIDC_CLIENT_ID -# domain (no table) -> APIP_AIW_DOMAIN +# [ai_workspace] domain -> APIP_AIW_DOMAIN +# [ai_workspace.platform_api] url -> APIP_AIW_PLATFORM_API_URL +# [ai_workspace.oidc] client_id -> APIP_AIW_OIDC_CLIENT_ID # # To source a value from a mounted file instead of the environment — the right # choice for secrets — swap the token: @@ -58,12 +64,16 @@ # # QUICK START (basic-auth mode): # 1. Copy this file to config.toml. -# 2. Set auth_mode = "basic" (uses the users defined in config-platform-api.toml). +# 2. Set [ai_workspace] auth_mode = "basic" (uses the users defined in +# config-platform-api.toml). # 3. Run: docker compose up # -# OIDC mode: set auth_mode = "oidc" and fill in the [oidc] table below. +# OIDC mode: set [ai_workspace] auth_mode = "oidc" and fill in the [ai_workspace.oidc] +# table below. # -------------------------------------------------------------------- +[ai_workspace] + # ==================================================================== # Deployment identity # @@ -72,7 +82,7 @@ # ==================================================================== # Authentication mode for the UI: "basic" (local users) or "oidc" (external IDP). -# Choosing "oidc" enables the [oidc] table below. +# Choosing "oidc" enables the [ai_workspace.oidc] table below. auth_mode = '{{ env "APIP_AIW_AUTH_MODE" "basic" }}' # Domain shown in the browser address bar (host:port, or just host for port 80/443). @@ -100,8 +110,8 @@ debug = '{{ env "APIP_AIW_DEBUG" "false" }}' # Server # # Everything from here down configures the AI Workspace server itself. These values -# stay server-side and are never sent to the browser (the [oidc] claim names being the -# one exception — the SPA needs them to display user/org identity). +# stay server-side and are never sent to the browser (the [ai_workspace.oidc] claim +# names being the one exception — the SPA needs them to display user/org identity). # # All keys are optional: the value in each token's default position is the built-in # default. @@ -128,7 +138,7 @@ log_format = '{{ env "APIP_AIW_LOG_FORMAT" "text" }}' # text | json # --------------------------------------------------------------------------- # Upstream Platform API — the server-to-server hop the BFF proxies to. # --------------------------------------------------------------------------- -[platform_api] +[ai_workspace.platform_api] # Base URL used to reach the Platform API. REQUIRED. In docker compose this is the # compose hostname; running locally, use localhost. Its http/https scheme decides @@ -152,7 +162,7 @@ ca_file = '{{ env "APIP_AIW_PLATFORM_API_CA_FILE" "" }}' # --------------------------------------------------------------------------- # TLS on the AI Workspace listener. # --------------------------------------------------------------------------- -[tls] +[ai_workspace.tls] # Terminate TLS on this listener. Set false only when a trusted upstream (ingress # controller, service-mesh sidecar) terminates TLS for you — no certificate is then @@ -168,7 +178,7 @@ key_file = '{{ env "APIP_AIW_TLS_KEY_FILE" "/etc/ai-workspace/tls/key.pem" }}' # --------------------------------------------------------------------------- # Server-side session lifetime. # --------------------------------------------------------------------------- -[session] +[ai_workspace.session] # Session store. Only "memory" is supported today ("redis" is future). store = '{{ env "APIP_AIW_SESSION_STORE" "memory" }}' @@ -183,7 +193,7 @@ absolute_ttl = '{{ env "APIP_AIW_SESSION_ABSOLUTE_TTL" "8h" }}' # --------------------------------------------------------------------------- # Session cookie attributes. # --------------------------------------------------------------------------- -[cookie] +[ai_workspace.cookie] name = '{{ env "APIP_AIW_COOKIE_NAME" "_ai_workspace_session" }}' @@ -195,12 +205,12 @@ samesite = '{{ env "APIP_AIW_COOKIE_SAMESITE" "lax" }}' # --------------------------------------------------------------------------- -# OIDC — set auth_mode = "oidc" above to enable. +# OIDC — set [ai_workspace] auth_mode = "oidc" above to enable. # # The BFF is a confidential client: it performs the whole handshake, and the client # credentials never reach the browser. # --------------------------------------------------------------------------- -[oidc] +[ai_workspace.oidc] # Force the OIDC client on independently of auth_mode. Setting auth_mode = "oidc" # already enables it, so this is rarely needed. @@ -234,19 +244,20 @@ scope = '{{ env "APIP_AIW_OIDC_SCOPE" "" }}' # --------------------------------------------------------------------------- # JWT claim name mappings — which token claim carries each user/org field. # -# This table mirrors [auth.idp.claim_mappings] in config-platform-api.toml key for key, -# and the two must agree: both services read the same claims out of the same token. -# The variables line up one-to-one as well: +# This table mirrors [platform_api.auth.idp.claim_mappings] in +# config-platform-api.toml key for key, and the two must agree: both services read +# the same claims out of the same token. The variables line up one-to-one as well: # # APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME (AI Workspace) # APIP_CP_AUTH_IDP_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME (Platform API) # # Override only if your IDP uses claim names other than the defaults shown. # -# Must be the last table under [oidc]: in TOML, a sub-table header ends the parent -# table's key section, so any plain [oidc] key placed below it would land here instead. +# Must be the last table under [ai_workspace.oidc]: in TOML, a sub-table header ends +# the parent table's key section, so any plain [ai_workspace.oidc] key placed below +# it would land here instead. # --------------------------------------------------------------------------- -[oidc.claim_mappings] +[ai_workspace.oidc.claim_mappings] organization_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME" "org_id" }}' # claim carrying the org ID org_name_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ORG_NAME_CLAIM_NAME" "org_name" }}' # org display name diff --git a/portals/ai-workspace/configs/config.toml b/portals/ai-workspace/configs/config.toml index 443b511e3..bc8eb5462 100644 --- a/portals/ai-workspace/configs/config.toml +++ b/portals/ai-workspace/configs/config.toml @@ -6,8 +6,8 @@ # 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). See README.md → "Configuration". -# See configs/config-template.toml for the full set of available settings. + +[ai_workspace] auth_mode = '{{ env "APIP_AIW_AUTH_MODE" "basic" }}' domain = '{{ env "APIP_AIW_DOMAIN" "localhost:5380" }}' @@ -20,14 +20,14 @@ static_dir = '{{ env "APIP_AIW_STATIC_DIR" "/app" }}' log_level = '{{ env "APIP_AIW_LOG_LEVEL" "info" }}' # debug | info | warn | error -[platform_api] +[ai_workspace.platform_api] url = '{{ env "APIP_AIW_PLATFORM_API_URL" "https://platform-api:9243" }}' ca_file = "/etc/ai-workspace/tls/cert.pem" tls_skip_verify = '{{ env "APIP_AIW_PLATFORM_API_TLS_SKIP_VERIFY" "false" }}' -[tls] +[ai_workspace.tls] enabled = '{{ env "APIP_AIW_TLS_ENABLED" "true" }}' cert_file = "/etc/ai-workspace/tls/cert.pem" diff --git a/portals/ai-workspace/distribution/README.md b/portals/ai-workspace/distribution/README.md index 4bd731390..3bde50c5e 100644 --- a/portals/ai-workspace/distribution/README.md +++ b/portals/ai-workspace/distribution/README.md @@ -75,31 +75,33 @@ Environment overrides go in `api-platform.env` (git-ignored; loaded into both co ### AI Workspace (`configs/config.toml`) -| Setting | Description | Default | -|---------|-------------|---------| -| `domain` | Host and port shown in the browser address bar | `localhost:5380` | -| `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)_ | +| Setting | Description | +|---------|-------------| +| `domain` | Host and port shown in the browser address bar | +| `auth_mode` | `basic` (file-based quickstart) or `oidc` (external IDP) | +| `controlplane_host` | Address gateways use to reach the Platform API | +| `platform_gateway_versions` | Gateway versions shown in the create-gateway selector | +| `[ai_workspace.platform_api].url` | Base URL of the upstream Platform API hop | +| `[ai_workspace.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` | +| `[ai_workspace.platform_api].tls_skip_verify` | Skip upstream cert verification — local dev only | +| `[ai_workspace.tls].cert_file` / `key_file` | Listener certificate pair — required when `[ai_workspace.tls].enabled` is `true`. Fixed to the mounted path, same as `ca_file` above | +| `[ai_workspace.oidc].*` | Used only when `auth_mode = "oidc"` — see [OIDC](#oidc-production) below | ### Platform API (`configs/config-platform-api.toml`) -| Setting | Description | Default | -|---------|-------------|---------| -| `log_level` | Log level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | `INFO` | -| `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` | +| Setting | Description | +|---------|-------------| +| `log_level` | Log level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | +| `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` | +| `[database].driver` | `sqlite3` or `postgres` | +| `[auth.jwt].secret_key` | 32-byte HMAC key signing login JWTs | +| `[auth.idp]` | JWKS-based IDP auth — disabled in quickstart mode; enable for Asgardeo, Keycloak, Auth0, etc. | +| `[auth.file_based.users]` | Local user credentials (change the password hash before sharing) | +| `[https]` | Listener on `:9243`; `cert_dir` holds `cert.pem`/`key.pem` | -See `configs/config-template.toml` and `configs/config-platform-api-template.toml` for a fully-commented reference of every available setting. +Each key's default value is written inline in `configs/config-template.toml` and +`configs/config-platform-api-template.toml` — those files are a fully-commented reference of +every available setting and its default, so defaults are not restated here. ## Authentication Modes @@ -118,7 +120,7 @@ Replace the `password_hash` value in `configs/config-platform-api.toml` before s 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), 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`: +2. **AI Workspace** (`configs/config.toml`): set `auth_mode = "oidc"`. Every `[ai_workspace.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 APIP_AIW_OIDC_AUTHORITY=https://idp.example.com @@ -130,7 +132,7 @@ To delegate login to an external OIDC-compliant provider (Asgardeo, Keycloak, Au Leaving `APIP_AIW_OIDC_SCOPE` unset requests the full `ap:*` scope set. -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. +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 `[ai_workspace.oidc.claim_mappings]` in `configs/config.toml` — both services must read the same claims out of the same token. 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. diff --git a/portals/ai-workspace/production/README.md b/portals/ai-workspace/production/README.md index 0087476a8..cbfb27b0b 100644 --- a/portals/ai-workspace/production/README.md +++ b/portals/ai-workspace/production/README.md @@ -134,6 +134,7 @@ tables; a value comes from the environment only through an `{{ env }}` token (se Open `configs/config.toml` and fill in the values for your deployment: ```toml +[ai_workspace] # Host shown in the browser address bar. domain = "" # e.g. app.example.com @@ -150,12 +151,12 @@ default_org_region = "us" # Each entry: version (helm chart minor), latestVersion (image/chart tag), channel ("STS" | "LTS"). platform_gateway_versions = '[{"version":"1.2","latestVersion":"v1.2.0-alpha2","channel":"STS"}]' -[platform_api] +[ai_workspace.platform_api] # The upstream the BFF proxies to, server-to-server. An origin, not a base path — the # browser never uses it: the SPA calls the same-origin proxy prefix and the BFF forwards. url = "https://" -[oidc] +[ai_workspace.oidc] # Issuer URL — the BFF auto-discovers OIDC endpoints from # {authority}/.well-known/openid-configuration. authority = "https://api.asgardeo.io/t//oauth2/token" @@ -163,9 +164,9 @@ authority = "https://api.asgardeo.io/t//oauth2/token" # Client ID of the AI Workspace confidential application (from the IDP Protocol tab). client_id = "" -# JWT claim name mappings — this table mirrors [auth.idp.claim_mappings] in the Platform -# API config (section 2) key for key, and the two must agree. -[oidc.claim_mappings] +# JWT claim name mappings — this table mirrors [platform_api.auth.idp.claim_mappings] in +# the Platform API config (section 2) key for key, and the two must agree. +[ai_workspace.oidc.claim_mappings] organization_claim_name = "org_id" org_name_claim_name = "org_name" org_handle_claim_name = "org_handle" @@ -176,7 +177,7 @@ file** — it is referenced with an interpolation token resolved at startup. In as a secret file (a Docker/Kubernetes secret) so the value never enters the environment at all: ```toml -[oidc] +[ai_workspace.oidc] # BFF callback registered in the IDP (section 1.2) — NOT the SPA /signin route. redirect_url = "https:///api/auth/callback" post_logout_redirect_url = "https:///login" @@ -197,7 +198,7 @@ empty credential. `{{ file }}` paths must live under `/etc/ai-workspace` or `/se (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 the git-ignored `api-platform.env`. -> `[oidc] redirect_url` must exactly match the authorized redirect URL registered in the IDP +> `[ai_workspace.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. ### Setting config.toml keys from the environment @@ -207,31 +208,20 @@ layer. A key takes its value from the environment when it is written as an `{{ e names the variable explicitly: ```toml -[oidc] +[ai_workspace.oidc] client_id = '{{ env "APIP_AIW_OIDC_CLIENT_ID" "" }}' # ^ variable read at startup ^ used when it is unset ``` Setting `APIP_AIW_OIDC_CLIENT_ID` in a `docker run -e` or a Kubernetes `env:` block then sets -`[oidc] client_id`, with no edit to the file. Setting it while the key is absent from the file, or +`[ai_workspace.oidc] client_id`, with no edit to the file. Setting it while the key is absent from the file, or written as a plain literal, does nothing. The shipped `config.toml` already writes its keys this way, naming each variable by the same -convention: the key's table path uppercased, dots as underscores, prefixed with **`APIP_AIW_`** (the -Platform API uses `APIP_CP_`, the Developer Portal `APIP_DP_`). - -| config.toml key | Variable named by its shipped token | -|--------------------------------------|------------------------------------------| -| `domain` | `APIP_AIW_DOMAIN` | -| `auth_mode` | `APIP_AIW_AUTH_MODE` | -| `controlplane_host` | `APIP_AIW_CONTROLPLANE_HOST` | -| `log_level` | `APIP_AIW_LOG_LEVEL` | -| `[platform_api] url` | `APIP_AIW_PLATFORM_API_URL` | -| `[oidc] authority` | `APIP_AIW_OIDC_AUTHORITY` | -| `[oidc] client_id` | `APIP_AIW_OIDC_CLIENT_ID` | -| `[oidc] client_secret` | `APIP_AIW_OIDC_CLIENT_SECRET` | -| `[oidc] redirect_url` | `APIP_AIW_OIDC_REDIRECT_URL` | -| `[oidc.claim_mappings] organization_claim_name` | `APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME` | +convention: the key's path under `[ai_workspace]` uppercased, dots as underscores, prefixed with +**`APIP_AIW_`** (the Platform API uses `APIP_CP_`, the Developer Portal `APIP_DP_`). Every key's +exact token — and the default it falls back to when the variable is unset — is written inline in +`configs/config-template.toml`; that file is the source of truth, so it is not restated here. A token may name any variable, not only the conventional one — that is what lets a key read a secret that already exists under its own name. For credentials, prefer a mounted secret file From ddd20798ad8d5c93d94dfc73f9bba5d9ca89157c Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Mon, 20 Jul 2026 17:29:05 +0530 Subject: [PATCH 06/18] Enhance CI integration and update platform API configuration - Added a step to build the platform-api image in the CI workflow for integration tests. - Updated the environment variable for the platform API image in the developer portal's Docker Compose files to allow for using a freshly built image. - Modified the configuration in `harness_test.go` to change the authentication mode to "external_token" for better integration with the updated API settings. --- .github/workflows/devportal-integration-test.yml | 10 ++++++++++ platform-api/internal/integration/harness_test.go | 2 +- .../it/docker-compose.test.postgres.yaml | 6 +++++- portals/developer-portal/it/docker-compose.test.yaml | 6 +++++- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/devportal-integration-test.yml b/.github/workflows/devportal-integration-test.yml index 93f094aad..0cd7e8c80 100644 --- a/.github/workflows/devportal-integration-test.yml +++ b/.github/workflows/devportal-integration-test.yml @@ -33,10 +33,15 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Build platform-api image + run: make -C platform-api build IMAGE_NAME=platform-api VERSION=it-devportal + - name: Build developer-portal image run: make -C portals/developer-portal build - name: Run REST API integration tests (${{ matrix.db }}) + env: + PLATFORM_API_IMAGE: platform-api:it-devportal run: | if [ "${{ matrix.db }}" = "postgres" ]; then make -C portals/developer-portal/it test-rest-api-postgres @@ -70,10 +75,15 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Build platform-api image + run: make -C platform-api build IMAGE_NAME=platform-api VERSION=it-devportal + - name: Build developer-portal image run: make -C portals/developer-portal build - name: Run Cypress integration tests + env: + PLATFORM_API_IMAGE: platform-api:it-devportal run: make -C portals/developer-portal it - name: Upload Cypress reports diff --git a/platform-api/internal/integration/harness_test.go b/platform-api/internal/integration/harness_test.go index fd398ef4f..166407272 100644 --- a/platform-api/internal/integration/harness_test.go +++ b/platform-api/internal/integration/harness_test.go @@ -50,7 +50,7 @@ func TestMain(m *testing.M) { if err != nil { panic(fmt.Sprintf("integration harness: create temp config: %v", err)) } - if _, err := fmt.Fprintf(f, "[platform_api]\nencryption_key = %q\n\n[platform_api.auth.jwt]\nenabled = true\nsecret_key = %q\n", testKey, testKey); err != nil { + if _, err := fmt.Fprintf(f, "[platform_api]\nencryption_key = %q\n\n[platform_api.auth]\nmode = \"external_token\"\n\n[platform_api.auth.jwt]\nsecret_key = %q\n", testKey, testKey); err != nil { panic(fmt.Sprintf("integration harness: write temp config: %v", err)) } _ = f.Close() diff --git a/portals/developer-portal/it/docker-compose.test.postgres.yaml b/portals/developer-portal/it/docker-compose.test.postgres.yaml index 4f31606b0..f76770015 100644 --- a/portals/developer-portal/it/docker-compose.test.postgres.yaml +++ b/portals/developer-portal/it/docker-compose.test.postgres.yaml @@ -42,7 +42,11 @@ services: - it-devportal-network platform-api: - image: ghcr.io/wso2/api-platform/platform-api:0.12.0 + # Overridable so CI can point this at a freshly built image (make -C platform-api + # build) instead of the last release — this fixture's config-platform-api-it.toml + # tracks platform-api's current config schema, which a pinned old release may not + # understand. + image: ${PLATFORM_API_IMAGE:-ghcr.io/wso2/api-platform/platform-api:0.12.0} command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: APIP_CP_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" diff --git a/portals/developer-portal/it/docker-compose.test.yaml b/portals/developer-portal/it/docker-compose.test.yaml index d3f5bc589..00b782448 100644 --- a/portals/developer-portal/it/docker-compose.test.yaml +++ b/portals/developer-portal/it/docker-compose.test.yaml @@ -26,7 +26,11 @@ services: platform-api: - image: ghcr.io/wso2/api-platform/platform-api:0.12.0 + # Overridable so CI can point this at a freshly built image (make -C platform-api + # build) instead of the last release — this fixture's config-platform-api-it.toml + # tracks platform-api's current config schema, which a pinned old release may not + # understand. + image: ${PLATFORM_API_IMAGE:-ghcr.io/wso2/api-platform/platform-api:0.12.0} command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: APIP_CP_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" From c9028d684900d33ce7c7ecc05cdd8adc852e2cfc Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Tue, 21 Jul 2026 11:25:35 +0530 Subject: [PATCH 07/18] Refactor AI Workspace configuration and update related documentation --- .../authentication/asgardeo-setup.md | 26 ++++--- docs/ai-workspace/authentication/oidc-auth.md | 32 ++++---- docs/ai-workspace/configuration.md | 60 +++++++++------ platform-api/README.md | 2 +- platform-api/config/config-template.toml | 23 +++--- platform-api/config/config.go | 25 ++++--- platform-api/config/default_config.go | 14 ++-- platform-api/internal/middleware/auth.go | 34 +++++++-- .../middleware/auth_role_extraction_test.go | 73 +++++++++++++++++++ platform-api/internal/server/server.go | 18 ++--- platform-api/resources/roles.yaml | 8 +- portals/ai-workspace/Makefile | 8 +- portals/ai-workspace/README.md | 28 +++---- portals/ai-workspace/VERSION | 2 +- .../bff/internal/config/config.go | 54 +++++++------- .../bff/internal/config/config_test.go | 66 ++++++++--------- .../bff/internal/config/runtime_config.go | 14 ++-- .../bff/internal/config/settings.go | 8 +- .../internal/config/shipped_config_test.go | 24 +++--- .../bff/internal/config/toml_test.go | 4 +- .../bff/internal/proxy/transport.go | 4 +- .../bff/internal/server/composite_handlers.go | 2 +- .../server/composite_handlers_test.go | 12 +-- .../bff/internal/server/server.go | 8 +- portals/ai-workspace/bff/main.go | 2 +- .../ai-workspace/configs/config-template.toml | 65 ++++++++++------- portals/ai-workspace/configs/config.toml | 14 ++-- portals/ai-workspace/distribution/README.md | 10 +-- portals/ai-workspace/production/README.md | 37 +++++----- portals/ai-workspace/src/config.env.ts | 14 ++-- portals/ai-workspace/vite.config.ts | 4 +- 31 files changed, 412 insertions(+), 283 deletions(-) diff --git a/docs/ai-workspace/authentication/asgardeo-setup.md b/docs/ai-workspace/authentication/asgardeo-setup.md index d1593e294..a4df8b475 100644 --- a/docs/ai-workspace/authentication/asgardeo-setup.md +++ b/docs/ai-workspace/authentication/asgardeo-setup.md @@ -128,9 +128,9 @@ issuer = ["https://api.asgardeo.io/t//oauth2/token"] audience = [""] [auth.idp.claim_mappings] -organization_claim_name = "org_id" -org_name_claim_name = "org_name" -org_handle_claim_name = "org_handle" +organization = "org_id" +org_name = "org_name" +org_handle = "org_handle" [auth.file_based] enabled = false @@ -148,12 +148,14 @@ Update `configs/config.toml`: [ai_workspace] domain = "" auth_mode = "oidc" -controlplane_host = "" default_org_region = "us" -[ai_workspace.platform_api] +[ai_workspace.control_plane] url = "https://" +[ai_workspace.gateway] +controlplane_host = "" + [ai_workspace.oidc] authority = "https://api.asgardeo.io/t//oauth2/token" client_id = "" @@ -170,9 +172,9 @@ client_secret = '{{ file "/secrets/ai-workspace/oidc_client_secret" }}' # agree. Must stay the last table under [ai_workspace.oidc]: plain [ai_workspace.oidc] keys # placed below this header would land in [ai_workspace.oidc.claim_mappings] instead. [ai_workspace.oidc.claim_mappings] -organization_claim_name = "org_id" -org_name_claim_name = "org_name" -org_handle_claim_name = "org_handle" +organization = "org_id" +org_name = "org_name" +org_handle = "org_handle" ``` The redirect URLs and the client secret are BFF settings and never reach the browser. The @@ -189,13 +191,13 @@ token so the raw value never lands in the file. ``` Asgardeo token ├── sub → user identity - ├── org_id → organization UUID (→ organization_claim_name in Platform API) - ├── org_name → org display name (→ org_name_claim_name in Platform API) - ├── org_handle → org slug (→ org_handle_claim_name in Platform API) + ├── org_id → organization UUID (→ organization in Platform API) + ├── org_name → org display name (→ org_name in Platform API) + ├── org_handle → org slug (→ org_handle in Platform API) └── scope → space-separated ap:* scopes validated by Platform API ``` The claim names must be consistent across all three places: - Asgardeo token mapper output - `[ai_workspace.oidc.claim_mappings]` in `config.toml` -- `*_claim_name` in Platform API `[auth.idp.claim_mappings]` +- The matching keys in Platform API `[auth.idp.claim_mappings]` diff --git a/docs/ai-workspace/authentication/oidc-auth.md b/docs/ai-workspace/authentication/oidc-auth.md index dd9d6fbe2..ef004d5e2 100644 --- a/docs/ai-workspace/authentication/oidc-auth.md +++ b/docs/ai-workspace/authentication/oidc-auth.md @@ -23,14 +23,16 @@ Tested IDPs: [Asgardeo](asgardeo-setup.md), Keycloak, Auth0, Okta. ```toml [ai_workspace] -domain = "app.example.com" -auth_mode = "oidc" -controlplane_host = "api.example.com" +domain = "app.example.com" +auth_mode = "oidc" -[ai_workspace.platform_api] +[ai_workspace.control_plane] # The upstream the BFF proxies to (an origin — the API paths are appended by the proxy). url = "https://api.example.com" +[ai_workspace.gateway] +controlplane_host = "api.example.com" + [ai_workspace.oidc] # IDP issuer URL — the discovery doc is fetched from {authority}/.well-known/openid-configuration authority = "https://idp.example.com/realms/my-realm" @@ -53,9 +55,9 @@ client_secret = '{{ file "/secrets/ai-workspace/oidc_client_secret" }}' # keys placed below this header would land in [ai_workspace.oidc.claim_mappings] # instead. [ai_workspace.oidc.claim_mappings] -organization_claim_name = "org_id" -org_name_claim_name = "org_name" -org_handle_claim_name = "org_handle" +organization = "org_id" +org_name = "org_name" +org_handle = "org_handle" ``` The redirect URLs and the client secret are BFF settings — they are **never sent to the browser**. @@ -89,9 +91,9 @@ audience = ["ai-workspace"] # must match [ai_workspace.oidc] client_id # Map IDP-specific claim names to Platform API's expected fields # These must match the [ai_workspace.oidc.claim_mappings] values in config.toml above [auth.idp.claim_mappings] -organization_claim_name = "org_id" -org_name_claim_name = "org_name" -org_handle_claim_name = "org_handle" +organization = "org_id" +org_name = "org_name" +org_handle = "org_handle" # Disable file-based auth [auth.file_based] @@ -102,10 +104,10 @@ Optional claim overrides (defaults shown): ```toml [auth.idp.claim_mappings] -user_id_claim_name = "sub" -username_claim_name = "username" -email_claim_name = "email" -scope_claim_name = "scope" +user_id = "sub" +username = "username" +email = "email" +scope = "scope" ``` Validation mode (default `scope`): @@ -150,7 +152,7 @@ You must register the `ap:*` scopes as an API resource in your IDP and grant the **Platform API returns 401** - Check that `jwks_url` and `issuer` in Platform API config match the IDP's discovery doc values. - Check that `audience` matches the `[ai_workspace.oidc] client_id` of the confidential application. -- Ensure `organization_claim_name` matches on both sides — `[platform_api.auth.idp.claim_mappings]` in the Platform API and `[ai_workspace.oidc.claim_mappings]` in AI Workspace. +- Ensure `organization` matches on both sides — `[platform_api.auth.idp.claim_mappings]` in the Platform API and `[ai_workspace.oidc.claim_mappings]` in AI Workspace. **"Organization not found" error** - The `org_id` claim in the token does not match any organization in Platform API's database. diff --git a/docs/ai-workspace/configuration.md b/docs/ai-workspace/configuration.md index 786da6675..f4a95e121 100644 --- a/docs/ai-workspace/configuration.md +++ b/docs/ai-workspace/configuration.md @@ -2,7 +2,7 @@ AI Workspace is configured through a `config.toml` file mounted into the container at `/etc/ai-workspace/config.toml`. -All AI Workspace settings live under a single top-level `[ai_workspace]` table — the same namespacing convention the Platform API uses for its own `[platform_api]` table — so one `config.toml` can hold both services' sections side by side without their keys colliding. Keys are grouped into TOML tables (`[ai_workspace.platform_api]`, `[ai_workspace.tls]`, `[ai_workspace.session]`, `[ai_workspace.cookie]`, `[ai_workspace.oidc]`); deployment-identity keys such as `domain` and `auth_mode` sit directly under `[ai_workspace]`. +All AI Workspace settings live under a single top-level `[ai_workspace]` table — the same namespacing convention the Platform API uses for its own `[platform_api]` table — so one `config.toml` can hold both services' sections side by side without their keys colliding. Keys are grouped into TOML tables (`[ai_workspace.control_plane]`, `[ai_workspace.tls]`, `[ai_workspace.session]`, `[ai_workspace.cookie]`, `[ai_workspace.oidc]`); deployment-identity keys such as `domain` and `auth_mode` sit directly under `[ai_workspace]`. The file is the **only** source of configuration. Each value in it is written as an interpolation token that is resolved once at startup, so where the value comes from is visible in place: @@ -14,7 +14,7 @@ client_id = '{{ env "APIP_AIW_OIDC_CLIENT_ID" "default" }}' A key written this way can be set from the environment without editing the file. That token is the *only* thing that lets an environment variable reach a config key — there is no implicit override, so a key written as a plain literal (`key = "value"`), or absent from the file, ignores the variable entirely. Add the key with a token to make it settable that way. -By convention the variable a token names is the key's path **under `[ai_workspace]`** (the `ai_workspace` segment itself is not part of the name) — table and key, uppercased, dots as underscores — prefixed with **`APIP_AIW_`**: `[ai_workspace.oidc] client_id` → `APIP_AIW_OIDC_CLIENT_ID`, `[ai_workspace.platform_api] url` → `APIP_AIW_PLATFORM_API_URL`, and `[ai_workspace] 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. +By convention the variable a token names is the key's path **under `[ai_workspace]`** (the `ai_workspace` segment itself is not part of the name) — table and key, uppercased, dots as underscores — prefixed with **`APIP_AIW_`**: `[ai_workspace.oidc] client_id` → `APIP_AIW_OIDC_CLIENT_ID`, `[ai_workspace.control_plane] url` → `APIP_AIW_CONTROL_PLANE_URL`, and `[ai_workspace] 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). 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). @@ -53,11 +53,10 @@ map of what each table is for. |-----|-------------| | `domain` | Host (and optional port) shown in the browser address bar. | | `auth_mode` | Authentication mode. `"basic"` for file-based local auth; `"oidc"` for external IDP. | -| `controlplane_host` | Externally reachable `host:port` that deployed gateways use to reach the Platform API. Shown in gateway setup instructions. Must be an absolute address, not a relative path. | | `default_org_region` | Default region label assigned to new organizations on first login. | | `log_level` | `debug` \| `info` \| `warn` \| `error`. | -### `[ai_workspace.platform_api]` — the upstream hop +### `[ai_workspace.control_plane]` — the upstream hop | Key | Description | |-----|-------------| @@ -65,6 +64,17 @@ map of what each table is for. | `tls_skip_verify` | Skip upstream certificate verification entirely (local development only) — prefer `ca_file`. | | `ca_file` | PEM bundle trusted for the upstream certificate, appended to the system roots. Prefer this over `tls_skip_verify`. | +### `[ai_workspace.gateway]` — gateway deployment info shown to the browser + +Distinct from `[ai_workspace.control_plane]` above: that table is the BFF's own +server-to-server hop, while this one is what an externally deployed gateway needs to +reach the Platform API itself. + +| Key | Description | +|-----|-------------| +| `controlplane_host` | Externally reachable `host:port` that deployed gateways use to reach the Platform API. Shown in gateway setup instructions. Must be an absolute address, not a relative path. | +| `platform_gateway_versions` | Gateway versions offered in the create-gateway version selector (JSON array string). | + ### `[ai_workspace.oidc]` (only required when `auth_mode = "oidc"`) | Key | Description | @@ -81,13 +91,13 @@ This table mirrors the Platform API's `[platform_api.auth.idp.claim_mappings]` k | Key | Description | |-----|-------------| -| `organization_claim_name` | Claim carrying the organization UUID. | -| `org_name_claim_name` | Claim carrying the human-readable organization name. | -| `org_handle_claim_name` | Claim carrying the organization handle (slug). | -| `username_claim_name` | Claim carrying the display name. | -| `email_claim_name` | Claim carrying the email address. | -| `scope_claim_name` | Claim carrying the space-separated scope string. | -| `role_claim_name` | Claim carrying the platform role. Server-side only — not published to the browser. | +| `organization` | Claim carrying the organization UUID. | +| `org_name` | Claim carrying the human-readable organization name. | +| `org_handle` | Claim carrying the organization handle (slug). | +| `username` | Claim carrying the display name. | +| `email` | Claim carrying the email address. | +| `scope` | Claim carrying the space-separated scope string. | +| `role` | Claim carrying the platform role. Server-side only — not published to the browser. | `[ai_workspace.oidc.claim_mappings]` must be the **last** table under `[ai_workspace.oidc]`: in TOML a sub-table header ends the parent table's key section, so a plain `[ai_workspace.oidc]` key written below it would land in the sub-table instead. @@ -102,12 +112,14 @@ The remaining tables (`[ai_workspace.tls]`, `[ai_workspace.session]`, `[ai_works ```toml [ai_workspace] -domain = "localhost:8080" -auth_mode = "basic" -controlplane_host = "localhost:9243" +domain = "localhost:8080" +auth_mode = "basic" -[ai_workspace.platform_api] +[ai_workspace.control_plane] url = "https://localhost:9243" + +[ai_workspace.gateway] +controlplane_host = "localhost:9243" ``` ## Minimal Production Config (OIDC) @@ -116,12 +128,14 @@ url = "https://localhost:9243" [ai_workspace] domain = "app.example.com" auth_mode = "oidc" -controlplane_host = "api.example.com" default_org_region = "us" -[ai_workspace.platform_api] +[ai_workspace.control_plane] url = "https://api.example.com" +[ai_workspace.gateway] +controlplane_host = "api.example.com" + [ai_workspace.oidc] authority = "https://api.asgardeo.io/t//oauth2/token" client_id = "" @@ -130,9 +144,9 @@ redirect_url = "https://app.example.com/api/auth/callback" # Mirrors [platform_api.auth.idp.claim_mappings] in the Platform API config — the two must agree. [ai_workspace.oidc.claim_mappings] -organization_claim_name = "org_id" -org_name_claim_name = "org_name" -org_handle_claim_name = "org_handle" +organization = "org_id" +org_name = "org_name" +org_handle = "org_handle" ``` ## Platform API Configuration @@ -151,9 +165,9 @@ issuer = ["https://api.asgardeo.io/t//oauth2/token"] audience = [""] [auth.idp.claim_mappings] -organization_claim_name = "org_id" -org_name_claim_name = "org_name" -org_handle_claim_name = "org_handle" +organization = "org_id" +org_name = "org_name" +org_handle = "org_handle" [auth.file_based] enabled = false # Disable file-based auth in production diff --git a/platform-api/README.md b/platform-api/README.md index 34a6a171e..054624af7 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -310,7 +310,7 @@ Per-route scope checks are enforced when `platform_api.auth.scope_validation = t In **`external_token`/`file` mode**, scopes are read directly from the `scope` claim in the token. In **`idp` mode**, scopes or roles are read from the claim(s) named in `[platform_api.auth.idp.claim_mappings]`, per `validation_mode` (`scope` reads the scope claim directly; `role` expands IDP roles from -`roles_claim_path` via `role_mappings`). +`roles` via `role_mappings`). ### Providing secrets via the config file diff --git a/platform-api/config/config-template.toml b/platform-api/config/config-template.toml index a8a2a0046..5381106cf 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -182,20 +182,23 @@ 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. +# "role" checks the roles claim configured below at claim_mappings.roles. validation_mode = '{{ env "APIP_CP_AUTH_IDP_VALIDATION_MODE" "scope" }}' role_mappings = '{{ env "APIP_CP_AUTH_IDP_ROLE_MAPPINGS" "" }}' # path to a YAML file mapping IDP roles to platform scopes -# JWT claim name mappings — configure to match your IDP's token structure. +# JWT claim name mappings — configure to match your IDP's token structure. Each +# value is either a flat top-level claim name ("org_id") or a dot-separated +# path into a nested claim ("realm_access.org_id") — useful for IDPs like +# Keycloak that nest fields such as roles under realm_access.roles. [platform_api.auth.idp.claim_mappings] -organization_claim_name = '{{ env "APIP_CP_AUTH_IDP_CLAIM_ORGANIZATION" "organization" }}' # claim carrying the org ID -org_name_claim_name = '{{ env "APIP_CP_AUTH_IDP_CLAIM_ORG_NAME" "org_name" }}' # claim carrying the org display name -org_handle_claim_name = '{{ env "APIP_CP_AUTH_IDP_CLAIM_ORG_HANDLE" "org_handle" }}' # claim carrying the org URL slug -user_id_claim_name = '{{ env "APIP_CP_AUTH_IDP_CLAIM_USER_ID" "sub" }}' # claim used as the user's unique ID -username_claim_name = '{{ env "APIP_CP_AUTH_IDP_CLAIM_USERNAME" "username" }}' -email_claim_name = '{{ env "APIP_CP_AUTH_IDP_CLAIM_EMAIL" "email" }}' -scope_claim_name = '{{ env "APIP_CP_AUTH_IDP_CLAIM_SCOPE" "scope" }}' # space-separated scope string -roles_claim_path = '{{ env "APIP_CP_AUTH_IDP_CLAIM_ROLES_PATH" "" }}' # JSON path to roles array, e.g. "realm_access.roles" +organization = '{{ env "APIP_CP_AUTH_IDP_CLAIM_ORGANIZATION" "organization" }}' # claim carrying the org ID +org_name = '{{ env "APIP_CP_AUTH_IDP_CLAIM_ORG_NAME" "org_name" }}' # claim carrying the org display name +org_handle = '{{ env "APIP_CP_AUTH_IDP_CLAIM_ORG_HANDLE" "org_handle" }}' # claim carrying the org URL slug +user_id = '{{ env "APIP_CP_AUTH_IDP_CLAIM_USER_ID" "sub" }}' # claim used as the user's unique ID +username = '{{ env "APIP_CP_AUTH_IDP_CLAIM_USERNAME" "username" }}' +email = '{{ env "APIP_CP_AUTH_IDP_CLAIM_EMAIL" "email" }}' +scope = '{{ env "APIP_CP_AUTH_IDP_CLAIM_SCOPE" "scope" }}' # space-separated scope string +roles = '{{ env "APIP_CP_AUTH_IDP_CLAIM_ROLES" "" }}' # e.g. "realm_access.roles" (Keycloak) or "roles" (Asgardeo) # File auth — local username/password login, used when mode = "file". Ideal for # initial / air-gapped setup; not recommended for production — prefer an IDP. diff --git a/platform-api/config/config.go b/platform-api/config/config.go index 58856d386..6068083c1 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -129,16 +129,19 @@ type Auth struct { File FileBased `koanf:"file"` } -// IDPClaimMappings holds JWT claim name mappings for an IDP. +// IDPClaimMappings holds JWT claim name mappings for an IDP. Every field +// accepts either a flat top-level claim name ("org_id") or a dot-separated +// path into a nested claim ("realm_access.org_id") — see resolveClaimPath in +// internal/middleware/auth.go. type IDPClaimMappings struct { - OrganizationClaimName string `koanf:"organization_claim_name"` - OrgNameClaimName string `koanf:"org_name_claim_name"` - OrgHandleClaimName string `koanf:"org_handle_claim_name"` - UserIDClaimName string `koanf:"user_id_claim_name"` - UsernameClaimName string `koanf:"username_claim_name"` - EmailClaimName string `koanf:"email_claim_name"` - ScopeClaimName string `koanf:"scope_claim_name"` - RolesClaimPath string `koanf:"roles_claim_path"` + Organization string `koanf:"organization"` + OrgName string `koanf:"org_name"` + OrgHandle string `koanf:"org_handle"` + UserID string `koanf:"user_id"` + Username string `koanf:"username"` + Email string `koanf:"email"` + Scope string `koanf:"scope"` + Roles string `koanf:"roles"` } // IDP holds configuration for JWKS-based identity providers. Active when @@ -654,8 +657,8 @@ func validateIDPConfig(idp *IDP) error { default: return fmt.Errorf("auth.idp.validation_mode must be \"scope\" or \"role\" (got %q)", idp.ValidationMode) } - if idp.ValidationMode == "role" && idp.ClaimMappings.RolesClaimPath == "" { - return fmt.Errorf("auth.idp.validation_mode=role requires auth.idp.claim_mappings.roles_claim_path to be configured") + if idp.ValidationMode == "role" && idp.ClaimMappings.Roles == "" { + return fmt.Errorf("auth.idp.validation_mode=role requires auth.idp.claim_mappings.roles to be configured") } return nil } diff --git a/platform-api/config/default_config.go b/platform-api/config/default_config.go index ba3464f10..439e17f38 100644 --- a/platform-api/config/default_config.go +++ b/platform-api/config/default_config.go @@ -70,13 +70,13 @@ func defaultConfig() *Server { IDP: IDP{ ValidationMode: "scope", ClaimMappings: IDPClaimMappings{ - OrganizationClaimName: "organization", - OrgNameClaimName: "org_name", - OrgHandleClaimName: "org_handle", - UserIDClaimName: "sub", - UsernameClaimName: "username", - EmailClaimName: "email", - ScopeClaimName: "scope", + Organization: "organization", + OrgName: "org_name", + OrgHandle: "org_handle", + UserID: "sub", + Username: "username", + Email: "email", + Scope: "scope", }, }, File: FileBased{ diff --git a/platform-api/internal/middleware/auth.go b/platform-api/internal/middleware/auth.go index 3065504e5..2f2f4af04 100644 --- a/platform-api/internal/middleware/auth.go +++ b/platform-api/internal/middleware/auth.go @@ -325,23 +325,34 @@ func resolvePlatformRoles(claims jwt.MapClaims, claimPath string, roleScopeMap m } func extractClaimByPath(claims jwt.MapClaims, path string) []string { - return extractByPath(map[string]interface{}(claims), path) + val, ok := resolveClaimPath(map[string]interface{}(claims), path) + if !ok { + return nil + } + return toStringSlice(val) } -func extractByPath(obj map[string]interface{}, path string) []string { +// resolveClaimPath walks a dot-separated path into nested claim objects and +// returns the raw value found there. A path with no "." is a single flat +// claim lookup, so every claim_mappings field — not just roles — can point at +// either a top-level claim ("org_id") or a nested one ("realm_access.org_id"). +func resolveClaimPath(obj map[string]interface{}, path string) (interface{}, bool) { + if path == "" { + return nil, false + } parts := strings.SplitN(path, ".", 2) val, ok := obj[parts[0]] if !ok { - return nil + return nil, false } if len(parts) == 1 { - return toStringSlice(val) + return val, true } nested, ok := val.(map[string]interface{}) if !ok { - return nil + return nil, false } - return extractByPath(nested, parts[1]) + return resolveClaimPath(nested, parts[1]) } func toStringSlice(val interface{}) []string { @@ -360,12 +371,19 @@ func toStringSlice(val interface{}) []string { return nil } +// getStringClaim resolves name as a claim path (see resolveClaimPath) and +// returns the value as a string. name may be a flat claim ("email") or a +// dot-separated path into a nested claim ("realm_access.email"). func getStringClaim(claims jwt.MapClaims, name string) string { if name == "" { return "" } - v, _ := claims[name].(string) - return v + val, ok := resolveClaimPath(map[string]interface{}(claims), name) + if !ok { + return "" + } + s, _ := val.(string) + return s } // resolveUserID returns the stable user identifier used for audit fields diff --git a/platform-api/internal/middleware/auth_role_extraction_test.go b/platform-api/internal/middleware/auth_role_extraction_test.go index 143261391..065371857 100644 --- a/platform-api/internal/middleware/auth_role_extraction_test.go +++ b/platform-api/internal/middleware/auth_role_extraction_test.go @@ -198,3 +198,76 @@ func TestResolvePlatformRoles(t *testing.T) { }) } } + +// TestGetStringClaim verifies that every claim_mappings field — not just +// roles — resolves a dot-separated path into a nested claim, not only a flat +// top-level claim name. +func TestGetStringClaim(t *testing.T) { + tests := []struct { + name string + claims jwt.MapClaims + path string + want string + }{ + { + name: "flat top-level claim", + claims: jwt.MapClaims{"org_id": "acme"}, + path: "org_id", + want: "acme", + }, + { + name: "nested path into a sub-object", + claims: jwt.MapClaims{ + "realm_access": map[string]interface{}{"org_id": "acme"}, + }, + path: "realm_access.org_id", + want: "acme", + }, + { + name: "deeply nested path", + claims: jwt.MapClaims{ + "a": map[string]interface{}{ + "b": map[string]interface{}{"email": "user@example.com"}, + }, + }, + path: "a.b.email", + want: "user@example.com", + }, + { + name: "missing claim returns empty string", + claims: jwt.MapClaims{"org_id": "acme"}, + path: "missing", + want: "", + }, + { + name: "path through a non-object value returns empty string", + claims: jwt.MapClaims{ + "org_id": "acme", + }, + path: "org_id.nested", + want: "", + }, + { + name: "empty path returns empty string", + claims: jwt.MapClaims{"org_id": "acme"}, + path: "", + want: "", + }, + { + name: "non-string value at path returns empty string", + claims: jwt.MapClaims{ + "org_id": []interface{}{"acme"}, + }, + path: "org_id", + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := getStringClaim(tc.claims, tc.path); got != tc.want { + t.Errorf("getStringClaim() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index a460a5f7b..a66235c05 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -568,7 +568,7 @@ func buildAuthenticator(cfg *config.Server, slogger *slog.Logger, roleScopeMap m Enabled: true, IssuerURL: issuerURL, JWKSUrl: cfg.Auth.IDP.JWKSUrl, - ScopeClaim: cfg.Auth.IDP.ClaimMappings.ScopeClaimName, + ScopeClaim: cfg.Auth.IDP.ClaimMappings.Scope, } // Enforce audience validation only when at least one audience is configured. if len(cfg.Auth.IDP.Audience) > 0 { @@ -583,14 +583,14 @@ func buildAuthenticator(cfg *config.Server, slogger *slog.Logger, roleScopeMap m return nil, fmt.Errorf("failed to initialize IDP auth middleware: %w", err) } claimsMiddleware := middleware.PlatformClaimsMiddleware(middleware.PlatformClaimNames{ - OrganizationClaim: cfg.Auth.IDP.ClaimMappings.OrganizationClaimName, - OrgNameClaim: cfg.Auth.IDP.ClaimMappings.OrgNameClaimName, - OrgHandleClaim: cfg.Auth.IDP.ClaimMappings.OrgHandleClaimName, - UserIDClaim: cfg.Auth.IDP.ClaimMappings.UserIDClaimName, - UsernameClaim: cfg.Auth.IDP.ClaimMappings.UsernameClaimName, - EmailClaim: cfg.Auth.IDP.ClaimMappings.EmailClaimName, - ScopeClaim: cfg.Auth.IDP.ClaimMappings.ScopeClaimName, - RolesClaimPath: cfg.Auth.IDP.ClaimMappings.RolesClaimPath, + OrganizationClaim: cfg.Auth.IDP.ClaimMappings.Organization, + OrgNameClaim: cfg.Auth.IDP.ClaimMappings.OrgName, + OrgHandleClaim: cfg.Auth.IDP.ClaimMappings.OrgHandle, + UserIDClaim: cfg.Auth.IDP.ClaimMappings.UserID, + UsernameClaim: cfg.Auth.IDP.ClaimMappings.Username, + EmailClaim: cfg.Auth.IDP.ClaimMappings.Email, + ScopeClaim: cfg.Auth.IDP.ClaimMappings.Scope, + RolesClaimPath: cfg.Auth.IDP.ClaimMappings.Roles, RoleScopeMap: roleScopeMap, }) diff --git a/platform-api/resources/roles.yaml b/platform-api/resources/roles.yaml index 7e60e0fef..a779b46e8 100644 --- a/platform-api/resources/roles.yaml +++ b/platform-api/resources/roles.yaml @@ -1,14 +1,14 @@ # Role-to-scope mapping for IDP role mode (auth.idp.validation_mode: role). # # Each entry maps an IDP role name (as it appears in the token claim configured -# by auth.idp.roles_claim_path) to the list of platform scopes that role grants. +# by auth.idp.roles) to the list of platform scopes that role grants. # When a token carries multiple roles, the effective scopes are the union of all # role entries — most-permissive wins. # # Supported IDPs: -# Asgardeo — set roles_claim_path: roles -# Keycloak — set roles_claim_path: realm_access.roles (or resource_access..roles) -# Microsoft Entra ID — set roles_claim_path: roles +# Asgardeo — set roles: roles +# Keycloak — set roles: realm_access.roles (or resource_access..roles) +# Microsoft Entra ID — set roles: roles # # Scope convention: # ap::manage — full access to all actions on that resource (covers read/create/update/delete) diff --git a/portals/ai-workspace/Makefile b/portals/ai-workspace/Makefile index 6125e0154..a7384985a 100644 --- a/portals/ai-workspace/Makefile +++ b/portals/ai-workspace/Makefile @@ -31,7 +31,7 @@ IMAGE_NAME := $(DOCKER_REGISTRY)/ai-workspace # browser→backend traffic, and owns authentication. BFF_DIR := bff BFF_DEV_ADDR ?= :8081 -PLATFORM_API_URL ?= https://localhost:9243 +CONTROL_PLANE_URL ?= https://localhost:9243 help: ## Show this help message @echo 'AI Workspace Build System (Version: $(VERSION))' @@ -55,11 +55,11 @@ bff-build: ## Build the BFF binary # thing that lets an environment variable reach a key, so a key absent from the file # 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`) +bff-run: ## Run the BFF locally (proxies to CONTROL_PLANE_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_CONTROL_PLANE_URL=$(CONTROL_PLANE_URL) \ + APIP_AIW_CONTROL_PLANE_TLS_SKIP_VERIFY=true \ APIP_AIW_TLS_CERT_FILE=../resources/certificates/cert.pem \ APIP_AIW_TLS_KEY_FILE=../resources/certificates/key.pem \ APIP_AIW_LISTEN_ADDR=$(BFF_DEV_ADDR) \ diff --git a/portals/ai-workspace/README.md b/portals/ai-workspace/README.md index 5262255ab..62d9e1893 100644 --- a/portals/ai-workspace/README.md +++ b/portals/ai-workspace/README.md @@ -76,7 +76,7 @@ The token is what reads the environment, so setting `APIP_AIW_LOG_LEVEL` does no `[ai_workspace]` — the same namespacing convention the Platform API uses for its own `[platform_api]` table, so a shared config.toml can hold both services' sections without their keys colliding. Keys are grouped into TOML tables under it -(`[ai_workspace.platform_api]`, `[ai_workspace.tls]`, `[ai_workspace.session]`, +(`[ai_workspace.control_plane]`, `[ai_workspace.tls]`, `[ai_workspace.session]`, `[ai_workspace.cookie]`, `[ai_workspace.oidc]`), and by convention a token names the key's path under `[ai_workspace]` uppercased with underscores (`[ai_workspace.oidc] client_id` → `APIP_AIW_OIDC_CLIENT_ID`) — but a token may name any variable. @@ -167,9 +167,9 @@ cd portals/ai-workspace make bff-run # serves /api/* on https://localhost:8081, proxies to the Platform API ``` The Vite dev server proxies `/api` and `/runtime-config.js` to the BFF, so the browser -talks only to the app origin (same topology as production). Set `PLATFORM_API_URL` if the +talks only to the app origin (same topology as production). Set `CONTROL_PLANE_URL` if the Platform API is not on `https://localhost:9243` — `make bff-run` forwards it to the BFF as -`APIP_AIW_PLATFORM_API_URL`, the variable the `[ai_workspace.platform_api] url` token names, so the two +`APIP_AIW_CONTROL_PLANE_URL`, the variable the `[ai_workspace.control_plane] url` token names, so the two names are the same setting. Ensure all three services are running before accessing the application. @@ -286,9 +286,9 @@ 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 +# APIP_CP_AUTH_IDP_CLAIM_ORGANIZATION=org_id +# APIP_CP_AUTH_IDP_CLAIM_ORG_NAME=org_name +# APIP_CP_AUTH_IDP_CLAIM_ORG_HANDLE=org_handle ``` Then start the stack: @@ -328,9 +328,9 @@ audience = [""] # match Asgardeo's aud, or [] to skip the chec # Asgardeo emits org_id (not the default "organization") — these overrides are required. [auth.idp.claim_mappings] -organization_claim_name = "org_id" -org_name_claim_name = "org_name" -org_handle_claim_name = "org_handle" +organization = "org_id" +org_name = "org_name" +org_handle = "org_handle" [auth.file_based] enabled = false # required: mutually exclusive with the IDP @@ -348,8 +348,8 @@ the `platform-api` compose hostname does **not** resolve outside the compose net ```bash cd portals/ai-workspace -export APIP_AIW_PLATFORM_API_URL=https://localhost:9243 # NOT https://platform-api:9243 when run locally -export APIP_AIW_PLATFORM_API_TLS_SKIP_VERIFY=true +export APIP_AIW_CONTROL_PLANE_URL=https://localhost:9243 # NOT https://platform-api:9243 when run locally +export APIP_AIW_CONTROL_PLANE_TLS_SKIP_VERIFY=true export APIP_AIW_AUTH_MODE=oidc export APIP_AIW_OIDC_AUTHORITY=https://api.asgardeo.io/t//oauth2/token export APIP_AIW_OIDC_CLIENT_ID= @@ -370,7 +370,7 @@ failures, by symptom: |---|---|---| | `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: 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 `[ai_workspace.platform_api] url` points at the compose hostname | Set `APIP_AIW_PLATFORM_API_URL=https://localhost:9243` (step 3, Option 2) | +| `502` + `dial tcp: lookup platform-api: no such host` | BFF run locally but `[ai_workspace.control_plane] url` points at the compose hostname | Set `APIP_AIW_CONTROL_PLANE_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: 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 | @@ -464,7 +464,7 @@ resources/certificates/ 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 `[ai_workspace.platform_api] ca_file` in +the BFF trusts for the upstream platform-api hop, referenced by `[ai_workspace.control_plane] ca_file` in `configs/config.toml`. Then restart the stack: `docker compose up -d --force-recreate` (a plain `docker @@ -485,5 +485,5 @@ credentials, and self-signed certificates); for production, provide real values: | **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 (`[ai_workspace.platform_api] ca_file`) | Point `ca_file` at your CA bundle; never use `tls_skip_verify` in production | +| **Upstream trust (BFF → Platform API)** | The generated shared cert is mounted as a CA bundle (`[ai_workspace.control_plane] 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/VERSION b/portals/ai-workspace/VERSION index 12fc46a00..d07947ac0 100644 --- a/portals/ai-workspace/VERSION +++ b/portals/ai-workspace/VERSION @@ -1 +1 @@ -1.0.0-alpha2-SNAPSHOT +1.0.0-beta-SNAPSHOT diff --git a/portals/ai-workspace/bff/internal/config/config.go b/portals/ai-workspace/bff/internal/config/config.go index 7a7da9d43..ef1ea4d5b 100644 --- a/portals/ai-workspace/bff/internal/config/config.go +++ b/portals/ai-workspace/bff/internal/config/config.go @@ -44,8 +44,8 @@ type Config struct { // TLS for the BFF listener TLS TLSConfig - // Upstream Platform API - PlatformAPI PlatformAPIConfig + // Upstream Platform API (control plane) + ControlPlane ControlPlaneConfig // Same-origin reverse-proxy prefix the SPA calls (stripped before forwarding) ProxyPrefix string @@ -65,9 +65,9 @@ type Config struct { RuntimeConfig map[string]string } -// PlatformAPIConfig groups everything about the upstream Platform API hop: where -// it is, and how its TLS certificate is trusted. -type PlatformAPIConfig struct { +// ControlPlaneConfig groups everything about the upstream Platform API (control +// plane) hop: where it is, and how its TLS certificate is trusted. +type ControlPlaneConfig struct { // URL is the base URL, e.g. https://platform-api:9243. Its http/https scheme is // the single source of truth for whether the outbound hop uses TLS — there is // deliberately no separate boolean, since that could contradict the URL. @@ -219,7 +219,7 @@ func Load(path string) (*Config, error) { if err != nil { return nil, err } - platformTLSSkipVerify, err := s.getbool("platform_api.tls_skip_verify", false) + controlPlaneTLSSkipVerify, err := s.getbool("control_plane.tls_skip_verify", false) if err != nil { return nil, err } @@ -252,11 +252,11 @@ func Load(path string) (*Config, error) { 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", ""), "/"), - CAFile: s.get("platform_api.ca_file", ""), - TLSSkipVerify: platformTLSSkipVerify, - LoginPath: s.get("platform_api.login_path", "/api/portal/v0.9/auth/login"), + ControlPlane: ControlPlaneConfig{ + URL: strings.TrimRight(s.get("control_plane.url", ""), "/"), + CAFile: s.get("control_plane.ca_file", ""), + TLSSkipVerify: controlPlaneTLSSkipVerify, + LoginPath: s.get("control_plane.login_path", "/api/portal/v0.9/auth/login"), }, ProxyPrefix: strings.TrimRight(s.get("proxy_prefix", "/api/proxy"), "/"), Session: SessionConfig{ @@ -293,40 +293,40 @@ func Load(path string) (*Config, error) { // The same keys drive the BFF's session mapping and the SPA's runtime // config, so one config entry keeps both layers in sync. Claims: ClaimMappingConfig{ - Username: s.get("oidc.claim_mappings.username_claim_name", "username"), - Email: s.get("oidc.claim_mappings.email_claim_name", "email"), - Role: s.get("oidc.claim_mappings.role_claim_name", "platform_role"), - Scope: s.get("oidc.claim_mappings.scope_claim_name", "scope"), - OrgID: s.get("oidc.claim_mappings.organization_claim_name", "org_id"), - OrgName: s.get("oidc.claim_mappings.org_name_claim_name", "org_name"), - OrgHandle: s.get("oidc.claim_mappings.org_handle_claim_name", "org_handle"), + Username: s.get("oidc.claim_mappings.username", "username"), + Email: s.get("oidc.claim_mappings.email", "email"), + Role: s.get("oidc.claim_mappings.role", "platform_role"), + Scope: s.get("oidc.claim_mappings.scope", "scope"), + OrgID: s.get("oidc.claim_mappings.organization", "org_id"), + OrgName: s.get("oidc.claim_mappings.org_name", "org_name"), + OrgHandle: s.get("oidc.claim_mappings.org_handle", "org_handle"), }, }, } - if cfg.PlatformAPI.URL == "" { - return nil, fmt.Errorf("[platform_api] url is required: set it in config.toml, " + + if cfg.ControlPlane.URL == "" { + return nil, fmt.Errorf("[control_plane] url is required: set it in config.toml, " + "either as a literal or via an {{ env }} / {{ file }} token") } // The scheme is the single source of truth for the outbound TLS decision, so a // missing/typo'd scheme must fail at startup rather than surface as an opaque // dial error on the first proxied request. - u, err := url.Parse(cfg.PlatformAPI.URL) + u, err := url.Parse(cfg.ControlPlane.URL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { - return nil, fmt.Errorf("[platform_api] url must be an absolute http:// or https:// URL, got %q", cfg.PlatformAPI.URL) + return nil, fmt.Errorf("[control_plane] url must be an absolute http:// or https:// URL, got %q", cfg.ControlPlane.URL) } // Trust knobs only apply to an https upstream; flag them on a plain-http URL so a // mistaken belief that TLS is in effect is caught early. if u.Scheme == "http" { - if cfg.PlatformAPI.CAFile != "" || cfg.PlatformAPI.TLSSkipVerify { - 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)") + if cfg.ControlPlane.CAFile != "" || cfg.ControlPlane.TLSSkipVerify { + return nil, fmt.Errorf("[control_plane] ca_file / tls_skip_verify are set but [control_plane] url is http:// (no TLS on the upstream hop)") } } // 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 u.Scheme == "https" && cfg.ControlPlane.TLSSkipVerify { + slog.Warn("[control_plane] tls_skip_verify = true — upstream certificate verification is DISABLED. " + + "Trust the upstream certificate with [control_plane] ca_file instead.") } if cfg.OIDC.Enabled { if cfg.OIDC.Issuer == "" || cfg.OIDC.ClientID == "" || cfg.OIDC.ClientSecret == "" || cfg.OIDC.RedirectURL == "" { diff --git a/portals/ai-workspace/bff/internal/config/config_test.go b/portals/ai-workspace/bff/internal/config/config_test.go index 54ed1f2fe..45323dc58 100644 --- a/portals/ai-workspace/bff/internal/config/config_test.go +++ b/portals/ai-workspace/bff/internal/config/config_test.go @@ -40,7 +40,7 @@ func TestLoad_ConfigFileValue(t *testing.T) { [ai_workspace] log_level = "warn" -[ai_workspace.platform_api] +[ai_workspace.control_plane] url = "https://platform-api:9243" `) @@ -48,8 +48,8 @@ url = "https://platform-api:9243" if err != nil { t.Fatalf("Load() error = %v", err) } - if cfg.PlatformAPI.URL != "https://platform-api:9243" { - t.Errorf("PlatformAPI.URL = %q, want the config.toml value", cfg.PlatformAPI.URL) + if cfg.ControlPlane.URL != "https://platform-api:9243" { + t.Errorf("ControlPlane.URL = %q, want the config.toml value", cfg.ControlPlane.URL) } if cfg.LogLevel != "warn" { t.Errorf("LogLevel = %q, want %q", cfg.LogLevel, "warn") @@ -64,7 +64,7 @@ func TestLoad_EnvTokenSuppliesValueAndDefault(t *testing.T) { log_level = '{{ env "APIP_AIW_LOG_LEVEL" "info" }}' log_format = '{{ env "APIP_AIW_LOG_FORMAT" "text" }}' -[ai_workspace.platform_api] +[ai_workspace.control_plane] url = "https://platform-api:9243" `) t.Setenv("APIP_AIW_LOG_LEVEL", "debug") // named by the token @@ -90,7 +90,7 @@ func TestLoad_EnvVarWithoutTokenIsIgnored(t *testing.T) { [ai_workspace] log_level = "warn" -[ai_workspace.platform_api] +[ai_workspace.control_plane] url = "https://platform-api:9243" `) t.Setenv("APIP_AIW_LOG_LEVEL", "debug") @@ -112,7 +112,7 @@ func TestLoad_InterpolatesEnvToken(t *testing.T) { [ai_workspace] auth_mode = "oidc" -[ai_workspace.platform_api] +[ai_workspace.control_plane] url = "https://platform-api:9243" [ai_workspace.oidc] @@ -144,7 +144,7 @@ func TestLoad_InterpolatesFileToken(t *testing.T) { [ai_workspace] auth_mode = "oidc" -[ai_workspace.platform_api] +[ai_workspace.control_plane] url = "https://platform-api:9243" [ai_workspace.oidc] @@ -174,7 +174,7 @@ func TestLoad_FileTokenOutsideAllowlist_Errors(t *testing.T) { t.Setenv("APIP_CONFIG_FILE_SOURCE_ALLOWLIST", t.TempDir()) // a different directory cfgPath := writeConfig(t, ` -[ai_workspace.platform_api] +[ai_workspace.control_plane] url = "https://platform-api:9243" [ai_workspace.oidc] @@ -190,7 +190,7 @@ client_secret = '{{ file "`+outside+`" }}' // yield an empty secret. func TestLoad_MissingEnvToken_Errors(t *testing.T) { cfgPath := writeConfig(t, ` -[ai_workspace.platform_api] +[ai_workspace.control_plane] url = "https://platform-api:9243" [ai_workspace.oidc] @@ -208,15 +208,15 @@ client_secret = '{{ env "CUSTOM_SECRET_VAR" }}' } // The upstream URL is mandatory — the BFF has nothing to proxy to without it. -func TestLoad_MissingPlatformAPIURL_Errors(t *testing.T) { +func TestLoad_MissingControlPlaneURL_Errors(t *testing.T) { cfgPath := writeConfig(t, "[ai_workspace]\nlog_level = \"info\"") _, err := Load(cfgPath) if err == nil { - t.Fatal("Load() succeeded, want an error when [platform_api] url is unset") + t.Fatal("Load() succeeded, want an error when [control_plane] url is unset") } - if !strings.Contains(err.Error(), "[platform_api] url") { - t.Errorf("error = %v, want it to name [platform_api] url", err) + if !strings.Contains(err.Error(), "[control_plane] url") { + t.Errorf("error = %v, want it to name [control_plane] url", err) } } @@ -228,7 +228,7 @@ func TestLoad_RuntimeConfigExcludesServerSideKeys(t *testing.T) { auth_mode = "oidc" domain = "localhost:5380" -[ai_workspace.platform_api] +[ai_workspace.control_plane] url = "https://platform-api:9243" [ai_workspace.oidc] @@ -266,7 +266,7 @@ func TestLoad_BrowserSafeKeyUsesSameName(t *testing.T) { [ai_workspace] moesif_web_url = "https://moesif.example.com" -[ai_workspace.platform_api] +[ai_workspace.control_plane] url = "https://platform-api:9243" `) @@ -286,7 +286,7 @@ func TestLoad_BrowserSafeKeyFromEnvToken(t *testing.T) { [ai_workspace] domain = '{{ env "APIP_AIW_DOMAIN" "localhost:5380" }}' -[ai_workspace.platform_api] +[ai_workspace.control_plane] url = "https://platform-api:9243" `) t.Setenv("APIP_AIW_DOMAIN", "app.example.com") @@ -304,7 +304,7 @@ url = "https://platform-api:9243" // string, but a plain literal is naturally typed. Both forms must reach the same value. func TestLoad_BareTOMLScalars(t *testing.T) { cfgPath := writeConfig(t, ` -[ai_workspace.platform_api] +[ai_workspace.control_plane] url = "https://platform-api:9243" [ai_workspace.cookie] @@ -333,7 +333,7 @@ func TestLoad_SameKeyInDifferentTables(t *testing.T) { [ai_workspace] auth_mode = "oidc" -[ai_workspace.platform_api] +[ai_workspace.control_plane] url = "https://platform-api:9243" [ai_workspace.tls] @@ -363,15 +363,15 @@ redirect_url = "https://localhost:5380/api/auth/callback" // key. The BFF reads those keys for its own session mapping, and the browser-safe ones // reach the SPA under the matching APIP_AIW_OIDC_CLAIM_MAPPINGS_* names that // src/config.env.ts looks up. -func TestLoad_ClaimMappingsMirrorPlatformAPI(t *testing.T) { +func TestLoad_ClaimMappingsMirrorControlPlane(t *testing.T) { cfgPath := writeConfig(t, ` -[ai_workspace.platform_api] +[ai_workspace.control_plane] url = "https://platform-api:9243" [ai_workspace.oidc.claim_mappings] -organization_claim_name = "org_uuid" -username_claim_name = "given_name" -role_claim_name = "roles" +organization = "org_uuid" +username = "given_name" +role = "roles" `) cfg, err := Load(cfgPath) @@ -379,27 +379,27 @@ role_claim_name = "roles" t.Fatalf("Load() error = %v", err) } if cfg.OIDC.Claims.OrgID != "org_uuid" { - t.Errorf("Claims.OrgID = %q, want %q from organization_claim_name", cfg.OIDC.Claims.OrgID, "org_uuid") + t.Errorf("Claims.OrgID = %q, want %q from organization", cfg.OIDC.Claims.OrgID, "org_uuid") } if cfg.OIDC.Claims.Role != "roles" { - t.Errorf("Claims.Role = %q, want %q from role_claim_name", cfg.OIDC.Claims.Role, "roles") + t.Errorf("Claims.Role = %q, want %q from role", cfg.OIDC.Claims.Role, "roles") } - if got := cfg.RuntimeConfig["APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME"]; got != "org_uuid" { - t.Errorf("runtime APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME = %q, want %q", got, "org_uuid") + if got := cfg.RuntimeConfig["APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION"]; got != "org_uuid" { + t.Errorf("runtime APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION = %q, want %q", got, "org_uuid") } - if got := cfg.RuntimeConfig["APIP_AIW_OIDC_CLAIM_MAPPINGS_USERNAME_CLAIM_NAME"]; got != "given_name" { - t.Errorf("runtime APIP_AIW_OIDC_CLAIM_MAPPINGS_USERNAME_CLAIM_NAME = %q, want %q", got, "given_name") + if got := cfg.RuntimeConfig["APIP_AIW_OIDC_CLAIM_MAPPINGS_USERNAME"]; got != "given_name" { + t.Errorf("runtime APIP_AIW_OIDC_CLAIM_MAPPINGS_USERNAME = %q, want %q", got, "given_name") } - // role_claim_name drives the BFF's session mapping only — it must not be published. - if _, ok := cfg.RuntimeConfig["APIP_AIW_OIDC_CLAIM_MAPPINGS_ROLE_CLAIM_NAME"]; ok { - t.Error("role_claim_name must not reach the browser — it is not in the browser-safe allowlist") + // role drives the BFF's session mapping only — it must not be published. + if _, ok := cfg.RuntimeConfig["APIP_AIW_OIDC_CLAIM_MAPPINGS_ROLE"]; ok { + t.Error("role must not reach the browser — it is not in the browser-safe allowlist") } } // A malformed boolean must fail startup rather than fall back to the default. func TestLoad_InvalidBool_Errors(t *testing.T) { cfgPath := writeConfig(t, ` -[ai_workspace.platform_api] +[ai_workspace.control_plane] url = "https://platform-api:9243" [ai_workspace.cookie] diff --git a/portals/ai-workspace/bff/internal/config/runtime_config.go b/portals/ai-workspace/bff/internal/config/runtime_config.go index 1a32ebd62..b19d00e79 100644 --- a/portals/ai-workspace/bff/internal/config/runtime_config.go +++ b/portals/ai-workspace/bff/internal/config/runtime_config.go @@ -30,19 +30,19 @@ var browserSafeKeys = []string{ // always emits it from the parsed cfg.AuthMode instead. "domain", "default_org_region", - "controlplane_host", - "platform_gateway_versions", + "gateway.controlplane_host", + "gateway.platform_gateway_versions", "csrf_header", "debug", // Claim names the SPA displays user/org identity from. The keys mirror the // Platform API's [auth.idp.claim_mappings] exactly — same claim, same name. "oidc.scope", - "oidc.claim_mappings.username_claim_name", - "oidc.claim_mappings.email_claim_name", - "oidc.claim_mappings.organization_claim_name", - "oidc.claim_mappings.org_name_claim_name", - "oidc.claim_mappings.org_handle_claim_name", + "oidc.claim_mappings.username", + "oidc.claim_mappings.email", + "oidc.claim_mappings.organization", + "oidc.claim_mappings.org_name", + "oidc.claim_mappings.org_handle", // External links and SPA-only endpoints "dev_portal_base_url", diff --git a/portals/ai-workspace/bff/internal/config/settings.go b/portals/ai-workspace/bff/internal/config/settings.go index 2c7970f18..c1dcba5ca 100644 --- a/portals/ai-workspace/bff/internal/config/settings.go +++ b/portals/ai-workspace/bff/internal/config/settings.go @@ -41,11 +41,11 @@ import ( const EnvPrefix = "APIP_AIW_" // aiWorkspaceConfigKey is the top-level TOML table all AI Workspace settings live -// under (e.g. [ai_workspace], [ai_workspace.platform_api]). It mirrors the Platform +// under (e.g. [ai_workspace], [ai_workspace.control_plane]). It mirrors the Platform // API's platformAPIConfigKey: this namespacing lets an AI Workspace config file // coexist with sibling services' sections ([platform_api], ...) in a shared // deployment config, the same file convention as the Platform API's [platform_api] -// table. Every key below this cut (log_level, platform_api.url, oidc.*, ...) is +// table. Every key below this cut (log_level, control_plane.url, oidc.*, ...) is // resolved relative to [ai_workspace], not the file root. const aiWorkspaceConfigKey = "ai_workspace" @@ -60,7 +60,7 @@ var defaultFileSourceAllowlist = []string{ // settings is the fully-resolved configuration: the config.toml values under // [ai_workspace], with every {{ env }} / {{ file }} token expanded and flattened to // dotted paths relative to that table. A key is its path under [ai_workspace] joined -// with dots — [ai_workspace.platform_api] url becomes "platform_api.url" — and a key +// with dots — [ai_workspace.control_plane] url becomes "control_plane.url" — and a key // directly under [ai_workspace] keeps its bare name ("domain"). Sibling top-level // tables belonging to other services (e.g. [platform_api]) are ignored. type settings map[string]string @@ -78,7 +78,7 @@ type settings map[string]string // an empty credential. // // A missing config.toml is not an error — the built-in defaults still apply — but the -// required keys (platform_api_url) then have no value, so Load fails on them. +// required keys (control_plane.url) then have no value, so Load fails on them. func loadSettings(tomlPath string) (settings, error) { raw, err := parseTOML(tomlPath) if err != nil { 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 16513a259..515a8edb5 100644 --- a/portals/ai-workspace/bff/internal/config/shipped_config_test.go +++ b/portals/ai-workspace/bff/internal/config/shipped_config_test.go @@ -54,19 +54,19 @@ func TestShippedConfig_QuickstartLoadsWithNoEnv(t *testing.T) { if cfg.StaticDir != "/app" { t.Errorf("StaticDir = %q, want the container default %q", cfg.StaticDir, "/app") } - if cfg.PlatformAPI.URL != "https://platform-api:9243" { - t.Errorf("PlatformAPI.URL = %q, want the compose hostname", cfg.PlatformAPI.URL) + if cfg.ControlPlane.URL != "https://platform-api:9243" { + t.Errorf("ControlPlane.URL = %q, want the compose hostname", cfg.ControlPlane.URL) } // 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") + if cfg.ControlPlane.TLSSkipVerify { + t.Error("ControlPlane.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 + // docker-compose no longer injects APIP_AIW_CONTROL_PLANE_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") + if cfg.ControlPlane.CAFile != "/etc/ai-workspace/tls/cert.pem" { + t.Errorf("ControlPlane.CAFile = %q, want the docker-compose mount path %q", cfg.ControlPlane.CAFile, "/etc/ai-workspace/tls/cert.pem") } } @@ -77,8 +77,8 @@ func TestShippedConfig_QuickstartLoadsWithNoEnv(t *testing.T) { // /app. This test pins the Makefile's contract with the file. func TestShippedConfig_MakeBffRunOverrides(t *testing.T) { // Exactly the variables the bff-run target sets. - t.Setenv("APIP_AIW_PLATFORM_API_URL", "https://localhost:9243") - t.Setenv("APIP_AIW_PLATFORM_API_TLS_SKIP_VERIFY", "true") + t.Setenv("APIP_AIW_CONTROL_PLANE_URL", "https://localhost:9243") + t.Setenv("APIP_AIW_CONTROL_PLANE_TLS_SKIP_VERIFY", "true") t.Setenv("APIP_AIW_LISTEN_ADDR", ":8081") t.Setenv("APIP_AIW_STATIC_DIR", "../dist") t.Setenv("APIP_AIW_LOG_LEVEL", "debug") @@ -91,7 +91,7 @@ func TestShippedConfig_MakeBffRunOverrides(t *testing.T) { for _, tc := range []struct{ name, got, want string }{ {"Addr", cfg.Addr, ":8081"}, {"StaticDir", cfg.StaticDir, "../dist"}, - {"PlatformAPI.URL", cfg.PlatformAPI.URL, "https://localhost:9243"}, + {"ControlPlane.URL", cfg.ControlPlane.URL, "https://localhost:9243"}, {"LogLevel", cfg.LogLevel, "debug"}, } { if tc.got != tc.want { @@ -145,8 +145,8 @@ func TestShippedConfig_TemplateLoads(t *testing.T) { } // Unlike the quickstart file, the template defaults to verifying the upstream // certificate — it is the starting point for a real deployment. - if cfg.PlatformAPI.TLSSkipVerify { - t.Error("PlatformAPI.TLSSkipVerify = true, want false — the template must default to a verified upstream") + if cfg.ControlPlane.TLSSkipVerify { + t.Error("ControlPlane.TLSSkipVerify = true, want false — the template must default to a verified upstream") } } diff --git a/portals/ai-workspace/bff/internal/config/toml_test.go b/portals/ai-workspace/bff/internal/config/toml_test.go index e5017c18f..f5050cf47 100644 --- a/portals/ai-workspace/bff/internal/config/toml_test.go +++ b/portals/ai-workspace/bff/internal/config/toml_test.go @@ -39,7 +39,7 @@ client_secret = '{{ env "APIP_AIW_OIDC_CLIENT_SECRET" }}' greeting = "line1\nline2 \"quoted\" é" [oidc.claim_mappings] -organization_claim_name = "org_id" +organization = "org_id" `, "\n", "\r\n") got, err := parseTOMLSubset([]byte(doc)) @@ -58,7 +58,7 @@ organization_claim_name = "org_id" "client_secret": `{{ env "APIP_AIW_OIDC_CLIENT_SECRET" }}`, "greeting": "line1\nline2 \"quoted\" é", "claim_mappings": map[string]any{ - "organization_claim_name": "org_id", + "organization": "org_id", }, }, } diff --git a/portals/ai-workspace/bff/internal/proxy/transport.go b/portals/ai-workspace/bff/internal/proxy/transport.go index 806413302..ba3c7cc35 100644 --- a/portals/ai-workspace/bff/internal/proxy/transport.go +++ b/portals/ai-workspace/bff/internal/proxy/transport.go @@ -75,14 +75,14 @@ func NewTransport(opts TLSClientOptions) (*http.Transport, error) { func caPool(path string) (*x509.CertPool, error) { pem, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("read platform_api_ca_file %q: %w", path, err) + return nil, fmt.Errorf("read control_plane_ca_file %q: %w", path, err) } pool, err := x509.SystemCertPool() if err != nil || pool == nil { pool = x509.NewCertPool() } if !pool.AppendCertsFromPEM(pem) { - return nil, fmt.Errorf("no valid certificates in platform_api_ca_file %q", path) + return nil, fmt.Errorf("no valid certificates in control_plane_ca_file %q", path) } return pool, nil } diff --git a/portals/ai-workspace/bff/internal/server/composite_handlers.go b/portals/ai-workspace/bff/internal/server/composite_handlers.go index f9f642c31..b354cd9bb 100644 --- a/portals/ai-workspace/bff/internal/server/composite_handlers.go +++ b/portals/ai-workspace/bff/internal/server/composite_handlers.go @@ -67,7 +67,7 @@ func extractSecretHandles(body []byte) []string { // platformDo performs a single authenticated request against the Platform API, // returning the raw response. The caller is responsible for closing the body. func (s *Server) platformDo(ctx context.Context, jwt, method, path string, header http.Header, body []byte) (*http.Response, error) { - url := strings.TrimRight(s.cfg.PlatformAPI.URL, "/") + path + url := strings.TrimRight(s.cfg.ControlPlane.URL, "/") + path var reqBody io.Reader if len(body) > 0 { reqBody = bytes.NewReader(body) 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 9719b1665..76ab42049 100644 --- a/portals/ai-workspace/bff/internal/server/composite_handlers_test.go +++ b/portals/ai-workspace/bff/internal/server/composite_handlers_test.go @@ -101,7 +101,7 @@ func buildTestServer(t *testing.T, platformURL, jwt string) (*Server, *httptest. } cfg := &config.Config{ - PlatformAPI: config.PlatformAPIConfig{URL: platformURL}, + ControlPlane: config.ControlPlaneConfig{URL: platformURL}, ProxyPrefix: "/api/proxy", Cookie: config.CookieConfig{Name: "_ai_workspace_session"}, CSRFHeader: "X-Requested-By", @@ -140,7 +140,7 @@ type recordedRequest struct { auth string } -func fakePlatformAPI(t *testing.T, responses map[string]struct { +func fakeControlPlane(t *testing.T, responses map[string]struct { status int body string }) (*httptest.Server, *[]recordedRequest) { @@ -168,7 +168,7 @@ func fakePlatformAPI(t *testing.T, responses map[string]struct { } func TestHandleCreateWithSecretCompensation_Success(t *testing.T) { - platform, calls := fakePlatformAPI(t, map[string]struct { + platform, calls := fakeControlPlane(t, map[string]struct { status int body string }{ @@ -239,7 +239,7 @@ func TestHandleCreateWithSecretCompensation_ProviderFailTriggersDelete(t *testin } func TestHandleCreateWithSecretCompensation_NoSecretNoDelete(t *testing.T) { - platform, calls := fakePlatformAPI(t, map[string]struct { + platform, calls := fakeControlPlane(t, map[string]struct { status int body string }{ @@ -269,9 +269,9 @@ func TestHandleCreateWithSecretCompensation_NoSecretNoDelete(t *testing.T) { } func TestHandleCreateWithSecretCompensation_Unauthenticated(t *testing.T) { - platform, _ := fakePlatformAPI(t, nil) + platform, _ := fakeControlPlane(t, nil) cfg := &config.Config{ - PlatformAPI: config.PlatformAPIConfig{URL: platform.URL}, + ControlPlane: config.ControlPlaneConfig{URL: platform.URL}, ProxyPrefix: "/api/proxy", Cookie: config.CookieConfig{Name: "_ai_workspace_session"}, } diff --git a/portals/ai-workspace/bff/internal/server/server.go b/portals/ai-workspace/bff/internal/server/server.go index 3ee6fdf56..ce8fab882 100644 --- a/portals/ai-workspace/bff/internal/server/server.go +++ b/portals/ai-workspace/bff/internal/server/server.go @@ -60,14 +60,14 @@ type Server struct { // session store, the file-based authenticator, and (when enabled) the OIDC // authenticator — discovering the IDP endpoints up front. func New(ctx context.Context, cfg *config.Config) (*Server, error) { - target, err := url.Parse(cfg.PlatformAPI.URL) + target, err := url.Parse(cfg.ControlPlane.URL) if err != nil { return nil, err } transport, err := proxy.NewTransport(proxy.TLSClientOptions{ - CAFile: cfg.PlatformAPI.CAFile, - SkipVerify: cfg.PlatformAPI.TLSSkipVerify, + CAFile: cfg.ControlPlane.CAFile, + SkipVerify: cfg.ControlPlane.TLSSkipVerify, }) if err != nil { return nil, err @@ -76,7 +76,7 @@ func New(ctx context.Context, cfg *config.Config) (*Server, error) { s := &Server{ cfg: cfg, - fileBased: auth.NewFileBased(upstream, cfg.PlatformAPI.URL, cfg.PlatformAPI.LoginPath, cfg.Session.AbsoluteTTL), + fileBased: auth.NewFileBased(upstream, cfg.ControlPlane.URL, cfg.ControlPlane.LoginPath, cfg.Session.AbsoluteTTL), proxy: proxy.ReverseProxy(target, cfg.ProxyPrefix, transport), refreshLocks: make(map[string]*refreshLock), } diff --git a/portals/ai-workspace/bff/main.go b/portals/ai-workspace/bff/main.go index d32fc5358..212fd5724 100644 --- a/portals/ai-workspace/bff/main.go +++ b/portals/ai-workspace/bff/main.go @@ -133,7 +133,7 @@ func main() { "addr", cfg.Addr, "url", url, "auth_mode", cfg.AuthMode, - "platform_api", cfg.PlatformAPI.URL, + "control_plane", cfg.ControlPlane.URL, "oidc_enabled", cfg.OIDC.Enabled, ) printStartedMarker(url) diff --git a/portals/ai-workspace/configs/config-template.toml b/portals/ai-workspace/configs/config-template.toml index de6b70b26..1e9fae5b0 100644 --- a/portals/ai-workspace/configs/config-template.toml +++ b/portals/ai-workspace/configs/config-template.toml @@ -34,7 +34,7 @@ # prefixed with APIP_AIW_: # # [ai_workspace] domain -> APIP_AIW_DOMAIN -# [ai_workspace.platform_api] url -> APIP_AIW_PLATFORM_API_URL +# [ai_workspace.control_plane] url -> APIP_AIW_CONTROL_PLANE_URL # [ai_workspace.oidc] client_id -> APIP_AIW_OIDC_CLIENT_ID # # To source a value from a mounted file instead of the environment — the right @@ -88,20 +88,9 @@ 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 host:port that deployed gateways use to reach the Platform -# API. Shown in gateway setup instructions (keys.env, helm values). Must be the -# externally reachable address, not a relative/proxy path. -controlplane_host = '{{ env "APIP_AIW_CONTROLPLANE_HOST" "localhost:9243" }}' - # Default region assigned to new organizations on first login. default_org_region = '{{ env "APIP_AIW_DEFAULT_ORG_REGION" "us" }}' -# Gateway versions offered in the create-gateway version selector (JSON array string). -# Each entry: version (helm chart minor), latestVersion (image/chart tag shown to the -# user and used in the helm --version flag), channel ("STS" | "LTS"). The first entry -# is the default selection. -platform_gateway_versions = '{{ env "APIP_AIW_PLATFORM_GATEWAY_VERSIONS" "[{\"version\":\"1.2\",\"latestVersion\":\"v1.2.0-alpha2\",\"channel\":\"STS\"}]" }}' - # Verbose logging in the browser console. debug = '{{ env "APIP_AIW_DEBUG" "false" }}' @@ -136,27 +125,47 @@ log_format = '{{ env "APIP_AIW_LOG_FORMAT" "text" }}' # text | json # --------------------------------------------------------------------------- -# Upstream Platform API — the server-to-server hop the BFF proxies to. +# Upstream Platform API (control plane) — the server-to-server hop the BFF proxies to. # --------------------------------------------------------------------------- -[ai_workspace.platform_api] +[ai_workspace.control_plane] # Base URL used to reach the Platform API. REQUIRED. In docker compose this is the # compose hostname; running locally, use localhost. Its http/https scheme decides # whether the upstream hop uses TLS. -url = '{{ env "APIP_AIW_PLATFORM_API_URL" "https://platform-api:9243" }}' +url = '{{ env "APIP_AIW_CONTROL_PLANE_URL" "https://platform-api:9243" }}' # File-based login path on the Platform API (basic-auth mode). -login_path = '{{ env "APIP_AIW_PLATFORM_API_LOGIN_PATH" "/api/portal/v0.9/auth/login" }}' +login_path = '{{ env "APIP_AIW_CONTROL_PLANE_LOGIN_PATH" "/api/portal/v0.9/auth/login" }}' # --- Trust for the upstream's TLS certificate --- # 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" }}' +tls_skip_verify = '{{ env "APIP_AIW_CONTROL_PLANE_TLS_SKIP_VERIFY" "false" }}' # PEM bundle to trust for the upstream's TLS certificate. Appended to the system # roots, so public CAs keep working. -ca_file = '{{ env "APIP_AIW_PLATFORM_API_CA_FILE" "" }}' +ca_file = '{{ env "APIP_AIW_CONTROL_PLANE_CA_FILE" "" }}' + + +# --------------------------------------------------------------------------- +# Gateway deployment info — browser-safe values the SPA shows in gateway setup +# instructions. Distinct from [ai_workspace.control_plane] above: that table is the +# BFF's own server-to-server hop, while this one is what an externally deployed +# gateway needs to reach the Platform API itself. +# --------------------------------------------------------------------------- +[ai_workspace.gateway] + +# Control-plane host — the host:port that deployed gateways use to reach the Platform +# API. Shown in gateway setup instructions (keys.env, helm values). Must be the +# externally reachable address, not a relative/proxy path. +controlplane_host = '{{ env "APIP_AIW_GATEWAY_CONTROLPLANE_HOST" "localhost:9243" }}' + +# Gateway versions offered in the create-gateway version selector (JSON array string). +# Each entry: version (helm chart minor), latestVersion (image/chart tag shown to the +# user and used in the helm --version flag), channel ("STS" | "LTS"). The first entry +# is the default selection. +platform_gateway_versions = '{{ env "APIP_AIW_GATEWAY_PLATFORM_GATEWAY_VERSIONS" "[{\"version\":\"1.2\",\"latestVersion\":\"v1.2.0-alpha2\",\"channel\":\"STS\"}]" }}' # --------------------------------------------------------------------------- @@ -246,10 +255,10 @@ scope = '{{ env "APIP_AIW_OIDC_SCOPE" "" }}' # # This table mirrors [platform_api.auth.idp.claim_mappings] in # config-platform-api.toml key for key, and the two must agree: both services read -# the same claims out of the same token. The variables line up one-to-one as well: +# the same claims out of the same token. The variables it maps to: # -# APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME (AI Workspace) -# APIP_CP_AUTH_IDP_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME (Platform API) +# APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION (AI Workspace) +# APIP_CP_AUTH_IDP_CLAIM_ORGANIZATION (Platform API) # # Override only if your IDP uses claim names other than the defaults shown. # @@ -259,13 +268,13 @@ scope = '{{ env "APIP_AIW_OIDC_SCOPE" "" }}' # --------------------------------------------------------------------------- [ai_workspace.oidc.claim_mappings] -organization_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME" "org_id" }}' # claim carrying the org ID -org_name_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ORG_NAME_CLAIM_NAME" "org_name" }}' # org display name -org_handle_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ORG_HANDLE_CLAIM_NAME" "org_handle" }}' # org URL slug -username_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_USERNAME_CLAIM_NAME" "username" }}' -email_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_EMAIL_CLAIM_NAME" "email" }}' -scope_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_SCOPE_CLAIM_NAME" "scope" }}' # space-separated scope string -role_claim_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ROLE_CLAIM_NAME" "platform_role" }}' +organization = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION" "org_id" }}' # claim carrying the org ID +org_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ORG_NAME" "org_name" }}' # org display name +org_handle = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ORG_HANDLE" "org_handle" }}' # org URL slug +username = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_USERNAME" "username" }}' +email = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_EMAIL" "email" }}' +scope = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_SCOPE" "scope" }}' # space-separated scope string +role = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ROLE" "platform_role" }}' # ==================================================================== diff --git a/portals/ai-workspace/configs/config.toml b/portals/ai-workspace/configs/config.toml index bc8eb5462..aba33f906 100644 --- a/portals/ai-workspace/configs/config.toml +++ b/portals/ai-workspace/configs/config.toml @@ -11,20 +11,24 @@ auth_mode = '{{ env "APIP_AIW_AUTH_MODE" "basic" }}' domain = '{{ env "APIP_AIW_DOMAIN" "localhost:5380" }}' -controlplane_host = '{{ env "APIP_AIW_CONTROLPLANE_HOST" "host.docker.internal:9243" }}' default_org_region = '{{ env "APIP_AIW_DEFAULT_ORG_REGION" "us" }}' -platform_gateway_versions = '{{ env "APIP_AIW_PLATFORM_GATEWAY_VERSIONS" "[{\"version\":\"1.2\",\"latestVersion\":\"v1.2.0-alpha2\",\"channel\":\"STS\"}]" }}' 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 -[ai_workspace.platform_api] +[ai_workspace.control_plane] -url = '{{ env "APIP_AIW_PLATFORM_API_URL" "https://platform-api:9243" }}' +url = '{{ env "APIP_AIW_CONTROL_PLANE_URL" "https://platform-api:9243" }}' ca_file = "/etc/ai-workspace/tls/cert.pem" -tls_skip_verify = '{{ env "APIP_AIW_PLATFORM_API_TLS_SKIP_VERIFY" "false" }}' +tls_skip_verify = '{{ env "APIP_AIW_CONTROL_PLANE_TLS_SKIP_VERIFY" "false" }}' + + +[ai_workspace.gateway] + +controlplane_host = '{{ env "APIP_AIW_GATEWAY_CONTROLPLANE_HOST" "host.docker.internal:9243" }}' +platform_gateway_versions = '{{ env "APIP_AIW_GATEWAY_PLATFORM_GATEWAY_VERSIONS" "[{\"version\":\"1.2\",\"latestVersion\":\"v1.2.0-alpha2\",\"channel\":\"STS\"}]" }}' [ai_workspace.tls] diff --git a/portals/ai-workspace/distribution/README.md b/portals/ai-workspace/distribution/README.md index 3bde50c5e..309c1f9d4 100644 --- a/portals/ai-workspace/distribution/README.md +++ b/portals/ai-workspace/distribution/README.md @@ -79,11 +79,11 @@ Environment overrides go in `api-platform.env` (git-ignored; loaded into both co |---------|-------------| | `domain` | Host and port shown in the browser address bar | | `auth_mode` | `basic` (file-based quickstart) or `oidc` (external IDP) | -| `controlplane_host` | Address gateways use to reach the Platform API | -| `platform_gateway_versions` | Gateway versions shown in the create-gateway selector | -| `[ai_workspace.platform_api].url` | Base URL of the upstream Platform API hop | -| `[ai_workspace.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` | -| `[ai_workspace.platform_api].tls_skip_verify` | Skip upstream cert verification — local dev only | +| `[ai_workspace.control_plane].url` | Base URL of the upstream Platform API hop | +| `[ai_workspace.control_plane].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` | +| `[ai_workspace.control_plane].tls_skip_verify` | Skip upstream cert verification — local dev only | +| `[ai_workspace.gateway].controlplane_host` | Address gateways use to reach the Platform API | +| `[ai_workspace.gateway].platform_gateway_versions` | Gateway versions shown in the create-gateway selector | | `[ai_workspace.tls].cert_file` / `key_file` | Listener certificate pair — required when `[ai_workspace.tls].enabled` is `true`. Fixed to the mounted path, same as `ca_file` above | | `[ai_workspace.oidc].*` | Used only when `auth_mode = "oidc"` — see [OIDC](#oidc-production) below | diff --git a/portals/ai-workspace/production/README.md b/portals/ai-workspace/production/README.md index cbfb27b0b..264209a49 100644 --- a/portals/ai-workspace/production/README.md +++ b/portals/ai-workspace/production/README.md @@ -105,9 +105,9 @@ audience = [""] # Client ID from Asgardeo Protocol tab # Asgardeo-specific claim name overrides. [platform_api.auth.idp.claim_mappings] -organization_claim_name = "org_id" -org_name_claim_name = "org_name" -org_handle_claim_name = "org_handle" +organization = "org_id" +org_name = "org_name" +org_handle = "org_handle" ``` Optional overrides (defaults shown): @@ -117,10 +117,10 @@ Optional overrides (defaults shown): validation_mode = "scope" # or "role" for role-based auth [auth.idp.claim_mappings] -user_id_claim_name = "sub" -username_claim_name = "username" -email_claim_name = "email" -scope_claim_name = "scope" +user_id = "sub" +username = "username" +email = "email" +scope = "scope" ``` --- @@ -141,21 +141,22 @@ domain = "" # e.g. app.ex # Set to "oidc" for production (Asgardeo or any OIDC-compliant IDP). auth_mode = "oidc" -# Externally reachable host:port that deployed gateways use to reach the Platform API. -controlplane_host = "" - # Default region assigned to new organizations on first login. default_org_region = "us" -# Available gateway versions shown in the create-gateway version selector (JSON array string). -# Each entry: version (helm chart minor), latestVersion (image/chart tag), channel ("STS" | "LTS"). -platform_gateway_versions = '[{"version":"1.2","latestVersion":"v1.2.0-alpha2","channel":"STS"}]' - -[ai_workspace.platform_api] +[ai_workspace.control_plane] # The upstream the BFF proxies to, server-to-server. An origin, not a base path — the # browser never uses it: the SPA calls the same-origin proxy prefix and the BFF forwards. url = "https://" +[ai_workspace.gateway] +# Externally reachable host:port that deployed gateways use to reach the Platform API. +controlplane_host = "" + +# Available gateway versions shown in the create-gateway version selector (JSON array string). +# Each entry: version (helm chart minor), latestVersion (image/chart tag), channel ("STS" | "LTS"). +platform_gateway_versions = '[{"version":"1.2","latestVersion":"v1.2.0-alpha2","channel":"STS"}]' + [ai_workspace.oidc] # Issuer URL — the BFF auto-discovers OIDC endpoints from # {authority}/.well-known/openid-configuration. @@ -167,9 +168,9 @@ client_id = "" # JWT claim name mappings — this table mirrors [platform_api.auth.idp.claim_mappings] in # the Platform API config (section 2) key for key, and the two must agree. [ai_workspace.oidc.claim_mappings] -organization_claim_name = "org_id" -org_name_claim_name = "org_name" -org_handle_claim_name = "org_handle" +organization = "org_id" +org_name = "org_name" +org_handle = "org_handle" ``` The redirect URLs are ordinary `config.toml` keys. The **client secret is never written into the diff --git a/portals/ai-workspace/src/config.env.ts b/portals/ai-workspace/src/config.env.ts index e00aafcc3..db23952e9 100644 --- a/portals/ai-workspace/src/config.env.ts +++ b/portals/ai-workspace/src/config.env.ts @@ -46,9 +46,9 @@ export const OIDC_CLIENT_ID = getEnvOrDefault('APIP_AIW_OIDC_CLIENT_ID', ''); // JWT claim name mappings — configure to match your IDP's token structure. The names // mirror the Platform API's [auth.idp.claim_mappings] key for key, and must agree with // them: both sides read the same claims out of the same token. -export const OIDC_ORG_ID_CLAIM = getEnvOrDefault('APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION_CLAIM_NAME', 'org_id'); -export const OIDC_ORG_NAME_CLAIM = getEnvOrDefault('APIP_AIW_OIDC_CLAIM_MAPPINGS_ORG_NAME_CLAIM_NAME', 'org_name'); -export const OIDC_ORG_HANDLE_CLAIM = getEnvOrDefault('APIP_AIW_OIDC_CLAIM_MAPPINGS_ORG_HANDLE_CLAIM_NAME', 'org_handle'); +export const OIDC_ORG_ID_CLAIM = getEnvOrDefault('APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION', 'org_id'); +export const OIDC_ORG_NAME_CLAIM = getEnvOrDefault('APIP_AIW_OIDC_CLAIM_MAPPINGS_ORG_NAME', 'org_name'); +export const OIDC_ORG_HANDLE_CLAIM = getEnvOrDefault('APIP_AIW_OIDC_CLAIM_MAPPINGS_ORG_HANDLE', 'org_handle'); // Default region used when auto-registering an organization on first login. export const DEFAULT_ORG_REGION = getEnvOrDefault('APIP_AIW_DEFAULT_ORG_REGION', 'us'); @@ -139,7 +139,7 @@ export interface GatewayVersionEntry { } export const PLATFORM_GATEWAY_VERSIONS = getEnvOrDefault( - 'APIP_AIW_PLATFORM_GATEWAY_VERSIONS', + 'APIP_AIW_GATEWAY_PLATFORM_GATEWAY_VERSIONS', [ { version: '1.2', latestVersion: 'v1.2.0-alpha2', channel: 'STS' } ] @@ -172,7 +172,7 @@ export const BFF_COMPOSITE_BASE_URL = '/api/bff'; // Control-plane host shown in gateway setup instructions (host:port). // Distinct from PLATFORM_API_BASE_URL which may be a relative nginx proxy path. export const CONTROLPLANE_HOST = getEnvOrDefault( - 'APIP_AIW_CONTROLPLANE_HOST', + 'APIP_AIW_GATEWAY_CONTROLPLANE_HOST', 'host.docker.internal:9243' ); @@ -191,8 +191,8 @@ export const CSRF_VALUE = 'ai-workspace'; // The defaults mirror the BFF's [oidc.claim_mappings] defaults, so both sides read the // same claim when the key is left unset. // Common alternatives: 'name', 'given_name', 'preferred_username' (Keycloak), 'upn' (Azure AD) -export const OIDC_USERNAME_CLAIM = getEnvOrDefault('APIP_AIW_OIDC_CLAIM_MAPPINGS_USERNAME_CLAIM_NAME', 'username'); -export const OIDC_EMAIL_CLAIM = getEnvOrDefault('APIP_AIW_OIDC_CLAIM_MAPPINGS_EMAIL_CLAIM_NAME', 'email'); +export const OIDC_USERNAME_CLAIM = getEnvOrDefault('APIP_AIW_OIDC_CLAIM_MAPPINGS_USERNAME', 'username'); +export const OIDC_EMAIL_CLAIM = getEnvOrDefault('APIP_AIW_OIDC_CLAIM_MAPPINGS_EMAIL', 'email'); // Auth mode: 'basic' (default) posts credentials to /api/portal/v0.9/auth/login; 'oidc' uses react-oidc-context. export const AUTH_MODE = getEnvOrDefault('APIP_AIW_AUTH_MODE', 'basic') as 'oidc' | 'basic'; diff --git a/portals/ai-workspace/vite.config.ts b/portals/ai-workspace/vite.config.ts index ea1b68732..4cbc31c9f 100644 --- a/portals/ai-workspace/vite.config.ts +++ b/portals/ai-workspace/vite.config.ts @@ -69,8 +69,8 @@ const browserSafeEnvVars = [ 'APIP_AIW_DOMAIN', 'APIP_AIW_AUTH_MODE', 'APIP_AIW_DEFAULT_ORG_REGION', - 'APIP_AIW_CONTROLPLANE_HOST', - 'APIP_AIW_PLATFORM_GATEWAY_VERSIONS', + 'APIP_AIW_GATEWAY_CONTROLPLANE_HOST', + 'APIP_AIW_GATEWAY_PLATFORM_GATEWAY_VERSIONS', 'APIP_AIW_CSRF_HEADER', 'APIP_AIW_DEBUG', 'APIP_AIW_OIDC_SCOPE', From 3988959118bc27b500330e29f648f303494feb37 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Tue, 21 Jul 2026 15:34:55 +0530 Subject: [PATCH 08/18] Refactor AI Workspace authentication configuration and update documentation --- .github/workflows/ai-workspace-pr-check.yml | 2 +- docs/ai-workspace/authentication/README.md | 2 +- .../authentication/asgardeo-setup.md | 32 +- .../authentication/file-based-auth.md | 32 +- docs/ai-workspace/authentication/oidc-auth.md | 50 ++- docs/ai-workspace/configuration.md | 70 ++-- platform-api/README.md | 31 +- platform-api/cmd/main.go | 6 +- platform-api/config/config-template.toml | 300 +++++++++--------- platform-api/config/config.go | 154 +++++---- platform-api/config/config.toml | 2 + platform-api/config/config_test.go | 52 +-- platform-api/config/default_config.go | 64 ++-- platform-api/internal/database/connection.go | 24 +- platform-api/internal/handler/auth_login.go | 35 +- platform-api/internal/handler/websocket.go | 39 +-- .../internal/integration/harness_test.go | 2 +- platform-api/internal/middleware/auth.go | 42 ++- .../repository/subscription_repository.go | 8 +- platform-api/internal/server/server.go | 67 ++-- platform-api/internal/webhook/envelope.go | 1 - platform-api/internal/webhook/receiver.go | 11 +- platform-api/internal/websocket/errors.go | 37 --- platform-api/internal/websocket/manager.go | 106 ++----- portals/ai-workspace/Makefile | 6 +- portals/ai-workspace/README.md | 93 +++--- .../bff/internal/auth/filebased.go | 14 +- .../bff/internal/config/config.go | 154 +++++---- .../bff/internal/config/config_test.go | 88 ++--- .../bff/internal/config/runtime_config.go | 27 +- .../bff/internal/config/settings.go | 12 +- .../internal/config/shipped_config_test.go | 43 +-- .../bff/internal/proxy/reverse_proxy.go | 2 +- .../bff/internal/proxy/reverse_proxy_test.go | 8 +- .../server/composite_handlers_test.go | 9 +- .../bff/internal/server/handlers.go | 4 +- .../bff/internal/server/middleware.go | 4 +- .../bff/internal/server/middleware_test.go | 4 +- .../bff/internal/server/server.go | 25 +- .../bff/internal/session/claims.go | 12 +- .../ai-workspace/configs/config-template.toml | 252 ++++++--------- portals/ai-workspace/configs/config.toml | 21 +- .../001-provider-and-proxy.cy.js | 16 +- .../002-provider-secret-management.cy.js | 22 +- .../003-llm-proxy-secret-management.cy.js | 24 +- .../002-mcp-proxy-sample-url.cy.js | 12 +- .../003-mcp-secret-management.cy.js | 16 +- .../004-mcp-secret-management-update.cy.js | 16 +- .../005-custom-provider-template.cy.js | 10 +- .../ai-workspace/cypress/support/commands.js | 12 +- portals/ai-workspace/distribution/README.md | 33 +- portals/ai-workspace/production/README.md | 34 +- portals/ai-workspace/src/config.env.ts | 59 ++-- .../src/contexts/OIDCAppAuthProvider.tsx | 20 +- portals/ai-workspace/src/utils/logger.ts | 2 +- portals/ai-workspace/vite.config.ts | 11 +- .../it/configs/config-platform-api-it.toml | 3 + .../integration-e2e/platform-api-config.toml | 9 +- 58 files changed, 1108 insertions(+), 1138 deletions(-) delete mode 100644 platform-api/internal/websocket/errors.go diff --git a/.github/workflows/ai-workspace-pr-check.yml b/.github/workflows/ai-workspace-pr-check.yml index e72700317..437eb8278 100644 --- a/.github/workflows/ai-workspace-pr-check.yml +++ b/.github/workflows/ai-workspace-pr-check.yml @@ -84,7 +84,7 @@ jobs: # requests; 502/000 means it is still warming up. run: | for i in $(seq 1 30); do - STATUS=$(curl -sk -o /dev/null -w "%{http_code}" https://host.docker.internal:5380/api/proxy/api/v0.9/organizations) + STATUS=$(curl -sk -o /dev/null -w "%{http_code}" https://host.docker.internal:5380/proxy/api/v0.9/organizations) if [ "$STATUS" = "200" ] || [ "$STATUS" = "401" ] || [ "$STATUS" = "403" ]; then echo "API proxy ready (HTTP $STATUS)" exit 0 diff --git a/docs/ai-workspace/authentication/README.md b/docs/ai-workspace/authentication/README.md index 5cdbe2fe5..87673c055 100644 --- a/docs/ai-workspace/authentication/README.md +++ b/docs/ai-workspace/authentication/README.md @@ -1,6 +1,6 @@ # Authentication -AI Workspace supports two authentication modes, configured via the `auth_mode` key in `config.toml`. +AI Workspace supports two authentication modes, configured via the `mode` key in `[ai_workspace.auth]` in `config.toml`. ## Modes diff --git a/docs/ai-workspace/authentication/asgardeo-setup.md b/docs/ai-workspace/authentication/asgardeo-setup.md index a4df8b475..93c19b65a 100644 --- a/docs/ai-workspace/authentication/asgardeo-setup.md +++ b/docs/ai-workspace/authentication/asgardeo-setup.md @@ -117,23 +117,19 @@ In each sub-organization that should have access: Update `configs/config-platform-api.toml`: ```toml -[auth.jwt] -enabled = false +[auth] +mode = "idp" [auth.idp] -enabled = true name = "asgardeo" jwks_url = "https://api.asgardeo.io/t//oauth2/jwks" issuer = ["https://api.asgardeo.io/t//oauth2/token"] audience = [""] -[auth.idp.claim_mappings] +[auth.claim_mappings] organization = "org_id" org_name = "org_name" org_handle = "org_handle" - -[auth.file_based] -enabled = false ``` > Asgardeo uses `org_id` as the claim for the organization UUID. The Platform API defaults to `organization`, so the claim name override above is required. @@ -147,7 +143,6 @@ Update `configs/config.toml`: ```toml [ai_workspace] domain = "" -auth_mode = "oidc" default_org_region = "us" [ai_workspace.control_plane] @@ -156,7 +151,10 @@ url = "https://" [ai_workspace.gateway] controlplane_host = "" -[ai_workspace.oidc] +[ai_workspace.auth] +mode = "oidc" + +[ai_workspace.auth.oidc] authority = "https://api.asgardeo.io/t//oauth2/token" client_id = "" @@ -165,13 +163,13 @@ redirect_url = "https:///api/auth/callback" # the BFF post_logout_redirect_url = "https:///login" # 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. +# token for '{{ env "APIP_AIW_AUTH_OIDC_CLIENT_SECRET" }}': the key needs one token or the other. client_secret = '{{ file "/secrets/ai-workspace/oidc_client_secret" }}' -# Mirrors [platform_api.auth.idp.claim_mappings] in config-platform-api.toml — the two must -# agree. Must stay the last table under [ai_workspace.oidc]: plain [ai_workspace.oidc] keys -# placed below this header would land in [ai_workspace.oidc.claim_mappings] instead. -[ai_workspace.oidc.claim_mappings] +# Mirrors [platform_api.auth.claim_mappings] in config-platform-api.toml — the two must +# agree. A sibling of [ai_workspace.auth.oidc], not nested in it: this table applies to +# both auth modes, since basic-mode tokens are signed using these same mapped claim names. +[ai_workspace.auth.claim_mappings] organization = "org_id" org_name = "org_name" org_handle = "org_handle" @@ -181,7 +179,7 @@ The redirect URLs and the client secret are BFF settings and never reach the bro redirect URLs are ordinary `config.toml` keys; the secret is referenced with an interpolation token so the raw value never lands in the file. -> `[ai_workspace.oidc] redirect_url` must exactly match the authorized redirect URL registered in section 2. +> `[ai_workspace.auth.oidc] redirect_url` must exactly match the authorized redirect URL registered in section 2. > A missing client secret fails startup — see [Configuration → Secrets](../configuration.md#secrets). --- @@ -199,5 +197,5 @@ Asgardeo token The claim names must be consistent across all three places: - Asgardeo token mapper output -- `[ai_workspace.oidc.claim_mappings]` in `config.toml` -- The matching keys in Platform API `[auth.idp.claim_mappings]` +- `[ai_workspace.auth.claim_mappings]` in `config.toml` +- The matching keys in Platform API `[auth.claim_mappings]` diff --git a/docs/ai-workspace/authentication/file-based-auth.md b/docs/ai-workspace/authentication/file-based-auth.md index c3ae309ca..e282eb2cb 100644 --- a/docs/ai-workspace/authentication/file-based-auth.md +++ b/docs/ai-workspace/authentication/file-based-auth.md @@ -4,31 +4,32 @@ File-based auth (also called `basic` mode) stores a user list in the Platform AP ## How It Works -When `auth_mode = "basic"`, the AI Workspace login page renders a username/password form. Credentials are sent to the Platform API, which validates them against a hashed user list in `config-platform-api.toml`. On success, the Platform API issues a signed JWT that the UI stores and sends with subsequent API requests. +When `[ai_workspace.auth] mode = "basic"`, the AI Workspace login page renders a username/password form. Credentials are sent to the Platform API, which validates them against a hashed user list in `config-platform-api.toml`. On success, the Platform API issues a signed JWT that the UI stores and sends with subsequent API requests. ## Configuration ### 1. Set auth mode in `configs/config.toml` ```toml -auth_mode = "basic" +[ai_workspace.auth] +mode = "basic" ``` ### 2. Define users in `configs/config-platform-api.toml` ```toml -[auth.file_based] -enabled = true +[auth] +mode = "file" -[[auth.file_based.users]] -username = "admin" +[[auth.file.users]] +username = "admin" password_hash = "$2a$10$..." # bcrypt hash of the password -role = "admin" +scopes = "ap:organization:manage ap:gateway:manage ..." # space-separated ap:* scopes -[[auth.file_based.users]] -username = "viewer" +[[auth.file.users]] +username = "viewer" password_hash = "$2a$10$..." -role = "viewer" +scopes = "ap:organization:read ap:gateway:read" ``` ### 3. Generate password hashes @@ -59,13 +60,14 @@ The Quick Start bundle ships with a default `admin` / `admin` credential — **c In file-based mode, all users belong to a single organization defined in `config-platform-api.toml`: ```toml -[auth.file_based.organization] -id = "" # Leave empty to auto-generate a UUID on first start -name = "My Organization" -handle = "my-org" +[auth.file.organization] +id = "default" # organization handle (URL-safe slug) +display_name = "My Organization" +region = "us" +uuid = "" # Leave empty to auto-generate a UUID on first start ``` -If `id` is left empty, the Platform API generates a stable UUID on first startup and writes it back to the config. This UUID persists across restarts as long as the config file is preserved. +If `uuid` is left empty, the Platform API generates a stable UUID on first startup. Pin it to keep the organization stable across fresh databases. ## Limitations diff --git a/docs/ai-workspace/authentication/oidc-auth.md b/docs/ai-workspace/authentication/oidc-auth.md index ef004d5e2..0baa01f12 100644 --- a/docs/ai-workspace/authentication/oidc-auth.md +++ b/docs/ai-workspace/authentication/oidc-auth.md @@ -23,8 +23,7 @@ Tested IDPs: [Asgardeo](asgardeo-setup.md), Keycloak, Auth0, Okta. ```toml [ai_workspace] -domain = "app.example.com" -auth_mode = "oidc" +domain = "app.example.com" [ai_workspace.control_plane] # The upstream the BFF proxies to (an origin — the API paths are appended by the proxy). @@ -33,7 +32,10 @@ url = "https://api.example.com" [ai_workspace.gateway] controlplane_host = "api.example.com" -[ai_workspace.oidc] +[ai_workspace.auth] +mode = "oidc" + +[ai_workspace.auth.oidc] # IDP issuer URL — the discovery doc is fetched from {authority}/.well-known/openid-configuration authority = "https://idp.example.com/realms/my-realm" @@ -49,12 +51,12 @@ post_logout_redirect_url = "https:///login" client_secret = '{{ file "/secrets/ai-workspace/oidc_client_secret" }}' # JWT claim names for organization identity. This table mirrors -# [platform_api.auth.idp.claim_mappings] in the Platform API config (below) key for +# [platform_api.auth.claim_mappings] in the Platform API config (below) key for # key — both services read the same claims out of the same token, so the two must -# agree. Must stay the last table under [ai_workspace.oidc]: plain [ai_workspace.oidc] -# keys placed below this header would land in [ai_workspace.oidc.claim_mappings] -# instead. -[ai_workspace.oidc.claim_mappings] +# agree. A sibling of [ai_workspace.auth.oidc], not nested in it: this table applies +# to both auth modes, since basic-mode tokens are signed using these same mapped +# claim names. +[ai_workspace.auth.claim_mappings] organization = "org_id" org_name = "org_name" org_handle = "org_handle" @@ -64,46 +66,40 @@ The redirect URLs and the client secret are BFF settings — they are **never se The redirect URLs are ordinary `config.toml` keys; the secret is *referenced* by the config rather 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 +For a simpler local setup, swap the `{{ file }}` token for `'{{ env "APIP_AIW_AUTH_OIDC_CLIENT_SECRET" }}'` and keep 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). -`[ai_workspace.oidc] redirect_url` (the BFF callback `/api/auth/callback`) and `post_logout_redirect_url` +`[ai_workspace.auth.oidc] redirect_url` (the BFF callback `/api/auth/callback`) and `post_logout_redirect_url` must be registered as allowed redirect URIs in your IDP application. The redirect is **not** the SPA `/signin` route — the BFF, not the browser, completes the code exchange. ### Platform API (`configs/config-platform-api.toml`) ```toml -# Disable local JWT signing — tokens come from the IDP -[auth.jwt] -enabled = false +# Validate tokens against the IDP's JWKS instead of signing locally +[auth] +mode = "idp" -# Enable JWKS-based validation of IDP tokens [auth.idp] -enabled = true name = "my-idp" jwks_url = "https://idp.example.com/realms/my-realm/protocol/openid-connect/certs" issuer = ["https://idp.example.com/realms/my-realm"] -audience = ["ai-workspace"] # must match [ai_workspace.oidc] client_id +audience = ["ai-workspace"] # must match [ai_workspace.auth.oidc] client_id # Map IDP-specific claim names to Platform API's expected fields -# These must match the [ai_workspace.oidc.claim_mappings] values in config.toml above -[auth.idp.claim_mappings] +# These must match the [ai_workspace.auth.claim_mappings] values in config.toml above +[auth.claim_mappings] organization = "org_id" org_name = "org_name" org_handle = "org_handle" - -# Disable file-based auth -[auth.file_based] -enabled = false ``` Optional claim overrides (defaults shown): ```toml -[auth.idp.claim_mappings] +[auth.claim_mappings] user_id = "sub" username = "username" email = "email" @@ -143,16 +139,16 @@ You must register the `ap:*` scopes as an API resource in your IDP and grant the **Users see a blank screen or redirect loop after login** - Verify `domain` in `config.toml` matches the actual host:port in the browser. - Verify the redirect URI `https:///api/auth/callback` (the BFF callback) is registered - in the IDP and matches `[ai_workspace.oidc] redirect_url`. + in the IDP and matches `[ai_workspace.auth.oidc] redirect_url`. **Token endpoint rejects the BFF with `unauthorized_client` / "not authorized to use the requested grant type"** - The app is registered as a public/SPA client. Re-register it as a **confidential** client - (authorization-code + refresh-token grants, PKCE) and set `[ai_workspace.oidc] client_secret`. + (authorization-code + refresh-token grants, PKCE) and set `[ai_workspace.auth.oidc] client_secret`. **Platform API returns 401** - Check that `jwks_url` and `issuer` in Platform API config match the IDP's discovery doc values. -- Check that `audience` matches the `[ai_workspace.oidc] client_id` of the confidential application. -- Ensure `organization` matches on both sides — `[platform_api.auth.idp.claim_mappings]` in the Platform API and `[ai_workspace.oidc.claim_mappings]` in AI Workspace. +- Check that `audience` matches the `[ai_workspace.auth.oidc] client_id` of the confidential application. +- Ensure `organization` matches on both sides — `[platform_api.auth.claim_mappings]` in the Platform API and `[ai_workspace.auth.claim_mappings]` in AI Workspace. **"Organization not found" error** - The `org_id` claim in the token does not match any organization in Platform API's database. diff --git a/docs/ai-workspace/configuration.md b/docs/ai-workspace/configuration.md index f4a95e121..c89ab6e49 100644 --- a/docs/ai-workspace/configuration.md +++ b/docs/ai-workspace/configuration.md @@ -2,19 +2,19 @@ AI Workspace is configured through a `config.toml` file mounted into the container at `/etc/ai-workspace/config.toml`. -All AI Workspace settings live under a single top-level `[ai_workspace]` table — the same namespacing convention the Platform API uses for its own `[platform_api]` table — so one `config.toml` can hold both services' sections side by side without their keys colliding. Keys are grouped into TOML tables (`[ai_workspace.control_plane]`, `[ai_workspace.tls]`, `[ai_workspace.session]`, `[ai_workspace.cookie]`, `[ai_workspace.oidc]`); deployment-identity keys such as `domain` and `auth_mode` sit directly under `[ai_workspace]`. +All AI Workspace settings live under a single top-level `[ai_workspace]` table — the same namespacing convention the Platform API uses for its own `[platform_api]` table — so one `config.toml` can hold both services' sections side by side without their keys colliding. Keys are grouped into TOML tables (`[ai_workspace.logging]`, `[ai_workspace.control_plane]`, `[ai_workspace.tls]`, `[ai_workspace.session]`, `[ai_workspace.auth]`, `[ai_workspace.auth.oidc]`); deployment-identity keys such as `domain` sit directly under `[ai_workspace]`. The session cookie's name, `Secure`, and `SameSite` attributes are not configurable — they are internal details of the BFF's session mechanism. The file is the **only** source of configuration. Each value in it is written as an interpolation token that is resolved once at startup, so where the value comes from is visible in place: ```toml -[ai_workspace.oidc] -client_id = '{{ env "APIP_AIW_OIDC_CLIENT_ID" "default" }}' +[ai_workspace.auth.oidc] +client_id = '{{ env "APIP_AIW_AUTH_OIDC_CLIENT_ID" "default" }}' # ^ environment variable ^ value used when the variable is unset ``` A key written this way can be set from the environment without editing the file. That token is the *only* thing that lets an environment variable reach a config key — there is no implicit override, so a key written as a plain literal (`key = "value"`), or absent from the file, ignores the variable entirely. Add the key with a token to make it settable that way. -By convention the variable a token names is the key's path **under `[ai_workspace]`** (the `ai_workspace` segment itself is not part of the name) — table and key, uppercased, dots as underscores — prefixed with **`APIP_AIW_`**: `[ai_workspace.oidc] client_id` → `APIP_AIW_OIDC_CLIENT_ID`, `[ai_workspace.control_plane] url` → `APIP_AIW_CONTROL_PLANE_URL`, and `[ai_workspace] 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. +By convention the variable a token names is the key's path **under `[ai_workspace]`** (the `ai_workspace` segment itself is not part of the name) — table and key, uppercased, dots as underscores — prefixed with **`APIP_AIW_`**: `[ai_workspace.auth.oidc] client_id` → `APIP_AIW_AUTH_OIDC_CLIENT_ID`, `[ai_workspace.control_plane] url` → `APIP_AIW_CONTROL_PLANE_URL`, and `[ai_workspace.logging] 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). 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). @@ -27,14 +27,14 @@ Never write a secret as a literal in `config.toml`, and never hardcode one in `d **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 -[ai_workspace.oidc] -client_secret = '{{ env "APIP_AIW_OIDC_CLIENT_SECRET" }}' +[ai_workspace.auth.oidc] +client_secret = '{{ env "APIP_AIW_AUTH_OIDC_CLIENT_SECRET" }}' ``` **Mounted secret file (preferred in production)** — swap the token so the value never enters the environment at all: ```toml -[ai_workspace.oidc] +[ai_workspace.auth.oidc] client_secret = '{{ file "/secrets/ai-workspace/oidc_client_secret" }}' ``` @@ -52,9 +52,20 @@ map of what each table is for. | Key | Description | |-----|-------------| | `domain` | Host (and optional port) shown in the browser address bar. | -| `auth_mode` | Authentication mode. `"basic"` for file-based local auth; `"oidc"` for external IDP. | | `default_org_region` | Default region label assigned to new organizations on first login. | + +### `[ai_workspace.logging]` + +| Key | Description | +|-----|-------------| | `log_level` | `debug` \| `info` \| `warn` \| `error`. | +| `log_format` | `text` \| `json`. | + +### `[ai_workspace.auth]` — login mode + +| Key | Description | +|-----|-------------| +| `mode` | Authentication mode. `"basic"` for file-based local auth; `"oidc"` for external IDP. | ### `[ai_workspace.control_plane]` — the upstream hop @@ -75,7 +86,7 @@ reach the Platform API itself. | `controlplane_host` | Externally reachable `host:port` that deployed gateways use to reach the Platform API. Shown in gateway setup instructions. Must be an absolute address, not a relative path. | | `platform_gateway_versions` | Gateway versions offered in the create-gateway version selector (JSON array string). | -### `[ai_workspace.oidc]` (only required when `auth_mode = "oidc"`) +### `[ai_workspace.auth.oidc]` (only required when `[ai_workspace.auth] mode = "oidc"`) | Key | Description | |-----|-------------| @@ -85,9 +96,9 @@ reach the Platform API itself. | `redirect_url` | The BFF callback, e.g. `https:///api/auth/callback`. | | `post_logout_redirect_url` | Post-logout URL, e.g. `https:///login`. Must be an absolute, pre-registered URL. | -### `[ai_workspace.oidc.claim_mappings]` — which token claim carries each field +### `[ai_workspace.auth.claim_mappings]` — which token claim carries each field -This table mirrors the Platform API's `[platform_api.auth.idp.claim_mappings]` key for key, and the two must agree: both services read the same claims out of the same token. +A sibling of `[ai_workspace.auth.oidc]`, not nested inside it: this table applies to **both** auth modes. In basic mode the Platform API's file-based login endpoint signs its JWTs using these same mapped claim names, so the BFF reads basic-mode tokens by this mapping too. It mirrors the Platform API's `[platform_api.auth.claim_mappings]` key for key, and the two must agree: both services read the same claims out of the same token. | Key | Description | |-----|-------------| @@ -97,29 +108,29 @@ This table mirrors the Platform API's `[platform_api.auth.idp.claim_mappings]` k | `username` | Claim carrying the display name. | | `email` | Claim carrying the email address. | | `scope` | Claim carrying the space-separated scope string. | -| `role` | Claim carrying the platform role. Server-side only — not published to the browser. | - -`[ai_workspace.oidc.claim_mappings]` must be the **last** table under `[ai_workspace.oidc]`: in TOML a sub-table header ends the parent table's key section, so a plain `[ai_workspace.oidc]` key written below it would land in the sub-table instead. +| `roles` | Claim carrying the platform role. Server-side only — not published to the browser. | -`[ai_workspace.oidc] redirect_url` and `post_logout_redirect_url` must be registered as authorized redirect +`[ai_workspace.auth.oidc] redirect_url` and `post_logout_redirect_url` must be registered as authorized redirect URLs in your IDP application. The sign-in redirect is the **BFF callback** `/api/auth/callback` (the BFF, not the browser, completes the code exchange) — not a `/signin` route. -The remaining tables (`[ai_workspace.tls]`, `[ai_workspace.session]`, `[ai_workspace.cookie]`) and the `[ai_workspace]` listener keys are documented inline in +The remaining tables (`[ai_workspace.tls]`, `[ai_workspace.session]`) and the `[ai_workspace]` listener keys are documented inline in [`configs/config-template.toml`](../../portals/ai-workspace/configs/config-template.toml). ## Minimal Quick-Start Config (basic auth) ```toml [ai_workspace] -domain = "localhost:8080" -auth_mode = "basic" +domain = "localhost:8080" [ai_workspace.control_plane] url = "https://localhost:9243" [ai_workspace.gateway] controlplane_host = "localhost:9243" + +[ai_workspace.auth] +mode = "basic" ``` ## Minimal Production Config (OIDC) @@ -127,7 +138,6 @@ controlplane_host = "localhost:9243" ```toml [ai_workspace] domain = "app.example.com" -auth_mode = "oidc" default_org_region = "us" [ai_workspace.control_plane] @@ -136,14 +146,18 @@ url = "https://api.example.com" [ai_workspace.gateway] controlplane_host = "api.example.com" -[ai_workspace.oidc] +[ai_workspace.auth] +mode = "oidc" + +[ai_workspace.auth.oidc] authority = "https://api.asgardeo.io/t//oauth2/token" client_id = "" client_secret = '{{ file "/secrets/ai-workspace/oidc_client_secret" }}' redirect_url = "https://app.example.com/api/auth/callback" -# Mirrors [platform_api.auth.idp.claim_mappings] in the Platform API config — the two must agree. -[ai_workspace.oidc.claim_mappings] +# Mirrors [platform_api.auth.claim_mappings] in the Platform API config — the two must agree. +# Applies to both auth modes, so it's a sibling of [ai_workspace.auth.oidc], not nested in it. +[ai_workspace.auth.claim_mappings] organization = "org_id" org_name = "org_name" org_handle = "org_handle" @@ -154,23 +168,19 @@ org_handle = "org_handle" The Platform API has its own config file: `configs/config-platform-api.toml` (mounted at `/etc/platform-api/config.toml`). Key sections: ```toml -[auth.jwt] -enabled = false # Disable local JWT signing when using an external IDP +[auth] +mode = "idp" # "external_token", "file", or "idp" — exactly one mode is active [auth.idp] -enabled = true name = "asgardeo" jwks_url = "https://api.asgardeo.io/t//oauth2/jwks" issuer = ["https://api.asgardeo.io/t//oauth2/token"] audience = [""] -[auth.idp.claim_mappings] +[auth.claim_mappings] organization = "org_id" org_name = "org_name" org_handle = "org_handle" - -[auth.file_based] -enabled = false # Disable file-based auth in production ``` Never write sensitive values (JWT signing key, encryption key, database password) as raw @@ -195,7 +205,7 @@ Example — override just the authority for a staging environment: ```bash docker run \ - -e APIP_AIW_OIDC_AUTHORITY=https://api.asgardeo.io/t/staging-tenant/oauth2/token \ + -e APIP_AIW_AUTH_OIDC_AUTHORITY=https://api.asgardeo.io/t/staging-tenant/oauth2/token \ -v ./configs/config.toml:/etc/ai-workspace/config.toml \ ghcr.io/wso2/api-platform/ai-workspace: ``` diff --git a/platform-api/README.md b/platform-api/README.md index 054624af7..57e251d69 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -42,7 +42,7 @@ driver = "sqlserver" host = "sqlserver.example.internal" port = "1433" name = "platform_api" -user = "sa" +username = "sa" password = '{{ env "DB_PASSWORD" }}' # or '{{ file "/secrets/platform-api/db_password" }}' ssl_mode = "disable" ``` @@ -269,16 +269,19 @@ All settings live under `[platform_api]` / `[platform_api.*]`. The main sections | Section | Purpose | |---|---| -| `[platform_api]` | `log_level`, `log_format`, resource paths, `encryption_key` (**required** — at-rest AES-256 key, 32 bytes as hex or base64, never auto-generated) | +| `[platform_api]` | resource paths | +| `[platform_api.logging]` | `log_level`, `log_format` | +| `[platform_api.security]` | `encryption_key` (**required** — at-rest AES-256 key, 32 bytes as hex or base64, never auto-generated) | +| `[platform_api.security.api_key]` | `hashing_algorithms` accepted for API key verification | | `[platform_api.database]` | `driver` (`sqlite3` / `postgres` / `sqlserver`), connection fields, pool sizing | | `[platform_api.auth]` | `mode` — one of `external_token`, `file`, or `idp`; `scope_validation`; `skip_paths` | | `[platform_api.auth.jwt]` | HMAC login token settings: `issuer`, `secret_key` (**required**), `token_ttl` | -| `[platform_api.auth.idp]` / `[platform_api.auth.idp.claim_mappings]` | JWKS endpoint, issuer/audience, validation mode, and JWT claim-name mappings for `idp` mode | +| `[platform_api.auth.idp]` / `[platform_api.auth.claim_mappings]` | JWKS endpoint, issuer/audience, validation mode, and JWT claim-name mappings for `idp` mode | | `[platform_api.auth.file.organization]` / `[[platform_api.auth.file.users]]` | Local org + username/password/scope entries for `file` mode | | `[platform_api.server.http]` / `[platform_api.server.https]` / `[platform_api.server.https.tls]` | Listener enablement, ports, and TLS cert/key paths (certificates are always required for HTTPS — no self-signed fallback) | -| `[platform_api.listener_timeouts]` | Read/write/idle timeouts | -| `[platform_api.cors]` | `allowed_origins` for credentialed cross-origin requests | -| `[platform_api.websocket]` | Gateway WebSocket connection limits and rate limiting | +| `[platform_api.server.timeouts]` | Read/write/idle timeouts | +| `[platform_api.server.cors]` | `allowed_origins` for credentialed cross-origin requests | +| `[platform_api.server.websocket]` | Gateway WebSocket connection limits and rate limiting | | `[platform_api.deployments]` | Deployment caps and stuck-deployment timeout handling | | `[platform_api.gateway]` | Gateway registration verification toggles | | `[platform_api.event_hub]` | Multi-replica event delivery polling/retention | @@ -307,18 +310,22 @@ Per-route scope checks are enforced when `platform_api.auth.scope_validation = t | `operator` | CI/CD service account | Deploy and undeploy operations only; cannot create resources or manage credentials | | `viewer` | Auditor | Read-only access to all resources | -In **`external_token`/`file` mode**, scopes are read directly from the `scope` claim in the token. -In **`idp` mode**, scopes or roles are read from the claim(s) named in `[platform_api.auth.idp.claim_mappings]`, -per `validation_mode` (`scope` reads the scope claim directly; `role` expands IDP roles from -`roles` via `role_mappings`). +All three modes read identity fields — including scope — through the same +`[platform_api.auth.claim_mappings]` table (`scope` defaults to the `scope` claim); `file` mode's +login endpoint also signs the tokens it issues using these same claim names, so issuance and +validation never drift apart. In **`idp` mode**, `validation_mode` additionally controls whether +authorization uses the scope claim directly or expands IDP roles from `claim_mappings.roles` via +`role_mappings`. ### Providing secrets via the config file Never write raw secret values into the config file, and never hardcode them as literals in a -compose file. Reference each secret (`encryption_key`, `auth.jwt.secret_key`, `database.password`, -`webhook.secret`, …) with an interpolation token, preferring a mounted file over an env var: +compose file. Reference each secret (`security.encryption_key`, `auth.jwt.secret_key`, +`database.password`, `webhook.secret`, …) with an interpolation token, preferring a mounted file +over an env var: ```toml +[platform_api.security] encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' # from an env var # preferred — from a mounted secret file: # encryption_key = '{{ file "/secrets/platform-api/encryption_key" }}' diff --git a/platform-api/cmd/main.go b/platform-api/cmd/main.go index 7f498bb03..8ced18d31 100644 --- a/platform-api/cmd/main.go +++ b/platform-api/cmd/main.go @@ -37,8 +37,8 @@ func main() { // Initialize logger logConfig := logger.Config{ - Level: cfg.LogLevel, - Format: cfg.LogFormat, + Level: cfg.Logging.LogLevel, + Format: cfg.Logging.LogFormat, } slogger := logger.NewLogger(logConfig) @@ -52,7 +52,7 @@ func main() { slogger.Info("Starting server", "http_enabled", cfg.Listeners.HTTP.Enabled, "http_port", cfg.Listeners.HTTP.Port, "https_enabled", cfg.Listeners.HTTPS.Enabled, "https_port", cfg.Listeners.HTTPS.Port) - if err := srv.Start(cfg.Listeners, cfg.Timeouts); err != nil { + if err := srv.Start(cfg.Listeners, cfg.Listeners.Timeouts); err != nil { slogger.Error("Failed to start server", "error", err) os.Exit(1) } diff --git a/platform-api/config/config-template.toml b/platform-api/config/config-template.toml index 5381106cf..87185498b 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -10,41 +10,33 @@ # Platform API configuration template # # Copy this file to your deployment's config location and edit the values. -# This file is read by the Platform API (Go binary). +# Read by the Platform API (Go binary). Lists EVERY key the Platform API +# reads, so it doubles as the configuration reference — every key below is +# already at its built-in default, so deleting a key just restores that +# default. Ships with distributions (e.g. the AI Workspace zip), copied at +# build time from here. # -# This template lists EVERY key the Platform API reads, so it doubles as the -# 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. -# -# 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 per key: an explicit {{ env "..." }} / {{ file "..." }} token in this -# file resolves its value from that source; a plain literal value in this file is -# used as-is; a key omitted from this file entirely falls back to its built-in -# default. Every non-secret key below is written as a {{ env "VAR" "default" }} -# token, so it can be overridden by setting VAR without editing this file — the -# quoted string after the var name is the fallback used when VAR is unset. The -# env var name inside a token is a free choice; APIP_CP_ is just this template's -# naming convention, not something the loader requires or enforces. +# PRECEDENCE: a {{ env "..." }} / {{ file "..." }} token resolves from that +# source; a plain literal is used as-is; an omitted key falls back to its +# built-in default. Every non-secret key below is a {{ env "VAR" "default" }} +# token — override by setting VAR, no file edit needed. The env var name is +# just this template's naming convention (APIP_CP_), not enforced by the loader. # # QUICK START (file auth mode — no external IDP needed): # 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 `api-platform.env` file. -# The encryption_key / secret_key fields below read them via {{ env "..." }} -# tokens — never paste raw key values into this file. +# 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 `api-platform.env`. The fields below read them via +# {{ env "..." }} tokens — never paste raw key values into this file. # 4. Set [platform_api.auth] mode = "file" and configure users. # 5. Run: docker compose up # -# 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. A {{ env }} / {{ file }} -# token whose source is unset — and has no fallback value — fails config load; only -# reference sources that exist. +# Secrets resolve from env vars ({{ env "..." }}) or mounted files +# ({{ file "..." }}, preferred in production) — never hardcode secrets here +# or in docker-compose.yaml. A token with no fallback fails config load if +# its source is unset; only reference sources that exist. # -# OIDC mode: set [platform_api.auth] mode = "idp" and configure the [platform_api.auth.idp] fields. +# OIDC mode: set [platform_api.auth] mode = "idp" and configure [platform_api.auth.idp]. # -------------------------------------------------------------------- # --------------------------------------------------------------------------- @@ -52,36 +44,39 @@ # --------------------------------------------------------------------------- [platform_api] -# --------------------------------------------------------------------------- -# Logging -# --------------------------------------------------------------------------- -log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' # DEBUG | INFO | WARN | ERROR - -# Log output encoding. Use "json" when shipping logs to an aggregator. -log_format = '{{ env "APIP_CP_LOG_FORMAT" "text" }}' # text | json - -# Paths to resources the binary loads at startup. The defaults are correct for -# the published container image; override only for a custom layout. +# Resource paths loaded at startup. Defaults match the published container +# image — override only for a custom layout. db_schema_path = '{{ env "APIP_CP_DB_SCHEMA_PATH" "./internal/database/schema.sql" }}' openapi_spec_path = '{{ env "APIP_CP_OPENAPI_SPEC_PATH" "./resources/openapi.yaml" }}' llm_template_definitions_path = '{{ env "APIP_CP_LLM_TEMPLATE_DEFINITIONS_PATH" "./resources/default-llm-provider-templates" }}' -# Cap on the bytes read when fetching a remote OpenAPI spec by URL. <= 0 falls -# back to the built-in 5 MiB default. +# Byte cap when fetching a remote OpenAPI spec by URL. <= 0 uses the +# built-in 5 MiB default. openapi_spec_max_fetch_bytes = '{{ env "APIP_CP_OPENAPI_SPEC_MAX_FETCH_BYTES" "5242880" }}' +[platform_api.logging] +log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' # DEBUG | INFO | WARN | ERROR + +# Log encoding. Use "json" when shipping logs to an aggregator. +log_format = '{{ env "APIP_CP_LOG_FORMAT" "text" }}' # text | json + # --------------------------------------------------------------------------- -# Encryption +# Security # --------------------------------------------------------------------------- -# Single 32-byte key (64 hex chars or base64) used for ALL at-rest encryption -# (secrets, subscription tokens, WebSub HMAC secrets). -# REQUIRED — the server refuses to start without it; no fallback is given, so an -# unset APIP_CP_ENCRYPTION_KEY fails config load. Generate with: -# openssl rand -hex 32 -# Resolved from the APIP_CP_ENCRYPTION_KEY env var in `api-platform.env`. Files preferred: +[platform_api.security] +# Single 32-byte key (64 hex chars or base64) used for ALL at-rest +# encryption (secrets, subscription tokens, WebSub HMAC secrets). +# REQUIRED — no fallback, so an unset APIP_CP_ENCRYPTION_KEY fails config +# load. Generate with: openssl rand -hex 32 +# Prefer a mounted secret file in production: # encryption_key = '{{ file "/secrets/platform-api/encryption_key" }}' encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' +[platform_api.security.api_key] +# Accepted API key hashing algorithms (comma-separated). sha256 is the +# default; add "sha512" to accept both. +hashing_algorithms = '{{ env "APIP_CP_API_KEY_HASHING_ALGORITHMS" "sha256" }}' + # --------------------------------------------------------------------------- # Database # --------------------------------------------------------------------------- @@ -91,20 +86,30 @@ driver = '{{ env "APIP_CP_DATABASE_DRIVER" "sqlite3" }}' # "sqlite3" | "postgr # SQLite — ignored when driver = "postgres". path = '{{ env "APIP_CP_DATABASE_PATH" "/app/data/api_platform.db" }}' -# PostgreSQL/SQL Server — ignored when driver = "sqlite3"; host, port, name, and -# user are all required for every non-sqlite3 driver, or config load fails. +# PostgreSQL/SQL Server — ignored when driver = "sqlite3". host, port, name, +# and user are required for every non-sqlite3 driver, or config load fails. host = '{{ env "APIP_CP_DATABASE_HOST" "localhost" }}' port = '{{ env "APIP_CP_DATABASE_PORT" "5432" }}' name = '{{ env "APIP_CP_DATABASE_NAME" "platform_api" }}' -user = '{{ env "APIP_CP_DATABASE_USER" "platform_api" }}' -# Resolve the password from a mounted secret file (preferred) or env var, e.g. +username = '{{ env "APIP_CP_DATABASE_USER" "platform_api" }}' +# Prefer a mounted secret file in production, e.g. # password = '{{ file "/secrets/platform-api/postgres_password" }}' -# The referenced file/env var must exist or config load fails; the fallback here -# is "" so a driver that needs no password (sqlite3) is unaffected. +# The referenced file/env var must exist or config load fails; the "" +# fallback here is safe because sqlite3 needs no password. password = '{{ env "APIP_CP_DATABASE_PASSWORD" "" }}' ssl_mode = '{{ env "APIP_CP_DATABASE_SSL_MODE" "disable" }}' # "disable" | "require" | "verify-ca" | "verify-full" -# Connection pool — tune for production workloads +# CA cert used to verify the server's certificate. Required when ssl_mode +# is "verify-ca" or "verify-full"; ignored otherwise. +ssl_root_cert = '{{ env "APIP_CP_DATABASE_SSL_ROOT_CERT" "" }}' + +# Client cert/key pair for mutual TLS (PostgreSQL only — SQL Server's driver +# has no client-certificate support). Both must be set together or both +# left empty. +ssl_cert = '{{ env "APIP_CP_DATABASE_SSL_CERT" "" }}' +ssl_key = '{{ env "APIP_CP_DATABASE_SSL_KEY" "" }}' + +# Connection pool — tune for production workloads. max_open_conns = '{{ env "APIP_CP_DATABASE_MAX_OPEN_CONNS" "25" }}' # maximum open connections max_idle_conns = '{{ env "APIP_CP_DATABASE_MAX_IDLE_CONNS" "10" }}' # maximum idle connections in the pool conn_max_lifetime = '{{ env "APIP_CP_DATABASE_CONN_MAX_LIFETIME" "300" }}' # seconds before a connection is recycled @@ -112,14 +117,13 @@ conn_max_lifetime = '{{ env "APIP_CP_DATABASE_CONN_MAX_LIFETIME" "300" }}' # s # --------------------------------------------------------------------------- # Authentication # -# auth.mode selects exactly one authentication mode: -# "external_token" — verify locally-signed HMAC JWTs ([platform_api.auth.jwt]); -# tokens are minted externally, e.g. by the Developer -# Portal using the shared secret. -# "file" — "external_token" plus local username/password login: the -# login endpoint authenticates users from -# [platform_api.auth.file] and issues HMAC JWTs signed -# with the same [platform_api.auth.jwt] secret. +# auth.mode selects exactly one mode: +# "external_token" — verify locally-signed HMAC JWTs, minted externally +# (e.g. by the Developer Portal) with the shared secret. +# "file" — external_token + local username/password login: the +# login endpoint authenticates from +# [platform_api.auth.file] and issues HMAC JWTs +# signed with the same secret. # "idp" — validate tokens against an external IDP's JWKS # ([platform_api.auth.idp]). # Only the selected mode's section is read; the others are ignored. @@ -127,17 +131,16 @@ conn_max_lifetime = '{{ env "APIP_CP_DATABASE_CONN_MAX_LIFETIME" "300" }}' # s [platform_api.auth] mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' -# Enforce per-endpoint OAuth2 scopes on validated tokens. When true, every -# request must carry the scope its endpoint declares. -# Set to false only to temporarily bypass authorization during development. +# Enforce per-endpoint OAuth2 scopes on validated tokens. Set false only to +# temporarily bypass authorization during development. scope_validation = '{{ env "APIP_CP_AUTH_SCOPE_VALIDATION" "true" }}' -# 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. -# This is a structured list, not a scalar, so it is edited directly rather than -# via a single env var token. +# Paths that bypass the auth middleware entirely. The list below is the +# built-in default: health/metrics probes, the file-based login endpoint, +# and internal gateway routes (which authenticate with a gateway token +# instead). Setting this key REPLACES the default list — keep the entries +# you still need. Structured list, so it's edited directly rather than via +# a single env var token. skip_paths = [ "/health", "/metrics", @@ -157,21 +160,22 @@ skip_paths = [ "/api/internal/v0.9/webhook/events", ] -# JWT (local HMAC) — used in the "external_token" and "file" modes: -# "external_token" mode only verifies externally-minted tokens with this key; -# "file" mode also signs the tokens its login endpoint issues with it. -# Signature validation is always on. Resolved from APIP_CP_AUTH_JWT_SECRET_KEY -# in `api-platform.env` file; secret files preferred: -# secret_key = '{{ file "/secrets/platform-api/jwt_secret" }}' -[platform_api.auth.jwt] -issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' -# Signing key. REQUIRED — a 32-byte key (64 hex chars or base64); never generated, -# and has no fallback, so an unset APIP_CP_AUTH_JWT_SECRET_KEY fails config load. -secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' -# Lifetime of tokens issued by the file-mode login endpoint (Go duration syntax). -# Not used to validate externally-minted "external_token"-mode tokens — their -# expiry is whatever "exp" claim the issuer set. -token_ttl = '{{ env "APIP_CP_AUTH_JWT_TOKEN_TTL" "1h" }}' +# JWT claim name mappings — shared by all three auth modes ("idp" reads +# incoming claims by these names; "file" mode's login endpoint signs tokens +# using these names; "external_token" mode reads externally-minted tokens by +# these names too), so issuance and validation never drift apart. Each value +# is either a flat top-level claim name ("org_id") or a dot-separated path +# into a nested claim ("realm_access.org_id") — useful for IDPs like +# Keycloak that nest fields such as roles under realm_access.roles. +[platform_api.auth.claim_mappings] +organization = '{{ env "APIP_CP_AUTH_CLAIM_ORGANIZATION" "organization" }}' # claim carrying the org ID +org_name = '{{ env "APIP_CP_AUTH_CLAIM_ORG_NAME" "org_name" }}' # claim carrying the org display name +org_handle = '{{ env "APIP_CP_AUTH_CLAIM_ORG_HANDLE" "org_handle" }}' # claim carrying the org URL slug +user_id = '{{ env "APIP_CP_AUTH_CLAIM_USER_ID" "sub" }}' # claim used as the user's unique ID +username = '{{ env "APIP_CP_AUTH_CLAIM_USERNAME" "username" }}' +email = '{{ env "APIP_CP_AUTH_CLAIM_EMAIL" "email" }}' +scope = '{{ env "APIP_CP_AUTH_CLAIM_SCOPE" "scope" }}' # space-separated scope string +roles = '{{ env "APIP_CP_AUTH_CLAIM_ROLES" "" }}' # e.g. "realm_access.roles" (Keycloak) or "roles" (Asgardeo) # IDP (JWKS-based) — used when mode = "idp" (Asgardeo, Keycloak, Auth0, etc.). # jwks_url and issuer are required in that mode. @@ -186,22 +190,9 @@ audience = [] # accepted "aud" values; em validation_mode = '{{ env "APIP_CP_AUTH_IDP_VALIDATION_MODE" "scope" }}' role_mappings = '{{ env "APIP_CP_AUTH_IDP_ROLE_MAPPINGS" "" }}' # path to a YAML file mapping IDP roles to platform scopes -# JWT claim name mappings — configure to match your IDP's token structure. Each -# value is either a flat top-level claim name ("org_id") or a dot-separated -# path into a nested claim ("realm_access.org_id") — useful for IDPs like -# Keycloak that nest fields such as roles under realm_access.roles. -[platform_api.auth.idp.claim_mappings] -organization = '{{ env "APIP_CP_AUTH_IDP_CLAIM_ORGANIZATION" "organization" }}' # claim carrying the org ID -org_name = '{{ env "APIP_CP_AUTH_IDP_CLAIM_ORG_NAME" "org_name" }}' # claim carrying the org display name -org_handle = '{{ env "APIP_CP_AUTH_IDP_CLAIM_ORG_HANDLE" "org_handle" }}' # claim carrying the org URL slug -user_id = '{{ env "APIP_CP_AUTH_IDP_CLAIM_USER_ID" "sub" }}' # claim used as the user's unique ID -username = '{{ env "APIP_CP_AUTH_IDP_CLAIM_USERNAME" "username" }}' -email = '{{ env "APIP_CP_AUTH_IDP_CLAIM_EMAIL" "email" }}' -scope = '{{ env "APIP_CP_AUTH_IDP_CLAIM_SCOPE" "scope" }}' # space-separated scope string -roles = '{{ env "APIP_CP_AUTH_IDP_CLAIM_ROLES" "" }}' # e.g. "realm_access.roles" (Keycloak) or "roles" (Asgardeo) - -# File auth — local username/password login, used when mode = "file". Ideal for -# initial / air-gapped setup; not recommended for production — prefer an IDP. +# File auth — local username/password login, used when mode = "file". Ideal +# for initial / air-gapped setup; not recommended for production — prefer +# an IDP. [platform_api.auth.file.organization] id = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_ID" "default" }}' # Required: organization handle (URL-safe slug) display_name = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_DISPLAY_NAME" "Default" }}' @@ -215,8 +206,8 @@ uuid = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_UUID" "99089a17-72e0-4dd8-a2f4-c8 # Generate a bcrypt hash with: htpasswd -bnBC 12 "" | tr -d ':\n' [[platform_api.auth.file.users]] # The quickstart resolves these from api-platform.env (generated by setup.sh). -# Neither has a fallback — an unset var fails config load rather than starting -# with a blank or guessable credential. +# Neither has a fallback — an unset var fails config load rather than +# starting with a blank or guessable credential. username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' # Full scope set — trim to restrict permissions for this user. @@ -229,6 +220,21 @@ scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:mana # password_hash = "$2a$12$" # scopes = "ap:organization:read ap:gateway:read ap:rest_api:read ap:llm_provider:read" +# JWT (local HMAC) — used by "external_token" and "file" modes: +# "external_token" only verifies externally-minted tokens with this key; +# "file" also signs the tokens its login endpoint issues. Resolved from +# APIP_CP_AUTH_JWT_SECRET_KEY; prefer a secret file in production: +# secret_key = '{{ file "/secrets/platform-api/jwt_secret" }}' +[platform_api.auth.jwt] +issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' +# REQUIRED — a 32-byte key (64 hex chars or base64); never generated, no +# fallback, so an unset APIP_CP_AUTH_JWT_SECRET_KEY fails config load. +secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' +# Lifetime of tokens issued by the file-mode login endpoint (Go duration +# syntax). Not used for "external_token" tokens — their expiry is whatever +# "exp" claim the issuer set. +token_ttl = '{{ env "APIP_CP_AUTH_JWT_TOKEN_TTL" "1h" }}' + # --------------------------------------------------------------------------- # Server listeners (HTTP + HTTPS) # --------------------------------------------------------------------------- @@ -238,13 +244,12 @@ scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:mana # # Defaults: the HTTPS listener is on at 9243; the plain-HTTP listener is off. # Enable the plain-HTTP listener only when a trusted upstream (ingress, -# service-mesh sidecar) terminates TLS, or for internal traffic — never expose -# it directly to untrusted networks. +# service-mesh sidecar) terminates TLS, or for internal traffic — never +# expose it directly to untrusted networks. # -# platform_api.server.https.tls.cert_file / key_file must point at a certificate -# pair when platform_api.server.https.enabled = true. Certificates are always -# required — there is no self-signed fallback. setup.sh generates a pair for -# the quickstart. +# tls.cert_file / key_file must point at a certificate pair when +# server.https.enabled = true. Certificates are always required — there is +# no self-signed fallback. setup.sh generates a pair for the quickstart. [platform_api.server.http] enabled = '{{ env "APIP_CP_SERVER_HTTP_ENABLED" "false" }}' port = '{{ env "APIP_CP_SERVER_HTTP_PORT" "9080" }}' @@ -260,54 +265,49 @@ key_file = '{{ env "APIP_CP_SERVER_HTTPS_TLS_KEY_FILE" "/app/data/certs/key.pem # --------------------------------------------------------------------------- # 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". +# 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). +# 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_LISTENER_TIMEOUTS_READ_HEADER / APIP_CP_LISTENER_TIMEOUTS_READ / -# APIP_CP_LISTENER_TIMEOUTS_WRITE / APIP_CP_LISTENER_TIMEOUTS_IDLE. -[platform_api.listener_timeouts] -read_header = '{{ env "APIP_CP_LISTENER_TIMEOUTS_READ_HEADER" "10s" }}' -read = '{{ env "APIP_CP_LISTENER_TIMEOUTS_READ" "60s" }}' -write = '{{ env "APIP_CP_LISTENER_TIMEOUTS_WRITE" "120s" }}' -idle = '{{ env "APIP_CP_LISTENER_TIMEOUTS_IDLE" "120s" }}' +[platform_api.server.timeouts] +read_header = '{{ env "APIP_CP_SERVER_TIMEOUTS_READ_HEADER" "10s" }}' +read = '{{ env "APIP_CP_SERVER_TIMEOUTS_READ" "60s" }}' +write = '{{ env "APIP_CP_SERVER_TIMEOUTS_WRITE" "120s" }}' +idle = '{{ env "APIP_CP_SERVER_TIMEOUTS_IDLE" "120s" }}' # --------------------------------------------------------------------------- # CORS # --------------------------------------------------------------------------- -# Origins allowed to make credentialed cross-origin requests, e.g. the Developer -# 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). -[platform_api.cors] -allowed_origins = '{{ env "APIP_CP_CORS_ALLOWED_ORIGINS" "" }}' # e.g. "https://workspace.example.com,https://devportal.example.com" - -# --------------------------------------------------------------------------- -# API Key -# --------------------------------------------------------------------------- -[platform_api.api_key] -# Hashing algorithms accepted for API key verification (comma-separated). -# sha256 is the default; add "sha512" to accept both. -hashing_algorithms = '{{ env "APIP_CP_API_KEY_HASHING_ALGORITHMS" "sha256" }}' +# Origins allowed to make credentialed cross-origin requests, e.g. the +# Developer Portal and AI Workspace origins. Must never contain "*"; leave +# empty to disable cross-origin access (the default). +[platform_api.server.cors] +allowed_origins = '{{ env "APIP_CP_CORS_ALLOWED_ORIGINS" "" }}' # comma-separated, e.g. "https://workspace.example.com,https://devportal.example.com" # --------------------------------------------------------------------------- # WebSocket # --------------------------------------------------------------------------- -[platform_api.websocket] +[platform_api.server.websocket] max_connections = '{{ env "APIP_CP_WEBSOCKET_MAX_CONNECTIONS" "1000" }}' # global WebSocket connection limit connection_timeout = '{{ env "APIP_CP_WEBSOCKET_CONNECTION_TIMEOUT" "30" }}' # seconds before an idle connection is closed rate_limit_per_min = '{{ env "APIP_CP_WEBSOCKET_RATE_LIMIT_PER_MIN" "1000" }}' # maximum messages per minute per connection -max_connections_per_org = '{{ env "APIP_CP_WEBSOCKET_MAX_CONNECTIONS_PER_ORG" "3" }}' # maximum concurrent WebSocket connections per org metrics_log_enabled = '{{ env "APIP_CP_WEBSOCKET_METRICS_LOG_ENABLED" "true" }}' # emit WebSocket metrics to the log metrics_log_interval = '{{ env "APIP_CP_WEBSOCKET_METRICS_LOG_INTERVAL" "10" }}' # seconds between metrics log lines +# --------------------------------------------------------------------------- +# Gateway +# --------------------------------------------------------------------------- +[platform_api.gateway] +# Reject gateway registrations whose runtime version does not match the expected range. +enable_version_verification = '{{ env "APIP_CP_GATEWAY_ENABLE_VERSION_VERIFICATION" "false" }}' +# Reject gateways that report an unexpected functionality type. +enable_functionality_type_verification = '{{ env "APIP_CP_GATEWAY_ENABLE_FUNCTIONALITY_TYPE_VERIFICATION" "false" }}' # --------------------------------------------------------------------------- # Deployments @@ -321,14 +321,6 @@ timeout_enabled = '{{ env "APIP_CP_DEPLOYMENTS_TIMEOUT_ENABLED" "true" }}' timeout_interval = '{{ env "APIP_CP_DEPLOYMENTS_TIMEOUT_INTERVAL" "20" }}' # seconds between timeout check sweeps timeout_duration = '{{ env "APIP_CP_DEPLOYMENTS_TIMEOUT_DURATION" "60" }}' # seconds before a stuck deployment is timed out -# --------------------------------------------------------------------------- -# Gateway -# --------------------------------------------------------------------------- -[platform_api.gateway] -# Reject gateway registrations whose runtime version does not match the expected range. -enable_version_verification = '{{ env "APIP_CP_GATEWAY_ENABLE_VERSION_VERIFICATION" "false" }}' -# Reject gateways that report an unexpected functionality type. -enable_functionality_type_verification = '{{ env "APIP_CP_GATEWAY_ENABLE_FUNCTIONALITY_TYPE_VERIFICATION" "false" }}' # --------------------------------------------------------------------------- # EventHub — multi-replica HA event delivery @@ -342,20 +334,18 @@ retention_period = '{{ env "APIP_CP_EVENT_HUB_RETENTION_PERIOD" "1h" }}' # ho # --------------------------------------------------------------------------- # Webhook — control-plane webhook receiver # --------------------------------------------------------------------------- -# The Developer Portal delivers signed events (API key / subscription changes) -# to this endpoint. +# The Developer Portal delivers signed events (API key / subscription +# changes) to this endpoint. [platform_api.webhook] enabled = '{{ env "APIP_CP_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. The "" fallback -# below is only ever read while enabled = false. +# — the referenced env var/file must exist or config load fails. The "" +# fallback below is only ever read while enabled = false. secret = '{{ env "APIP_CP_WEBHOOK_SECRET" "" }}' -# PEM RSA private key used to decrypt encrypted_key fields. Required only for -# events that carry encrypted secrets (API key generate/regenerate). +# 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 = '{{ env "APIP_CP_WEBHOOK_PRIVATE_KEY_PATH" "" }}' -# Events with a different gateway_type are accepted as a no-op. -gateway_type = '{{ env "APIP_CP_WEBHOOK_GATEWAY_TYPE" "wso2/api-platform" }}' signature_tolerance = '{{ env "APIP_CP_WEBHOOK_SIGNATURE_TOLERANCE" "5m" }}' # max age of a signed request (replay protection) max_body_size = '{{ env "APIP_CP_WEBHOOK_MAX_BODY_SIZE" "1048576" }}' # request body cap in bytes (1 MiB) signature_header = '{{ env "APIP_CP_WEBHOOK_SIGNATURE_HEADER" "X-Devportal-Signature" }}' # header carrying the "t=...,v1=..." signature diff --git a/platform-api/config/config.go b/platform-api/config/config.go index 6068083c1..07e24510c 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -46,7 +46,7 @@ type FileBasedUser struct { } // FileBasedUsers is a slice of FileBasedUser that can be decoded from a JSON string (env var) -// or from a TOML array of tables ([[auth.file_based.users]]). +// or from a TOML array of tables ([[auth.file.users]]). type FileBasedUsers []FileBasedUser // FileBasedOrg holds the single organization used in file-based auth mode. @@ -73,26 +73,26 @@ type FileBased struct { Users FileBasedUsers `koanf:"users"` } -// Server holds the configuration parameters for the application. -type Server struct { +// Logging holds logging configuration. +type Logging struct { LogLevel string `koanf:"log_level"` LogFormat string `koanf:"log_format"` +} + +// Server holds the configuration parameters for the application. +type Server struct { + Logging Logging `koanf:"logging"` DBSchemaPath string `koanf:"db_schema_path"` OpenAPISpecPath string `koanf:"openapi_spec_path"` LLMTemplateDefinitionsPath string `koanf:"llm_template_definitions_path"` OpenAPISpecMaxFetchBytes int64 `koanf:"openapi_spec_max_fetch_bytes"` - EncryptionKey string `koanf:"encryption_key"` - Database Database `koanf:"database"` Auth Auth `koanf:"auth"` - WebSocket WebSocket `koanf:"websocket"` Deployments Deployments `koanf:"deployments"` Listeners ServerListeners `koanf:"server"` - Timeouts Timeouts `koanf:"listener_timeouts"` - CORS CORS `koanf:"cors"` - APIKey APIKey `koanf:"api_key"` + Security Security `koanf:"security"` Gateway Gateway `koanf:"gateway"` EventHub EventHub `koanf:"event_hub"` Webhook Webhook `koanf:"webhook"` @@ -127,13 +127,19 @@ type Auth struct { // with it. JWT JWT `koanf:"jwt"` File FileBased `koanf:"file"` -} - -// IDPClaimMappings holds JWT claim name mappings for an IDP. Every field -// accepts either a flat top-level claim name ("org_id") or a dot-separated -// path into a nested claim ("realm_access.org_id") — see resolveClaimPath in -// internal/middleware/auth.go. -type IDPClaimMappings struct { + // ClaimMappings names the JWT claims that carry each identity field. It is + // shared by all three auth modes: "idp" reads incoming claims by these + // names, "file" mode's login endpoint signs tokens using these names, and + // "external_token" mode reads externally-minted tokens by these names too + // — one mapping, so issuance and validation can never drift apart. Every + // field accepts either a flat top-level claim name ("org_id") or a + // dot-separated path into a nested claim ("realm_access.org_id") — see + // resolveClaimPath in internal/middleware/auth.go. + ClaimMappings ClaimMappings `koanf:"claim_mappings"` +} + +// ClaimMappings holds JWT claim name mappings, shared across all auth modes. +type ClaimMappings struct { Organization string `koanf:"organization"` OrgName string `koanf:"org_name"` OrgHandle string `koanf:"org_handle"` @@ -147,13 +153,12 @@ type IDPClaimMappings struct { // IDP holds configuration for JWKS-based identity providers. Active when // Auth.Mode is AuthModeIDP. type IDP struct { - Name string `koanf:"name"` - JWKSUrl string `koanf:"jwks_url"` - Issuer []string `koanf:"issuer"` - Audience []string `koanf:"audience"` - ValidationMode string `koanf:"validation_mode"` - RoleMappings string `koanf:"role_mappings"` - ClaimMappings IDPClaimMappings `koanf:"claim_mappings"` + Name string `koanf:"name"` + JWKSUrl string `koanf:"jwks_url"` + Issuer []string `koanf:"issuer"` + Audience []string `koanf:"audience"` + ValidationMode string `koanf:"validation_mode"` + RoleMappings string `koanf:"role_mappings"` } // EventHub holds EventHub-specific configuration for multi-replica HA event delivery. @@ -174,9 +179,6 @@ type Webhook struct { // PrivateKeyPath points to the PEM RSA private key used to decrypt encrypted_key fields. // Optional: required only for events that carry encrypted secrets (API key generate/regenerate). PrivateKeyPath string `koanf:"private_key_path"` - // GatewayType filters events meant for this platform type. Events with a different - // gateway_type are accepted as a no-op. - GatewayType string `koanf:"gateway_type"` // SignatureTolerance bounds how old a signed request may be (replay protection). SignatureTolerance time.Duration `koanf:"signature_tolerance"` // MaxBodySize caps the request body size in bytes. @@ -191,13 +193,18 @@ type Gateway struct { EnableFunctionalityTypeVerification bool `koanf:"enable_functionality_type_verification"` } -// ServerListeners models the two independent listeners under the [server] -// section. Each is enabled independently and bound to its own port, so a +// ServerListeners models the [server] section: the two independent HTTP +// listeners (each enabled independently and bound to its own port, so a // deployment can serve plain HTTP internally, HTTPS externally, or both at -// once (e.g. to migrate clients between the two without downtime). +// once to migrate clients between them without downtime), plus the +// cross-cutting settings — timeouts, CORS, WebSocket — that apply to +// whichever listener(s) are serving requests. type ServerListeners struct { - HTTP HTTPListener `koanf:"http"` - HTTPS HTTPSListener `koanf:"https"` + HTTP HTTPListener `koanf:"http"` + HTTPS HTTPSListener `koanf:"https"` + Timeouts Timeouts `koanf:"timeouts"` + CORS CORS `koanf:"cors"` + WebSocket WebSocket `koanf:"websocket"` } // HTTPListener configures the plain-HTTP listener. Enable it only when a trusted @@ -267,12 +274,11 @@ type JWT struct { // WebSocket holds WebSocket-specific configuration. type WebSocket struct { - MaxConnections int `koanf:"max_connections"` - ConnectionTimeout int `koanf:"connection_timeout"` - RateLimitPerMin int `koanf:"rate_limit_per_min"` - MaxConnectionsPerOrg int `koanf:"max_connections_per_org"` - MetricsLogEnabled bool `koanf:"metrics_log_enabled"` - MetricsLogInterval int `koanf:"metrics_log_interval"` + MaxConnections int `koanf:"max_connections"` + ConnectionTimeout int `koanf:"connection_timeout"` + RateLimitPerMin int `koanf:"rate_limit_per_min"` + MetricsLogEnabled bool `koanf:"metrics_log_enabled"` + MetricsLogInterval int `koanf:"metrics_log_interval"` } // Database holds database-specific configuration. @@ -280,13 +286,20 @@ type Database struct { // Driver supports: sqlite3, postgres/postgresql/pgx, sqlserver/mssql. Driver string `koanf:"driver"` // Path is the file path for SQLite databases. - Path string `koanf:"path"` - Host string `koanf:"host"` - Port int `koanf:"port"` - Name string `koanf:"name"` - User string `koanf:"user"` - Password string `koanf:"password"` - SSLMode string `koanf:"ssl_mode"` + Path string `koanf:"path"` + Host string `koanf:"host"` + Port int `koanf:"port"` + Name string `koanf:"name"` + Username string `koanf:"username"` + Password string `koanf:"password"` + SSLMode string `koanf:"ssl_mode"` + // SSLRootCert is the CA certificate file path used to verify the server's + // certificate. Required when SSLMode is "verify-ca" or "verify-full". + SSLRootCert string `koanf:"ssl_root_cert"` + // SSLCert and SSLKey are the client certificate/key pair used for mutual + // TLS. Optional; both must be set together or not at all. + SSLCert string `koanf:"ssl_cert"` + SSLKey string `koanf:"ssl_key"` MaxOpenConns int `koanf:"max_open_conns"` MaxIdleConns int `koanf:"max_idle_conns"` ConnMaxLifetime int `koanf:"conn_max_lifetime"` @@ -306,6 +319,14 @@ type APIKey struct { HashingAlgorithms []string `koanf:"hashing_algorithms"` } +// Security holds cryptographic/secret-handling configuration. +type Security struct { + // EncryptionKey is the single 32-byte key used for ALL at-rest encryption + // (secrets, subscription tokens, WebSub HMAC secrets). + EncryptionKey string `koanf:"encryption_key"` + APIKey APIKey `koanf:"api_key"` +} + // package-level singleton. var ( configFilePath string @@ -384,12 +405,12 @@ func LoadConfig(configPath string) (*Server, error) { // Install the configured logger as the slog default so the warnings/info logs // emitted below (and any package-level slog.* call in this file) use the same // format as the rest of the application, instead of slog's default handler. - slog.SetDefault(logger.NewLogger(logger.Config{Level: cfg.LogLevel, Format: cfg.LogFormat})) + slog.SetDefault(logger.NewLogger(logger.Config{Level: cfg.Logging.LogLevel, Format: cfg.Logging.LogFormat})) - if err := validateLoggingConfig(cfg.LogLevel, cfg.LogFormat); err != nil { + if err := validateLoggingConfig(cfg.Logging.LogLevel, cfg.Logging.LogFormat); err != nil { return nil, err } - if err := validateTimeoutsConfig(&cfg.Timeouts); err != nil { + if err := validateTimeoutsConfig(&cfg.Listeners.Timeouts); err != nil { return nil, err } if err := validateDeploymentsConfig(&cfg.Deployments); err != nil { @@ -404,7 +425,7 @@ func LoadConfig(configPath string) (*Server, error) { if err := validateWebhookConfig(&cfg.Webhook); err != nil { return nil, err } - if err := validateEncryptionKey(cfg.EncryptionKey); err != nil { + if err := validateEncryptionKey(cfg.Security.EncryptionKey); err != nil { return nil, err } if err := validateDatabaseConfig(&cfg.Database); err != nil { @@ -413,7 +434,7 @@ func LoadConfig(configPath string) (*Server, error) { if err := validateListenersConfig(&cfg.Listeners); err != nil { return nil, err } - if err := validateCORSConfig(&cfg.CORS); err != nil { + if err := validateCORSConfig(&cfg.Listeners.CORS); err != nil { return nil, err } @@ -492,10 +513,10 @@ func validateTimeoutsConfig(cfg *Timeouts) error { name string value time.Duration }{ - {"listener_timeouts.read_header", cfg.ReadHeader}, - {"listener_timeouts.read", cfg.Read}, - {"listener_timeouts.write", cfg.Write}, - {"listener_timeouts.idle", cfg.Idle}, + {"server.timeouts.read_header", cfg.ReadHeader}, + {"server.timeouts.read", cfg.Read}, + {"server.timeouts.write", cfg.Write}, + {"server.timeouts.idle", cfg.Idle}, } { if f.value < 0 { return fmt.Errorf("%s must not be negative (got %s); use 0 to disable the timeout", f.name, f.value) @@ -503,7 +524,7 @@ func validateTimeoutsConfig(cfg *Timeouts) error { } if cfg.Read > 0 && cfg.ReadHeader > cfg.Read { return fmt.Errorf( - "listener_timeouts.read_header (%s) must not exceed listener_timeouts.read (%s): the header deadline would never be reached", + "server.timeouts.read_header (%s) must not exceed server.timeouts.read (%s): the header deadline would never be reached", cfg.ReadHeader, cfg.Read, ) } @@ -531,7 +552,7 @@ func validateAuthConfig(auth *Auth) error { } return validateFileBasedConfig(&auth.File) case AuthModeIDP: - return validateIDPConfig(&auth.IDP) + return validateIDPConfig(&auth.IDP, &auth.ClaimMappings) default: return fmt.Errorf("auth.mode must be %q, %q, or %q (got %q)", AuthModeExternalToken, AuthModeFile, AuthModeIDP, auth.Mode) } @@ -573,12 +594,12 @@ func validateLoggingConfig(level, format string) error { switch strings.ToUpper(level) { case "DEBUG", "INFO", "WARN", "WARNING", "ERROR": default: - return fmt.Errorf("log_level must be one of \"DEBUG\", \"INFO\", \"WARN\", or \"ERROR\" (got %q)", level) + return fmt.Errorf("logging.log_level must be one of \"DEBUG\", \"INFO\", \"WARN\", or \"ERROR\" (got %q)", level) } switch strings.ToLower(format) { case "text", "json": default: - return fmt.Errorf("log_format must be \"text\" or \"json\" (got %q)", format) + return fmt.Errorf("logging.log_format must be \"text\" or \"json\" (got %q)", format) } return nil } @@ -606,14 +627,20 @@ func validateDatabaseConfig(cfg *Database) error { if cfg.Name == "" { return fmt.Errorf("database.name is required when database.driver is %q", cfg.Driver) } - if cfg.User == "" { - return fmt.Errorf("database.user is required when database.driver is %q", cfg.Driver) + if cfg.Username == "" { + return fmt.Errorf("database.username is required when database.driver is %q", cfg.Driver) } switch cfg.SSLMode { case "", "disable", "require", "verify-ca", "verify-full": default: return fmt.Errorf("database.ssl_mode must be \"disable\", \"require\", \"verify-ca\", or \"verify-full\" (got %q)", cfg.SSLMode) } + if (cfg.SSLMode == "verify-ca" || cfg.SSLMode == "verify-full") && cfg.SSLRootCert == "" { + return fmt.Errorf("database.ssl_root_cert is required when database.ssl_mode is %q", cfg.SSLMode) + } + if (cfg.SSLCert == "") != (cfg.SSLKey == "") { + return fmt.Errorf("database.ssl_cert and database.ssl_key must both be set together, or both left empty") + } return nil } @@ -645,7 +672,7 @@ func validateCORSConfig(c *CORS) error { return nil } -func validateIDPConfig(idp *IDP) error { +func validateIDPConfig(idp *IDP, claimMappings *ClaimMappings) error { if idp.JWKSUrl == "" { return fmt.Errorf("auth.mode=%q requires auth.idp.jwks_url to be configured", AuthModeIDP) } @@ -657,8 +684,8 @@ func validateIDPConfig(idp *IDP) error { default: return fmt.Errorf("auth.idp.validation_mode must be \"scope\" or \"role\" (got %q)", idp.ValidationMode) } - if idp.ValidationMode == "role" && idp.ClaimMappings.Roles == "" { - return fmt.Errorf("auth.idp.validation_mode=role requires auth.idp.claim_mappings.roles to be configured") + if idp.ValidationMode == "role" && claimMappings.Roles == "" { + return fmt.Errorf("auth.idp.validation_mode=role requires auth.claim_mappings.roles to be configured") } return nil } @@ -702,9 +729,6 @@ func validateWebhookConfig(w *Webhook) error { if w.SignatureHeader == "" { w.SignatureHeader = "X-Devportal-Signature" } - if w.GatewayType == "" { - w.GatewayType = "wso2/api-platform" - } return nil } diff --git a/platform-api/config/config.toml b/platform-api/config/config.toml index 83ccc1a6f..432d83293 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -1,7 +1,9 @@ [platform_api] +[platform_api.logging] log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' +[platform_api.security] encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' [platform_api.database] diff --git a/platform-api/config/config_test.go b/platform-api/config/config_test.go index 67a991de2..9dc703b45 100644 --- a/platform-api/config/config_test.go +++ b/platform-api/config/config_test.go @@ -39,7 +39,7 @@ const ( // interpolation. Environment variables reach config ONLY through these tokens now // (there is no direct env-key override), so tests must go through a config file. const validKeysBase = ` -[platform_api] +[platform_api.security] encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' [platform_api.auth.jwt] @@ -68,7 +68,7 @@ func loadWithKeys(t *testing.T, extra string) (*Server, error) { func TestLoadConfig_ValidKeys_Succeeds(t *testing.T) { cfg, err := loadWithKeys(t, "") require.NoError(t, err) - assert.Equal(t, validInlineKey, cfg.EncryptionKey) + assert.Equal(t, validInlineKey, cfg.Security.EncryptionKey) } // The encryption key is required and never generated — a config that omits it fails startup. @@ -89,7 +89,7 @@ func TestLoadConfig_InvalidEncryptionKey_Errors(t *testing.T) { t.Setenv("APIP_CP_AUTH_JWT_SECRET_KEY", validJWTKey) _, err := loadTOML(t, ` -[platform_api] +[platform_api.security] encryption_key = "not-a-valid-32-byte-key" [platform_api.auth.jwt] @@ -104,7 +104,7 @@ func TestLoadConfig_MissingJWTSecretKey_Errors(t *testing.T) { t.Setenv("APIP_CP_ENCRYPTION_KEY", validInlineKey) _, err := loadTOML(t, ` -[platform_api] +[platform_api.security] encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' `) require.Error(t, err) @@ -116,7 +116,7 @@ func TestLoadConfig_InvalidJWTSecretKey_Errors(t *testing.T) { t.Setenv("APIP_CP_ENCRYPTION_KEY", validInlineKey) _, err := loadTOML(t, ` -[platform_api] +[platform_api.security] encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' [platform_api.auth.jwt] @@ -272,42 +272,42 @@ port = '{{ env "APIP_CP_SERVER_HTTP_PORT" }}' func TestLoadConfig_Timeouts_DefaultToFiniteValues(t *testing.T) { cfg, err := loadWithKeys(t, "") require.NoError(t, err) - assert.Equal(t, 10*time.Second, cfg.Timeouts.ReadHeader) - assert.Equal(t, 60*time.Second, cfg.Timeouts.Read) - assert.Equal(t, 120*time.Second, cfg.Timeouts.Write) - assert.Equal(t, 120*time.Second, cfg.Timeouts.Idle) + assert.Equal(t, 10*time.Second, cfg.Listeners.Timeouts.ReadHeader) + assert.Equal(t, 60*time.Second, cfg.Listeners.Timeouts.Read) + assert.Equal(t, 120*time.Second, cfg.Listeners.Timeouts.Write) + assert.Equal(t, 120*time.Second, cfg.Listeners.Timeouts.Idle) } // Duration strings resolved from {{ env }} tokens must decode into time.Duration fields. func TestLoadConfig_Timeouts_TokenOverride(t *testing.T) { - t.Setenv("APIP_CP_LISTENER_TIMEOUTS_READ_HEADER", "5s") - t.Setenv("APIP_CP_LISTENER_TIMEOUTS_READ", "30s") - t.Setenv("APIP_CP_LISTENER_TIMEOUTS_WRITE", "2m") - t.Setenv("APIP_CP_LISTENER_TIMEOUTS_IDLE", "90s") + t.Setenv("APIP_CP_SERVER_TIMEOUTS_READ_HEADER", "5s") + t.Setenv("APIP_CP_SERVER_TIMEOUTS_READ", "30s") + t.Setenv("APIP_CP_SERVER_TIMEOUTS_WRITE", "2m") + t.Setenv("APIP_CP_SERVER_TIMEOUTS_IDLE", "90s") cfg, err := loadWithKeys(t, ` -[platform_api.listener_timeouts] -read_header = '{{ env "APIP_CP_LISTENER_TIMEOUTS_READ_HEADER" }}' -read = '{{ env "APIP_CP_LISTENER_TIMEOUTS_READ" }}' -write = '{{ env "APIP_CP_LISTENER_TIMEOUTS_WRITE" }}' -idle = '{{ env "APIP_CP_LISTENER_TIMEOUTS_IDLE" }}' +[platform_api.server.timeouts] +read_header = '{{ env "APIP_CP_SERVER_TIMEOUTS_READ_HEADER" }}' +read = '{{ env "APIP_CP_SERVER_TIMEOUTS_READ" }}' +write = '{{ env "APIP_CP_SERVER_TIMEOUTS_WRITE" }}' +idle = '{{ env "APIP_CP_SERVER_TIMEOUTS_IDLE" }}' `) require.NoError(t, err) - assert.Equal(t, 5*time.Second, cfg.Timeouts.ReadHeader) - assert.Equal(t, 30*time.Second, cfg.Timeouts.Read) - assert.Equal(t, 2*time.Minute, cfg.Timeouts.Write) - assert.Equal(t, 90*time.Second, cfg.Timeouts.Idle) + assert.Equal(t, 5*time.Second, cfg.Listeners.Timeouts.ReadHeader) + assert.Equal(t, 30*time.Second, cfg.Listeners.Timeouts.Read) + assert.Equal(t, 2*time.Minute, cfg.Listeners.Timeouts.Write) + assert.Equal(t, 90*time.Second, cfg.Listeners.Timeouts.Idle) } // 0 is the net/http "no timeout" sentinel and must be accepted as-is, rather // than being silently replaced by the default. func TestLoadConfig_Timeouts_ZeroDisablesTimeout(t *testing.T) { cfg, err := loadWithKeys(t, ` -[platform_api.listener_timeouts] +[platform_api.server.timeouts] write = "0s" `) require.NoError(t, err) - assert.Zero(t, cfg.Timeouts.Write, "listener_timeouts.write = 0 must disable the write timeout") + assert.Zero(t, cfg.Listeners.Timeouts.Write, "server.timeouts.write = 0 must disable the write timeout") } // A negative duration would expire immediately and break every request; a @@ -316,7 +316,7 @@ write = "0s" func TestLoadConfig_Timeouts_RejectsInvalidValues(t *testing.T) { t.Run("negative", func(t *testing.T) { _, err := loadWithKeys(t, ` -[platform_api.listener_timeouts] +[platform_api.server.timeouts] read = "-1s" `) require.Error(t, err) @@ -325,7 +325,7 @@ read = "-1s" t.Run("read_header exceeds read", func(t *testing.T) { _, err := loadWithKeys(t, ` -[platform_api.listener_timeouts] +[platform_api.server.timeouts] read_header = "30s" read = "10s" `) diff --git a/platform-api/config/default_config.go b/platform-api/config/default_config.go index 439e17f38..30fe39ccf 100644 --- a/platform-api/config/default_config.go +++ b/platform-api/config/default_config.go @@ -26,8 +26,10 @@ import ( // defaultConfig returns a Server with all default values. func defaultConfig() *Server { return &Server{ - LogLevel: "INFO", - LogFormat: "text", + Logging: Logging{ + LogLevel: "INFO", + LogFormat: "text", + }, DBSchemaPath: "./internal/database/schema.sql", OpenAPISpecPath: "./resources/openapi.yaml", LLMTemplateDefinitionsPath: "./resources/default-llm-provider-templates", @@ -69,15 +71,15 @@ func defaultConfig() *Server { }, IDP: IDP{ ValidationMode: "scope", - ClaimMappings: IDPClaimMappings{ - Organization: "organization", - OrgName: "org_name", - OrgHandle: "org_handle", - UserID: "sub", - Username: "username", - Email: "email", - Scope: "scope", - }, + }, + ClaimMappings: ClaimMappings{ + Organization: "organization", + OrgName: "org_name", + OrgHandle: "org_handle", + UserID: "sub", + Username: "username", + Email: "email", + Scope: "scope", }, File: FileBased{ Organization: FileBasedOrg{ @@ -96,14 +98,6 @@ func defaultConfig() *Server { }, }, }, - WebSocket: WebSocket{ - MaxConnections: 1000, - ConnectionTimeout: 30, - RateLimitPerMin: 1000, - MaxConnectionsPerOrg: 3, - MetricsLogEnabled: true, - MetricsLogInterval: 10, - }, Deployments: Deployments{ MaxPerAPIGateway: 20, TimeoutEnabled: true, @@ -127,18 +121,27 @@ func defaultConfig() *Server { KeyFile: "./data/certs/key.pem", }, }, + // Finite by default so a slow or idle peer cannot hold a connection open + // indefinitely. Write is the loosest of the four because some handlers + // proxy slow upstreams (LLM completions, deployments). + Timeouts: Timeouts{ + ReadHeader: 10 * time.Second, + Read: 60 * time.Second, + Write: 120 * time.Second, + Idle: 120 * time.Second, + }, + WebSocket: WebSocket{ + MaxConnections: 1000, + ConnectionTimeout: 30, + RateLimitPerMin: 1000, + MetricsLogEnabled: true, + MetricsLogInterval: 10, + }, }, - // Finite by default so a slow or idle peer cannot hold a connection open - // indefinitely. Write is the loosest of the four because some handlers - // proxy slow upstreams (LLM completions, deployments). - Timeouts: Timeouts{ - ReadHeader: 10 * time.Second, - Read: 60 * time.Second, - Write: 120 * time.Second, - Idle: 120 * time.Second, - }, - APIKey: APIKey{ - HashingAlgorithms: []string{"sha256"}, + Security: Security{ + APIKey: APIKey{ + HashingAlgorithms: []string{"sha256"}, + }, }, EventHub: EventHub{ PollInterval: 3 * time.Second, @@ -147,7 +150,6 @@ func defaultConfig() *Server { }, Webhook: Webhook{ Enabled: false, - GatewayType: "wso2/api-platform", SignatureTolerance: 5 * time.Minute, MaxBodySize: 1 << 20, // 1 MiB SignatureHeader: "X-Devportal-Signature", diff --git a/platform-api/internal/database/connection.go b/platform-api/internal/database/connection.go index 905cb4574..1c025d1d2 100644 --- a/platform-api/internal/database/connection.go +++ b/platform-api/internal/database/connection.go @@ -109,8 +109,19 @@ func NewConnection(cfg *config.Database, slogger *slog.Logger) (*DB, error) { // Build PostgreSQL DSN from config dsn := fmt.Sprintf( "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", - cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.Name, cfg.SSLMode, + cfg.Host, cfg.Port, cfg.Username, cfg.Password, cfg.Name, cfg.SSLMode, ) + // sslrootcert verifies the server certificate (verify-ca/verify-full); + // sslcert/sslkey present a client certificate for mutual TLS. + if cfg.SSLRootCert != "" { + dsn += fmt.Sprintf(" sslrootcert=%s", cfg.SSLRootCert) + } + if cfg.SSLCert != "" { + dsn += fmt.Sprintf(" sslcert=%s", cfg.SSLCert) + } + if cfg.SSLKey != "" { + dsn += fmt.Sprintf(" sslkey=%s", cfg.SSLKey) + } // Use pgx stdlib driver for PostgreSQL db, err = sql.Open(DriverPGX, dsn) @@ -582,6 +593,13 @@ func buildSQLServerDSN(cfg *config.Database) string { q.Set("database", cfg.Name) q.Set("encrypt", encrypt) q.Set("TrustServerCertificate", trustServerCertificate) + // certificate pins the CA used to verify the server certificate; only + // meaningful once TrustServerCertificate is false. go-mssqldb has no + // client-certificate (mutual TLS) support, so ssl_cert/ssl_key are not + // applicable to this driver. + if cfg.SSLRootCert != "" && trustServerCertificate == "false" { + q.Set("certificate", cfg.SSLRootCert) + } host := cfg.Host if host == "" { @@ -593,8 +611,8 @@ func buildSQLServerDSN(cfg *config.Database) string { Host: fmt.Sprintf("%s:%d", host, cfg.Port), RawQuery: q.Encode(), } - if cfg.User != "" { - u.User = url.UserPassword(cfg.User, cfg.Password) + if cfg.Username != "" { + u.User = url.UserPassword(cfg.Username, cfg.Password) } return u.String() diff --git a/platform-api/internal/handler/auth_login.go b/platform-api/internal/handler/auth_login.go index 2d19d6f10..71e98f17f 100644 --- a/platform-api/internal/handler/auth_login.go +++ b/platform-api/internal/handler/auth_login.go @@ -86,17 +86,25 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) error { return apperror.Unauthorized.New().WithLogMessage("login failed: password mismatch") } + // Claim names come from auth.claim_mappings — the same mapping IDP mode + // reads incoming claims by — so a token this endpoint signs is readable by + // validateLocalJWT (and by any other consumer configured against the same + // mapping) without the two ever drifting apart. Mapped names are used as + // flat claim keys here; a dot-separated nested path (meant for reading + // externally-issued tokens) is not meaningful to sign against and is used + // as a literal flat key if configured that way. + cm := h.cfg.Auth.ClaimMappings expiry := time.Now().Add(h.cfg.Auth.JWT.TokenTTL) claims := jwt.MapClaims{ - "sub": matched.Username, - "username": matched.Username, - "scope": matched.Scopes, - "organization": fileBasedAuth.Organization.UUID, - "org_name": fileBasedAuth.Organization.DisplayName, - "org_handle": fileBasedAuth.Organization.ID, - "iss": h.cfg.Auth.JWT.Issuer, - "exp": expiry.Unix(), - "iat": time.Now().Unix(), + "sub": matched.Username, + claimKey(cm.Username, "username"): matched.Username, + claimKey(cm.Scope, "scope"): matched.Scopes, + claimKey(cm.Organization, "organization"): fileBasedAuth.Organization.UUID, + claimKey(cm.OrgName, "org_name"): fileBasedAuth.Organization.DisplayName, + claimKey(cm.OrgHandle, "org_handle"): fileBasedAuth.Organization.ID, + "iss": h.cfg.Auth.JWT.Issuer, + "exp": expiry.Unix(), + "iat": time.Now().Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) @@ -111,3 +119,12 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) error { }) return nil } + +// claimKey returns name, falling back to def when the operator has left the +// corresponding auth.claim_mappings field unset. +func claimKey(name, def string) string { + if name == "" { + return def + } + return name +} diff --git a/platform-api/internal/handler/websocket.go b/platform-api/internal/handler/websocket.go index c41d42fc3..e13c1e889 100644 --- a/platform-api/internal/handler/websocket.go +++ b/platform-api/internal/handler/websocket.go @@ -102,16 +102,6 @@ func (h *WebSocketHandler) Connect(w http.ResponseWriter, r *http.Request) error WithLogMessage(fmt.Sprintf("WebSocket authentication failed from IP %s", clientIP)) } - // Check organization connection limit before upgrading to WebSocket - if !h.manager.CanAcceptOrgConnection(gateway.OrganizationID) { - stats := h.manager.GetOrgConnectionStats(gateway.OrganizationID) - h.manager.IncrementFailedConnections() - return apperror.TooManyRequests.New(fmt.Sprintf( - "Organization connection limit reached. Maximum allowed connections: %d", stats.MaxAllowed)). - WithLogMessage(fmt.Sprintf("organization connection limit exceeded for org %s (count=%d, max=%d)", - gateway.OrganizationID, stats.CurrentCount, stats.MaxAllowed)) - } - // Upgrade HTTP connection to WebSocket conn, err := h.upgrader.Upgrade(w, r, nil) if err != nil { @@ -129,29 +119,12 @@ func (h *WebSocketHandler) Connect(w http.ResponseWriter, r *http.Request) error h.slogger.Error("Connection registration failed", "gatewayID", gateway.ID, "orgID", gateway.OrganizationID, "error", err) h.manager.IncrementFailedConnections() - // Check if this is an org connection limit error - if orgLimitErr, ok := err.(*ws.OrgConnectionLimitError); ok { - errorMsg := map[string]interface{}{ - "type": "error", - "code": "ORG_CONNECTION_LIMIT_EXCEEDED", - "message": "Organization connection limit reached", - "currentCount": orgLimitErr.CurrentCount, - "maxAllowed": orgLimitErr.MaxAllowed, - } - if jsonErr, _ := json.Marshal(errorMsg); jsonErr != nil { - conn.WriteMessage(websocket.TextMessage, jsonErr) - } - h.slogger.Warn("Organization connection limit exceeded", "orgID", orgLimitErr.OrganizationID, - "count", orgLimitErr.CurrentCount, "max", orgLimitErr.MaxAllowed) - } else { - // Generic error - errorMsg := map[string]string{ - "type": "error", - "message": err.Error(), - } - if jsonErr, _ := json.Marshal(errorMsg); jsonErr != nil { - conn.WriteMessage(websocket.TextMessage, jsonErr) - } + errorMsg := map[string]string{ + "type": "error", + "message": err.Error(), + } + if jsonErr, _ := json.Marshal(errorMsg); jsonErr != nil { + conn.WriteMessage(websocket.TextMessage, jsonErr) } conn.Close() return nil diff --git a/platform-api/internal/integration/harness_test.go b/platform-api/internal/integration/harness_test.go index 166407272..da17194f8 100644 --- a/platform-api/internal/integration/harness_test.go +++ b/platform-api/internal/integration/harness_test.go @@ -50,7 +50,7 @@ func TestMain(m *testing.M) { if err != nil { panic(fmt.Sprintf("integration harness: create temp config: %v", err)) } - if _, err := fmt.Fprintf(f, "[platform_api]\nencryption_key = %q\n\n[platform_api.auth]\nmode = \"external_token\"\n\n[platform_api.auth.jwt]\nsecret_key = %q\n", testKey, testKey); err != nil { + if _, err := fmt.Fprintf(f, "[platform_api.security]\nencryption_key = %q\n\n[platform_api.auth]\nmode = \"external_token\"\n\n[platform_api.auth.jwt]\nsecret_key = %q\n", testKey, testKey); err != nil { panic(fmt.Sprintf("integration harness: write temp config: %v", err)) } _ = f.Close() diff --git a/platform-api/internal/middleware/auth.go b/platform-api/internal/middleware/auth.go index 2f2f4af04..6cec80198 100644 --- a/platform-api/internal/middleware/auth.go +++ b/platform-api/internal/middleware/auth.go @@ -64,15 +64,19 @@ type CustomClaims struct { // AuthConfig holds the configuration for the local JWT (non-IDP) authentication path. type AuthConfig struct { - SecretKey string - TokenIssuer string - SkipPaths []string - SkipValidation bool - OrganizationClaimName string -} - -// PlatformClaimNames holds the JWT claim names used to extract platform-specific values. -type PlatformClaimNames struct { + SecretKey string + TokenIssuer string + SkipPaths []string + SkipValidation bool + // ClaimMappings is the same claim-name mapping used by IDP mode + // (PlatformClaimsMiddleware) and by the file-mode login endpoint when it + // signs tokens — one mapping shared by issuance and validation. + ClaimMappings ClaimMappings +} + +// ClaimMappings holds the JWT claim names used to extract identity values, +// shared by the local-JWT (external_token/file) and IDP auth paths. +type ClaimMappings struct { OrganizationClaim string OrgNameClaim string OrgHandleClaim string @@ -184,7 +188,7 @@ func validateLocalJWT(r *http.Request, tokenString string, config AuthConfig) (* } } - orgClaimName := config.OrganizationClaimName + orgClaimName := config.ClaimMappings.OrganizationClaim if orgClaimName == "" { orgClaimName = "organization" } @@ -192,17 +196,19 @@ func validateLocalJWT(r *http.Request, tokenString string, config AuthConfig) (* if org == "" { return nil, fmt.Errorf("token missing required '%s' claim", orgClaimName) } + orgName := getStringClaim(mapClaims, config.ClaimMappings.OrgNameClaim) + orgHandle := getStringClaim(mapClaims, config.ClaimMappings.OrgHandleClaim) sub, _ := mapClaims["sub"].(string) - username := getStringClaim(mapClaims, "username") + username := getStringClaim(mapClaims, config.ClaimMappings.UsernameClaim) if username == "" { username = sub } claimsObj := &CustomClaims{ Organization: org, Username: username, - Email: getStringClaim(mapClaims, "email"), - Scope: getStringClaim(mapClaims, "scope"), + Email: getStringClaim(mapClaims, config.ClaimMappings.EmailClaim), + Scope: getStringClaim(mapClaims, config.ClaimMappings.ScopeClaim), Audience: audienceToString(mapClaims), JTI: getStringClaim(mapClaims, "jti"), RegisteredClaims: jwt.RegisteredClaims{ @@ -210,23 +216,27 @@ func validateLocalJWT(r *http.Request, tokenString string, config AuthConfig) (* }, } + platformRoles := resolvePlatformRoles(mapClaims, config.ClaimMappings.RolesClaimPath, config.ClaimMappings.RoleScopeMap) + ctx := r.Context() - ctx = context.WithValue(ctx, keyUserID, resolveUserID(mapClaims, "")) + ctx = context.WithValue(ctx, keyUserID, resolveUserID(mapClaims, config.ClaimMappings.UserIDClaim)) ctx = context.WithValue(ctx, keyUsername, claimsObj.Username) ctx = context.WithValue(ctx, keyEmail, claimsObj.Email) ctx = context.WithValue(ctx, keyFirstName, getStringClaim(mapClaims, "firstName")) ctx = context.WithValue(ctx, keyLastName, getStringClaim(mapClaims, "lastName")) ctx = context.WithValue(ctx, keyOrganization, org) + ctx = context.WithValue(ctx, keyOrgName, orgName) + ctx = context.WithValue(ctx, keyOrgHandle, orgHandle) ctx = context.WithValue(ctx, keyScope, claimsObj.Scope) ctx = context.WithValue(ctx, keyAudience, claimsObj.Audience) ctx = context.WithValue(ctx, keyClaims, claimsObj) - ctx = context.WithValue(ctx, keyPlatformRoles, []string{}) + ctx = context.WithValue(ctx, keyPlatformRoles, platformRoles) return r.WithContext(ctx), nil } // PlatformClaimsMiddleware extracts platform-specific values from the AuthContext set by // common/authenticators.AuthMiddleware (IDP mode) and populates per-key context entries. -func PlatformClaimsMiddleware(claimNames PlatformClaimNames) func(http.Handler) http.Handler { +func PlatformClaimsMiddleware(claimNames ClaimMappings) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authCtx, ok := authenticators.GetAuthContext(r) diff --git a/platform-api/internal/repository/subscription_repository.go b/platform-api/internal/repository/subscription_repository.go index 9f5de319f..619747dc6 100644 --- a/platform-api/internal/repository/subscription_repository.go +++ b/platform-api/internal/repository/subscription_repository.go @@ -52,11 +52,11 @@ func hashSubscriptionToken(token string) string { // The single configured EncryptionKey is used for all at-rest encryption. func getSubscriptionTokenEncryptionKey() ([]byte, error) { cfg := config.GetConfig() - if cfg.EncryptionKey == "" { + if cfg.Security.EncryptionKey == "" { return nil, fmt.Errorf("subscription token encryption requires EncryptionKey. Set encryption_key" + " in config via {{ env }}/{{ file }}") } - return utils.DeriveEncryptionKey(cfg.EncryptionKey) + return utils.DeriveEncryptionKey(cfg.Security.EncryptionKey) } // SubscriptionRepo implements SubscriptionRepository @@ -241,10 +241,10 @@ func (r *SubscriptionRepo) ListByFilters(orgUUID string, apiUUID *string, subscr // re-introduced for a migration if ever needed). func decryptionKeyCandidates() [][]byte { cfg := config.GetConfig() - if cfg.EncryptionKey == "" { + if cfg.Security.EncryptionKey == "" { return nil } - if k, err := utils.DeriveEncryptionKey(cfg.EncryptionKey); err == nil { + if k, err := utils.DeriveEncryptionKey(cfg.Security.EncryptionKey); err == nil { return [][]byte{k} } return nil diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index a66235c05..af264c9f9 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -71,7 +71,7 @@ type Server struct { // are not covered by config-load validation. All checks are unconditional: // there is no relaxed/demo mode. func validateServerConfig(cfg *config.Server) error { - if slices.Contains(cfg.CORS.AllowedOrigins, "*") { + if slices.Contains(cfg.Listeners.CORS.AllowedOrigins, "*") { return fmt.Errorf("cors.allowed_origins must not contain \"*\"; list explicit origins, or leave it empty to disable cross-origin access") } return nil @@ -187,14 +187,13 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, // Initialize WebSocket manager first (needed for GatewayEventsService) wsConfig := websocket.ManagerConfig{ - MaxConnections: cfg.WebSocket.MaxConnections, - HeartbeatInterval: 20 * time.Second, - HeartbeatTimeout: time.Duration(cfg.WebSocket.ConnectionTimeout) * time.Second, - MaxConnectionsPerOrg: cfg.WebSocket.MaxConnectionsPerOrg, - MetricsLogEnabled: cfg.WebSocket.MetricsLogEnabled, - MetricsLogInterval: time.Duration(cfg.WebSocket.MetricsLogInterval) * time.Second, + MaxConnections: cfg.Listeners.WebSocket.MaxConnections, + HeartbeatInterval: 20 * time.Second, + HeartbeatTimeout: time.Duration(cfg.Listeners.WebSocket.ConnectionTimeout) * time.Second, + MetricsLogEnabled: cfg.Listeners.WebSocket.MetricsLogEnabled, + MetricsLogInterval: time.Duration(cfg.Listeners.WebSocket.MetricsLogInterval) * time.Second, } - wsManager := websocket.NewManager(wsConfig, gatewayRepo, slogger) + wsManager := websocket.NewManager(wsConfig, slogger) // Initialize EventHub for multi-replica HA event delivery. // Events published here are polled by all platform-api instances; each instance @@ -240,7 +239,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, subscriptionService := service.NewSubscriptionService(apiRepo, artifactRepo, subscriptionRepo, subscriptionPlanRepo, orgRepo, gatewayEventsService, auditRepo, slogger) subscriptionPlanService := service.NewSubscriptionPlanService(subscriptionPlanRepo, gatewayRepo, orgRepo, gatewayEventsService, auditRepo, slogger) internalGatewayService := service.NewGatewayInternalAPIService(apiRepo, subscriptionRepo, subscriptionPlanRepo, llmProviderRepo, llmProxyRepo, mcpProxyRepo, deploymentRepo, gatewayRepo, orgRepo, projectRepo, apiKeyRepo, artifactRepo, secretRepo, cfg, slogger) - apiKeyService := service.NewAPIKeyService(apiRepo, artifactRepo, apiKeyRepo, gatewayEventsService, auditRepo, cfg.APIKey.HashingAlgorithms, slogger) + apiKeyService := service.NewAPIKeyService(apiRepo, artifactRepo, apiKeyRepo, gatewayEventsService, auditRepo, cfg.Security.APIKey.HashingAlgorithms, slogger) deploymentService := service.NewDeploymentService(apiRepo, artifactRepo, deploymentRepo, gatewayRepo, orgRepo, apiKeyRepo, gatewayEventsService, auditRepo, apiUtil, cfg, slogger) llmTemplateService := service.NewLLMProviderTemplateService(llmTemplateRepo, auditRepo, identityService) llmProviderService := service.NewLLMProviderService(llmProviderRepo, llmTemplateRepo, orgRepo, llmTemplateSeeder, deploymentRepo, gatewayRepo, gatewayEventsService, slogger, auditRepo, cfg, identityService) @@ -249,7 +248,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, // The single configured encryption key (APIP_CP_ENCRYPTION_KEY) is used for all encrypted DB // columns (secrets, subscription tokens, WebSub HMAC secrets) - dbEncryptionKey := cfg.EncryptionKey + dbEncryptionKey := cfg.Security.EncryptionKey llmProviderDeploymentService := service.NewLLMProviderDeploymentService( llmProviderRepo, llmTemplateRepo, @@ -301,7 +300,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, ) // Initialize secret vault and service using the single configured encryption key. - secretKey, keyErr := utils.DeriveEncryptionKey(cfg.EncryptionKey) + secretKey, keyErr := utils.DeriveEncryptionKey(cfg.Security.EncryptionKey) if keyErr != nil { return nil, fmt.Errorf("invalid encryption key: %w", keyErr) } @@ -319,7 +318,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService, subscriptionPlanService, identityService, slogger) subscriptionPlanHandler := handler.NewSubscriptionPlanHandler(subscriptionPlanService, identityService, slogger) appHandler := handler.NewApplicationHandler(appService, identityService, slogger) - wsHandler := handler.NewWebSocketHandler(wsManager, gatewayService, deploymentService, cfg.WebSocket.RateLimitPerMin, slogger) + wsHandler := handler.NewWebSocketHandler(wsManager, gatewayService, deploymentService, cfg.Listeners.WebSocket.RateLimitPerMin, slogger) internalGatewayHandler := handler.NewGatewayInternalAPIHandler(gatewayService, internalGatewayService, artifactImportService, secretService, slogger) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService, identityService, slogger) deploymentHandler := handler.NewDeploymentHandler(deploymentService, identityService, slogger) @@ -458,7 +457,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, slogger, ) webhookReceiver.RegisterRoutes(mux) - slogger.Info("Webhook receiver enabled", "path", webhook.RoutePath, "gatewayType", cfg.Webhook.GatewayType) + slogger.Info("Webhook receiver enabled", "path", webhook.RoutePath) } slogger.Info("Registered API routes successfully") @@ -469,7 +468,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, // Cross-origin access is disabled by default (empty AllowedOrigins fails closed in the // CORS middleware); operators must opt in explicitly via CORS.AllowedOrigins in config. - corsOrigins := cfg.CORS.AllowedOrigins + corsOrigins := cfg.Listeners.CORS.AllowedOrigins if len(corsOrigins) == 0 { slogger.Warn("cors.allowed_origins not set in config — cross-origin requests are disabled") } else if slices.Contains(corsOrigins, "*") { @@ -490,6 +489,7 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, TokenIssuer: cfg.Auth.JWT.Issuer, SkipPaths: cfg.Auth.SkipPaths, SkipValidation: false, + ClaimMappings: buildClaimMappings(cfg.Auth.ClaimMappings, roleScopeMap), })) } else { authenticator, err := buildAuthenticator(cfg, slogger, roleScopeMap) @@ -522,10 +522,9 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, })) slogger.Info("WebSocket manager initialized", - slog.Int("maxConnections", cfg.WebSocket.MaxConnections), - slog.Int("heartbeatTimeout", cfg.WebSocket.ConnectionTimeout), - slog.Int("rateLimitPerMin", cfg.WebSocket.RateLimitPerMin), - slog.Int("maxConnectionsPerOrg", cfg.WebSocket.MaxConnectionsPerOrg), + slog.Int("maxConnections", cfg.Listeners.WebSocket.MaxConnections), + slog.Int("heartbeatTimeout", cfg.Listeners.WebSocket.ConnectionTimeout), + slog.Int("rateLimitPerMin", cfg.Listeners.WebSocket.RateLimitPerMin), ) return &Server{ @@ -543,6 +542,23 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, }, nil } +// buildClaimMappings adapts the config-level claim name mapping (shared by all +// three auth modes) into the middleware package's ClaimMappings, attaching the +// resolved IDP-role-to-scope table alongside it. +func buildClaimMappings(cm config.ClaimMappings, roleScopeMap map[string][]string) middleware.ClaimMappings { + return middleware.ClaimMappings{ + OrganizationClaim: cm.Organization, + OrgNameClaim: cm.OrgName, + OrgHandleClaim: cm.OrgHandle, + UserIDClaim: cm.UserID, + UsernameClaim: cm.Username, + EmailClaim: cm.Email, + ScopeClaim: cm.Scope, + RolesClaimPath: cm.Roles, + RoleScopeMap: roleScopeMap, + } +} + // buildAuthenticator constructs an Authenticator from the server configuration. // Only called when the auth mode is "external_token" or "idp" (file mode wires // its own local-JWT middleware). @@ -555,6 +571,7 @@ func buildAuthenticator(cfg *config.Server, slogger *slog.Logger, roleScopeMap m TokenIssuer: cfg.Auth.JWT.Issuer, SkipPaths: cfg.Auth.SkipPaths, SkipValidation: false, + ClaimMappings: buildClaimMappings(cfg.Auth.ClaimMappings, roleScopeMap), }), ), nil } @@ -568,7 +585,7 @@ func buildAuthenticator(cfg *config.Server, slogger *slog.Logger, roleScopeMap m Enabled: true, IssuerURL: issuerURL, JWKSUrl: cfg.Auth.IDP.JWKSUrl, - ScopeClaim: cfg.Auth.IDP.ClaimMappings.Scope, + ScopeClaim: cfg.Auth.ClaimMappings.Scope, } // Enforce audience validation only when at least one audience is configured. if len(cfg.Auth.IDP.Audience) > 0 { @@ -582,17 +599,7 @@ func buildAuthenticator(cfg *config.Server, slogger *slog.Logger, roleScopeMap m if err != nil { return nil, fmt.Errorf("failed to initialize IDP auth middleware: %w", err) } - claimsMiddleware := middleware.PlatformClaimsMiddleware(middleware.PlatformClaimNames{ - OrganizationClaim: cfg.Auth.IDP.ClaimMappings.Organization, - OrgNameClaim: cfg.Auth.IDP.ClaimMappings.OrgName, - OrgHandleClaim: cfg.Auth.IDP.ClaimMappings.OrgHandle, - UserIDClaim: cfg.Auth.IDP.ClaimMappings.UserID, - UsernameClaim: cfg.Auth.IDP.ClaimMappings.Username, - EmailClaim: cfg.Auth.IDP.ClaimMappings.Email, - ScopeClaim: cfg.Auth.IDP.ClaimMappings.Scope, - RolesClaimPath: cfg.Auth.IDP.ClaimMappings.Roles, - RoleScopeMap: roleScopeMap, - }) + claimsMiddleware := middleware.PlatformClaimsMiddleware(buildClaimMappings(cfg.Auth.ClaimMappings, roleScopeMap)) idpLabel := cfg.Auth.IDP.Name if idpLabel == "" { diff --git a/platform-api/internal/webhook/envelope.go b/platform-api/internal/webhook/envelope.go index bd5e2987a..b7ec40b38 100644 --- a/platform-api/internal/webhook/envelope.go +++ b/platform-api/internal/webhook/envelope.go @@ -66,7 +66,6 @@ type Envelope struct { // OrgID is the legacy flat org identifier, superseded by Org.RefID. DecodeEnvelope copies // Org.RefID into it when present, so downstream handlers can keep reading env.OrgID. OrgID string `json:"org_id"` - GatewayType string `json:"gateway_type"` AggregateType string `json:"aggregate_type"` AggregateID string `json:"aggregate_id"` SchemaVersion string `json:"schema_version"` diff --git a/platform-api/internal/webhook/receiver.go b/platform-api/internal/webhook/receiver.go index 4e75f6527..c67c39b3b 100644 --- a/platform-api/internal/webhook/receiver.go +++ b/platform-api/internal/webhook/receiver.go @@ -133,7 +133,7 @@ func (r *Receiver) RegisterRoutes(mux *http.ServeMux) { } // ReceiveEvent runs the full webhook flow: size-limited read -> signature verify -> envelope -// decode/validate -> gateway_type filter -> idempotency -> dispatch -> mark processed. +// decode/validate -> idempotency -> dispatch -> mark processed. func (r *Receiver) ReceiveEvent(w http.ResponseWriter, req *http.Request) error { if !r.cfg.Enabled { return apperror.NotFound.New().WithLogMessage("webhook endpoint is disabled") @@ -175,14 +175,7 @@ func (r *Receiver) ReceiveEvent(w http.ResponseWriter, req *http.Request) error } log = log.With("orgId", env.OrgID) - // 4. gateway_type filter — events for other gateway types are a no-op (accepted, not processed). - if r.cfg.GatewayType != "" && env.GatewayType != "" && env.GatewayType != r.cfg.GatewayType { - log.Info("Webhook event for a different gateway_type; accepting as no-op", "eventGatewayType", env.GatewayType) - httputil.WriteJSON(w, http.StatusAccepted, map[string]string{"status": "ignored", "reason": "gateway_type mismatch"}) - return nil - } - - // 5. Dispatch to the matching handler. Duplicate (at-least-once) deliveries are made safe by + // 4. Dispatch to the matching handler. Duplicate (at-least-once) deliveries are made safe by // each handler being idempotent by domain identity, so no envelope-level dedup is needed. handle, ok := r.handlers[env.EventType] if !ok { diff --git a/platform-api/internal/websocket/errors.go b/platform-api/internal/websocket/errors.go deleted file mode 100644 index 5d8f61930..000000000 --- a/platform-api/internal/websocket/errors.go +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. - * - * Licensed 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 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package websocket - -import "fmt" - -// OrgConnectionLimitError is returned when an organization has reached its connection limit -type OrgConnectionLimitError struct { - OrganizationID string - CurrentCount int - MaxAllowed int -} - -func (e *OrgConnectionLimitError) Error() string { - return fmt.Sprintf("organization %s has reached maximum connection limit: %d/%d", - e.OrganizationID, e.CurrentCount, e.MaxAllowed) -} - -// IsOrgConnectionLimitError checks if an error is an OrgConnectionLimitError -func IsOrgConnectionLimitError(err error) bool { - _, ok := err.(*OrgConnectionLimitError) - return ok -} diff --git a/platform-api/internal/websocket/manager.go b/platform-api/internal/websocket/manager.go index 1509966e6..a654058d7 100644 --- a/platform-api/internal/websocket/manager.go +++ b/platform-api/internal/websocket/manager.go @@ -25,8 +25,6 @@ import ( "sync/atomic" "time" - "github.com/wso2/api-platform/platform-api/internal/repository" - "github.com/google/uuid" ) @@ -57,12 +55,6 @@ type Manager struct { // heartbeatTimeout specifies when to consider a connection dead (default 30s) heartbeatTimeout time.Duration - // maxConnectionsPerOrg enforces per-organization connection limits - maxConnectionsPerOrg int - - // gatewayRepo provides access to gateway data for org-scoped connection counting - gatewayRepo repository.GatewayRepository - // slogger is the structured logger instance slogger *slog.Logger @@ -95,48 +87,38 @@ type Manager struct { // ManagerConfig contains configuration parameters for the connection manager type ManagerConfig struct { - MaxConnections int // Maximum concurrent connections (default 1000) - HeartbeatInterval time.Duration // Ping interval (default 20s) - HeartbeatTimeout time.Duration // Pong timeout (default 30s) - MaxConnectionsPerOrg int // Maximum connections per organization (default 3) - MetricsLogEnabled bool // Enable periodic metrics logging (default true) - MetricsLogInterval time.Duration // Interval between metrics log entries (default 10s) -} - -type OrgConnectionStats struct { - OrganizationID string `json:"organizationId"` - CurrentCount int `json:"currentCount"` - MaxAllowed int `json:"maxAllowed"` + MaxConnections int // Maximum concurrent connections (default 1000) + HeartbeatInterval time.Duration // Ping interval (default 20s) + HeartbeatTimeout time.Duration // Pong timeout (default 30s) + MetricsLogEnabled bool // Enable periodic metrics logging (default true) + MetricsLogInterval time.Duration // Interval between metrics log entries (default 10s) } // DefaultManagerConfig returns sensible default configuration values func DefaultManagerConfig() ManagerConfig { return ManagerConfig{ - MaxConnections: 1000, - HeartbeatInterval: 20 * time.Second, - HeartbeatTimeout: 30 * time.Second, - MaxConnectionsPerOrg: 3, - MetricsLogEnabled: true, - MetricsLogInterval: 10 * time.Second, + MaxConnections: 1000, + HeartbeatInterval: 20 * time.Second, + HeartbeatTimeout: 30 * time.Second, + MetricsLogEnabled: true, + MetricsLogInterval: 10 * time.Second, } } // NewManager creates a new connection manager with the provided configuration -func NewManager(config ManagerConfig, gatewayRepo repository.GatewayRepository, slogger *slog.Logger) *Manager { +func NewManager(config ManagerConfig, slogger *slog.Logger) *Manager { ctx, cancel := context.WithCancel(context.Background()) mgr := &Manager{ - connections: sync.Map{}, - connectionCount: 0, - maxConnections: config.MaxConnections, - heartbeatInterval: config.HeartbeatInterval, - heartbeatTimeout: config.HeartbeatTimeout, - maxConnectionsPerOrg: config.MaxConnectionsPerOrg, - gatewayRepo: gatewayRepo, - slogger: slogger, - shutdownCtx: ctx, - shutdownFn: cancel, - metricsLogEnabled: config.MetricsLogEnabled, - metricsLogInterval: config.MetricsLogInterval, + connections: sync.Map{}, + connectionCount: 0, + maxConnections: config.MaxConnections, + heartbeatInterval: config.HeartbeatInterval, + heartbeatTimeout: config.HeartbeatTimeout, + slogger: slogger, + shutdownCtx: ctx, + shutdownFn: cancel, + metricsLogEnabled: config.MetricsLogEnabled, + metricsLogInterval: config.MetricsLogInterval, } // Disable metrics logging if the interval is non-positive to prevent // time.NewTicker from panicking. @@ -177,16 +159,6 @@ func (m *Manager) SetConnectionHooks(onConnect, onDisconnect func(gatewayID stri func (m *Manager) Register(gatewayID string, transport Transport, authToken string, orgID string) (*Connection, error) { - // Check per-org limit first (count from main connections map) - orgCount := m.countOrgConnections(orgID) - if orgCount >= m.maxConnectionsPerOrg { - return nil, &OrgConnectionLimitError{ - OrganizationID: orgID, - CurrentCount: orgCount, - MaxAllowed: m.maxConnectionsPerOrg, - } - } - // Check global connection limit m.mu.Lock() if m.connectionCount >= m.maxConnections { @@ -212,7 +184,7 @@ func (m *Manager) Register(gatewayID string, transport Transport, authToken stri m.IncrementSuccessfulConnections() m.slogger.Info("Gateway connected", "gatewayID", gatewayID, "connectionID", connectionID, - "orgID", orgID, "totalConnections", m.GetConnectionCount(), "orgConnections", m.countOrgConnections(orgID)) + "orgID", orgID, "totalConnections", m.GetConnectionCount()) if m.onConnect != nil { m.onConnect(gatewayID) @@ -311,25 +283,6 @@ func (m *Manager) GetConnectionCount() int { return m.connectionCount } -// countOrgConnections counts the number of connections for a specific organization -// by fetching the org's gateways and only counting connections for those gateway IDs. -func (m *Manager) countOrgConnections(orgID string) int { - gateways, err := m.gatewayRepo.GetByOrganizationID(orgID) - if err != nil { - m.slogger.Error("Failed to fetch gateways for org", "orgID", orgID, "error", err) - return 0 - } - - count := 0 - for _, gw := range gateways { - if connsInterface, ok := m.connections.Load(gw.ID); ok { - conns := connsInterface.([]*Connection) - count += len(conns) - } - } - return count -} - // monitorHeartbeat periodically sends ping frames and detects connection death. // Runs in a background goroutine for each connection. // @@ -411,21 +364,6 @@ func (m *Manager) Shutdown() { m.slogger.Info("WebSocket manager shutdown complete") } -// GetOrgConnectionStats returns connection statistics for a specific organization -func (m *Manager) GetOrgConnectionStats(orgID string) OrgConnectionStats { - return OrgConnectionStats{ - OrganizationID: orgID, - CurrentCount: m.countOrgConnections(orgID), - MaxAllowed: m.maxConnectionsPerOrg, - } -} - -// CanAcceptOrgConnection checks if the organization can accept a new connection -// without actually adding it. Use this for pre-upgrade validation. -func (m *Manager) CanAcceptOrgConnection(orgID string) bool { - return m.countOrgConnections(orgID) < m.maxConnectionsPerOrg -} - type metricsPayload struct { From string `json:"from"` To string `json:"to"` diff --git a/portals/ai-workspace/Makefile b/portals/ai-workspace/Makefile index a7384985a..492223653 100644 --- a/portals/ai-workspace/Makefile +++ b/portals/ai-workspace/Makefile @@ -30,7 +30,7 @@ IMAGE_NAME := $(DOCKER_REGISTRY)/ai-workspace # BFF (Backend-for-Frontend) — Go server that serves the SPA, proxies all # browser→backend traffic, and owns authentication. BFF_DIR := bff -BFF_DEV_ADDR ?= :8081 +BFF_DEV_PORT ?= 8081 CONTROL_PLANE_URL ?= https://localhost:9243 help: ## Show this help message @@ -62,8 +62,8 @@ bff-run: ## Run the BFF locally (proxies to CONTROL_PLANE_URL; pair with `make r APIP_AIW_CONTROL_PLANE_TLS_SKIP_VERIFY=true \ APIP_AIW_TLS_CERT_FILE=../resources/certificates/cert.pem \ APIP_AIW_TLS_KEY_FILE=../resources/certificates/key.pem \ - APIP_AIW_LISTEN_ADDR=$(BFF_DEV_ADDR) \ - APIP_AIW_STATIC_DIR=../dist \ + APIP_AIW_SERVER_PORT=$(BFF_DEV_PORT) \ + APIP_AIW_SERVER_STATIC_DIR=../dist \ APIP_AIW_LOG_LEVEL=debug \ go run . -config ../configs/config.toml diff --git a/portals/ai-workspace/README.md b/portals/ai-workspace/README.md index 62d9e1893..e71c1df48 100644 --- a/portals/ai-workspace/README.md +++ b/portals/ai-workspace/README.md @@ -18,15 +18,15 @@ The AI Workspace is a React/Vite SPA served by a **Go BFF (Backend-for-Frontend) ## Technology stack - **React** + **TypeScript** + **Vite** -- **Go BFF** — serves the SPA, reverse-proxies `/api/proxy/*` to the Platform API (injecting the session bearer token), and handles login/logout/session + the OIDC code flow +- **Go BFF** — serves the SPA, reverse-proxies `/proxy/*` to the Platform API (injecting the session bearer token), and handles login/logout/session + the OIDC code flow - **Docker Compose** — orchestrates AI Workspace + Platform API --- ## Auth modes -Controlled by `auth_mode` in `configs/config.toml` (whose shipped token also reads -`APIP_AIW_AUTH_MODE` from the environment): +Controlled by `mode` in `[ai_workspace.auth]` in `configs/config.toml` (whose shipped token also +reads `APIP_AIW_AUTH_MODE` from the environment): | Mode | When to use | |---|---| @@ -57,15 +57,15 @@ The shipped `config.toml` writes each key as an `{{ env }}` token, so a deployme them from the environment without editing the file: ```toml -[ai_workspace] +[ai_workspace.logging] # The token names the variable; the second argument is the value used when it is unset. log_level = '{{ env "APIP_AIW_LOG_LEVEL" "info" }}' -[ai_workspace.oidc] -# No default — an unset APIP_AIW_OIDC_CLIENT_SECRET fails startup rather than +[ai_workspace.auth.oidc] +# No default — an unset APIP_AIW_AUTH_OIDC_CLIENT_SECRET fails startup rather than # 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" }}' +client_secret = '{{ env "APIP_AIW_AUTH_OIDC_CLIENT_SECRET" }}' # Preferred in production — the secret never enters the environment at all. client_secret = '{{ file "/secrets/ai-workspace/oidc_client_secret" }}' @@ -76,10 +76,10 @@ The token is what reads the environment, so setting `APIP_AIW_LOG_LEVEL` does no `[ai_workspace]` — the same namespacing convention the Platform API uses for its own `[platform_api]` table, so a shared config.toml can hold both services' sections without their keys colliding. Keys are grouped into TOML tables under it -(`[ai_workspace.control_plane]`, `[ai_workspace.tls]`, `[ai_workspace.session]`, -`[ai_workspace.cookie]`, `[ai_workspace.oidc]`), and by convention a token names the key's path -under `[ai_workspace]` uppercased with underscores (`[ai_workspace.oidc] client_id` → -`APIP_AIW_OIDC_CLIENT_ID`) — but a token may name any variable. +(`[ai_workspace.logging]`, `[ai_workspace.control_plane]`, `[ai_workspace.tls]`, `[ai_workspace.session]`, +`[ai_workspace.auth]`, `[ai_workspace.auth.oidc]`), and by convention a token names the key's path +under `[ai_workspace]` uppercased with underscores (`[ai_workspace.auth.oidc] client_id` → +`APIP_AIW_AUTH_OIDC_CLIENT_ID`) — but a token may name any variable. All available options are documented in [configs/config-template.toml](configs/config-template.toml). @@ -146,13 +146,13 @@ npm run dev ``` This starts the AI Workspace frontend in development mode. -Update platform-api/config/config.toml and set the following configuration: +`platform-api/config/config.toml` already defaults to `[auth] mode = "file"`, so users configured +under `[auth.file]` can log in without any changes. To confirm or set it explicitly: ```bash -[auth.file_based] -enabled = true +[auth] +mode = "file" ``` -This enables file-based authentication, allowing users configured in the file-based authentication settings to log in. Terminal 2: ```bash @@ -187,7 +187,7 @@ you supply your IDP's issuer, JWKS URL and confidential-client credentials. By default the stack runs in `basic` (file-based) auth mode. In OIDC mode the **BFF is a confidential client**: it runs the authorization-code + PKCE flow back-channel, holds the client secret and tokens in a server-side session, and injects the access token on every -`/api/proxy/*` call. The browser never sees a token or the secret. +`/proxy/*` call. The browser never sees a token or the secret. The flow touches **two** components and both must agree on the IDP: @@ -257,10 +257,10 @@ stays out of the file, referenced there by an interpolation token: ```toml # portals/ai-workspace/configs/config.toml -[ai_workspace] -auth_mode = "oidc" +[ai_workspace.auth] +mode = "oidc" -[ai_workspace.oidc] +[ai_workspace.auth.oidc] authority = "https://idp.example.com/oauth2/token" # Asgardeo: https://api.asgardeo.io/t/acme/oauth2/token client_id = "" redirect_url = "https://localhost:5380/api/auth/callback" @@ -268,7 +268,7 @@ post_logout_redirect_url = "https://localhost:5380/login" # The secret's *value* stays out of the file — this token pulls it in from the # environment at startup. Without the key here, the variable is never read. -client_secret = '{{ env "APIP_AIW_OIDC_CLIENT_SECRET" }}' +client_secret = '{{ env "APIP_AIW_AUTH_OIDC_CLIENT_SECRET" }}' ``` The secret's value is **not** written into the config file. Put it — along with the Platform API's @@ -277,18 +277,16 @@ IDP settings — in `api-platform.env` next to `docker-compose.yaml` (git-ignore ```bash # portals/ai-workspace/api-platform.env — append below the setup.sh-generated keys -APIP_AIW_OIDC_CLIENT_SECRET= # read by the token above -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_AIW_AUTH_OIDC_CLIENT_SECRET= # read by the token above +APIP_CP_AUTH_MODE=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_ORGANIZATION=org_id -# APIP_CP_AUTH_IDP_CLAIM_ORG_NAME=org_name -# APIP_CP_AUTH_IDP_CLAIM_ORG_HANDLE=org_handle +# APIP_CP_AUTH_CLAIM_ORGANIZATION=org_id +# APIP_CP_AUTH_CLAIM_ORG_NAME=org_name +# APIP_CP_AUTH_CLAIM_ORG_HANDLE=org_handle ``` Then start the stack: @@ -298,15 +296,14 @@ docker compose up -d ``` In production, prefer mounting the secret as a file and referencing it with -`[ai_workspace.oidc] client_secret = '{{ file "/secrets/ai-workspace/oidc_client_secret" }}'` — the value then +`[ai_workspace.auth.oidc] client_secret = '{{ file "/secrets/ai-workspace/oidc_client_secret" }}'` — the value then never enters the environment at all. -(Skipping this section and leaving `auth_mode = "basic"`, the default, keeps the file-based +(Skipping this section and leaving `[ai_workspace.auth] mode = "basic"`, the default, keeps the file-based quickstart instead — the admin user generated by `setup.sh` — with no IDP involved.) -> The Platform API auth modes are mutually exclusive: enabling the IDP while local JWT -> or file-based auth is also on is rejected at startup — keep the -> `APIP_CP_AUTH_JWT_ENABLED=false` and `APIP_CP_AUTH_FILE_BASED_ENABLED=false` lines in `api-platform.env`. +> `platform_api.auth.mode` selects exactly one Platform API auth mode — set it to `idp` so the +> Platform API validates tokens against Asgardeo's JWKS instead of local JWT or file-based auth. #### Option 2 (local `make bff-run`) @@ -314,36 +311,32 @@ Running the BFF and Platform API directly (no compose) means no pre-wiring, so c sides by hand. Edit `platform-api/config/config.toml` so the Platform API validates the Asgardeo token — -remember the three auth modes are mutually exclusive, so turn the local ones off: +`auth.mode` selects exactly one mode, so set it to `idp`: ```toml -[auth.jwt] -enabled = false # required: mutually exclusive with the IDP +[auth] +mode = "idp" [auth.idp] -enabled = true jwks_url = "https://api.asgardeo.io/t//oauth2/jwks" issuer = ["https://api.asgardeo.io/t//oauth2/token"] audience = [""] # match Asgardeo's aud, or [] to skip the check # Asgardeo emits org_id (not the default "organization") — these overrides are required. -[auth.idp.claim_mappings] +[auth.claim_mappings] organization = "org_id" org_name = "org_name" org_handle = "org_handle" - -[auth.file_based] -enabled = false # required: mutually exclusive with the IDP ``` Then export the BFF settings and start it. `make bff-run` runs the BFF against [`configs/config.toml`](configs/config.toml) — the same file the container mounts, passed with `-config` — whose keys are all `{{ env }}` tokens naming the variables below, so exporting one -sets its key (`APIP_AIW_OIDC_CLIENT_ID` → `[ai_workspace.oidc] client_id`). A key absent from that file is not +sets its key (`APIP_AIW_AUTH_OIDC_CLIENT_ID` → `[ai_workspace.auth.oidc] client_id`). A key absent from that file is not settable this way; add it there first. Point the BFF at the locally published Platform API port — the `platform-api` compose hostname does **not** resolve outside the compose network: -> `[ai_workspace.oidc] authority` is Asgardeo's **token base** — the BFF appends +> `[ai_workspace.auth.oidc] authority` is Asgardeo's **token base** — the BFF appends > `/.well-known/openid-configuration` itself, so do **not** include the discovery suffix. ```bash @@ -351,13 +344,13 @@ cd portals/ai-workspace export APIP_AIW_CONTROL_PLANE_URL=https://localhost:9243 # NOT https://platform-api:9243 when run locally export APIP_AIW_CONTROL_PLANE_TLS_SKIP_VERIFY=true export APIP_AIW_AUTH_MODE=oidc -export APIP_AIW_OIDC_AUTHORITY=https://api.asgardeo.io/t//oauth2/token -export APIP_AIW_OIDC_CLIENT_ID= -export APIP_AIW_OIDC_CLIENT_SECRET= -export APIP_AIW_OIDC_REDIRECT_URL=https://localhost:5380/api/auth/callback -export APIP_AIW_OIDC_POST_LOGOUT_REDIRECT_URL=https://localhost:5380/login +export APIP_AIW_AUTH_OIDC_AUTHORITY=https://api.asgardeo.io/t//oauth2/token +export APIP_AIW_AUTH_OIDC_CLIENT_ID= +export APIP_AIW_AUTH_OIDC_CLIENT_SECRET= +export APIP_AIW_AUTH_OIDC_REDIRECT_URL=https://localhost:5380/api/auth/callback +export APIP_AIW_AUTH_OIDC_POST_LOGOUT_REDIRECT_URL=https://localhost:5380/login # Keep `offline_access` — without it the IDP issues no refresh token and the BFF cannot silently renew the session. -export APIP_AIW_OIDC_SCOPE="openid profile email offline_access ap:organization:manage ap:gateway:manage ap:rest_api:manage ..." +export APIP_AIW_AUTH_OIDC_SCOPE="openid profile email offline_access ap:organization:manage ap:gateway:manage ap:rest_api:manage ..." make bff-run ``` @@ -369,13 +362,13 @@ 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: 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) | +| Platform API exits at startup with *`auth.mode must be "external_token", "file", or "idp"`* | `auth.mode` is unset or misspelled | Compose: set `APIP_CP_AUTH_MODE=idp` in `api-platform.env` (step 3, Option 1). Local: set `[auth] mode = "idp"` in `config.toml` (step 3, Option 2) | | `502` + `dial tcp: lookup platform-api: no such host` | BFF run locally but `[ai_workspace.control_plane] url` points at the compose hostname | Set `APIP_AIW_CONTROL_PLANE_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: 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) | -| Logged out as soon as the access token expires; never silently refreshed | IDP returned no refresh token — `offline_access` scope missing from the request, or not permitted for the app | Keep `offline_access` in `[ai_workspace.oidc] scope` (it's in the default); allow it for the app in the IDP (step 1) | +| Logged out as soon as the access token expires; never silently refreshed | IDP returned no refresh token — `offline_access` scope missing from the request, or not permitted for the app | Keep `offline_access` in `[ai_workspace.auth.oidc] scope` (it's in the default); allow it for the app in the IDP (step 1) | | Refresh fails minutes after login | **Refresh Token** grant not enabled on the app | Enable it on the Protocol tab (step 1) | ## Session lifetime & token refresh diff --git a/portals/ai-workspace/bff/internal/auth/filebased.go b/portals/ai-workspace/bff/internal/auth/filebased.go index 046cc5c46..931170b56 100644 --- a/portals/ai-workspace/bff/internal/auth/filebased.go +++ b/portals/ai-workspace/bff/internal/auth/filebased.go @@ -38,6 +38,7 @@ type FileBased struct { client *http.Client loginURL string absTTL time.Duration + mapping session.ClaimMapping } type fileBasedLoginResponse struct { @@ -46,12 +47,17 @@ type fileBasedLoginResponse struct { } // NewFileBased builds a file-based authenticator. platformBaseURL is the Platform -// API origin (e.g. https://platform-api:9243); loginPath is its login route. -func NewFileBased(client *http.Client, platformBaseURL, loginPath string, absTTL time.Duration) *FileBased { +// API origin (e.g. https://platform-api:9243); portalBasePath is its portal route +// prefix (e.g. /api/portal/v0.9), under which /auth/login lives. mapping is the +// same claim mapping used for OIDC — the Platform API's file-based login endpoint +// signs its JWTs using these same mapped claim names, so an operator who +// customizes the claim names must not need to configure them twice. +func NewFileBased(client *http.Client, platformBaseURL, portalBasePath string, absTTL time.Duration, mapping session.ClaimMapping) *FileBased { return &FileBased{ client: client, - loginURL: strings.TrimRight(platformBaseURL, "/") + loginPath, + loginURL: strings.TrimRight(platformBaseURL, "/") + strings.TrimRight(portalBasePath, "/") + "/auth/login", absTTL: absTTL, + mapping: mapping, } } @@ -110,6 +116,6 @@ func (f *FileBased) Login(ctx context.Context, username, password string) (*sess AccessToken: body.Token, AccessExpiry: accessExpiry, AbsoluteExpiry: abs, - User: session.UserFromClaims(claims, nil, session.DefaultClaimMapping()), + User: session.UserFromClaims(claims, nil, f.mapping), }, nil } diff --git a/portals/ai-workspace/bff/internal/config/config.go b/portals/ai-workspace/bff/internal/config/config.go index ef1ea4d5b..929988784 100644 --- a/portals/ai-workspace/bff/internal/config/config.go +++ b/portals/ai-workspace/bff/internal/config/config.go @@ -27,14 +27,16 @@ import ( "fmt" "log/slog" "net/url" + "strconv" "strings" "time" ) // Config is the fully-resolved BFF configuration. type Config struct { - // Listener - Addr string // host:port to listen on, e.g. ":5380" + // Listener. Addr is derived from [server] port as ":" + port (e.g. ":5380") — + // there is no host to configure, since the listener always binds all interfaces. + Addr string StaticDir string // directory containing the built SPA (index.html + assets) // Logging @@ -54,13 +56,16 @@ type Config struct { Session SessionConfig Cookie CookieConfig - // CSRF - CSRFHeader string // custom header required on state-mutating requests - // Auth AuthMode string // "basic" | "oidc" — informs the SPA which login UX to show OIDC OIDCConfig + // Claims maps which token claim names carry each user/org field. Shared by both + // auth modes: OIDC tokens from the configured IDP, and the HMAC JWTs the Platform + // API's file-based login endpoint signs. Override per IDP when the defaults don't + // match (e.g. the display name lands on "sub"). + Claims ClaimMappingConfig + // Runtime config surfaced to the SPA (window.__RUNTIME_CONFIG__) RuntimeConfig map[string]string } @@ -81,8 +86,10 @@ type ControlPlaneConfig struct { // TLSSkipVerify disables upstream certificate verification entirely. // Last-resort escape hatch for dev/demo only; prefer CAFile. TLSSkipVerify bool - // LoginPath is the file-based login path on the Platform API. - LoginPath string + // PortalBasePath is the Platform API's portal route prefix (e.g. + // /api/portal/v0.9), used to build paths for BFF-initiated calls to the + // Platform API — file-based login today, others (e.g. /auth/me) later. + PortalBasePath string } // TLSConfig controls whether the BFF listener serves HTTPS directly or sits @@ -105,13 +112,28 @@ type SessionConfig struct { AbsoluteTTL time.Duration // hard cap regardless of activity / token exp } -// CookieConfig controls the session cookie attributes. +// CookieConfig controls the session cookie attributes. Not user-configurable: these +// are implementation details of the BFF's session mechanism, not a deployment concern. +// The BFF always terminates TLS (or sits behind a proxy that does), so Secure is +// unconditionally true; there is no supported plain-HTTP deployment that would need it +// false. type CookieConfig struct { Name string Secure bool SameSite string // "lax" | "strict" | "none" } +// cookieName is the session cookie's name. +const cookieName = "_ai_workspace_session" + +// CSRFHeaderName is the header the SPA must set on every state-mutating request, and +// the BFF checks for on the way in (see server/middleware.go requireCSRF). It is a +// fixed contract between the BFF and the SPA it ships, not a deployment concern — an +// operator changing it on one side without the other would silently break CSRF +// protection, so it is a constant rather than a config key. The SPA's copy lives in +// src/config.env.ts CSRF_HEADER and must be kept in sync with this value. +const CSRFHeaderName = "X-Requested-By" + // OIDCConfig holds the confidential-client settings. The client secret lives // only here on the BFF and is never emitted to the browser. type OIDCConfig struct { @@ -122,19 +144,16 @@ type OIDCConfig struct { RedirectURL string // must equal the IDP-registered redirect, points at /api/auth/callback PostLogoutRedirectURL string Scopes string // space-separated - - // Claims maps which token claim names carry each user/org field. Override per - // IDP when the defaults don't match (e.g. the display name lands on "sub"). - Claims ClaimMappingConfig } // ClaimMappingConfig configures which claim names the BFF reads for each -// user/org field from the OIDC tokens. Empty fields fall back to built-in -// defaults in the session package. +// user/org field. Empty fields fall back to built-in defaults in the session +// package. Shared by OIDC and file-based (basic) tokens — both are read through +// the same mapping so a custom claim name only needs to be set once. type ClaimMappingConfig struct { Username string Email string - Role string + Roles string Scope string OrgID string OrgName string @@ -144,7 +163,7 @@ type ClaimMappingConfig struct { // defaultOIDCScopes is the full set of scopes the BFF requests in OIDC mode so a // logged-in user's access token carries every ap:* permission the Platform API // authorizes against. The IDP must still have these scopes registered and granted -// to the user, otherwise it drops the ungranted ones. Override with the [oidc] scope +// to the user, otherwise it drops the ungranted ones. Override with the [auth.oidc] scope // config key to request a narrower set. // // offline_access is required: without it most IDPs (Asgardeo, WSO2 IS, Okta, @@ -206,11 +225,11 @@ func Load(path string) (*Config, error) { return nil, err } - authMode := strings.ToLower(s.get("auth_mode", "basic")) + authMode := strings.ToLower(s.get("auth.mode", "basic")) // A typo'd mode must not silently degrade to basic auth: any value other than // "oidc" would leave OIDC.Enabled false and hand the SPA an unknown login UX. if authMode != "basic" && authMode != "oidc" { - return nil, fmt.Errorf("invalid auth_mode %q: must be \"basic\" or \"oidc\"", authMode) + return nil, fmt.Errorf("invalid [auth] mode %q: must be \"basic\" or \"oidc\"", authMode) } // Parse typed values up front so a malformed one fails startup instead of @@ -231,20 +250,25 @@ func Load(path string) (*Config, error) { if err != nil { return nil, err } - cookieSecure, err := s.getbool("cookie.secure", true) + oidcEnabled, err := s.getbool("auth.oidc.enabled", false) if err != nil { return nil, err } - oidcEnabled, err := s.getbool("oidc.enabled", false) - if err != nil { - return nil, err + // A bare port number is easier to get right than a full "host:port" address (there + // is never a host to fill in — the listener always binds all interfaces), and it + // matches the Platform API's [server.http]/[server.https] port convention. Validate + // it up front so a typo'd value fails startup instead of a confusing net.Listen error. + port := s.get("server.port", "5380") + portNum, err := strconv.Atoi(port) + if err != nil || portNum < 1 || portNum > 65535 { + return nil, fmt.Errorf("[server] port must be a number between 1 and 65535, got %q", port) } cfg := &Config{ - Addr: s.get("listen_addr", ":5380"), - StaticDir: s.get("static_dir", "/app"), - LogLevel: strings.ToLower(s.get("log_level", "info")), - LogFormat: strings.ToLower(s.get("log_format", "text")), + Addr: ":" + port, + StaticDir: s.get("server.static_dir", "/app"), + LogLevel: strings.ToLower(s.get("logging.log_level", "info")), + LogFormat: strings.ToLower(s.get("logging.log_format", "text")), TLS: TLSConfig{ TerminateTLS: tlsEnabled, // Convention matches the container's mount path. A certificate pair is @@ -253,54 +277,60 @@ func Load(path string) (*Config, error) { KeyFile: s.get("tls.key_file", "/etc/ai-workspace/tls/key.pem"), }, ControlPlane: ControlPlaneConfig{ - URL: strings.TrimRight(s.get("control_plane.url", ""), "/"), - CAFile: s.get("control_plane.ca_file", ""), - TLSSkipVerify: controlPlaneTLSSkipVerify, - LoginPath: s.get("control_plane.login_path", "/api/portal/v0.9/auth/login"), + URL: strings.TrimRight(s.get("control_plane.url", ""), "/"), + CAFile: s.get("control_plane.ca_file", ""), + TLSSkipVerify: controlPlaneTLSSkipVerify, + PortalBasePath: strings.TrimRight(s.get("control_plane.portal_base_path", "/api/portal/v0.9"), "/"), }, - ProxyPrefix: strings.TrimRight(s.get("proxy_prefix", "/api/proxy"), "/"), + ProxyPrefix: strings.TrimRight(s.get("control_plane.proxy_prefix", "/proxy"), "/"), Session: SessionConfig{ Store: s.get("session.store", "memory"), IdleTimeout: idleTimeout, AbsoluteTTL: absoluteTTL, }, Cookie: CookieConfig{ - Name: s.get("cookie.name", "_ai_workspace_session"), - Secure: cookieSecure, - SameSite: strings.ToLower(s.get("cookie.samesite", "lax")), + Name: cookieName, + Secure: true, + SameSite: "lax", }, - CSRFHeader: s.get("csrf_header", "X-Requested-By"), - AuthMode: authMode, + AuthMode: authMode, OIDC: OIDCConfig{ Enabled: authMode == "oidc" || oidcEnabled, - Issuer: strings.TrimRight(s.get("oidc.authority", ""), "/"), - ClientID: s.get("oidc.client_id", ""), + Issuer: strings.TrimRight(s.get("auth.oidc.authority", ""), "/"), + ClientID: s.get("auth.oidc.client_id", ""), // Never write the secret as a literal. Point the key at an environment - // variable with '{{ env "APIP_AIW_OIDC_CLIENT_SECRET" }}', or — preferably — - // at a mounted file with '{{ file "/secrets/ai-workspace/oidc_client_secret" }}'. - ClientSecret: s.get("oidc.client_secret", ""), - RedirectURL: s.get("oidc.redirect_url", ""), + // variable with '{{ env "APIP_AIW_AUTH_OIDC_CLIENT_SECRET" }}', or — + // preferably — at a mounted file with + // '{{ file "/secrets/ai-workspace/oidc_client_secret" }}'. + ClientSecret: s.get("auth.oidc.client_secret", ""), + RedirectURL: s.get("auth.oidc.redirect_url", ""), // Empty by default: LogoutURL() forwards this as post_logout_redirect_uri, // which IDPs require to be an absolute, pre-registered URL. A relative // default would produce an invalid logout request, so leave it unset // unless an absolute URL is explicitly configured. - PostLogoutRedirectURL: s.get("oidc.post_logout_redirect_url", ""), - Scopes: s.get("oidc.scope", defaultOIDCScopes), - // [oidc.claim_mappings] deliberately mirrors the Platform API's - // [auth.idp.claim_mappings], key for key: both describe the same IDP token, - // and they must agree, so they are named the same on both sides. - // - // The same keys drive the BFF's session mapping and the SPA's runtime - // config, so one config entry keeps both layers in sync. - Claims: ClaimMappingConfig{ - Username: s.get("oidc.claim_mappings.username", "username"), - Email: s.get("oidc.claim_mappings.email", "email"), - Role: s.get("oidc.claim_mappings.role", "platform_role"), - Scope: s.get("oidc.claim_mappings.scope", "scope"), - OrgID: s.get("oidc.claim_mappings.organization", "org_id"), - OrgName: s.get("oidc.claim_mappings.org_name", "org_name"), - OrgHandle: s.get("oidc.claim_mappings.org_handle", "org_handle"), - }, + PostLogoutRedirectURL: s.get("auth.oidc.post_logout_redirect_url", ""), + Scopes: s.get("auth.oidc.scope", defaultOIDCScopes), + }, + // [auth.claim_mappings] deliberately mirrors the Platform API's + // [auth.claim_mappings], key for key: both describe the same claim names, and + // they must agree. It is a sibling of [auth.oidc], not nested inside it, + // because it applies to BOTH auth modes — OIDC tokens from the configured IDP, + // and the HMAC JWTs the Platform API's file-based login endpoint signs with + // these same mapped claim names (see platform-api's auth_login.go). Defaults + // below match the Platform API's own claim_mappings defaults so the two agree + // out of the box; override on both sides together when an IDP uses different + // claim names (e.g. Asgardeo's "org_id"). + // + // The same keys drive the BFF's session mapping and the SPA's runtime config, + // so one config entry keeps both layers in sync. + Claims: ClaimMappingConfig{ + Username: s.get("auth.claim_mappings.username", "username"), + Email: s.get("auth.claim_mappings.email", "email"), + Roles: s.get("auth.claim_mappings.roles", "roles"), + Scope: s.get("auth.claim_mappings.scope", "scope"), + OrgID: s.get("auth.claim_mappings.organization", "organization"), + OrgName: s.get("auth.claim_mappings.org_name", "org_name"), + OrgHandle: s.get("auth.claim_mappings.org_handle", "org_handle"), }, } @@ -330,7 +360,7 @@ func Load(path string) (*Config, error) { } if cfg.OIDC.Enabled { if cfg.OIDC.Issuer == "" || cfg.OIDC.ClientID == "" || cfg.OIDC.ClientSecret == "" || cfg.OIDC.RedirectURL == "" { - return nil, fmt.Errorf("OIDC mode requires [oidc] authority, client_id, client_secret and redirect_url") + return nil, fmt.Errorf("OIDC mode requires [auth.oidc] authority, client_id, client_secret and redirect_url") } } // Empty is fine (the key is optional), but a relative value would be forwarded as an @@ -338,7 +368,7 @@ func Load(path string) (*Config, error) { if cfg.OIDC.PostLogoutRedirectURL != "" { u, err := url.Parse(cfg.OIDC.PostLogoutRedirectURL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { - return nil, fmt.Errorf("[oidc] post_logout_redirect_url must be an absolute http:// or https:// URL, got %q", + return nil, fmt.Errorf("[auth.oidc] post_logout_redirect_url must be an absolute http:// or https:// URL, got %q", cfg.OIDC.PostLogoutRedirectURL) } } @@ -347,7 +377,7 @@ func Load(path string) (*Config, error) { // 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)") + "configure OIDC (set [auth] mode = \"oidc\" and [auth.oidc] authority, client_id, client_secret, redirect_url)") } cfg.RuntimeConfig = buildRuntimeConfig(cfg, s) diff --git a/portals/ai-workspace/bff/internal/config/config_test.go b/portals/ai-workspace/bff/internal/config/config_test.go index 45323dc58..dd36766b8 100644 --- a/portals/ai-workspace/bff/internal/config/config_test.go +++ b/portals/ai-workspace/bff/internal/config/config_test.go @@ -37,7 +37,7 @@ func writeConfig(t *testing.T, body string) string { // A literal config.toml value is used as written. func TestLoad_ConfigFileValue(t *testing.T) { cfgPath := writeConfig(t, ` -[ai_workspace] +[ai_workspace.logging] log_level = "warn" [ai_workspace.control_plane] @@ -60,7 +60,7 @@ url = "https://platform-api:9243" // supplies the variable's value, and its default applies when the variable is unset. func TestLoad_EnvTokenSuppliesValueAndDefault(t *testing.T) { cfgPath := writeConfig(t, ` -[ai_workspace] +[ai_workspace.logging] log_level = '{{ env "APIP_AIW_LOG_LEVEL" "info" }}' log_format = '{{ env "APIP_AIW_LOG_FORMAT" "text" }}' @@ -87,7 +87,7 @@ url = "https://platform-api:9243" // pulls a value in from the environment. func TestLoad_EnvVarWithoutTokenIsIgnored(t *testing.T) { cfgPath := writeConfig(t, ` -[ai_workspace] +[ai_workspace.logging] log_level = "warn" [ai_workspace.control_plane] @@ -109,13 +109,13 @@ url = "https://platform-api:9243" // variable — the APIP_AIW_ prefix is a convention, not a requirement. func TestLoad_InterpolatesEnvToken(t *testing.T) { cfgPath := writeConfig(t, ` -[ai_workspace] -auth_mode = "oidc" +[ai_workspace.auth] +mode = "oidc" [ai_workspace.control_plane] url = "https://platform-api:9243" -[ai_workspace.oidc] +[ai_workspace.auth.oidc] authority = "https://idp.example.com" client_id = "client-id" client_secret = '{{ env "CUSTOM_SECRET_VAR" }}' @@ -141,13 +141,13 @@ func TestLoad_InterpolatesFileToken(t *testing.T) { t.Setenv("APIP_CONFIG_FILE_SOURCE_ALLOWLIST", secretDir) cfgPath := writeConfig(t, ` -[ai_workspace] -auth_mode = "oidc" +[ai_workspace.auth] +mode = "oidc" [ai_workspace.control_plane] url = "https://platform-api:9243" -[ai_workspace.oidc] +[ai_workspace.auth.oidc] authority = "https://idp.example.com" client_id = "client-id" client_secret = '{{ file "`+filepath.Join(secretDir, "oidc_client_secret")+`" }}' @@ -177,7 +177,7 @@ func TestLoad_FileTokenOutsideAllowlist_Errors(t *testing.T) { [ai_workspace.control_plane] url = "https://platform-api:9243" -[ai_workspace.oidc] +[ai_workspace.auth.oidc] client_secret = '{{ file "`+outside+`" }}' `) @@ -193,7 +193,7 @@ func TestLoad_MissingEnvToken_Errors(t *testing.T) { [ai_workspace.control_plane] url = "https://platform-api:9243" -[ai_workspace.oidc] +[ai_workspace.auth.oidc] client_secret = '{{ env "CUSTOM_SECRET_VAR" }}' `) t.Setenv("CUSTOM_SECRET_VAR", "") @@ -225,13 +225,15 @@ func TestLoad_MissingControlPlaneURL_Errors(t *testing.T) { func TestLoad_RuntimeConfigExcludesServerSideKeys(t *testing.T) { cfgPath := writeConfig(t, ` [ai_workspace] -auth_mode = "oidc" domain = "localhost:5380" +[ai_workspace.auth] +mode = "oidc" + [ai_workspace.control_plane] url = "https://platform-api:9243" -[ai_workspace.oidc] +[ai_workspace.auth.oidc] authority = "https://idp.example.com" client_id = "client-id" client_secret = "s3cr3t" @@ -251,7 +253,7 @@ redirect_url = "https://localhost:5380/api/auth/callback" t.Errorf("runtime config leaked a server-side value: %q", v) } } - for _, key := range []string{"APIP_AIW_OIDC_CLIENT_SECRET", "APIP_AIW_OIDC_CLIENT_ID", "APIP_AIW_OIDC_AUTHORITY"} { + for _, key := range []string{"APIP_AIW_AUTH_OIDC_CLIENT_SECRET", "APIP_AIW_AUTH_OIDC_CLIENT_ID", "APIP_AIW_AUTH_OIDC_AUTHORITY"} { if _, ok := cfg.RuntimeConfig[key]; ok { t.Errorf("runtime config must not contain %s — the BFF owns the OIDC handshake", key) } @@ -307,8 +309,8 @@ func TestLoad_BareTOMLScalars(t *testing.T) { [ai_workspace.control_plane] url = "https://platform-api:9243" -[ai_workspace.cookie] -secure = false +[ai_workspace.tls] +enabled = false [ai_workspace.session] absolute_ttl = "2h" @@ -318,8 +320,8 @@ absolute_ttl = "2h" if err != nil { t.Fatalf("Load() error = %v", err) } - if cfg.Cookie.Secure { - t.Error("Cookie.Secure = true, want false from the bare TOML boolean") + if cfg.TLS.TerminateTLS { + t.Error("TLS.TerminateTLS = true, want false from the bare TOML boolean") } if cfg.Session.AbsoluteTTL != 2*time.Hour { t.Errorf("Session.AbsoluteTTL = %s, want 2h", cfg.Session.AbsoluteTTL) @@ -327,11 +329,11 @@ absolute_ttl = "2h" } // A key in a table must not collide with the same key in another table — they are -// distinct dotted paths, so [tls] enabled and [oidc] enabled are independent. +// distinct dotted paths, so [tls] enabled and [auth.oidc] enabled are independent. func TestLoad_SameKeyInDifferentTables(t *testing.T) { cfgPath := writeConfig(t, ` -[ai_workspace] -auth_mode = "oidc" +[ai_workspace.auth] +mode = "oidc" [ai_workspace.control_plane] url = "https://platform-api:9243" @@ -339,7 +341,7 @@ url = "https://platform-api:9243" [ai_workspace.tls] enabled = false -[ai_workspace.oidc] +[ai_workspace.auth.oidc] enabled = true authority = "https://idp.example.com" client_id = "client-id" @@ -352,47 +354,49 @@ redirect_url = "https://localhost:5380/api/auth/callback" t.Fatalf("Load() error = %v", err) } if cfg.TLS.TerminateTLS { - t.Error("TLS.TerminateTLS = true, want false — [tls] enabled must not read [oidc] enabled") + t.Error("TLS.TerminateTLS = true, want false — [tls] enabled must not read [auth.oidc] enabled") } if !cfg.OIDC.Enabled { t.Error("OIDC.Enabled = false, want true") } } -// [oidc.claim_mappings] mirrors the Platform API's [auth.idp.claim_mappings] key for -// key. The BFF reads those keys for its own session mapping, and the browser-safe ones -// reach the SPA under the matching APIP_AIW_OIDC_CLAIM_MAPPINGS_* names that -// src/config.env.ts looks up. +// [auth.claim_mappings] mirrors the Platform API's [auth.claim_mappings] key for +// key, and is shared by both auth modes (not nested under [auth.oidc]): OIDC +// tokens from the configured IDP, and the HMAC JWTs the Platform API's +// file-based login endpoint signs using these same mapped claim names. The +// browser-safe ones reach the SPA under the matching +// APIP_AIW_AUTH_CLAIM_MAPPINGS_* names that src/config.env.ts looks up. func TestLoad_ClaimMappingsMirrorControlPlane(t *testing.T) { cfgPath := writeConfig(t, ` [ai_workspace.control_plane] url = "https://platform-api:9243" -[ai_workspace.oidc.claim_mappings] +[ai_workspace.auth.claim_mappings] organization = "org_uuid" username = "given_name" -role = "roles" +roles = "roles" `) cfg, err := Load(cfgPath) if err != nil { t.Fatalf("Load() error = %v", err) } - if cfg.OIDC.Claims.OrgID != "org_uuid" { - t.Errorf("Claims.OrgID = %q, want %q from organization", cfg.OIDC.Claims.OrgID, "org_uuid") + if cfg.Claims.OrgID != "org_uuid" { + t.Errorf("Claims.OrgID = %q, want %q from organization", cfg.Claims.OrgID, "org_uuid") } - if cfg.OIDC.Claims.Role != "roles" { - t.Errorf("Claims.Role = %q, want %q from role", cfg.OIDC.Claims.Role, "roles") + if cfg.Claims.Roles != "roles" { + t.Errorf("Claims.Roles = %q, want %q from roles", cfg.Claims.Roles, "roles") } - if got := cfg.RuntimeConfig["APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION"]; got != "org_uuid" { - t.Errorf("runtime APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION = %q, want %q", got, "org_uuid") + if got := cfg.RuntimeConfig["APIP_AIW_AUTH_CLAIM_MAPPINGS_ORGANIZATION"]; got != "org_uuid" { + t.Errorf("runtime APIP_AIW_AUTH_CLAIM_MAPPINGS_ORGANIZATION = %q, want %q", got, "org_uuid") } - if got := cfg.RuntimeConfig["APIP_AIW_OIDC_CLAIM_MAPPINGS_USERNAME"]; got != "given_name" { - t.Errorf("runtime APIP_AIW_OIDC_CLAIM_MAPPINGS_USERNAME = %q, want %q", got, "given_name") + if got := cfg.RuntimeConfig["APIP_AIW_AUTH_CLAIM_MAPPINGS_USERNAME"]; got != "given_name" { + t.Errorf("runtime APIP_AIW_AUTH_CLAIM_MAPPINGS_USERNAME = %q, want %q", got, "given_name") } - // role drives the BFF's session mapping only — it must not be published. - if _, ok := cfg.RuntimeConfig["APIP_AIW_OIDC_CLAIM_MAPPINGS_ROLE"]; ok { - t.Error("role must not reach the browser — it is not in the browser-safe allowlist") + // roles drives the BFF's session mapping only — it must not be published. + if _, ok := cfg.RuntimeConfig["APIP_AIW_AUTH_CLAIM_MAPPINGS_ROLES"]; ok { + t.Error("roles must not reach the browser — it is not in the browser-safe allowlist") } } @@ -402,8 +406,8 @@ func TestLoad_InvalidBool_Errors(t *testing.T) { [ai_workspace.control_plane] url = "https://platform-api:9243" -[ai_workspace.cookie] -secure = "maybe" +[ai_workspace.tls] +enabled = "maybe" `) if _, err := Load(cfgPath); err == nil { diff --git a/portals/ai-workspace/bff/internal/config/runtime_config.go b/portals/ai-workspace/bff/internal/config/runtime_config.go index b19d00e79..b615fd2af 100644 --- a/portals/ai-workspace/bff/internal/config/runtime_config.go +++ b/portals/ai-workspace/bff/internal/config/runtime_config.go @@ -23,26 +23,27 @@ import "strings" // reach the browser, so a new server-side key (a secret, an upstream URL, a cookie // setting) cannot leak into the page merely by being added to config.toml. // -// [oidc] client_secret / client_id / authority are deliberately absent — the BFF +// [auth.oidc] client_secret / client_id / authority are deliberately absent — the BFF // performs the whole OIDC handshake, so the SPA needs no client identity. var browserSafeKeys = []string{ - // Identity of the deployment. auth_mode is not listed: buildRuntimeConfig + // Identity of the deployment. auth.mode is not listed: buildRuntimeConfig // always emits it from the parsed cfg.AuthMode instead. "domain", "default_org_region", "gateway.controlplane_host", "gateway.platform_gateway_versions", - "csrf_header", - "debug", + "logging.browser_debug", // Claim names the SPA displays user/org identity from. The keys mirror the - // Platform API's [auth.idp.claim_mappings] exactly — same claim, same name. - "oidc.scope", - "oidc.claim_mappings.username", - "oidc.claim_mappings.email", - "oidc.claim_mappings.organization", - "oidc.claim_mappings.org_name", - "oidc.claim_mappings.org_handle", + // Platform API's [auth.claim_mappings] exactly — same claim, same name. Shared + // by both auth modes, so it lives under [auth.claim_mappings], not nested in + // [auth.oidc]. + "auth.oidc.scope", + "auth.claim_mappings.username", + "auth.claim_mappings.email", + "auth.claim_mappings.organization", + "auth.claim_mappings.org_name", + "auth.claim_mappings.org_handle", // External links and SPA-only endpoints "dev_portal_base_url", @@ -53,8 +54,8 @@ var browserSafeKeys = []string{ } // runtimeKey converts a config key into the name the SPA reads: APIP_AIW_ + the key's -// dotted path uppercased, with dots as underscores ("oidc.scope" -> -// APIP_AIW_OIDC_SCOPE). It is the same spelling the key's {{ env }} token +// dotted path uppercased, with dots as underscores ("auth.oidc.scope" -> +// APIP_AIW_AUTH_OIDC_SCOPE). It is the same spelling the key's {{ env }} token // conventionally names, so a value has one name across config.toml, the environment, // Vite's import.meta.env, and window.__RUNTIME_CONFIG__. func runtimeKey(configKey string) string { diff --git a/portals/ai-workspace/bff/internal/config/settings.go b/portals/ai-workspace/bff/internal/config/settings.go index c1dcba5ca..055263c6b 100644 --- a/portals/ai-workspace/bff/internal/config/settings.go +++ b/portals/ai-workspace/bff/internal/config/settings.go @@ -33,9 +33,9 @@ import ( // It is a naming convention, not a binding: the environment reaches the config only // through the {{ env "NAME" }} tokens written in config.toml, which name the variable // explicitly. By convention a key's variable is its dotted path uppercased, with dots -// as underscores, prefixed — so [oidc] client_id ships as -// '{{ env "APIP_AIW_OIDC_CLIENT_ID" }}'. A token may name any variable, and a key with -// no token cannot be set from the environment at all. +// as underscores, prefixed — so [auth.oidc] client_id ships as +// '{{ env "APIP_AIW_AUTH_OIDC_CLIENT_ID" }}'. A token may name any variable, and a key +// with no token cannot be set from the environment at all. // // The prefix also namespaces the runtime config the SPA reads (see runtimeKey). const EnvPrefix = "APIP_AIW_" @@ -45,7 +45,7 @@ const EnvPrefix = "APIP_AIW_" // API's platformAPIConfigKey: this namespacing lets an AI Workspace config file // coexist with sibling services' sections ([platform_api], ...) in a shared // deployment config, the same file convention as the Platform API's [platform_api] -// table. Every key below this cut (log_level, control_plane.url, oidc.*, ...) is +// table. Every key below this cut (log_level, control_plane.url, auth.oidc.*, ...) is // resolved relative to [ai_workspace], not the file root. const aiWorkspaceConfigKey = "ai_workspace" @@ -135,8 +135,8 @@ func parseTOML(path string) (map[string]any, error) { return raw, nil } -// flatten collapses the decoded tree into dotted keys ([oidc] client_id -> -// "oidc.client_id"), stringifying scalars so a value may be written either quoted or +// flatten collapses the decoded tree into dotted keys ([auth.oidc] client_id -> +// "auth.oidc.client_id"), stringifying scalars so a value may be written either quoted or // bare — tls.enabled = true and tls.enabled = "true" both reach getbool as "true". // Arrays have no config key and are rejected by the parser before reaching here. func flatten(dst settings, prefix string, tree map[string]any) { 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 515a8edb5..7f216ad70 100644 --- a/portals/ai-workspace/bff/internal/config/shipped_config_test.go +++ b/portals/ai-workspace/bff/internal/config/shipped_config_test.go @@ -31,7 +31,7 @@ var ( // The shipped config.toml must load with nothing set in the environment — that is the // quickstart: `docker compose up` with no .env beyond the two secrets. It ships without -// an [ai_workspace.oidc] table at all, which must leave OIDC off rather than fail startup. +// an [ai_workspace.auth.oidc] table at all, which must leave OIDC off rather than fail startup. func TestShippedConfig_QuickstartLoadsWithNoEnv(t *testing.T) { cfg, err := Load(quickstartConfig) if err != nil { @@ -41,10 +41,10 @@ func TestShippedConfig_QuickstartLoadsWithNoEnv(t *testing.T) { if cfg.AuthMode != "basic" { t.Errorf("AuthMode = %q, want %q", cfg.AuthMode, "basic") } - // The missing [ai_workspace.oidc] table must not switch the OIDC client on, or Load - // would demand an authority/client_id/client_secret the quickstart has no use for. + // The missing [ai_workspace.auth.oidc] table must not switch the OIDC client on, or + // Load would demand an authority/client_id/client_secret the quickstart has no use for. if cfg.OIDC.Enabled { - t.Error("OIDC.Enabled = true, want false — the empty [ai_workspace.oidc] defaults must leave basic mode intact") + t.Error("OIDC.Enabled = true, want false — the empty [ai_workspace.auth.oidc] defaults must leave basic mode intact") } // The defaults in the file are the container's: the port it publishes and the SPA // baked into the image. @@ -79,8 +79,8 @@ func TestShippedConfig_MakeBffRunOverrides(t *testing.T) { // Exactly the variables the bff-run target sets. t.Setenv("APIP_AIW_CONTROL_PLANE_URL", "https://localhost:9243") t.Setenv("APIP_AIW_CONTROL_PLANE_TLS_SKIP_VERIFY", "true") - t.Setenv("APIP_AIW_LISTEN_ADDR", ":8081") - t.Setenv("APIP_AIW_STATIC_DIR", "../dist") + t.Setenv("APIP_AIW_SERVER_PORT", "8081") + t.Setenv("APIP_AIW_SERVER_STATIC_DIR", "../dist") t.Setenv("APIP_AIW_LOG_LEVEL", "debug") cfg, err := Load(quickstartConfig) @@ -101,29 +101,30 @@ func TestShippedConfig_MakeBffRunOverrides(t *testing.T) { } } -// configs/config.toml is deliberately minimal and ships without an [ai_workspace.oidc] -// table — the quickstart only ever runs in basic-auth mode. configs/config-template.toml -// is the file a real deployment copies from, and its [ai_workspace.oidc] tokens are what -// let OIDC be turned on from the environment alone, without further edits to the file. +// configs/config.toml is deliberately minimal and ships without an +// [ai_workspace.auth.oidc] table — the quickstart only ever runs in basic-auth mode. +// configs/config-template.toml is the file a real deployment copies from, and its +// [ai_workspace.auth.oidc] tokens are what let OIDC be turned on from the environment +// alone, without further edits to the file. func TestShippedConfig_OIDCFromEnvAlone(t *testing.T) { t.Setenv("APIP_AIW_AUTH_MODE", "oidc") - t.Setenv("APIP_AIW_OIDC_AUTHORITY", "https://idp.example.com") - t.Setenv("APIP_AIW_OIDC_CLIENT_ID", "client-id") - t.Setenv("APIP_AIW_OIDC_CLIENT_SECRET", "s3cr3t") - t.Setenv("APIP_AIW_OIDC_REDIRECT_URL", "https://localhost:5380/api/auth/callback") + t.Setenv("APIP_AIW_AUTH_OIDC_AUTHORITY", "https://idp.example.com") + t.Setenv("APIP_AIW_AUTH_OIDC_CLIENT_ID", "client-id") + t.Setenv("APIP_AIW_AUTH_OIDC_CLIENT_SECRET", "s3cr3t") + t.Setenv("APIP_AIW_AUTH_OIDC_REDIRECT_URL", "https://localhost:5380/api/auth/callback") cfg, err := Load(templateConfig) if err != nil { - t.Fatalf("Load(configs/config-template.toml) error = %v — the [ai_workspace.oidc] tokens must make OIDC settable from the environment", err) + t.Fatalf("Load(configs/config-template.toml) error = %v — the [ai_workspace.auth.oidc] tokens must make OIDC settable from the environment", err) } if !cfg.OIDC.Enabled { - t.Fatal("OIDC.Enabled = false, want true — auth_mode = oidc must enable the client") + t.Fatal("OIDC.Enabled = false, want true — [auth] mode = oidc must enable the client") } if cfg.OIDC.ClientSecret != "s3cr3t" { - t.Errorf("OIDC.ClientSecret = %q, want the value from APIP_AIW_OIDC_CLIENT_SECRET", cfg.OIDC.ClientSecret) + t.Errorf("OIDC.ClientSecret = %q, want the value from APIP_AIW_AUTH_OIDC_CLIENT_SECRET", cfg.OIDC.ClientSecret) } // Whatever the mode, the client credentials stay server-side. - for _, key := range []string{"APIP_AIW_OIDC_CLIENT_SECRET", "APIP_AIW_OIDC_CLIENT_ID", "APIP_AIW_OIDC_AUTHORITY"} { + for _, key := range []string{"APIP_AIW_AUTH_OIDC_CLIENT_SECRET", "APIP_AIW_AUTH_OIDC_CLIENT_ID", "APIP_AIW_AUTH_OIDC_AUTHORITY"} { if _, ok := cfg.RuntimeConfig[key]; ok { t.Errorf("runtime config must not contain %s — the BFF owns the OIDC handshake", key) } @@ -134,7 +135,7 @@ func TestShippedConfig_OIDCFromEnvAlone(t *testing.T) { // Its client_secret token deliberately has no default — an unset variable fails startup // rather than running OIDC with an empty credential — so the variable is set here. func TestShippedConfig_TemplateLoads(t *testing.T) { - t.Setenv("APIP_AIW_OIDC_CLIENT_SECRET", "s3cr3t") + t.Setenv("APIP_AIW_AUTH_OIDC_CLIENT_SECRET", "s3cr3t") cfg, err := Load(templateConfig) if err != nil { @@ -153,9 +154,9 @@ func TestShippedConfig_TemplateLoads(t *testing.T) { // The template's client_secret has no default on purpose: leaving the variable unset // must abort startup, not resolve to an empty credential. func TestShippedConfig_TemplateFailsClosedOnMissingSecret(t *testing.T) { - t.Setenv("APIP_AIW_OIDC_CLIENT_SECRET", "") + t.Setenv("APIP_AIW_AUTH_OIDC_CLIENT_SECRET", "") if _, err := Load(templateConfig); err == nil { - t.Fatal("Load(configs/config-template.toml) succeeded with APIP_AIW_OIDC_CLIENT_SECRET unset, want an error — the token has no default and must fail closed") + t.Fatal("Load(configs/config-template.toml) succeeded with APIP_AIW_AUTH_OIDC_CLIENT_SECRET unset, want an error — the token has no default and must fail closed") } } diff --git a/portals/ai-workspace/bff/internal/proxy/reverse_proxy.go b/portals/ai-workspace/bff/internal/proxy/reverse_proxy.go index a7aebe652..0893db933 100644 --- a/portals/ai-workspace/bff/internal/proxy/reverse_proxy.go +++ b/portals/ai-workspace/bff/internal/proxy/reverse_proxy.go @@ -46,7 +46,7 @@ func tokenFromContext(r *http.Request) string { } // ReverseProxy builds an httputil.ReverseProxy targeting the Platform API. -// prefix (e.g. "/api/proxy") is stripped from the path before forwarding. +// prefix (e.g. "/proxy") is stripped from the path before forwarding. func ReverseProxy(target *url.URL, prefix string, transport http.RoundTripper) *httputil.ReverseProxy { rp := &httputil.ReverseProxy{ Transport: transport, diff --git a/portals/ai-workspace/bff/internal/proxy/reverse_proxy_test.go b/portals/ai-workspace/bff/internal/proxy/reverse_proxy_test.go index 4202b5ef4..574db23c2 100644 --- a/portals/ai-workspace/bff/internal/proxy/reverse_proxy_test.go +++ b/portals/ai-workspace/bff/internal/proxy/reverse_proxy_test.go @@ -39,9 +39,9 @@ func TestReverseProxy_InjectsBearerStripsCookieAndPrefix(t *testing.T) { defer backend.Close() target, _ := url.Parse(backend.URL) - rp := ReverseProxy(target, "/api/proxy", backend.Client().Transport) + rp := ReverseProxy(target, "/proxy", backend.Client().Transport) - req := httptest.NewRequest(http.MethodGet, "/api/proxy/api/v0.9/projects", nil) + req := httptest.NewRequest(http.MethodGet, "/proxy/api/v0.9/projects", nil) req.Header.Set("Cookie", "_ai_workspace_session=secret-session-id") req = WithToken(req, "injected-bearer-token") @@ -73,9 +73,9 @@ func TestReverseProxy_NoTokenNoAuthHeader(t *testing.T) { defer backend.Close() target, _ := url.Parse(backend.URL) - rp := ReverseProxy(target, "/api/proxy", backend.Client().Transport) + rp := ReverseProxy(target, "/proxy", backend.Client().Transport) - req := httptest.NewRequest(http.MethodGet, "/api/proxy/health", nil) + req := httptest.NewRequest(http.MethodGet, "/proxy/health", nil) req.Header.Set("Authorization", "Bearer should-be-removed") rec := httptest.NewRecorder() rp.ServeHTTP(rec, req) 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 76ab42049..a7271831c 100644 --- a/portals/ai-workspace/bff/internal/server/composite_handlers_test.go +++ b/portals/ai-workspace/bff/internal/server/composite_handlers_test.go @@ -102,9 +102,8 @@ func buildTestServer(t *testing.T, platformURL, jwt string) (*Server, *httptest. cfg := &config.Config{ ControlPlane: config.ControlPlaneConfig{URL: platformURL}, - ProxyPrefix: "/api/proxy", - Cookie: config.CookieConfig{Name: "_ai_workspace_session"}, - CSRFHeader: "X-Requested-By", + ProxyPrefix: "/proxy", + Cookie: config.CookieConfig{Name: "_ai_workspace_session"}, } s := &Server{ @@ -272,8 +271,8 @@ func TestHandleCreateWithSecretCompensation_Unauthenticated(t *testing.T) { platform, _ := fakeControlPlane(t, nil) cfg := &config.Config{ ControlPlane: config.ControlPlaneConfig{URL: platform.URL}, - ProxyPrefix: "/api/proxy", - Cookie: config.CookieConfig{Name: "_ai_workspace_session"}, + ProxyPrefix: "/proxy", + Cookie: config.CookieConfig{Name: "_ai_workspace_session"}, } transport, err := proxy.NewTransport(proxy.TLSClientOptions{SkipVerify: true}) if err != nil { diff --git a/portals/ai-workspace/bff/internal/server/handlers.go b/portals/ai-workspace/bff/internal/server/handlers.go index abf3fa708..a54bf7343 100644 --- a/portals/ai-workspace/bff/internal/server/handlers.go +++ b/portals/ai-workspace/bff/internal/server/handlers.go @@ -178,7 +178,7 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { // Reverse proxy // --------------------------------------------------------------------------- -// handleProxy (/api/proxy/*) — take the JWT straight from the cookie and forward +// handleProxy (/proxy/*) — take the JWT straight from the cookie and forward // it upstream. No server-side lookup is involved unless the token is an OIDC // access token that is near expiry and must be refreshed. func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) { @@ -255,7 +255,7 @@ func (s *Server) userFromToken(ctx context.Context, jwt string) session.User { } return s.oidc.UserFromAccessToken(jwt) } - return session.UserFromClaims(session.DecodeJWTClaims(jwt), nil, session.DefaultClaimMapping()) + return session.UserFromClaims(session.DecodeJWTClaims(jwt), nil, s.claims) } // putRefreshState stores the OIDC refresh/id tokens keyed by the access JWT so diff --git a/portals/ai-workspace/bff/internal/server/middleware.go b/portals/ai-workspace/bff/internal/server/middleware.go index 0c5e7376d..878ed4695 100644 --- a/portals/ai-workspace/bff/internal/server/middleware.go +++ b/portals/ai-workspace/bff/internal/server/middleware.go @@ -22,6 +22,8 @@ import ( "log/slog" "net/http" "time" + + "ai-workspace-bff/internal/config" ) // chain applies middlewares in order (outermost first). @@ -81,7 +83,7 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler { case http.MethodGet, http.MethodHead, http.MethodOptions: // Safe methods: no CSRF token required. default: - if r.Header.Get(s.cfg.CSRFHeader) == "" { + if r.Header.Get(config.CSRFHeaderName) == "" { writeErrorJSON(w, http.StatusForbidden, "MISSING_CSRF_HEADER", "missing CSRF header") return } diff --git a/portals/ai-workspace/bff/internal/server/middleware_test.go b/portals/ai-workspace/bff/internal/server/middleware_test.go index c442729c4..190bfe210 100644 --- a/portals/ai-workspace/bff/internal/server/middleware_test.go +++ b/portals/ai-workspace/bff/internal/server/middleware_test.go @@ -25,7 +25,7 @@ import ( ) func csrfTestServer() *Server { - return &Server{cfg: &config.Config{CSRFHeader: "X-Requested-By"}} + return &Server{cfg: &config.Config{}} } func TestRequireCSRF(t *testing.T) { @@ -49,7 +49,7 @@ func TestRequireCSRF(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - req := httptest.NewRequest(tc.method, "/api/proxy/x", nil) + req := httptest.NewRequest(tc.method, "/proxy/x", nil) if tc.header { req.Header.Set("X-Requested-By", "ai-workspace") } diff --git a/portals/ai-workspace/bff/internal/server/server.go b/portals/ai-workspace/bff/internal/server/server.go index ce8fab882..45d3e8c19 100644 --- a/portals/ai-workspace/bff/internal/server/server.go +++ b/portals/ai-workspace/bff/internal/server/server.go @@ -46,6 +46,7 @@ type refreshLock struct { // Server holds the BFF dependencies and HTTP handler. type Server struct { cfg *config.Config + claims session.ClaimMapping store session.Store fileBased *auth.FileBased oidc *auth.OIDC @@ -74,9 +75,15 @@ func New(ctx context.Context, cfg *config.Config) (*Server, error) { } upstream := &http.Client{Transport: transport, Timeout: 60 * time.Second} + // Shared by both auth modes: OIDC tokens from the configured IDP, and the HMAC + // JWTs the Platform API's file-based login endpoint signs with the same mapped + // claim names. Building it once keeps the two readers from drifting apart. + claims := buildClaimMapping(cfg.Claims) + s := &Server{ cfg: cfg, - fileBased: auth.NewFileBased(upstream, cfg.ControlPlane.URL, cfg.ControlPlane.LoginPath, cfg.Session.AbsoluteTTL), + claims: claims, + fileBased: auth.NewFileBased(upstream, cfg.ControlPlane.URL, cfg.ControlPlane.PortalBasePath, cfg.Session.AbsoluteTTL, claims), proxy: proxy.ReverseProxy(target, cfg.ProxyPrefix, transport), refreshLocks: make(map[string]*refreshLock), } @@ -89,7 +96,7 @@ func New(ctx context.Context, cfg *config.Config) (*Server, error) { ctx, upstream, cfg.OIDC.Issuer, cfg.OIDC.ClientID, cfg.OIDC.ClientSecret, cfg.OIDC.RedirectURL, cfg.OIDC.PostLogoutRedirectURL, cfg.OIDC.Scopes, - oidcClaimMapping(cfg.OIDC.Claims), cfg.Session.AbsoluteTTL, + claims, cfg.Session.AbsoluteTTL, ) if err != nil { return nil, err @@ -116,11 +123,11 @@ func (s *Server) Close() error { return nil } -// oidcClaimMapping builds the claim mapping for OIDC tokens from config. Each -// field overrides the session-package default only when set, so an operator can -// point a single claim (e.g. the display name) at the right key via the -// OIDC_CLAIM_* env vars without re-specifying the rest. -func oidcClaimMapping(c config.ClaimMappingConfig) session.ClaimMapping { +// buildClaimMapping builds the claim mapping shared by both auth modes from +// config. Each field overrides the session-package default only when set, so an +// operator can point a single claim (e.g. the display name) at the right key via +// the CLAIM_MAPPINGS_* env vars without re-specifying the rest. +func buildClaimMapping(c config.ClaimMappingConfig) session.ClaimMapping { m := session.DefaultClaimMapping() if c.Username != "" { m.Username = c.Username @@ -128,8 +135,8 @@ func oidcClaimMapping(c config.ClaimMappingConfig) session.ClaimMapping { if c.Email != "" { m.Email = c.Email } - if c.Role != "" { - m.Role = c.Role + if c.Roles != "" { + m.Roles = c.Roles } if c.Scope != "" { m.Scope = c.Scope diff --git a/portals/ai-workspace/bff/internal/session/claims.go b/portals/ai-workspace/bff/internal/session/claims.go index b3d439ffb..05dd4d383 100644 --- a/portals/ai-workspace/bff/internal/session/claims.go +++ b/portals/ai-workspace/bff/internal/session/claims.go @@ -33,21 +33,21 @@ import ( type ClaimMapping struct { Username string Email string - Role string + Roles string Scope string OrgID string OrgName string OrgHandle string } -// DefaultClaimMapping returns the mapping used for file-based tokens (and as the -// fallback for OIDC). For OIDC, callers may override the org/user keys to match -// the IDP. +// DefaultClaimMapping returns the built-in fallback mapping, used whenever a +// config.ClaimMappingConfig field is left unset — for both file-based and OIDC +// tokens. Callers may override individual keys to match a specific IDP. func DefaultClaimMapping() ClaimMapping { return ClaimMapping{ Username: "username", Email: "email", - Role: "platform_role", + Roles: "roles", Scope: "scope", OrgID: "organization", OrgName: "org_name", @@ -119,7 +119,7 @@ func UserFromClaims(claims, idClaims map[string]any, m ClaimMapping) User { u := User{ Name: first(get(m.Username), get(m.Email), get("sub")), Email: get(m.Email), - Role: strClaim(claims, m.Role), + Role: strClaim(claims, m.Roles), Scopes: scopes(claims, m.Scope), } diff --git a/portals/ai-workspace/configs/config-template.toml b/portals/ai-workspace/configs/config-template.toml index 1e9fae5b0..91e5ac12e 100644 --- a/portals/ai-workspace/configs/config-template.toml +++ b/portals/ai-workspace/configs/config-template.toml @@ -9,14 +9,12 @@ # # AI Workspace configuration template # -# Copy this file to config.toml and edit the values for your deployment. -# This file is read by the AI Workspace container at startup, and is the only source of -# configuration. +# Copy this file to config.toml and edit the values for your deployment. It is the +# only source of configuration, read by the AI Workspace container at startup. # -# All AI Workspace settings live under [ai_workspace] / [ai_workspace.*]. This -# namespacing mirrors the Platform API's own [platform_api] / [platform_api.*] table -# and lets one config.toml hold both services' sections side by side in a shared -# deployment config without their tables colliding. +# All settings live under [ai_workspace] / [ai_workspace.*], mirroring the Platform +# API's own [platform_api] / [platform_api.*] tables so one config.toml can hold both +# services side by side without their tables colliding. # # HOW VALUES ARE RESOLVED # Each value is an interpolation token, resolved once at startup: @@ -24,52 +22,37 @@ # key = '{{ env "APIP_AIW_KEY" "default" }}' # └── environment variable └── value used when the variable is unset # -# So a key written this way can be set by its environment variable — no edit to this -# file required. The token is what reads the environment: there is no implicit override, -# so a key written as a plain literal (key = "value"), or left commented out below, -# ignores the variable entirely. Uncomment the key first to make it settable that way. +# The token is what reads the environment — a plain literal or a commented-out key +# ignores the variable entirely. By convention the variable name is the key's table +# path under [ai_workspace], uppercased with underscores, prefixed APIP_AIW_: # -# By convention the variable is the key's table path under [ai_workspace] (not -# including the [ai_workspace] segment itself), uppercased, dots as underscores, -# prefixed with APIP_AIW_: +# [ai_workspace] domain -> APIP_AIW_DOMAIN +# [ai_workspace.server] port -> APIP_AIW_SERVER_PORT +# [ai_workspace.auth.oidc] client_id -> APIP_AIW_AUTH_OIDC_CLIENT_ID # -# [ai_workspace] domain -> APIP_AIW_DOMAIN -# [ai_workspace.control_plane] url -> APIP_AIW_CONTROL_PLANE_URL -# [ai_workspace.oidc] client_id -> APIP_AIW_OIDC_CLIENT_ID -# -# To source a value from a mounted file instead of the environment — the right -# choice for secrets — swap the token: +# For secrets, source from a mounted file instead of the environment: # # key = '{{ file "/secrets/ai-workspace/key" }}' # -# A {{ file }} token is required and fails closed: if the file is missing, or lives -# outside the allowed source directories (/etc/ai-workspace and /secrets/ai-workspace -# by default; override with APIP_CONFIG_FILE_SOURCE_ALLOWLIST), the server refuses to -# start rather than run with an empty value. An {{ env }} token with no default -# behaves the same way. +# Both {{ file }} and a required {{ env }} (no default) fail closed at startup — a +# missing file, a file outside the allowed source directories (/etc/ai-workspace and +# /secrets/ai-workspace; override with APIP_CONFIG_FILE_SOURCE_ALLOWLIST), or an unset +# required variable all refuse to start rather than run with an empty value. # -# Never write a secret as a raw literal in this file, and never hardcode one in -# docker-compose.yaml. +# Never write a secret as a raw literal here or hardcode one in docker-compose.yaml. # # KEY ORDER -# Within every table, keys are ordered so the ones that decide whether the rest apply -# come first: -# -# 1. the gate — enabled, or the mode that selects this table (auth_mode) -# 2. required identity/connection keys (url, authority, client_id, client_secret) -# 3. optional behaviour, in the order you would tune it -# 4. TLS/trust and other hardening keys last -# +# Within each table: 1) the gate (enabled/mode), 2) required identity/connection keys +# (url, authority, client_id, client_secret), 3) optional behaviour, 4) TLS/trust last. # Keep new keys in that order rather than appending to the end of a table. # # QUICK START (basic-auth mode): # 1. Copy this file to config.toml. -# 2. Set [ai_workspace] auth_mode = "basic" (uses the users defined in +# 2. Set [ai_workspace.auth] mode = "basic" (uses the users defined in # config-platform-api.toml). # 3. Run: docker compose up # -# OIDC mode: set [ai_workspace] auth_mode = "oidc" and fill in the [ai_workspace.oidc] -# table below. +# OIDC mode: set [ai_workspace.auth] mode = "oidc" and fill in [ai_workspace.auth.oidc]. # -------------------------------------------------------------------- [ai_workspace] @@ -81,47 +64,42 @@ # window.__RUNTIME_CONFIG__. Never put a credential among them. # ==================================================================== -# Authentication mode for the UI: "basic" (local users) or "oidc" (external IDP). -# Choosing "oidc" enables the [ai_workspace.oidc] table below. -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" }}' # Default region assigned to new organizations on first login. default_org_region = '{{ env "APIP_AIW_DEFAULT_ORG_REGION" "us" }}' -# Verbose logging in the browser console. -debug = '{{ env "APIP_AIW_DEBUG" "false" }}' - -# ==================================================================== +# --------------------------------------------------------------------------- # Server # -# Everything from here down configures the AI Workspace server itself. These values -# stay server-side and are never sent to the browser (the [ai_workspace.oidc] claim -# names being the one exception — the SPA needs them to display user/org identity). +# Everything in this table configures the AI Workspace server itself. These values +# stay server-side and are never sent to the browser (the [ai_workspace.auth.oidc] +# claim names being the one exception — the SPA needs them to display user/org identity). # # All keys are optional: the value in each token's default position is the built-in # default. -# ==================================================================== +# --------------------------------------------------------------------------- +[ai_workspace.server] -# Address the server listens on (host:port). The container publishes this port. -listen_addr = '{{ env "APIP_AIW_LISTEN_ADDR" ":5380" }}' +# Port the server listens on. The listener always binds all interfaces (there is no +# host to configure), and the container publishes this port. +port = '{{ env "APIP_AIW_SERVER_PORT" "5380" }}' # Directory holding the built SPA (index.html + assets). -static_dir = '{{ env "APIP_AIW_STATIC_DIR" "/app" }}' +static_dir = '{{ env "APIP_AIW_SERVER_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" }}' -# Custom header required on state-mutating requests (CSRF protection). -csrf_header = '{{ env "APIP_AIW_CSRF_HEADER" "X-Requested-By" }}' +# --------------------------------------------------------------------------- +# Logging. log_level/log_format are this process's own logs; browser_debug is +# browser-safe — served to the SPA to control verbose console logging there. +# --------------------------------------------------------------------------- +[ai_workspace.logging] -log_level = '{{ env "APIP_AIW_LOG_LEVEL" "info" }}' # debug | info | warn | error -log_format = '{{ env "APIP_AIW_LOG_FORMAT" "text" }}' # text | json +log_level = '{{ env "APIP_AIW_LOG_LEVEL" "info" }}' # debug | info | warn | error +log_format = '{{ env "APIP_AIW_LOG_FORMAT" "text" }}' # text | json +browser_debug = '{{ env "APIP_AIW_LOGGING_BROWSER_DEBUG" "false" }}' # --------------------------------------------------------------------------- @@ -129,42 +107,42 @@ log_format = '{{ env "APIP_AIW_LOG_FORMAT" "text" }}' # text | json # --------------------------------------------------------------------------- [ai_workspace.control_plane] -# Base URL used to reach the Platform API. REQUIRED. In docker compose this is the -# compose hostname; running locally, use localhost. Its http/https scheme decides -# whether the upstream hop uses TLS. +# Base URL to reach the Platform API. REQUIRED. Use the compose hostname in docker +# compose, or localhost when running locally. The scheme decides if the hop uses TLS. url = '{{ env "APIP_AIW_CONTROL_PLANE_URL" "https://platform-api:9243" }}' -# File-based login path on the Platform API (basic-auth mode). -login_path = '{{ env "APIP_AIW_CONTROL_PLANE_LOGIN_PATH" "/api/portal/v0.9/auth/login" }}' +# Same-origin prefix the SPA calls; stripped before forwarding upstream, so the +# browser only ever talks to the app origin and never sees the platform-api cert. +proxy_prefix = '{{ env "APIP_AIW_CONTROL_PLANE_PROXY_PREFIX" "/proxy" }}' + +# Platform API's portal route prefix. BFF-initiated calls (e.g. file-based login) are +# built as this base path plus the route's own suffix, e.g. base + "/auth/login". +portal_base_path = '{{ env "APIP_AIW_CONTROL_PLANE_PORTAL_BASE_PATH" "/api/portal/v0.9" }}' # --- Trust for the upstream's TLS certificate --- -# Accept the Platform API's self-signed certificate without verification (local -# development only) — prefer trusting the certificate with ca_file instead. +# Accept the Platform API's self-signed certificate without verification (local dev +# only) — prefer ca_file instead. tls_skip_verify = '{{ env "APIP_AIW_CONTROL_PLANE_TLS_SKIP_VERIFY" "false" }}' -# PEM bundle to trust for the upstream's TLS certificate. Appended to the system -# roots, so public CAs keep working. +# PEM bundle to trust for the upstream's TLS certificate, appended to system roots. ca_file = '{{ env "APIP_AIW_CONTROL_PLANE_CA_FILE" "" }}' # --------------------------------------------------------------------------- # Gateway deployment info — browser-safe values the SPA shows in gateway setup -# instructions. Distinct from [ai_workspace.control_plane] above: that table is the -# BFF's own server-to-server hop, while this one is what an externally deployed -# gateway needs to reach the Platform API itself. +# instructions. Unlike [ai_workspace.control_plane] (the BFF's own hop), this is what +# an externally deployed gateway needs to reach the Platform API itself. # --------------------------------------------------------------------------- [ai_workspace.gateway] -# Control-plane host — the host:port that deployed gateways use to reach the Platform -# API. Shown in gateway setup instructions (keys.env, helm values). Must be the -# externally reachable address, not a relative/proxy path. +# host:port that deployed gateways use to reach the Platform API, shown in setup +# instructions (keys.env, helm values). Must be externally reachable, not a proxy path. controlplane_host = '{{ env "APIP_AIW_GATEWAY_CONTROLPLANE_HOST" "localhost:9243" }}' # Gateway versions offered in the create-gateway version selector (JSON array string). -# Each entry: version (helm chart minor), latestVersion (image/chart tag shown to the -# user and used in the helm --version flag), channel ("STS" | "LTS"). The first entry -# is the default selection. +# Each entry: version (helm chart minor), latestVersion (image/chart tag, used in the +# helm --version flag), channel ("STS" | "LTS"). The first entry is the default. platform_gateway_versions = '{{ env "APIP_AIW_GATEWAY_PLATFORM_GATEWAY_VERSIONS" "[{\"version\":\"1.2\",\"latestVersion\":\"v1.2.0-alpha2\",\"channel\":\"STS\"}]" }}' @@ -173,13 +151,12 @@ platform_gateway_versions = '{{ env "APIP_AIW_GATEWAY_PLATFORM_GATEWAY_VERSIONS" # --------------------------------------------------------------------------- [ai_workspace.tls] -# Terminate TLS on this listener. Set false only when a trusted upstream (ingress -# controller, service-mesh sidecar) terminates TLS for you — no certificate is then -# read, generated, or required. +# Terminate TLS on this listener. Set false only when a trusted upstream (ingress, +# service-mesh sidecar) terminates TLS for you — no certificate is read or required. 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. +# Paths to a mounted TLS certificate and key. REQUIRED when enabled = true; setup.sh +# generates a pair for the quickstart. cert_file = '{{ env "APIP_AIW_TLS_CERT_FILE" "/etc/ai-workspace/tls/cert.pem" }}' key_file = '{{ env "APIP_AIW_TLS_KEY_FILE" "/etc/ai-workspace/tls/key.pem" }}' @@ -200,97 +177,76 @@ absolute_ttl = '{{ env "APIP_AIW_SESSION_ABSOLUTE_TTL" "8h" }}' # --------------------------------------------------------------------------- -# Session cookie attributes. +# Auth — chooses the login UX and gates the OIDC client below. # --------------------------------------------------------------------------- -[ai_workspace.cookie] +[ai_workspace.auth] -name = '{{ env "APIP_AIW_COOKIE_NAME" "_ai_workspace_session" }}' +# Authentication mode for the UI: "basic" (local users) or "oidc" (external IDP). +# Choosing "oidc" enables the [ai_workspace.auth.oidc] table below. +mode = '{{ env "APIP_AIW_AUTH_MODE" "basic" }}' -# Mark the session cookie Secure (HTTPS-only). Set false only for local HTTP. -secure = '{{ env "APIP_AIW_COOKIE_SECURE" "true" }}' -# SameSite policy: "lax" | "strict" | "none". -samesite = '{{ env "APIP_AIW_COOKIE_SAMESITE" "lax" }}' +# --------------------------------------------------------------------------- +# JWT claim name mappings — which token claim carries each user/org field. Applies to +# BOTH auth modes (a sibling of [ai_workspace.auth.oidc], not nested in it): in basic +# mode the Platform API signs JWTs using these same claim names, so the two services +# must agree. Mirrors [platform_api.auth.claim_mappings] in config-platform-api.toml +# key for key — override only if your IDP uses different claim names. +# --------------------------------------------------------------------------- +[ai_workspace.auth.claim_mappings] + +organization = '{{ env "APIP_AIW_AUTH_CLAIM_MAPPINGS_ORGANIZATION" "organization" }}' # claim carrying the org ID +org_name = '{{ env "APIP_AIW_AUTH_CLAIM_MAPPINGS_ORG_NAME" "org_name" }}' # org display name +org_handle = '{{ env "APIP_AIW_AUTH_CLAIM_MAPPINGS_ORG_HANDLE" "org_handle" }}' # org URL slug +username = '{{ env "APIP_AIW_AUTH_CLAIM_MAPPINGS_USERNAME" "username" }}' +email = '{{ env "APIP_AIW_AUTH_CLAIM_MAPPINGS_EMAIL" "email" }}' +scope = '{{ env "APIP_AIW_AUTH_CLAIM_MAPPINGS_SCOPE" "scope" }}' # space-separated scope string +roles = '{{ env "APIP_AIW_AUTH_CLAIM_MAPPINGS_ROLES" "roles" }}' # --------------------------------------------------------------------------- -# OIDC — set [ai_workspace] auth_mode = "oidc" above to enable. +# OIDC — set [ai_workspace.auth] mode = "oidc" above to enable. # # The BFF is a confidential client: it performs the whole handshake, and the client # credentials never reach the browser. # --------------------------------------------------------------------------- -[ai_workspace.oidc] +[ai_workspace.auth.oidc] -# Force the OIDC client on independently of auth_mode. Setting auth_mode = "oidc" -# already enables it, so this is rarely needed. -enabled = '{{ env "APIP_AIW_OIDC_ENABLED" "false" }}' +# Force the OIDC client on independently of mode. Rarely needed — mode = "oidc" +# above already enables it. +enabled = '{{ env "APIP_AIW_AUTH_OIDC_ENABLED" "false" }}' # Issuer URL — endpoints are auto-discovered from /.well-known/openid-configuration. -authority = '{{ env "APIP_AIW_OIDC_AUTHORITY" "https://accounts.example.com" }}' +authority = '{{ env "APIP_AIW_AUTH_OIDC_AUTHORITY" "https://accounts.example.com" }}' # Client ID registered in your IDP. -client_id = '{{ env "APIP_AIW_OIDC_CLIENT_ID" "your-client-id" }}' +client_id = '{{ env "APIP_AIW_AUTH_OIDC_CLIENT_ID" "your-client-id" }}' -# The confidential-client secret. Required in OIDC mode, and deliberately has no -# default: an unset variable fails startup rather than running with an empty credential. -# Prefer the {{ file }} form in production — the value then never enters the -# environment at all. -client_secret = '{{ env "APIP_AIW_OIDC_CLIENT_SECRET" }}' +# Confidential-client secret. REQUIRED in OIDC mode with no default — an unset +# variable fails startup. Prefer the {{ file }} form in production. +client_secret = '{{ env "APIP_AIW_AUTH_OIDC_CLIENT_SECRET" }}' -# The callback the IDP redirects to after login — a server-side callback, not an SPA -# route. Must equal the redirect URI registered on the IDP application. -redirect_url = '{{ env "APIP_AIW_OIDC_REDIRECT_URL" "https://localhost:5380/api/auth/callback" }}' +# Server-side callback the IDP redirects to after login (not an SPA route). Must +# match the redirect URI registered on the IDP application. +redirect_url = '{{ env "APIP_AIW_AUTH_OIDC_REDIRECT_URL" "https://localhost:5380/api/auth/callback" }}' # Absolute URL the IDP redirects to after logout (must be pre-registered there). -post_logout_redirect_url = '{{ env "APIP_AIW_OIDC_POST_LOGOUT_REDIRECT_URL" "https://localhost:5380/login" }}' +post_logout_redirect_url = '{{ env "APIP_AIW_AUTH_OIDC_POST_LOGOUT_REDIRECT_URL" "https://localhost:5380/login" }}' -# Scopes requested at login (space-separated). Leave APIP_AIW_OIDC_SCOPE unset to -# request the built-in full ap:* set (recommended). Override only to request a -# narrower set — and keep offline_access in any override, or token refresh breaks. -scope = '{{ env "APIP_AIW_OIDC_SCOPE" "" }}' - - -# --------------------------------------------------------------------------- -# JWT claim name mappings — which token claim carries each user/org field. -# -# This table mirrors [platform_api.auth.idp.claim_mappings] in -# config-platform-api.toml key for key, and the two must agree: both services read -# the same claims out of the same token. The variables it maps to: -# -# APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION (AI Workspace) -# APIP_CP_AUTH_IDP_CLAIM_ORGANIZATION (Platform API) -# -# Override only if your IDP uses claim names other than the defaults shown. -# -# Must be the last table under [ai_workspace.oidc]: in TOML, a sub-table header ends -# the parent table's key section, so any plain [ai_workspace.oidc] key placed below -# it would land here instead. -# --------------------------------------------------------------------------- -[ai_workspace.oidc.claim_mappings] - -organization = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION" "org_id" }}' # claim carrying the org ID -org_name = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ORG_NAME" "org_name" }}' # org display name -org_handle = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ORG_HANDLE" "org_handle" }}' # org URL slug -username = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_USERNAME" "username" }}' -email = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_EMAIL" "email" }}' -scope = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_SCOPE" "scope" }}' # space-separated scope string -role = '{{ env "APIP_AIW_OIDC_CLAIM_MAPPINGS_ROLE" "platform_role" }}' +# Scopes requested at login (space-separated). Leave unset to request the built-in +# full ap:* set (recommended); if overriding, keep offline_access or refresh breaks. +scope = '{{ env "APIP_AIW_AUTH_OIDC_SCOPE" "" }}' # ==================================================================== # Settings with no config key # -# The path to this file cannot be a key in it: the server reads its mount, -# /etc/ai-workspace/config.toml, unless -config names another path -# (`bff -config ../configs/config.toml`, as `make bff-run` does). -# -# The variables below are read straight from the environment by the server itself, not -# through a token, because each is needed before (or independently of) the config file. -# They are not config keys, so they have no entry above. +# Read straight from the environment by the server itself (not through a token), +# since each is needed before or independently of the config file. # ==================================================================== # # APIP_CONFIG_FILE_SOURCE_ALLOWLIST # Comma-separated directories a {{ file "..." }} token may -# read from. Replaces (not extends) the defaults -# /etc/ai-workspace and /secrets/ai-workspace. Shared by -# every component in the platform. +# read from. Replaces the defaults /etc/ai-workspace and +# /secrets/ai-workspace. Shared by every component in the +# platform. diff --git a/portals/ai-workspace/configs/config.toml b/portals/ai-workspace/configs/config.toml index aba33f906..21e6df3b5 100644 --- a/portals/ai-workspace/configs/config.toml +++ b/portals/ai-workspace/configs/config.toml @@ -9,20 +9,26 @@ [ai_workspace] -auth_mode = '{{ env "APIP_AIW_AUTH_MODE" "basic" }}' domain = '{{ env "APIP_AIW_DOMAIN" "localhost:5380" }}' default_org_region = '{{ env "APIP_AIW_DEFAULT_ORG_REGION" "us" }}' -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 + +[ai_workspace.server] + +port = '{{ env "APIP_AIW_SERVER_PORT" "5380" }}' +static_dir = '{{ env "APIP_AIW_SERVER_STATIC_DIR" "/app" }}' + + +[ai_workspace.logging] + +log_level = '{{ env "APIP_AIW_LOG_LEVEL" "info" }}' # debug | info | warn | error [ai_workspace.control_plane] url = '{{ env "APIP_AIW_CONTROL_PLANE_URL" "https://platform-api:9243" }}' -ca_file = "/etc/ai-workspace/tls/cert.pem" tls_skip_verify = '{{ env "APIP_AIW_CONTROL_PLANE_TLS_SKIP_VERIFY" "false" }}' +ca_file = "/etc/ai-workspace/tls/cert.pem" [ai_workspace.gateway] @@ -36,3 +42,8 @@ platform_gateway_versions = '{{ env "APIP_AIW_GATEWAY_PLATFORM_GATEWAY_VERSIONS" enabled = '{{ env "APIP_AIW_TLS_ENABLED" "true" }}' cert_file = "/etc/ai-workspace/tls/cert.pem" key_file = "/etc/ai-workspace/tls/key.pem" + + +[ai_workspace.auth] + +mode = '{{ env "APIP_AIW_AUTH_MODE" "basic" }}' diff --git a/portals/ai-workspace/cypress/e2e/001-providers/001-provider-and-proxy.cy.js b/portals/ai-workspace/cypress/e2e/001-providers/001-provider-and-proxy.cy.js index 48fd91ec3..540d31920 100644 --- a/portals/ai-workspace/cypress/e2e/001-providers/001-provider-and-proxy.cy.js +++ b/portals/ai-workspace/cypress/e2e/001-providers/001-provider-and-proxy.cy.js @@ -39,7 +39,7 @@ describe('AI Workspace - OpenAI provider and proxy lifecycle', () => { cy.login(); cy.request({ method: 'POST', - url: '/api/proxy/api/portal/v0.9/auth/login', + url: '/proxy/api/portal/v0.9/auth/login', form: true, body: { username: Cypress.env('ADMIN_USER'), @@ -52,7 +52,7 @@ describe('AI Workspace - OpenAI provider and proxy lifecycle', () => { expect(authToken).to.not.equal(''); return cy.request({ - url: '/api/proxy/api/v0.9/organizations', + url: '/proxy/api/v0.9/organizations', headers: { Authorization: `Bearer ${authToken}`, }, @@ -212,7 +212,7 @@ function deleteLinkedProxies(authToken, organizationId, providerId) { return cy.wrap(null); } return requestWithAuth(authToken, { - url: `/api/proxy/api/v0.9/llm-providers/${encodeURIComponent(providerId)}/llm-proxies?organizationId=${encodeURIComponent(organizationId)}`, + url: `/proxy/api/v0.9/llm-providers/${encodeURIComponent(providerId)}/llm-proxies?organizationId=${encodeURIComponent(organizationId)}`, failOnStatusCode: false, }).then((response) => { expect(response.status).to.be.oneOf([200, 404]); @@ -229,7 +229,7 @@ function deleteLinkedProxies(authToken, organizationId, providerId) { return cy.wrap(proxies).each((proxy) => requestWithAuth(authToken, { method: 'DELETE', - url: `/api/proxy/api/v0.9/llm-proxies/${encodeURIComponent(proxy.id)}?organizationId=${encodeURIComponent(organizationId)}`, + url: `/proxy/api/v0.9/llm-proxies/${encodeURIComponent(proxy.id)}?organizationId=${encodeURIComponent(organizationId)}`, failOnStatusCode: false, }).then((deleteResponse) => { expect(deleteResponse.status).to.be.oneOf([200, 204, 404]); @@ -240,7 +240,7 @@ function deleteLinkedProxies(authToken, organizationId, providerId) { function deleteProjectByName(authToken, targetProjectName, fallbackProjectName) { return requestWithAuth(authToken, { - url: '/api/proxy/api/v0.9/projects', + url: '/proxy/api/v0.9/projects', }).then((response) => { expect(response.status).to.eq(200); @@ -266,7 +266,7 @@ function deleteProjectByName(authToken, targetProjectName, fallbackProjectName) function ensureFallbackProject(authToken, fallbackProjectName) { return requestWithAuth(authToken, { method: 'POST', - url: '/api/proxy/api/v0.9/projects', + url: '/proxy/api/v0.9/projects', body: { displayName: fallbackProjectName, description: 'Reserved project to satisfy E2E cleanup invariants.', @@ -280,7 +280,7 @@ function ensureFallbackProject(authToken, fallbackProjectName) { function deleteProject(authToken, projectId) { return requestWithAuth(authToken, { method: 'DELETE', - url: `/api/proxy/api/v0.9/projects/${encodeURIComponent(projectId)}`, + url: `/proxy/api/v0.9/projects/${encodeURIComponent(projectId)}`, failOnStatusCode: false, }).then((response) => { expect(response.status).to.be.oneOf([200, 204, 404]); @@ -293,7 +293,7 @@ function deleteProvider(authToken, organizationId, providerId) { } return requestWithAuth(authToken, { method: 'DELETE', - url: `/api/proxy/api/v0.9/llm-providers/${encodeURIComponent(providerId)}?organizationId=${encodeURIComponent(organizationId)}`, + url: `/proxy/api/v0.9/llm-providers/${encodeURIComponent(providerId)}?organizationId=${encodeURIComponent(organizationId)}`, failOnStatusCode: false, }).then((response) => { expect(response.status).to.be.oneOf([200, 204, 404]); diff --git a/portals/ai-workspace/cypress/e2e/001-providers/002-provider-secret-management.cy.js b/portals/ai-workspace/cypress/e2e/001-providers/002-provider-secret-management.cy.js index 21667a043..a22b461a6 100644 --- a/portals/ai-workspace/cypress/e2e/001-providers/002-provider-secret-management.cy.js +++ b/portals/ai-workspace/cypress/e2e/001-providers/002-provider-secret-management.cy.js @@ -88,7 +88,7 @@ describe('AI Workspace — LLM provider secret management (create flow)', () => cy.login(); cy.request({ method: 'POST', - url: '/api/proxy/api/portal/v0.9/auth/login', + url: '/proxy/api/portal/v0.9/auth/login', form: true, body: { username: Cypress.env('ADMIN_USER'), @@ -100,7 +100,7 @@ describe('AI Workspace — LLM provider secret management (create flow)', () => authToken = response.body?.token ?? ''; expect(authToken).to.not.equal(''); return cy.request({ - url: '/api/proxy/api/v0.9/organizations', + url: '/proxy/api/v0.9/organizations', headers: { Authorization: `Bearer ${authToken}` }, }); }) @@ -115,7 +115,7 @@ describe('AI Workspace — LLM provider secret management (create flow)', () => if (!authToken || !organizationId || !createdProviderId) return; cy.request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/llm-providers/${encodeURIComponent(createdProviderId)}?organizationId=${encodeURIComponent(organizationId)}`, + url: `/proxy/api/v0.9/llm-providers/${encodeURIComponent(createdProviderId)}?organizationId=${encodeURIComponent(organizationId)}`, headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }); @@ -174,7 +174,7 @@ describe('AI Workspace — LLM provider secret management (create flow)', () => // Pre-create the secret via API so there's a real secret backing the placeholder. cy.request({ method: 'POST', - url: '/api/proxy/api/v0.9/secrets', + url: '/proxy/api/v0.9/secrets', headers: { Authorization: `Bearer ${authToken}` }, form: true, body: { @@ -266,7 +266,7 @@ describe('AI Workspace — LLM provider secret management (create flow)', () => cy.request({ method: 'POST', - url: '/api/proxy/api/v0.9/secrets', + url: '/proxy/api/v0.9/secrets', headers: { Authorization: `Bearer ${authToken}` }, form: true, body: { @@ -284,7 +284,7 @@ describe('AI Workspace — LLM provider secret management (create flow)', () => // accumulates many secrets across the suite and the list defaults to // limit=25, which can miss the one just created). cy.request({ - url: `/api/proxy/api/v0.9/secrets/${encodeURIComponent(handle)}?organizationId=${encodeURIComponent(organizationId)}`, + url: `/proxy/api/v0.9/secrets/${encodeURIComponent(handle)}?organizationId=${encodeURIComponent(organizationId)}`, headers: { Authorization: `Bearer ${authToken}` }, }).then((r) => { expect(r.status).to.eq(200); @@ -297,7 +297,7 @@ describe('AI Workspace — LLM provider secret management (create flow)', () => cy.request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/secrets/${encodeURIComponent(handle)}?organizationId=${encodeURIComponent(organizationId)}`, + url: `/proxy/api/v0.9/secrets/${encodeURIComponent(handle)}?organizationId=${encodeURIComponent(organizationId)}`, headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }); @@ -327,7 +327,7 @@ describe('AI Workspace — LLM provider secret management (update flow)', () => cy.request({ method: 'POST', - url: '/api/proxy/api/portal/v0.9/auth/login', + url: '/proxy/api/portal/v0.9/auth/login', form: true, body: { username: Cypress.env('ADMIN_USER'), @@ -337,7 +337,7 @@ describe('AI Workspace — LLM provider secret management (update flow)', () => cy.then(() => cy.request({ - url: '/api/proxy/api/v0.9/organizations', + url: '/proxy/api/v0.9/organizations', headers: { Authorization: `Bearer ${authToken}` }, }) ).then((r) => { organizationId = r.body?.list?.[0]?.id ?? ''; }); @@ -380,7 +380,7 @@ describe('AI Workspace — LLM provider secret management (update flow)', () => if (authToken && organizationId && providerId) { cy.request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/llm-providers/${encodeURIComponent(providerId)}?organizationId=${encodeURIComponent(organizationId)}`, + url: `/proxy/api/v0.9/llm-providers/${encodeURIComponent(providerId)}?organizationId=${encodeURIComponent(organizationId)}`, headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }); @@ -451,7 +451,7 @@ describe('AI Workspace — LLM provider secret management (update flow)', () => // cy.request() bypasses cy.intercept(), so this won't affect secretCallCount below. cy.request({ method: 'POST', - url: '/api/proxy/api/v0.9/secrets', + url: '/proxy/api/v0.9/secrets', headers: { Authorization: `Bearer ${authToken}` }, form: true, body: { diff --git a/portals/ai-workspace/cypress/e2e/001-providers/003-llm-proxy-secret-management.cy.js b/portals/ai-workspace/cypress/e2e/001-providers/003-llm-proxy-secret-management.cy.js index 3f7a9403e..28512c890 100644 --- a/portals/ai-workspace/cypress/e2e/001-providers/003-llm-proxy-secret-management.cy.js +++ b/portals/ai-workspace/cypress/e2e/001-providers/003-llm-proxy-secret-management.cy.js @@ -66,7 +66,7 @@ function loginAndFetchAuthContext(setAuthToken, setOrganizationId) { .then((r) => { setAuthToken(r.body.accessToken); return cy.request({ - url: '/api/proxy/api/v0.9/organizations', + url: '/proxy/api/v0.9/organizations', headers: { Authorization: `Bearer ${r.body.accessToken}` }, }); }) @@ -144,7 +144,7 @@ describe('AI Workspace — LLM proxy secret management (create flow)', () => { if (authToken && organizationId && createdProxyId) { cy.request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/llm-proxies/${encodeURIComponent(createdProxyId)}?organizationId=${encodeURIComponent(organizationId)}`, + url: `/proxy/api/v0.9/llm-proxies/${encodeURIComponent(createdProxyId)}?organizationId=${encodeURIComponent(organizationId)}`, headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }); @@ -152,14 +152,14 @@ describe('AI Workspace — LLM proxy secret management (create flow)', () => { if (authToken && organizationId && createdProviderId) { cy.request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/llm-providers/${encodeURIComponent(createdProviderId)}?organizationId=${encodeURIComponent(organizationId)}`, + url: `/proxy/api/v0.9/llm-providers/${encodeURIComponent(createdProviderId)}?organizationId=${encodeURIComponent(organizationId)}`, headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }); } if (authToken) { cy.request({ - url: '/api/proxy/api/v0.9/projects', + url: '/proxy/api/v0.9/projects', headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }).then((r) => { @@ -167,7 +167,7 @@ describe('AI Workspace — LLM proxy secret management (create flow)', () => { if (proj?.id) { cy.request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/projects/${encodeURIComponent(proj.id)}`, + url: `/proxy/api/v0.9/projects/${encodeURIComponent(proj.id)}`, headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }); @@ -220,7 +220,7 @@ describe('AI Workspace — LLM proxy secret management (create flow)', () => { const existingHandle = `${toSlug(proxyName)}-provider-api-key`; cy.request({ method: 'POST', - url: '/api/proxy/api/v0.9/secrets', + url: '/proxy/api/v0.9/secrets', headers: { Authorization: `Bearer ${authToken}` }, form: true, body: { @@ -345,7 +345,7 @@ describe('AI Workspace — LLM proxy secret management (update flow)', () => { if (authToken && organizationId && proxyId) { cy.request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/llm-proxies/${encodeURIComponent(proxyId)}?organizationId=${encodeURIComponent(organizationId)}`, + url: `/proxy/api/v0.9/llm-proxies/${encodeURIComponent(proxyId)}?organizationId=${encodeURIComponent(organizationId)}`, headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }); @@ -354,14 +354,14 @@ describe('AI Workspace — LLM proxy secret management (update flow)', () => { if (authToken && organizationId && createdProviderId) { cy.request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/llm-providers/${encodeURIComponent(createdProviderId)}?organizationId=${encodeURIComponent(organizationId)}`, + url: `/proxy/api/v0.9/llm-providers/${encodeURIComponent(createdProviderId)}?organizationId=${encodeURIComponent(organizationId)}`, headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }); } if (authToken) { cy.request({ - url: '/api/proxy/api/v0.9/projects', + url: '/proxy/api/v0.9/projects', headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }).then((r) => { @@ -369,7 +369,7 @@ describe('AI Workspace — LLM proxy secret management (update flow)', () => { if (proj?.id) { cy.request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/projects/${encodeURIComponent(proj.id)}`, + url: `/proxy/api/v0.9/projects/${encodeURIComponent(proj.id)}`, headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }); @@ -421,7 +421,7 @@ describe('AI Workspace — LLM proxy secret management (update flow)', () => { cy.wrap(null).then(() => { expect(initialSecretHandle, 'captured the initial secret handle in beforeEach').to.not.equal(''); cy.request({ - url: `/api/proxy/api/v0.9/secrets/${encodeURIComponent(initialSecretHandle)}?organizationId=${encodeURIComponent(organizationId)}`, + url: `/proxy/api/v0.9/secrets/${encodeURIComponent(initialSecretHandle)}?organizationId=${encodeURIComponent(organizationId)}`, headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }).then((r) => { @@ -439,7 +439,7 @@ describe('AI Workspace — LLM proxy secret management (update flow)', () => { cy.request({ method: 'POST', - url: '/api/proxy/api/v0.9/secrets', + url: '/proxy/api/v0.9/secrets', headers: { Authorization: `Bearer ${authToken}` }, form: true, body: { diff --git a/portals/ai-workspace/cypress/e2e/002-mcp-proxies/002-mcp-proxy-sample-url.cy.js b/portals/ai-workspace/cypress/e2e/002-mcp-proxies/002-mcp-proxy-sample-url.cy.js index d280db6ad..27d090489 100644 --- a/portals/ai-workspace/cypress/e2e/002-mcp-proxies/002-mcp-proxy-sample-url.cy.js +++ b/portals/ai-workspace/cypress/e2e/002-mcp-proxies/002-mcp-proxy-sample-url.cy.js @@ -30,7 +30,7 @@ describe('AI Workspace - MCP proxy sample URL lifecycle', () => { // even when the UI flow fails before its inline cleanup runs. cy.request({ method: 'POST', - url: '/api/proxy/api/portal/v0.9/auth/login', + url: '/proxy/api/portal/v0.9/auth/login', form: true, body: { username: Cypress.env('ADMIN_USER'), @@ -191,7 +191,7 @@ function toSlug(value) { function sweepE2EMCPProxies(authToken) { const headers = { Authorization: `Bearer ${authToken}` }; cy.request({ - url: '/api/proxy/api/v0.9/projects', + url: '/proxy/api/v0.9/projects', headers, failOnStatusCode: false, }).then((response) => { @@ -200,7 +200,7 @@ function sweepE2EMCPProxies(authToken) { projects.forEach((project) => { if (!project.id) return; cy.request({ - url: `/api/proxy/api/v0.9/mcp-proxies?projectId=${encodeURIComponent(project.id)}&limit=100&offset=0`, + url: `/proxy/api/v0.9/mcp-proxies?projectId=${encodeURIComponent(project.id)}&limit=100&offset=0`, headers, failOnStatusCode: false, }).then((listResponse) => { @@ -214,7 +214,7 @@ function sweepE2EMCPProxies(authToken) { if (!proxy.id) return; cy.request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/mcp-proxies/${encodeURIComponent(proxy.id)}`, + url: `/proxy/api/v0.9/mcp-proxies/${encodeURIComponent(proxy.id)}`, headers, failOnStatusCode: false, }); @@ -227,7 +227,7 @@ function sweepE2EMCPProxies(authToken) { // Look up a project by its human-readable displayName and delete it by handle. function deleteProjectByName(authToken, targetName) { cy.request({ - url: '/api/proxy/api/v0.9/projects', + url: '/proxy/api/v0.9/projects', headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }).then((response) => { @@ -238,7 +238,7 @@ function deleteProjectByName(authToken, targetName) { if (!target?.id) return; cy.request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/projects/${encodeURIComponent(target.id)}`, + url: `/proxy/api/v0.9/projects/${encodeURIComponent(target.id)}`, headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }); diff --git a/portals/ai-workspace/cypress/e2e/002-mcp-proxies/003-mcp-secret-management.cy.js b/portals/ai-workspace/cypress/e2e/002-mcp-proxies/003-mcp-secret-management.cy.js index eebd41af1..2f74db8dc 100644 --- a/portals/ai-workspace/cypress/e2e/002-mcp-proxies/003-mcp-secret-management.cy.js +++ b/portals/ai-workspace/cypress/e2e/002-mcp-proxies/003-mcp-secret-management.cy.js @@ -45,7 +45,7 @@ describe('AI Workspace — MCP server secret management', () => { cy.login(); cy.request({ method: 'POST', - url: '/api/proxy/api/portal/v0.9/auth/login', + url: '/proxy/api/portal/v0.9/auth/login', form: true, body: { username: Cypress.env('ADMIN_USER'), @@ -56,7 +56,7 @@ describe('AI Workspace — MCP server secret management', () => { expect(response.status).to.eq(200); authToken = response.body?.token ?? ''; return cy.request({ - url: '/api/proxy/api/v0.9/organizations', + url: '/proxy/api/v0.9/organizations', headers: { Authorization: `Bearer ${authToken}` }, }); }) @@ -70,7 +70,7 @@ describe('AI Workspace — MCP server secret management', () => { if (authToken && organizationId && createdServerId) { cy.request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/mcp-proxies/${encodeURIComponent(createdServerId)}`, + url: `/proxy/api/v0.9/mcp-proxies/${encodeURIComponent(createdServerId)}`, headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }); @@ -182,7 +182,7 @@ describe('AI Workspace — MCP server secret management', () => { const existingHandle = `${serverId}-auth`; cy.request({ method: 'POST', - url: '/api/proxy/api/v0.9/secrets', + url: '/proxy/api/v0.9/secrets', headers: { Authorization: `Bearer ${authToken}`, }, @@ -446,7 +446,7 @@ describe('AI Workspace — MCP server secret management', () => { function deleteProjectByName(authToken, targetName, fallbackName) { if (!authToken) return; cy.request({ - url: '/api/proxy/api/v0.9/projects', + url: '/proxy/api/v0.9/projects', headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }).then((response) => { @@ -458,7 +458,7 @@ function deleteProjectByName(authToken, targetName, fallbackName) { if (projects.length <= 1) { cy.request({ method: 'POST', - url: '/api/proxy/api/v0.9/projects', + url: '/proxy/api/v0.9/projects', headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', @@ -468,7 +468,7 @@ function deleteProjectByName(authToken, targetName, fallbackName) { }).then(() => { cy.request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/projects/${encodeURIComponent(target.id)}`, + url: `/proxy/api/v0.9/projects/${encodeURIComponent(target.id)}`, headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }); @@ -476,7 +476,7 @@ function deleteProjectByName(authToken, targetName, fallbackName) { } else { cy.request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/projects/${encodeURIComponent(target.id)}`, + url: `/proxy/api/v0.9/projects/${encodeURIComponent(target.id)}`, headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }); 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 fe46e596e..ba6af8916 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 @@ -53,7 +53,7 @@ describe('AI Workspace — MCP server secret management (update / policy-save fl cy.request({ method: 'POST', - url: '/api/proxy/api/portal/v0.9/auth/login', + url: '/proxy/api/portal/v0.9/auth/login', form: true, body: { username: Cypress.env('ADMIN_USER'), @@ -63,7 +63,7 @@ describe('AI Workspace — MCP server secret management (update / policy-save fl cy.then(() => cy.request({ - url: '/api/proxy/api/v0.9/organizations', + url: '/proxy/api/v0.9/organizations', headers: { Authorization: `Bearer ${authToken}` }, }) ).then((r) => { organizationId = r.body?.list?.[0]?.id ?? ''; }); @@ -150,7 +150,7 @@ describe('AI Workspace — MCP server secret management (update / policy-save fl if (createdServerId && authToken) { cy.request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/mcp-proxies/${encodeURIComponent(createdServerId)}`, + url: `/proxy/api/v0.9/mcp-proxies/${encodeURIComponent(createdServerId)}`, headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }); @@ -159,7 +159,7 @@ describe('AI Workspace — MCP server secret management (update / policy-save fl if (createdProjectId && authToken) { cy.request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/projects/${encodeURIComponent(createdProjectId)}`, + url: `/proxy/api/v0.9/projects/${encodeURIComponent(createdProjectId)}`, headers: { Authorization: `Bearer ${authToken}` }, failOnStatusCode: false, }); @@ -228,7 +228,7 @@ describe('AI Workspace — MCP server secret management (update / no-auth server cy.request({ method: 'POST', - url: '/api/proxy/api/portal/v0.9/auth/login', + url: '/proxy/api/portal/v0.9/auth/login', form: true, body: { username: Cypress.env('ADMIN_USER'), @@ -238,7 +238,7 @@ describe('AI Workspace — MCP server secret management (update / no-auth server cy.then(() => cy.request({ - url: '/api/proxy/api/v0.9/organizations', + url: '/proxy/api/v0.9/organizations', headers: { Authorization: `Bearer ${authToken2}` }, }) ).then((r) => { organizationId2 = r.body?.list?.[0]?.id ?? ''; }); @@ -309,7 +309,7 @@ describe('AI Workspace — MCP server secret management (update / no-auth server if (createdServerId2 && authToken2) { cy.request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/mcp-proxies/${encodeURIComponent(createdServerId2)}`, + url: `/proxy/api/v0.9/mcp-proxies/${encodeURIComponent(createdServerId2)}`, headers: { Authorization: `Bearer ${authToken2}` }, failOnStatusCode: false, }); @@ -318,7 +318,7 @@ describe('AI Workspace — MCP server secret management (update / no-auth server if (createdProjectId2 && authToken2) { cy.request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/projects/${encodeURIComponent(createdProjectId2)}`, + url: `/proxy/api/v0.9/projects/${encodeURIComponent(createdProjectId2)}`, headers: { Authorization: `Bearer ${authToken2}` }, failOnStatusCode: false, }); diff --git a/portals/ai-workspace/cypress/e2e/005-provider-templates/005-custom-provider-template.cy.js b/portals/ai-workspace/cypress/e2e/005-provider-templates/005-custom-provider-template.cy.js index 503b2f92c..ed66ffc1b 100644 --- a/portals/ai-workspace/cypress/e2e/005-provider-templates/005-custom-provider-template.cy.js +++ b/portals/ai-workspace/cypress/e2e/005-provider-templates/005-custom-provider-template.cy.js @@ -37,7 +37,7 @@ describe('AI Workspace - Custom LLM provider template lifecycle', () => { cy.intercept('POST', /\/llm-providers(\?|$)/).as('createProvider'); cy.request({ method: 'POST', - url: '/api/proxy/api/portal/v0.9/auth/login', + url: '/proxy/api/portal/v0.9/auth/login', form: true, body: { username: Cypress.env('ADMIN_USER'), @@ -50,7 +50,7 @@ describe('AI Workspace - Custom LLM provider template lifecycle', () => { expect(authToken).to.not.equal(''); return cy.request({ - url: '/api/proxy/api/v0.9/organizations', + url: '/proxy/api/v0.9/organizations', headers: { Authorization: `Bearer ${authToken}`, }, @@ -226,7 +226,7 @@ describe('AI Workspace - Custom LLM provider template lifecycle', () => { function waitForProviderGone(authToken, organizationId, providerId) { return requestWithAuth(authToken, { - url: `/api/proxy/api/v0.9/llm-providers?organizationId=${encodeURIComponent(organizationId)}`, + url: `/proxy/api/v0.9/llm-providers?organizationId=${encodeURIComponent(organizationId)}`, }).then((res) => { const stillThere = (res.body?.list ?? []).some((p) => p.id === providerId); if (stillThere) { @@ -243,7 +243,7 @@ function deleteProvider(authToken, organizationId, targetProviderId) { } return requestWithAuth(authToken, { method: 'DELETE', - url: `/api/proxy/api/v0.9/llm-providers/${encodeURIComponent(targetProviderId)}?organizationId=${encodeURIComponent(organizationId)}`, + url: `/proxy/api/v0.9/llm-providers/${encodeURIComponent(targetProviderId)}?organizationId=${encodeURIComponent(organizationId)}`, failOnStatusCode: false, }).then((response) => { expect(response.status).to.be.oneOf([200, 204, 404]); @@ -253,7 +253,7 @@ function deleteProvider(authToken, organizationId, targetProviderId) { function deleteTemplateVersion(authToken, organizationId, templateId) { return requestWithAuth(authToken, { method: 'DELETE', - url: `/api/proxy/api/v0.9/llm-provider-templates/${encodeURIComponent(templateId)}`, + url: `/proxy/api/v0.9/llm-provider-templates/${encodeURIComponent(templateId)}`, failOnStatusCode: false, }).then((response) => { if (response.status === 409) { diff --git a/portals/ai-workspace/cypress/support/commands.js b/portals/ai-workspace/cypress/support/commands.js index 83b1d2c20..eac49695d 100644 --- a/portals/ai-workspace/cypress/support/commands.js +++ b/portals/ai-workspace/cypress/support/commands.js @@ -77,7 +77,7 @@ Cypress.Commands.add('sweepE2EProviders', (authToken, organizationId) => { cy .request({ method: 'GET', - url: `/api/proxy/api/v0.9/llm-providers?organizationId=${encodeURIComponent(orgId)}&limit=${PAGE_SIZE}&offset=${offset}`, + url: `/proxy/api/v0.9/llm-providers?organizationId=${encodeURIComponent(orgId)}&limit=${PAGE_SIZE}&offset=${offset}`, headers: headersFor(token), failOnStatusCode: false, }) @@ -103,7 +103,7 @@ Cypress.Commands.add('sweepE2EProviders', (authToken, organizationId) => { cy .request({ method: 'GET', - url: `/api/proxy/api/v0.9/llm-providers/${encodeURIComponent(providerId)}/llm-proxies?organizationId=${encodeURIComponent(orgId)}`, + url: `/proxy/api/v0.9/llm-providers/${encodeURIComponent(providerId)}/llm-proxies?organizationId=${encodeURIComponent(orgId)}`, headers: headersFor(token), failOnStatusCode: false, }) @@ -115,7 +115,7 @@ Cypress.Commands.add('sweepE2EProviders', (authToken, organizationId) => { cy .request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/llm-proxies/${encodeURIComponent(proxy.id)}?organizationId=${encodeURIComponent(orgId)}`, + url: `/proxy/api/v0.9/llm-proxies/${encodeURIComponent(proxy.id)}?organizationId=${encodeURIComponent(orgId)}`, headers: headersFor(token), failOnStatusCode: false, }) @@ -133,7 +133,7 @@ Cypress.Commands.add('sweepE2EProviders', (authToken, organizationId) => { cy .request({ method: 'DELETE', - url: `/api/proxy/api/v0.9/llm-providers/${encodeURIComponent(provider.id)}?organizationId=${encodeURIComponent(orgId)}`, + url: `/proxy/api/v0.9/llm-providers/${encodeURIComponent(provider.id)}?organizationId=${encodeURIComponent(orgId)}`, headers: headersFor(token), failOnStatusCode: false, }) @@ -153,7 +153,7 @@ Cypress.Commands.add('sweepE2EProviders', (authToken, organizationId) => { return cy .request({ method: 'POST', - url: '/api/proxy/api/portal/v0.9/auth/login', + url: '/proxy/api/portal/v0.9/auth/login', form: true, body: { username: Cypress.env('ADMIN_USER'), @@ -167,7 +167,7 @@ Cypress.Commands.add('sweepE2EProviders', (authToken, organizationId) => { if (!token) return; return cy .request({ - url: '/api/proxy/api/v0.9/organizations', + url: '/proxy/api/v0.9/organizations', headers: { Authorization: `Bearer ${token}` }, failOnStatusCode: false, }) diff --git a/portals/ai-workspace/distribution/README.md b/portals/ai-workspace/distribution/README.md index 309c1f9d4..fa94b8569 100644 --- a/portals/ai-workspace/distribution/README.md +++ b/portals/ai-workspace/distribution/README.md @@ -71,33 +71,34 @@ Edit `configs/config.toml` for AI Workspace settings and `configs/config-platfor 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. +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_AUTH_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 | |---------|-------------| | `domain` | Host and port shown in the browser address bar | -| `auth_mode` | `basic` (file-based quickstart) or `oidc` (external IDP) | +| `[ai_workspace.auth] mode` | `basic` (file-based quickstart) or `oidc` (external IDP) | | `[ai_workspace.control_plane].url` | Base URL of the upstream Platform API hop | | `[ai_workspace.control_plane].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` | | `[ai_workspace.control_plane].tls_skip_verify` | Skip upstream cert verification — local dev only | | `[ai_workspace.gateway].controlplane_host` | Address gateways use to reach the Platform API | | `[ai_workspace.gateway].platform_gateway_versions` | Gateway versions shown in the create-gateway selector | | `[ai_workspace.tls].cert_file` / `key_file` | Listener certificate pair — required when `[ai_workspace.tls].enabled` is `true`. Fixed to the mounted path, same as `ca_file` above | -| `[ai_workspace.oidc].*` | Used only when `auth_mode = "oidc"` — see [OIDC](#oidc-production) below | +| `[ai_workspace.auth.oidc].*` | Used only when `[ai_workspace.auth] mode = "oidc"` — see [OIDC](#oidc-production) below | ### Platform API (`configs/config-platform-api.toml`) | Setting | Description | |---------|-------------| -| `log_level` | Log level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | -| `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` | +| `[logging].log_level` | Log level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | +| `[security].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` | | `[database].driver` | `sqlite3` or `postgres` | +| `[auth].mode` | `file` (quickstart default), `external_token`, or `idp` — selects exactly one auth mode | | `[auth.jwt].secret_key` | 32-byte HMAC key signing login JWTs | -| `[auth.idp]` | JWKS-based IDP auth — disabled in quickstart mode; enable for Asgardeo, Keycloak, Auth0, etc. | -| `[auth.file_based.users]` | Local user credentials (change the password hash before sharing) | -| `[https]` | Listener on `:9243`; `cert_dir` holds `cert.pem`/`key.pem` | +| `[auth.idp]` | JWKS-based IDP auth — active when `mode = "idp"`; configure for Asgardeo, Keycloak, Auth0, etc. | +| `[auth.file.users]` | Local user credentials, active when `mode = "file"` (change the password hash before sharing) | +| `[server.https]` | Listener on `:9243`; `[server.https.tls] cert_file`/`key_file` point at `cert.pem`/`key.pem` | Each key's default value is written inline in `configs/config-template.toml` and `configs/config-platform-api-template.toml` — those files are a fully-commented reference of @@ -120,19 +121,19 @@ Replace the `password_hash` value in `configs/config-platform-api.toml` before s 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), 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 `[ai_workspace.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`: +2. **AI Workspace** (`configs/config.toml`): set `[ai_workspace.auth] mode = "oidc"`. Every `[ai_workspace.auth.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_AUTH_OIDC_*` token in `api-platform.env`: ```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 + APIP_AIW_AUTH_OIDC_AUTHORITY=https://idp.example.com + APIP_AIW_AUTH_OIDC_CLIENT_ID= + APIP_AIW_AUTH_OIDC_CLIENT_SECRET= + APIP_AIW_AUTH_OIDC_REDIRECT_URL=https:///api/auth/callback + APIP_AIW_AUTH_OIDC_POST_LOGOUT_REDIRECT_URL=https:///login ``` - Leaving `APIP_AIW_OIDC_SCOPE` unset requests the full `ap:*` scope set. + Leaving `APIP_AIW_AUTH_OIDC_SCOPE` unset requests the full `ap:*` scope set. -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 `[ai_workspace.oidc.claim_mappings]` in `configs/config.toml` — both services must read the same claims out of the same token. +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 `[auth] mode = "idp"` and fill in `jwks_url` and `issuer` for your IDP. `auth.mode` selects exactly one mode, so switching to `"idp"` stops the file-based login endpoint from being used. Align `[auth.claim_mappings]` with `[ai_workspace.auth.claim_mappings]` in `configs/config.toml` — both services must read the same claims out of the same token. 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. diff --git a/portals/ai-workspace/production/README.md b/portals/ai-workspace/production/README.md index 264209a49..48d429a34 100644 --- a/portals/ai-workspace/production/README.md +++ b/portals/ai-workspace/production/README.md @@ -104,7 +104,7 @@ issuer = ["https://api.asgardeo.io/t//oauth2/token"] audience = [""] # Client ID from Asgardeo Protocol tab # Asgardeo-specific claim name overrides. -[platform_api.auth.idp.claim_mappings] +[platform_api.auth.claim_mappings] organization = "org_id" org_name = "org_name" org_handle = "org_handle" @@ -116,7 +116,7 @@ Optional overrides (defaults shown): [auth.idp] validation_mode = "scope" # or "role" for role-based auth -[auth.idp.claim_mappings] +[auth.claim_mappings] user_id = "sub" username = "username" email = "email" @@ -138,9 +138,6 @@ Open `configs/config.toml` and fill in the values for your deployment: # Host shown in the browser address bar. domain = "" # e.g. app.example.com -# Set to "oidc" for production (Asgardeo or any OIDC-compliant IDP). -auth_mode = "oidc" - # Default region assigned to new organizations on first login. default_org_region = "us" @@ -157,7 +154,11 @@ controlplane_host = "" # Each entry: version (helm chart minor), latestVersion (image/chart tag), channel ("STS" | "LTS"). platform_gateway_versions = '[{"version":"1.2","latestVersion":"v1.2.0-alpha2","channel":"STS"}]' -[ai_workspace.oidc] +[ai_workspace.auth] +# Set to "oidc" for production (Asgardeo or any OIDC-compliant IDP). +mode = "oidc" + +[ai_workspace.auth.oidc] # Issuer URL — the BFF auto-discovers OIDC endpoints from # {authority}/.well-known/openid-configuration. authority = "https://api.asgardeo.io/t//oauth2/token" @@ -165,9 +166,10 @@ authority = "https://api.asgardeo.io/t//oauth2/token" # Client ID of the AI Workspace confidential application (from the IDP Protocol tab). client_id = "" -# JWT claim name mappings — this table mirrors [platform_api.auth.idp.claim_mappings] in -# the Platform API config (section 2) key for key, and the two must agree. -[ai_workspace.oidc.claim_mappings] +# JWT claim name mappings — this table mirrors [platform_api.auth.claim_mappings] in +# the Platform API config (section 2) key for key, and the two must agree. A sibling +# of [ai_workspace.auth.oidc], not nested in it: applies to both auth modes. +[ai_workspace.auth.claim_mappings] organization = "org_id" org_name = "org_name" org_handle = "org_handle" @@ -178,7 +180,7 @@ file** — it is referenced with an interpolation token resolved at startup. In as a secret file (a Docker/Kubernetes secret) so the value never enters the environment at all: ```toml -[ai_workspace.oidc] +[ai_workspace.auth.oidc] # BFF callback registered in the IDP (section 1.2) — NOT the SPA /signin route. redirect_url = "https:///api/auth/callback" post_logout_redirect_url = "https:///login" @@ -197,9 +199,9 @@ 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 the git-ignored `api-platform.env`. +`'{{ env "APIP_AIW_AUTH_OIDC_CLIENT_SECRET" }}'` and keep the value in the git-ignored `api-platform.env`. -> `[ai_workspace.oidc] redirect_url` must exactly match the authorized redirect URL registered in the IDP +> `[ai_workspace.auth.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. ### Setting config.toml keys from the environment @@ -209,13 +211,13 @@ layer. A key takes its value from the environment when it is written as an `{{ e names the variable explicitly: ```toml -[ai_workspace.oidc] -client_id = '{{ env "APIP_AIW_OIDC_CLIENT_ID" "" }}' +[ai_workspace.auth.oidc] +client_id = '{{ env "APIP_AIW_AUTH_OIDC_CLIENT_ID" "" }}' # ^ variable read at startup ^ used when it is unset ``` -Setting `APIP_AIW_OIDC_CLIENT_ID` in a `docker run -e` or a Kubernetes `env:` block then sets -`[ai_workspace.oidc] client_id`, with no edit to the file. Setting it while the key is absent from the file, or +Setting `APIP_AIW_AUTH_OIDC_CLIENT_ID` in a `docker run -e` or a Kubernetes `env:` block then sets +`[ai_workspace.auth.oidc] client_id`, with no edit to the file. Setting it while the key is absent from the file, or written as a plain literal, does nothing. The shipped `config.toml` already writes its keys this way, naming each variable by the same diff --git a/portals/ai-workspace/src/config.env.ts b/portals/ai-workspace/src/config.env.ts index db23952e9..8abf4d588 100644 --- a/portals/ai-workspace/src/config.env.ts +++ b/portals/ai-workspace/src/config.env.ts @@ -33,32 +33,44 @@ import { getEnvOrDefault } from './utils/getEnvOrDefault'; * Single line environment variable definitions with defaults using getEnvOrDefault utility to improve readability and maintainability. */ -// Debug mode -export const DEBUG = getEnvOrDefault('APIP_AIW_DEBUG', false); +// Verbose logging in the browser console. +export const DEBUG = getEnvOrDefault('APIP_AIW_LOGGING_BROWSER_DEBUG', false); // Domain and environment settings export const DOMAIN = getEnvOrDefault('APIP_AIW_DOMAIN', 'localhost:5380'); -//Static OIDC configuration — set these to match the root-org OIDC app in your IDP. -// Authority is the issuer URL; the OIDC client will auto-discover endpoints from {authority}/.well-known/openid-configuration. -export const OIDC_AUTHORITY = getEnvOrDefault('APIP_AIW_OIDC_AUTHORITY', ''); -export const OIDC_CLIENT_ID = getEnvOrDefault('APIP_AIW_OIDC_CLIENT_ID', ''); -// JWT claim name mappings — configure to match your IDP's token structure. The names -// mirror the Platform API's [auth.idp.claim_mappings] key for key, and must agree with -// them: both sides read the same claims out of the same token. -export const OIDC_ORG_ID_CLAIM = getEnvOrDefault('APIP_AIW_OIDC_CLAIM_MAPPINGS_ORGANIZATION', 'org_id'); -export const OIDC_ORG_NAME_CLAIM = getEnvOrDefault('APIP_AIW_OIDC_CLAIM_MAPPINGS_ORG_NAME', 'org_name'); -export const OIDC_ORG_HANDLE_CLAIM = getEnvOrDefault('APIP_AIW_OIDC_CLAIM_MAPPINGS_ORG_HANDLE', 'org_handle'); // Default region used when auto-registering an organization on first login. export const DEFAULT_ORG_REGION = getEnvOrDefault('APIP_AIW_DEFAULT_ORG_REGION', 'us'); +// Auth mode: 'basic' (default) posts credentials to /api/portal/v0.9/auth/login; 'oidc' uses react-oidc-context. +export const AUTH_MODE = getEnvOrDefault('APIP_AIW_AUTH_MODE', 'basic') as 'oidc' | 'basic'; + +// JWT claim name mappings — configure to match your IDP's token structure. The names +// mirror the Platform API's [auth.claim_mappings] key for key, and must agree with +// them: both sides read the same claims out of the same token. Shared by both auth +// modes on the BFF side, hence APIP_AIW_AUTH_CLAIM_MAPPINGS_* (not _OIDC_) names. +export const ORG_ID_CLAIM = getEnvOrDefault('APIP_AIW_AUTH_CLAIM_MAPPINGS_ORGANIZATION', 'organization'); +export const ORG_NAME_CLAIM = getEnvOrDefault('APIP_AIW_AUTH_CLAIM_MAPPINGS_ORG_NAME', 'org_name'); +export const ORG_HANDLE_CLAIM = getEnvOrDefault('APIP_AIW_AUTH_CLAIM_MAPPINGS_ORG_HANDLE', 'org_handle'); +// JWT claim names for user display — configure to match your IDP's token structure. +// The defaults mirror the BFF's [auth.claim_mappings] defaults, so both sides read +// the same claim when the key is left unset. +// Common alternatives: 'name', 'given_name', 'preferred_username' (Keycloak), 'upn' (Azure AD) +export const USERNAME_CLAIM = getEnvOrDefault('APIP_AIW_AUTH_CLAIM_MAPPINGS_USERNAME', 'username'); +export const EMAIL_CLAIM = getEnvOrDefault('APIP_AIW_AUTH_CLAIM_MAPPINGS_EMAIL', 'email'); + +//Static OIDC configuration — set these to match the root-org OIDC app in your IDP. +// Authority is the issuer URL; the OIDC client will auto-discover endpoints from {authority}/.well-known/openid-configuration. +export const OIDC_AUTHORITY = getEnvOrDefault('APIP_AIW_OIDC_AUTHORITY', ''); +export const OIDC_CLIENT_ID = getEnvOrDefault('APIP_AIW_OIDC_CLIENT_ID', ''); + // Scopes to request at login — derived from openapi.yaml x-required-scopes (ap: prefix). // offline_access is required so the IDP issues a refresh token; without it the // BFF cannot silently renew the access token and the user is logged out as soon // as it expires. Keep it if you override this scope list. export const OIDC_SCOPE = getEnvOrDefault( - 'APIP_AIW_OIDC_SCOPE', + 'APIP_AIW_AUTH_OIDC_SCOPE', 'openid profile email offline_access' + ' ap:organization:read ap:organization:manage' + ' ap:project:read ap:project:create ap:project:update ap:project:delete ap:project:manage' + @@ -153,7 +165,7 @@ export const POLICY_HUB_WEB_URL = getEnvOrDefault( ); // Platform API base URL. Defaults to a relative path routed same-origin through the -// BFF reverse proxy (/api/proxy/* → Platform API) so the browser only ever talks to +// BFF reverse proxy (/proxy/* → Platform API) so the browser only ever talks to // the app origin, never holds a token, and never sees the platform-api self-signed cert. // Overrides should normally point at another BFF proxy base. Pointing this at the // Platform API directly bypasses the BFF session: the browser holds no token in this @@ -161,7 +173,7 @@ export const POLICY_HUB_WEB_URL = getEnvOrDefault( // path to attach credentials to those calls. export const PLATFORM_API_BASE_URL = getEnvOrDefault( 'APIP_AIW_PLATFORM_API_BASE_URL', - '/api/proxy/api/v0.9' + '/proxy/api/v0.9' ); // Base URL for BFF composite endpoints. These are handled directly by the BFF @@ -178,21 +190,12 @@ export const CONTROLPLANE_HOST = getEnvOrDefault( export const PORTAL_API_BASE_URL = getEnvOrDefault( 'APIP_AIW_PORTAL_API_BASE_URL', - '/api/proxy/api/portal/v0.9' + '/proxy/api/portal/v0.9' ); // CSRF header sent on all BFF requests. Cross-site attackers cannot set a custom // header (CORS is closed), so its presence proves the request is same-origin. -// Must match the BFF's CSRF_HEADER config (default: X-Requested-By). -export const CSRF_HEADER = getEnvOrDefault('APIP_AIW_CSRF_HEADER', 'X-Requested-By'); +// Fixed, not configurable: it is a contract between the BFF and this SPA, not a +// deployment concern. Must match the BFF's config.CSRFHeaderName constant. +export const CSRF_HEADER = 'X-Requested-By'; export const CSRF_VALUE = 'ai-workspace'; - -// JWT claim names for user display — configure to match your IDP's token structure. -// The defaults mirror the BFF's [oidc.claim_mappings] defaults, so both sides read the -// same claim when the key is left unset. -// Common alternatives: 'name', 'given_name', 'preferred_username' (Keycloak), 'upn' (Azure AD) -export const OIDC_USERNAME_CLAIM = getEnvOrDefault('APIP_AIW_OIDC_CLAIM_MAPPINGS_USERNAME', 'username'); -export const OIDC_EMAIL_CLAIM = getEnvOrDefault('APIP_AIW_OIDC_CLAIM_MAPPINGS_EMAIL', 'email'); - -// Auth mode: 'basic' (default) posts credentials to /api/portal/v0.9/auth/login; 'oidc' uses react-oidc-context. -export const AUTH_MODE = getEnvOrDefault('APIP_AIW_AUTH_MODE', 'basic') as 'oidc' | 'basic'; diff --git a/portals/ai-workspace/src/contexts/OIDCAppAuthProvider.tsx b/portals/ai-workspace/src/contexts/OIDCAppAuthProvider.tsx index 7e423eb46..436c4f7d2 100644 --- a/portals/ai-workspace/src/contexts/OIDCAppAuthProvider.tsx +++ b/portals/ai-workspace/src/contexts/OIDCAppAuthProvider.tsx @@ -20,11 +20,11 @@ import React, { useCallback, useMemo } from 'react'; import { useAuth } from 'react-oidc-context'; import { AppAuthContext, type AppUser, type AppOrg } from './AppAuthContext'; import { - OIDC_USERNAME_CLAIM, - OIDC_EMAIL_CLAIM, - OIDC_ORG_ID_CLAIM, - OIDC_ORG_NAME_CLAIM, - OIDC_ORG_HANDLE_CLAIM, + USERNAME_CLAIM, + EMAIL_CLAIM, + ORG_ID_CLAIM, + ORG_NAME_CLAIM, + ORG_HANDLE_CLAIM, } from '../config.env'; import { checkPermission, isPlatformRole } from '../auth/permissions'; import type { PlatformRole } from '../auth/permissions'; @@ -77,16 +77,16 @@ export function OIDCAppAuthProvider({ children }: { children: React.ReactNode }) const claim = (key: string) => (idClaims[key] as string | undefined) || (atClaims[key] as string | undefined) || null; - const orgId = (atClaims[OIDC_ORG_ID_CLAIM] as string | undefined) || null; - const orgName = (atClaims[OIDC_ORG_NAME_CLAIM] as string | undefined) || null; - const orgHandle = (atClaims[OIDC_ORG_HANDLE_CLAIM] as string | undefined) || null; + const orgId = (atClaims[ORG_ID_CLAIM] as string | undefined) || null; + const orgName = (atClaims[ORG_NAME_CLAIM] as string | undefined) || null; + const orgHandle = (atClaims[ORG_HANDLE_CLAIM] as string | undefined) || null; const org: AppOrg | null = (orgId || orgHandle) ? { id: orgId ?? '', name: orgName ?? orgHandle ?? '', handle: orgHandle ?? '' } : null; return { - name: claim(OIDC_USERNAME_CLAIM), - email: claim(OIDC_EMAIL_CLAIM), + name: claim(USERNAME_CLAIM), + email: claim(EMAIL_CLAIM), role, scopes, org, diff --git a/portals/ai-workspace/src/utils/logger.ts b/portals/ai-workspace/src/utils/logger.ts index ed9c385b9..cd14d1320 100644 --- a/portals/ai-workspace/src/utils/logger.ts +++ b/portals/ai-workspace/src/utils/logger.ts @@ -19,7 +19,7 @@ import { DEBUG } from '../config.env'; /** - * Logger utility that respects the APIP_AIW_DEBUG environment variable. + * Logger utility that respects the APIP_AIW_LOGGING_BROWSER_DEBUG environment variable. * All console output is suppressed when DEBUG is disabled (default). * * Usage: diff --git a/portals/ai-workspace/vite.config.ts b/portals/ai-workspace/vite.config.ts index 4cbc31c9f..cd29d3303 100644 --- a/portals/ai-workspace/vite.config.ts +++ b/portals/ai-workspace/vite.config.ts @@ -64,17 +64,16 @@ const readyLogPlugin: PluginOption = { // This mirrors the BFF's runtime allowlist (bff/internal/config/runtime_config.go // browserSafeKeys): the same APIP_AIW_ names work at build time and at runtime, but // only these — a blanket 'APIP_AIW_' prefix would also inline secrets that share the -// namespace (e.g. APIP_AIW_OIDC_CLIENT_SECRET) into the bundle if set at build time. +// namespace (e.g. APIP_AIW_AUTH_OIDC_CLIENT_SECRET) into the bundle if set at build time. const browserSafeEnvVars = [ 'APIP_AIW_DOMAIN', 'APIP_AIW_AUTH_MODE', 'APIP_AIW_DEFAULT_ORG_REGION', 'APIP_AIW_GATEWAY_CONTROLPLANE_HOST', 'APIP_AIW_GATEWAY_PLATFORM_GATEWAY_VERSIONS', - 'APIP_AIW_CSRF_HEADER', - 'APIP_AIW_DEBUG', - 'APIP_AIW_OIDC_SCOPE', - 'APIP_AIW_OIDC_CLAIM_MAPPINGS_', // all claim-name mappings, no secrets share this + 'APIP_AIW_LOGGING_BROWSER_DEBUG', + 'APIP_AIW_AUTH_OIDC_SCOPE', + 'APIP_AIW_AUTH_CLAIM_MAPPINGS_', // all claim-name mappings, no secrets share this 'APIP_AIW_DEV_PORTAL_BASE_URL', 'APIP_AIW_API_POLICY_HUB', 'APIP_AIW_POLICY_HUB_WEB_URL', @@ -117,7 +116,7 @@ export default defineConfig({ // In dev, run the BFF locally (default https://localhost:8081) and route // all same-origin BFF traffic to it, mirroring the production topology. // `make bff-run` starts it against configs/config.toml, whose {{ env }} tokens read - // the APIP_AIW_* variables (PLATFORM_API_URL, LISTEN_ADDR, ...). + // the APIP_AIW_* variables (CONTROL_PLANE_URL, SERVER_PORT, ...). '/api': { target: process.env.BFF_DEV_TARGET || 'https://localhost:8081', changeOrigin: true, 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 3e1af2437..789d3e8be 100644 --- a/portals/developer-portal/it/configs/config-platform-api-it.toml +++ b/portals/developer-portal/it/configs/config-platform-api-it.toml @@ -19,8 +19,11 @@ # -------------------------------------------------------------------- [platform_api] + +[platform_api.logging] log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' +[platform_api.security] encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' [platform_api.server.https] diff --git a/tests/integration-e2e/platform-api-config.toml b/tests/integration-e2e/platform-api-config.toml index a9185950d..7a584c5f0 100644 --- a/tests/integration-e2e/platform-api-config.toml +++ b/tests/integration-e2e/platform-api-config.toml @@ -3,7 +3,11 @@ # this file supplies file-based auth so the scenario can log in (admin/admin). [platform_api] -log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' + +[platform_api.logging] +log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' + +[platform_api.security] encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' [platform_api.database] @@ -12,7 +16,7 @@ path = '{{ env "APIP_CP_DATABASE_PATH" "/app/data/platform.db" }}' host = '{{ env "APIP_CP_DATABASE_HOST" "" }}' port = '{{ env "APIP_CP_DATABASE_PORT" "5432" }}' name = '{{ env "APIP_CP_DATABASE_NAME" "" }}' -user = '{{ env "APIP_CP_DATABASE_USER" "" }}' +username = '{{ env "APIP_CP_DATABASE_USER" "" }}' password = '{{ env "APIP_CP_DATABASE_PASSWORD" "" }}' ssl_mode = '{{ env "APIP_CP_DATABASE_SSL_MODE" "disable" }}' @@ -39,4 +43,3 @@ scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_poli enabled = '{{ env "APIP_CP_WEBHOOK_ENABLED" "false" }}' secret = '{{ env "APIP_CP_WEBHOOK_SECRET" "" }}' private_key_path = '{{ env "APIP_CP_WEBHOOK_PRIVATE_KEY_PATH" "" }}' -gateway_type = '{{ env "APIP_CP_WEBHOOK_GATEWAY_TYPE" "wso2/api-platform" }}' From f2e28d767aa677d304e0bdeaa803434781384bf0 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Tue, 21 Jul 2026 16:38:36 +0530 Subject: [PATCH 09/18] Refactor logging configuration in AI Workspace and Platform API --- docs/ai-workspace/configuration.md | 10 ++-- platform-api/README.md | 4 +- platform-api/config/config-template.toml | 19 ++++--- platform-api/config/config.go | 39 +++++++-------- platform-api/config/config.toml | 2 +- platform-api/config/config_test.go | 4 +- platform-api/config/default_config.go | 12 ++--- platform-api/internal/database/connection.go | 6 +-- platform-api/internal/server/server.go | 8 +-- .../internal/server/server_tls_test.go | 18 +++---- portals/ai-workspace/Makefile | 8 +-- portals/ai-workspace/README.md | 8 +-- .../bff/internal/config/config.go | 27 +++++----- .../bff/internal/config/config_test.go | 26 +++++----- .../bff/internal/config/settings.go | 22 ++++++++- .../internal/config/shipped_config_test.go | 4 +- .../ai-workspace/configs/config-template.toml | 49 ++++++++++--------- portals/ai-workspace/configs/config.toml | 17 +++---- portals/ai-workspace/distribution/README.md | 8 +-- .../it/configs/config-platform-api-it.toml | 12 ++--- .../integration-e2e/platform-api-config.toml | 2 +- 21 files changed, 154 insertions(+), 151 deletions(-) diff --git a/docs/ai-workspace/configuration.md b/docs/ai-workspace/configuration.md index c89ab6e49..31ed393b9 100644 --- a/docs/ai-workspace/configuration.md +++ b/docs/ai-workspace/configuration.md @@ -2,7 +2,7 @@ AI Workspace is configured through a `config.toml` file mounted into the container at `/etc/ai-workspace/config.toml`. -All AI Workspace settings live under a single top-level `[ai_workspace]` table — the same namespacing convention the Platform API uses for its own `[platform_api]` table — so one `config.toml` can hold both services' sections side by side without their keys colliding. Keys are grouped into TOML tables (`[ai_workspace.logging]`, `[ai_workspace.control_plane]`, `[ai_workspace.tls]`, `[ai_workspace.session]`, `[ai_workspace.auth]`, `[ai_workspace.auth.oidc]`); deployment-identity keys such as `domain` sit directly under `[ai_workspace]`. The session cookie's name, `Secure`, and `SameSite` attributes are not configurable — they are internal details of the BFF's session mechanism. +All AI Workspace settings live under a single top-level `[ai_workspace]` table — the same namespacing convention the Platform API uses for its own `[platform_api]` table — so one `config.toml` can hold both services' sections side by side without their keys colliding. Keys are grouped into TOML tables (`[ai_workspace.logging]`, `[ai_workspace.control_plane]`, `[ai_workspace.server.https]`, `[ai_workspace.session]`, `[ai_workspace.auth]`, `[ai_workspace.auth.oidc]`); deployment-identity keys such as `domain` sit directly under `[ai_workspace]`. The session cookie's name, `Secure`, and `SameSite` attributes are not configurable — they are internal details of the BFF's session mechanism. The file is the **only** source of configuration. Each value in it is written as an interpolation token that is resolved once at startup, so where the value comes from is visible in place: @@ -14,7 +14,7 @@ client_id = '{{ env "APIP_AIW_AUTH_OIDC_CLIENT_ID" "default" }}' A key written this way can be set from the environment without editing the file. That token is the *only* thing that lets an environment variable reach a config key — there is no implicit override, so a key written as a plain literal (`key = "value"`), or absent from the file, ignores the variable entirely. Add the key with a token to make it settable that way. -By convention the variable a token names is the key's path **under `[ai_workspace]`** (the `ai_workspace` segment itself is not part of the name) — table and key, uppercased, dots as underscores — prefixed with **`APIP_AIW_`**: `[ai_workspace.auth.oidc] client_id` → `APIP_AIW_AUTH_OIDC_CLIENT_ID`, `[ai_workspace.control_plane] url` → `APIP_AIW_CONTROL_PLANE_URL`, and `[ai_workspace.logging] 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. +By convention the variable a token names is the key's path **under `[ai_workspace]`** (the `ai_workspace` segment itself is not part of the name) — table and key, uppercased, dots as underscores — prefixed with **`APIP_AIW_`**: `[ai_workspace.auth.oidc] client_id` → `APIP_AIW_AUTH_OIDC_CLIENT_ID`, `[ai_workspace.control_plane] url` → `APIP_AIW_CONTROL_PLANE_URL`, and `[ai_workspace.logging] level` → `APIP_AIW_LOGGING_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). 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). @@ -58,8 +58,8 @@ map of what each table is for. | Key | Description | |-----|-------------| -| `log_level` | `debug` \| `info` \| `warn` \| `error`. | -| `log_format` | `text` \| `json`. | +| `level` | `debug` \| `info` \| `warn` \| `error` (matched case-insensitively). | +| `format` | `text` \| `json`. | ### `[ai_workspace.auth]` — login mode @@ -114,7 +114,7 @@ A sibling of `[ai_workspace.auth.oidc]`, not nested inside it: this table applie URLs in your IDP application. The sign-in redirect is the **BFF callback** `/api/auth/callback` (the BFF, not the browser, completes the code exchange) — not a `/signin` route. -The remaining tables (`[ai_workspace.tls]`, `[ai_workspace.session]`) and the `[ai_workspace]` listener keys are documented inline in +The remaining tables (`[ai_workspace.server.https]`, `[ai_workspace.session]`) and the `[ai_workspace]` listener keys are documented inline in [`configs/config-template.toml`](../../portals/ai-workspace/configs/config-template.toml). ## Minimal Quick-Start Config (basic auth) diff --git a/platform-api/README.md b/platform-api/README.md index 57e251d69..3fbae9dcf 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -270,7 +270,7 @@ All settings live under `[platform_api]` / `[platform_api.*]`. The main sections | Section | Purpose | |---|---| | `[platform_api]` | resource paths | -| `[platform_api.logging]` | `log_level`, `log_format` | +| `[platform_api.logging]` | `level`, `format` | | `[platform_api.security]` | `encryption_key` (**required** — at-rest AES-256 key, 32 bytes as hex or base64, never auto-generated) | | `[platform_api.security.api_key]` | `hashing_algorithms` accepted for API key verification | | `[platform_api.database]` | `driver` (`sqlite3` / `postgres` / `sqlserver`), connection fields, pool sizing | @@ -278,7 +278,7 @@ All settings live under `[platform_api]` / `[platform_api.*]`. The main sections | `[platform_api.auth.jwt]` | HMAC login token settings: `issuer`, `secret_key` (**required**), `token_ttl` | | `[platform_api.auth.idp]` / `[platform_api.auth.claim_mappings]` | JWKS endpoint, issuer/audience, validation mode, and JWT claim-name mappings for `idp` mode | | `[platform_api.auth.file.organization]` / `[[platform_api.auth.file.users]]` | Local org + username/password/scope entries for `file` mode | -| `[platform_api.server.http]` / `[platform_api.server.https]` / `[platform_api.server.https.tls]` | Listener enablement, ports, and TLS cert/key paths (certificates are always required for HTTPS — no self-signed fallback) | +| `[platform_api.server.http]` / `[platform_api.server.https]` | Listener enablement, ports, and (HTTPS) `cert_file` / `key_file` paths (certificates are always required for HTTPS — no self-signed fallback) | | `[platform_api.server.timeouts]` | Read/write/idle timeouts | | `[platform_api.server.cors]` | `allowed_origins` for credentialed cross-origin requests | | `[platform_api.server.websocket]` | Gateway WebSocket connection limits and rate limiting | diff --git a/platform-api/config/config-template.toml b/platform-api/config/config-template.toml index 87185498b..4195cd319 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -55,10 +55,11 @@ llm_template_definitions_path = '{{ env "APIP_CP_LLM_TEMPLATE_DEFINITIONS_PATH" openapi_spec_max_fetch_bytes = '{{ env "APIP_CP_OPENAPI_SPEC_MAX_FETCH_BYTES" "5242880" }}' [platform_api.logging] -log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' # DEBUG | INFO | WARN | ERROR +# Level is matched case-insensitively; lowercase is canonical. +level = '{{ env "APIP_CP_LOGGING_LEVEL" "info" }}' # debug | info | warn | error # Log encoding. Use "json" when shipping logs to an aggregator. -log_format = '{{ env "APIP_CP_LOG_FORMAT" "text" }}' # text | json +format = '{{ env "APIP_CP_LOGGING_FORMAT" "text" }}' # text | json # --------------------------------------------------------------------------- # Security @@ -91,7 +92,7 @@ path = '{{ env "APIP_CP_DATABASE_PATH" "/app/data/api_platform.db" }}' host = '{{ env "APIP_CP_DATABASE_HOST" "localhost" }}' port = '{{ env "APIP_CP_DATABASE_PORT" "5432" }}' name = '{{ env "APIP_CP_DATABASE_NAME" "platform_api" }}' -username = '{{ env "APIP_CP_DATABASE_USER" "platform_api" }}' +user = '{{ env "APIP_CP_DATABASE_USER" "platform_api" }}' # Prefer a mounted secret file in production, e.g. # password = '{{ file "/secrets/platform-api/postgres_password" }}' # The referenced file/env var must exist or config load fails; the "" @@ -247,7 +248,7 @@ token_ttl = '{{ env "APIP_CP_AUTH_JWT_TOKEN_TTL" "1h" }}' # service-mesh sidecar) terminates TLS, or for internal traffic — never # expose it directly to untrusted networks. # -# tls.cert_file / key_file must point at a certificate pair when +# cert_file / key_file must point at a certificate pair when # server.https.enabled = true. Certificates are always required — there is # no self-signed fallback. setup.sh generates a pair for the quickstart. [platform_api.server.http] @@ -255,12 +256,10 @@ enabled = '{{ env "APIP_CP_SERVER_HTTP_ENABLED" "false" }}' port = '{{ env "APIP_CP_SERVER_HTTP_PORT" "9080" }}' [platform_api.server.https] -enabled = '{{ env "APIP_CP_SERVER_HTTPS_ENABLED" "true" }}' -port = '{{ env "APIP_CP_SERVER_HTTPS_PORT" "9243" }}' - -[platform_api.server.https.tls] -cert_file = '{{ env "APIP_CP_SERVER_HTTPS_TLS_CERT_FILE" "/app/data/certs/cert.pem" }}' # default: ./data/certs/cert.pem -key_file = '{{ env "APIP_CP_SERVER_HTTPS_TLS_KEY_FILE" "/app/data/certs/key.pem" }}' # default: ./data/certs/key.pem +enabled = '{{ env "APIP_CP_SERVER_HTTPS_ENABLED" "true" }}' +port = '{{ env "APIP_CP_SERVER_HTTPS_PORT" "9243" }}' +cert_file = '{{ env "APIP_CP_SERVER_HTTPS_CERT_FILE" "/app/data/certs/cert.pem" }}' # default: ./data/certs/cert.pem +key_file = '{{ env "APIP_CP_SERVER_HTTPS_KEY_FILE" "/app/data/certs/key.pem" }}' # default: ./data/certs/key.pem # --------------------------------------------------------------------------- # Listener timeouts diff --git a/platform-api/config/config.go b/platform-api/config/config.go index 07e24510c..f03c6827d 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -75,8 +75,8 @@ type FileBased struct { // Logging holds logging configuration. type Logging struct { - LogLevel string `koanf:"log_level"` - LogFormat string `koanf:"log_format"` + LogLevel string `koanf:"level"` + LogFormat string `koanf:"format"` } // Server holds the configuration parameters for the application. @@ -215,17 +215,11 @@ type HTTPListener struct { Port int `koanf:"port"` } -// HTTPSListener configures the TLS listener. TLS.CertFile and TLS.KeyFile must -// point at a certificate pair when Enabled is true; there is no self-signed -// fallback. +// HTTPSListener configures the TLS listener. CertFile and KeyFile must point at a +// certificate pair when Enabled is true; there is no self-signed fallback. type HTTPSListener struct { - Enabled bool `koanf:"enabled"` - Port int `koanf:"port"` - TLS ListenerTLS `koanf:"tls"` -} - -// ListenerTLS holds the certificate material for the HTTPS listener. -type ListenerTLS struct { + Enabled bool `koanf:"enabled"` + Port int `koanf:"port"` CertFile string `koanf:"cert_file"` KeyFile string `koanf:"key_file"` } @@ -290,7 +284,7 @@ type Database struct { Host string `koanf:"host"` Port int `koanf:"port"` Name string `koanf:"name"` - Username string `koanf:"username"` + User string `koanf:"user"` Password string `koanf:"password"` SSLMode string `koanf:"ssl_mode"` // SSLRootCert is the CA certificate file path used to verify the server's @@ -587,19 +581,20 @@ func validateEncryptionKey(key string) error { return nil } -// validateLoggingConfig rejects a log_level/log_format typo at startup instead -// of silently falling back to logger.NewLogger's default (info/json), which -// would leave an operator's requested verbosity or encoding silently ignored. +// validateLoggingConfig rejects a logging.level/logging.format typo at startup +// instead of silently falling back to logger.NewLogger's default (info/json), +// which would leave an operator's requested verbosity or encoding silently +// ignored. The level is matched case-insensitively (canonical form is lowercase). func validateLoggingConfig(level, format string) error { - switch strings.ToUpper(level) { - case "DEBUG", "INFO", "WARN", "WARNING", "ERROR": + switch strings.ToLower(level) { + case "debug", "info", "warn", "warning", "error": default: - return fmt.Errorf("logging.log_level must be one of \"DEBUG\", \"INFO\", \"WARN\", or \"ERROR\" (got %q)", level) + return fmt.Errorf("logging.level must be one of \"debug\", \"info\", \"warn\", or \"error\" (got %q)", level) } switch strings.ToLower(format) { case "text", "json": default: - return fmt.Errorf("logging.log_format must be \"text\" or \"json\" (got %q)", format) + return fmt.Errorf("logging.format must be \"text\" or \"json\" (got %q)", format) } return nil } @@ -627,8 +622,8 @@ func validateDatabaseConfig(cfg *Database) error { if cfg.Name == "" { return fmt.Errorf("database.name is required when database.driver is %q", cfg.Driver) } - if cfg.Username == "" { - return fmt.Errorf("database.username is required when database.driver is %q", cfg.Driver) + if cfg.User == "" { + return fmt.Errorf("database.user is required when database.driver is %q", cfg.Driver) } switch cfg.SSLMode { case "", "disable", "require", "verify-ca", "verify-full": diff --git a/platform-api/config/config.toml b/platform-api/config/config.toml index 432d83293..50f622e6f 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -1,7 +1,7 @@ [platform_api] [platform_api.logging] -log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' +level = '{{ env "APIP_CP_LOGGING_LEVEL" "info" }}' [platform_api.security] encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' diff --git a/platform-api/config/config_test.go b/platform-api/config/config_test.go index 9dc703b45..4793968be 100644 --- a/platform-api/config/config_test.go +++ b/platform-api/config/config_test.go @@ -234,8 +234,8 @@ func TestLoadConfig_HTTPSEnabled_DefaultsToTrue(t *testing.T) { assert.True(t, cfg.Listeners.HTTPS.Enabled, "server.https.enabled must default to true when unset") assert.Equal(t, 9243, cfg.Listeners.HTTPS.Port, "server.https.port must default to 9243") assert.False(t, cfg.Listeners.HTTP.Enabled, "server.http.enabled must default to false when unset") - assert.Equal(t, "./data/certs/cert.pem", cfg.Listeners.HTTPS.TLS.CertFile) - assert.Equal(t, "./data/certs/key.pem", cfg.Listeners.HTTPS.TLS.KeyFile) + assert.Equal(t, "./data/certs/cert.pem", cfg.Listeners.HTTPS.CertFile) + assert.Equal(t, "./data/certs/key.pem", cfg.Listeners.HTTPS.KeyFile) } // A {{ env }} token feeding a bool field must survive koanf's weakly-typed decode, diff --git a/platform-api/config/default_config.go b/platform-api/config/default_config.go index 30fe39ccf..bdaf7813e 100644 --- a/platform-api/config/default_config.go +++ b/platform-api/config/default_config.go @@ -27,7 +27,7 @@ import ( func defaultConfig() *Server { return &Server{ Logging: Logging{ - LogLevel: "INFO", + LogLevel: "info", LogFormat: "text", }, DBSchemaPath: "./internal/database/schema.sql", @@ -114,12 +114,10 @@ func defaultConfig() *Server { Port: 9080, }, HTTPS: HTTPSListener{ - Enabled: true, - Port: 9243, - TLS: ListenerTLS{ - CertFile: "./data/certs/cert.pem", - KeyFile: "./data/certs/key.pem", - }, + Enabled: true, + Port: 9243, + CertFile: "./data/certs/cert.pem", + KeyFile: "./data/certs/key.pem", }, // Finite by default so a slow or idle peer cannot hold a connection open // indefinitely. Write is the loosest of the four because some handlers diff --git a/platform-api/internal/database/connection.go b/platform-api/internal/database/connection.go index 1c025d1d2..3c1dfd322 100644 --- a/platform-api/internal/database/connection.go +++ b/platform-api/internal/database/connection.go @@ -109,7 +109,7 @@ func NewConnection(cfg *config.Database, slogger *slog.Logger) (*DB, error) { // Build PostgreSQL DSN from config dsn := fmt.Sprintf( "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", - cfg.Host, cfg.Port, cfg.Username, cfg.Password, cfg.Name, cfg.SSLMode, + cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.Name, cfg.SSLMode, ) // sslrootcert verifies the server certificate (verify-ca/verify-full); // sslcert/sslkey present a client certificate for mutual TLS. @@ -611,8 +611,8 @@ func buildSQLServerDSN(cfg *config.Database) string { Host: fmt.Sprintf("%s:%d", host, cfg.Port), RawQuery: q.Encode(), } - if cfg.Username != "" { - u.User = url.UserPassword(cfg.Username, cfg.Password) + if cfg.User != "" { + u.User = url.UserPassword(cfg.User, cfg.Password) } return u.String() diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index af264c9f9..1badfcb75 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -641,17 +641,17 @@ func loadRoleScopeMap(cfg *config.Server, registry *middleware.ScopeRegistry, sl // 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) { - certFile := httpsCfg.TLS.CertFile - keyFile := httpsCfg.TLS.KeyFile + certFile := httpsCfg.CertFile + keyFile := httpsCfg.KeyFile if certFile == "" || keyFile == "" { - return nil, fmt.Errorf("HTTPS listener enabled but server.https.tls.cert_file / server.https.tls.key_file is not configured") + return nil, fmt.Errorf("HTTPS listener enabled but server.https.cert_file / server.https.key_file is not configured") } cert, err := tls.LoadX509KeyPair(certFile, keyFile) if err != nil { return nil, fmt.Errorf( "failed to load TLS certificates (cert %q / key %q): %w. "+ - "Mount a certificate pair and point server.https.tls.cert_file / key_file at it, "+ + "Mount a certificate pair and point server.https.cert_file / key_file at it, "+ "or set server.https.enabled=false to serve plain HTTP behind a TLS-terminating proxy", certFile, keyFile, err, ) diff --git a/platform-api/internal/server/server_tls_test.go b/platform-api/internal/server/server_tls_test.go index 3982d5fe1..82b9492a2 100644 --- a/platform-api/internal/server/server_tls_test.go +++ b/platform-api/internal/server/server_tls_test.go @@ -43,11 +43,9 @@ func testServer() *Server { func TestBuildTLSConfig_MissingCert_Errors(t *testing.T) { missingDir := filepath.Join(t.TempDir(), "does-not-exist") _, err := testServer().buildTLSConfig(config.HTTPSListener{ - Enabled: true, - TLS: config.ListenerTLS{ - CertFile: filepath.Join(missingDir, "cert.pem"), - KeyFile: filepath.Join(missingDir, "key.pem"), - }, + Enabled: true, + CertFile: filepath.Join(missingDir, "cert.pem"), + KeyFile: filepath.Join(missingDir, "key.pem"), }) if err == nil { t.Fatal("expected an error when the HTTPS listener has no certificates") @@ -68,12 +66,10 @@ func TestBuildTLSConfig_MountedCert_Loads(t *testing.T) { writeTestCertPair(t, certDir) tlsConfig, err := testServer().buildTLSConfig(config.HTTPSListener{ - Enabled: true, - Port: 9243, - TLS: config.ListenerTLS{ - CertFile: filepath.Join(certDir, "cert.pem"), - KeyFile: filepath.Join(certDir, "key.pem"), - }, + Enabled: true, + Port: 9243, + CertFile: filepath.Join(certDir, "cert.pem"), + KeyFile: filepath.Join(certDir, "key.pem"), }) if err != nil { t.Fatalf("expected mounted certificates to load, got %v", err) diff --git a/portals/ai-workspace/Makefile b/portals/ai-workspace/Makefile index 492223653..91f0de8c6 100644 --- a/portals/ai-workspace/Makefile +++ b/portals/ai-workspace/Makefile @@ -60,11 +60,11 @@ bff-run: ## Run the BFF locally (proxies to CONTROL_PLANE_URL; pair with `make r cd $(BFF_DIR) && \ APIP_AIW_CONTROL_PLANE_URL=$(CONTROL_PLANE_URL) \ APIP_AIW_CONTROL_PLANE_TLS_SKIP_VERIFY=true \ - APIP_AIW_TLS_CERT_FILE=../resources/certificates/cert.pem \ - APIP_AIW_TLS_KEY_FILE=../resources/certificates/key.pem \ - APIP_AIW_SERVER_PORT=$(BFF_DEV_PORT) \ + APIP_AIW_SERVER_HTTPS_CERT_FILE=../resources/certificates/cert.pem \ + APIP_AIW_SERVER_HTTPS_KEY_FILE=../resources/certificates/key.pem \ + APIP_AIW_SERVER_HTTPS_PORT=$(BFF_DEV_PORT) \ APIP_AIW_SERVER_STATIC_DIR=../dist \ - APIP_AIW_LOG_LEVEL=debug \ + APIP_AIW_LOGGING_LEVEL=debug \ go run . -config ../configs/config.toml bff-test: ## Run BFF unit tests diff --git a/portals/ai-workspace/README.md b/portals/ai-workspace/README.md index e71c1df48..b6e18dace 100644 --- a/portals/ai-workspace/README.md +++ b/portals/ai-workspace/README.md @@ -59,7 +59,7 @@ them from the environment without editing the file: ```toml [ai_workspace.logging] # The token names the variable; the second argument is the value used when it is unset. -log_level = '{{ env "APIP_AIW_LOG_LEVEL" "info" }}' +level = '{{ env "APIP_AIW_LOGGING_LEVEL" "info" }}' [ai_workspace.auth.oidc] # No default — an unset APIP_AIW_AUTH_OIDC_CLIENT_SECRET fails startup rather than @@ -71,12 +71,12 @@ client_secret = '{{ env "APIP_AIW_AUTH_OIDC_CLIENT_SECRET" }}' client_secret = '{{ file "/secrets/ai-workspace/oidc_client_secret" }}' ``` -The token is what reads the environment, so setting `APIP_AIW_LOG_LEVEL` does nothing unless -`log_level` is present in the file with its token. All AI Workspace settings live under +The token is what reads the environment, so setting `APIP_AIW_LOGGING_LEVEL` does nothing unless +`level` is present in the file with its token. All AI Workspace settings live under `[ai_workspace]` — the same namespacing convention the Platform API uses for its own `[platform_api]` table, so a shared config.toml can hold both services' sections without their keys colliding. Keys are grouped into TOML tables under it -(`[ai_workspace.logging]`, `[ai_workspace.control_plane]`, `[ai_workspace.tls]`, `[ai_workspace.session]`, +(`[ai_workspace.logging]`, `[ai_workspace.control_plane]`, `[ai_workspace.server.https]`, `[ai_workspace.session]`, `[ai_workspace.auth]`, `[ai_workspace.auth.oidc]`), and by convention a token names the key's path under `[ai_workspace]` uppercased with underscores (`[ai_workspace.auth.oidc] client_id` → `APIP_AIW_AUTH_OIDC_CLIENT_ID`) — but a token may name any variable. diff --git a/portals/ai-workspace/bff/internal/config/config.go b/portals/ai-workspace/bff/internal/config/config.go index 929988784..a249064bc 100644 --- a/portals/ai-workspace/bff/internal/config/config.go +++ b/portals/ai-workspace/bff/internal/config/config.go @@ -34,7 +34,7 @@ import ( // Config is the fully-resolved BFF configuration. type Config struct { - // Listener. Addr is derived from [server] port as ":" + port (e.g. ":5380") — + // Listener. Addr is derived from [server.https] port as ":" + port (e.g. ":5380") — // there is no host to configure, since the listener always binds all interfaces. Addr string StaticDir string // directory containing the built SPA (index.html + assets) @@ -97,7 +97,7 @@ type ControlPlaneConfig struct { type TLSConfig struct { // TerminateTLS makes the BFF serve HTTPS on its own listener: it presents the // certificate and decrypts inbound TLS itself. Defaults to true (config key - // [tls] enabled). Set to false only when a trusted upstream (ingress, + // [server.https] enabled). Set to false only when a trusted upstream (ingress, // service-mesh sidecar) terminates TLS and forwards plain HTTP to the BFF; no // certificate is then read, generated, or required. TerminateTLS bool @@ -234,7 +234,7 @@ 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. - tlsEnabled, err := s.getbool("tls.enabled", true) + tlsEnabled, err := s.getbool("server.https.enabled", true) if err != nil { return nil, err } @@ -256,25 +256,24 @@ func Load(path string) (*Config, error) { } // A bare port number is easier to get right than a full "host:port" address (there // is never a host to fill in — the listener always binds all interfaces), and it - // matches the Platform API's [server.http]/[server.https] port convention. Validate - // it up front so a typo'd value fails startup instead of a confusing net.Listen error. - port := s.get("server.port", "5380") - portNum, err := strconv.Atoi(port) - if err != nil || portNum < 1 || portNum > 65535 { - return nil, fmt.Errorf("[server] port must be a number between 1 and 65535, got %q", port) + // matches the Platform API's [server.https] port convention. Parsed as an int so a + // typo'd value fails startup instead of a confusing net.Listen error. + portNum, err := s.getint("server.https.port", 5380, 1, 65535) + if err != nil { + return nil, err } cfg := &Config{ - Addr: ":" + port, + Addr: ":" + strconv.Itoa(portNum), StaticDir: s.get("server.static_dir", "/app"), - LogLevel: strings.ToLower(s.get("logging.log_level", "info")), - LogFormat: strings.ToLower(s.get("logging.log_format", "text")), + LogLevel: strings.ToLower(s.get("logging.level", "info")), + LogFormat: strings.ToLower(s.get("logging.format", "text")), TLS: TLSConfig{ 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/cert.pem"), - KeyFile: s.get("tls.key_file", "/etc/ai-workspace/tls/key.pem"), + CertFile: s.get("server.https.cert_file", "/etc/ai-workspace/tls/cert.pem"), + KeyFile: s.get("server.https.key_file", "/etc/ai-workspace/tls/key.pem"), }, ControlPlane: ControlPlaneConfig{ URL: strings.TrimRight(s.get("control_plane.url", ""), "/"), diff --git a/portals/ai-workspace/bff/internal/config/config_test.go b/portals/ai-workspace/bff/internal/config/config_test.go index dd36766b8..afcdc1768 100644 --- a/portals/ai-workspace/bff/internal/config/config_test.go +++ b/portals/ai-workspace/bff/internal/config/config_test.go @@ -38,7 +38,7 @@ func writeConfig(t *testing.T, body string) string { func TestLoad_ConfigFileValue(t *testing.T) { cfgPath := writeConfig(t, ` [ai_workspace.logging] -log_level = "warn" +level = "warn" [ai_workspace.control_plane] url = "https://platform-api:9243" @@ -61,14 +61,14 @@ url = "https://platform-api:9243" func TestLoad_EnvTokenSuppliesValueAndDefault(t *testing.T) { cfgPath := writeConfig(t, ` [ai_workspace.logging] -log_level = '{{ env "APIP_AIW_LOG_LEVEL" "info" }}' -log_format = '{{ env "APIP_AIW_LOG_FORMAT" "text" }}' +level = '{{ env "APIP_AIW_LOGGING_LEVEL" "info" }}' +format = '{{ env "APIP_AIW_LOGGING_FORMAT" "text" }}' [ai_workspace.control_plane] url = "https://platform-api:9243" `) - t.Setenv("APIP_AIW_LOG_LEVEL", "debug") // named by the token - // APIP_AIW_LOG_FORMAT is left unset, so the token's default stands. + t.Setenv("APIP_AIW_LOGGING_LEVEL", "debug") // named by the token + // APIP_AIW_LOGGING_FORMAT is left unset, so the token's default stands. cfg, err := Load(cfgPath) if err != nil { @@ -88,12 +88,12 @@ url = "https://platform-api:9243" func TestLoad_EnvVarWithoutTokenIsIgnored(t *testing.T) { cfgPath := writeConfig(t, ` [ai_workspace.logging] -log_level = "warn" +level = "warn" [ai_workspace.control_plane] url = "https://platform-api:9243" `) - t.Setenv("APIP_AIW_LOG_LEVEL", "debug") + t.Setenv("APIP_AIW_LOGGING_LEVEL", "debug") cfg, err := Load(cfgPath) if err != nil { @@ -209,7 +209,7 @@ client_secret = '{{ env "CUSTOM_SECRET_VAR" }}' // The upstream URL is mandatory — the BFF has nothing to proxy to without it. func TestLoad_MissingControlPlaneURL_Errors(t *testing.T) { - cfgPath := writeConfig(t, "[ai_workspace]\nlog_level = \"info\"") + cfgPath := writeConfig(t, "[ai_workspace]\ndomain = \"localhost:5380\"") _, err := Load(cfgPath) if err == nil { @@ -309,7 +309,7 @@ func TestLoad_BareTOMLScalars(t *testing.T) { [ai_workspace.control_plane] url = "https://platform-api:9243" -[ai_workspace.tls] +[ai_workspace.server.https] enabled = false [ai_workspace.session] @@ -329,7 +329,7 @@ absolute_ttl = "2h" } // A key in a table must not collide with the same key in another table — they are -// distinct dotted paths, so [tls] enabled and [auth.oidc] enabled are independent. +// distinct dotted paths, so [server.https] enabled and [auth.oidc] enabled are independent. func TestLoad_SameKeyInDifferentTables(t *testing.T) { cfgPath := writeConfig(t, ` [ai_workspace.auth] @@ -338,7 +338,7 @@ mode = "oidc" [ai_workspace.control_plane] url = "https://platform-api:9243" -[ai_workspace.tls] +[ai_workspace.server.https] enabled = false [ai_workspace.auth.oidc] @@ -354,7 +354,7 @@ redirect_url = "https://localhost:5380/api/auth/callback" t.Fatalf("Load() error = %v", err) } if cfg.TLS.TerminateTLS { - t.Error("TLS.TerminateTLS = true, want false — [tls] enabled must not read [auth.oidc] enabled") + t.Error("TLS.TerminateTLS = true, want false — [server.https] enabled must not read [auth.oidc] enabled") } if !cfg.OIDC.Enabled { t.Error("OIDC.Enabled = false, want true") @@ -406,7 +406,7 @@ func TestLoad_InvalidBool_Errors(t *testing.T) { [ai_workspace.control_plane] url = "https://platform-api:9243" -[ai_workspace.tls] +[ai_workspace.server.https] enabled = "maybe" `) diff --git a/portals/ai-workspace/bff/internal/config/settings.go b/portals/ai-workspace/bff/internal/config/settings.go index 055263c6b..8c7e7129a 100644 --- a/portals/ai-workspace/bff/internal/config/settings.go +++ b/portals/ai-workspace/bff/internal/config/settings.go @@ -45,7 +45,7 @@ const EnvPrefix = "APIP_AIW_" // API's platformAPIConfigKey: this namespacing lets an AI Workspace config file // coexist with sibling services' sections ([platform_api], ...) in a shared // deployment config, the same file convention as the Platform API's [platform_api] -// table. Every key below this cut (log_level, control_plane.url, auth.oidc.*, ...) is +// table. Every key below this cut (logging.level, control_plane.url, auth.oidc.*, ...) is // resolved relative to [ai_workspace], not the file root. const aiWorkspaceConfigKey = "ai_workspace" @@ -69,7 +69,7 @@ type settings map[string]string // the only source of configuration: there is no implicit environment overlay, so a // value comes from the environment exactly when the key's token asks for it, e.g. // -// log_level = '{{ env "APIP_AIW_LOG_LEVEL" "info" }}' +// level = '{{ env "APIP_AIW_LOGGING_LEVEL" "info" }}' // // One mechanism therefore covers both ordinary settings and secrets, and every source // a value can come from is visible in the file itself rather than implied by a naming @@ -178,6 +178,24 @@ func (s settings) getbool(key string, def bool) (bool, error) { return b, nil } +// getint parses key as an integer within [min, max]. A missing or empty value +// returns def; a malformed or out-of-range value fails startup rather than being +// silently replaced by the default. +func (s settings) getint(key string, def, min, max int) (int, error) { + v, ok := s[key] + if !ok || v == "" { + return def, nil + } + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return 0, fmt.Errorf("invalid integer for %s=%q: %w", key, v, err) + } + if n < min || n > max { + return 0, fmt.Errorf("invalid integer for %s=%q: must be between %d and %d", key, v, min, max) + } + return n, nil +} + // getdur parses key as a Go duration. A malformed, zero, or negative value fails // startup — every duration setting is a lifetime or timeout, where <= 0 is never // meaningful. 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 7f216ad70..e8285b6a9 100644 --- a/portals/ai-workspace/bff/internal/config/shipped_config_test.go +++ b/portals/ai-workspace/bff/internal/config/shipped_config_test.go @@ -79,9 +79,9 @@ func TestShippedConfig_MakeBffRunOverrides(t *testing.T) { // Exactly the variables the bff-run target sets. t.Setenv("APIP_AIW_CONTROL_PLANE_URL", "https://localhost:9243") t.Setenv("APIP_AIW_CONTROL_PLANE_TLS_SKIP_VERIFY", "true") - t.Setenv("APIP_AIW_SERVER_PORT", "8081") + t.Setenv("APIP_AIW_SERVER_HTTPS_PORT", "8081") t.Setenv("APIP_AIW_SERVER_STATIC_DIR", "../dist") - t.Setenv("APIP_AIW_LOG_LEVEL", "debug") + t.Setenv("APIP_AIW_LOGGING_LEVEL", "debug") cfg, err := Load(quickstartConfig) if err != nil { diff --git a/portals/ai-workspace/configs/config-template.toml b/portals/ai-workspace/configs/config-template.toml index 91e5ac12e..f3051b19f 100644 --- a/portals/ai-workspace/configs/config-template.toml +++ b/portals/ai-workspace/configs/config-template.toml @@ -26,9 +26,9 @@ # ignores the variable entirely. By convention the variable name is the key's table # path under [ai_workspace], uppercased with underscores, prefixed APIP_AIW_: # -# [ai_workspace] domain -> APIP_AIW_DOMAIN -# [ai_workspace.server] port -> APIP_AIW_SERVER_PORT -# [ai_workspace.auth.oidc] client_id -> APIP_AIW_AUTH_OIDC_CLIENT_ID +# [ai_workspace] domain -> APIP_AIW_DOMAIN +# [ai_workspace.server.https] port -> APIP_AIW_SERVER_HTTPS_PORT +# [ai_workspace.auth.oidc] client_id -> APIP_AIW_AUTH_OIDC_CLIENT_ID # # For secrets, source from a mounted file instead of the environment: # @@ -83,22 +83,37 @@ default_org_region = '{{ env "APIP_AIW_DEFAULT_ORG_REGION" "us" }}' # --------------------------------------------------------------------------- [ai_workspace.server] -# Port the server listens on. The listener always binds all interfaces (there is no -# host to configure), and the container publishes this port. -port = '{{ env "APIP_AIW_SERVER_PORT" "5380" }}' - # Directory holding the built SPA (index.html + assets). static_dir = '{{ env "APIP_AIW_SERVER_STATIC_DIR" "/app" }}' +# The single listener follows the platform-wide [server.https] shape. The listener +# always binds all interfaces (there is no host to configure), and the container +# publishes this port. +[ai_workspace.server.https] + +# Terminate TLS on this listener. Set false only when a trusted upstream (ingress, +# service-mesh sidecar) terminates TLS for you — the listener then serves plain HTTP +# on the same port and no certificate is read or required. +enabled = '{{ env "APIP_AIW_SERVER_HTTPS_ENABLED" "true" }}' + +# Port the server listens on. +port = '{{ env "APIP_AIW_SERVER_HTTPS_PORT" "5380" }}' + +# Paths to a mounted TLS certificate and key. REQUIRED when enabled = true; setup.sh +# generates a pair for the quickstart. +cert_file = '{{ env "APIP_AIW_SERVER_HTTPS_CERT_FILE" "/etc/ai-workspace/tls/cert.pem" }}' +key_file = '{{ env "APIP_AIW_SERVER_HTTPS_KEY_FILE" "/etc/ai-workspace/tls/key.pem" }}' + # --------------------------------------------------------------------------- -# Logging. log_level/log_format are this process's own logs; browser_debug is +# Logging. level/format are this process's own logs; browser_debug is # browser-safe — served to the SPA to control verbose console logging there. +# Level is matched case-insensitively; lowercase is canonical. # --------------------------------------------------------------------------- [ai_workspace.logging] -log_level = '{{ env "APIP_AIW_LOG_LEVEL" "info" }}' # debug | info | warn | error -log_format = '{{ env "APIP_AIW_LOG_FORMAT" "text" }}' # text | json +level = '{{ env "APIP_AIW_LOGGING_LEVEL" "info" }}' # debug | info | warn | error +format = '{{ env "APIP_AIW_LOGGING_FORMAT" "text" }}' # text | json browser_debug = '{{ env "APIP_AIW_LOGGING_BROWSER_DEBUG" "false" }}' @@ -146,20 +161,6 @@ controlplane_host = '{{ env "APIP_AIW_GATEWAY_CONTROLPLANE_HOST" "localhost:9243 platform_gateway_versions = '{{ env "APIP_AIW_GATEWAY_PLATFORM_GATEWAY_VERSIONS" "[{\"version\":\"1.2\",\"latestVersion\":\"v1.2.0-alpha2\",\"channel\":\"STS\"}]" }}' -# --------------------------------------------------------------------------- -# TLS on the AI Workspace listener. -# --------------------------------------------------------------------------- -[ai_workspace.tls] - -# Terminate TLS on this listener. Set false only when a trusted upstream (ingress, -# service-mesh sidecar) terminates TLS for you — no certificate is read or required. -enabled = '{{ env "APIP_AIW_TLS_ENABLED" "true" }}' - -# Paths to a mounted TLS certificate and key. REQUIRED when enabled = true; setup.sh -# generates a pair for the quickstart. -cert_file = '{{ env "APIP_AIW_TLS_CERT_FILE" "/etc/ai-workspace/tls/cert.pem" }}' -key_file = '{{ env "APIP_AIW_TLS_KEY_FILE" "/etc/ai-workspace/tls/key.pem" }}' - # --------------------------------------------------------------------------- # Server-side session lifetime. diff --git a/portals/ai-workspace/configs/config.toml b/portals/ai-workspace/configs/config.toml index 21e6df3b5..651b9f2dd 100644 --- a/portals/ai-workspace/configs/config.toml +++ b/portals/ai-workspace/configs/config.toml @@ -15,13 +15,19 @@ default_org_region = '{{ env "APIP_AIW_DEFAULT_ORG_REGION" "us" }}' [ai_workspace.server] -port = '{{ env "APIP_AIW_SERVER_PORT" "5380" }}' static_dir = '{{ env "APIP_AIW_SERVER_STATIC_DIR" "/app" }}' +[ai_workspace.server.https] + +enabled = '{{ env "APIP_AIW_SERVER_HTTPS_ENABLED" "true" }}' +port = '{{ env "APIP_AIW_SERVER_HTTPS_PORT" "5380" }}' +cert_file = "/etc/ai-workspace/tls/cert.pem" +key_file = "/etc/ai-workspace/tls/key.pem" + [ai_workspace.logging] -log_level = '{{ env "APIP_AIW_LOG_LEVEL" "info" }}' # debug | info | warn | error +level = '{{ env "APIP_AIW_LOGGING_LEVEL" "info" }}' # debug | info | warn | error [ai_workspace.control_plane] @@ -37,13 +43,6 @@ controlplane_host = '{{ env "APIP_AIW_GATEWAY_CONTROLPLANE_HOST" "host.docker.in platform_gateway_versions = '{{ env "APIP_AIW_GATEWAY_PLATFORM_GATEWAY_VERSIONS" "[{\"version\":\"1.2\",\"latestVersion\":\"v1.2.0-alpha2\",\"channel\":\"STS\"}]" }}' -[ai_workspace.tls] - -enabled = '{{ env "APIP_AIW_TLS_ENABLED" "true" }}' -cert_file = "/etc/ai-workspace/tls/cert.pem" -key_file = "/etc/ai-workspace/tls/key.pem" - - [ai_workspace.auth] mode = '{{ env "APIP_AIW_AUTH_MODE" "basic" }}' diff --git a/portals/ai-workspace/distribution/README.md b/portals/ai-workspace/distribution/README.md index fa94b8569..eb607ec39 100644 --- a/portals/ai-workspace/distribution/README.md +++ b/portals/ai-workspace/distribution/README.md @@ -69,7 +69,7 @@ Open the AI Workspace in a browser at `https://localhost:5380` and log in with t 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. +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_LOGGING_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_AUTH_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. @@ -84,21 +84,21 @@ Environment overrides go in `api-platform.env` (git-ignored; loaded into both co | `[ai_workspace.control_plane].tls_skip_verify` | Skip upstream cert verification — local dev only | | `[ai_workspace.gateway].controlplane_host` | Address gateways use to reach the Platform API | | `[ai_workspace.gateway].platform_gateway_versions` | Gateway versions shown in the create-gateway selector | -| `[ai_workspace.tls].cert_file` / `key_file` | Listener certificate pair — required when `[ai_workspace.tls].enabled` is `true`. Fixed to the mounted path, same as `ca_file` above | +| `[ai_workspace.server.https].cert_file` / `key_file` | Listener certificate pair — required when `[ai_workspace.server.https].enabled` is `true`. Fixed to the mounted path, same as `ca_file` above | | `[ai_workspace.auth.oidc].*` | Used only when `[ai_workspace.auth] mode = "oidc"` — see [OIDC](#oidc-production) below | ### Platform API (`configs/config-platform-api.toml`) | Setting | Description | |---------|-------------| -| `[logging].log_level` | Log level (`DEBUG`, `INFO`, `WARN`, `ERROR`) | +| `[logging].level` | Log level (`debug`, `info`, `warn`, `error`; matched case-insensitively) | | `[security].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` | | `[database].driver` | `sqlite3` or `postgres` | | `[auth].mode` | `file` (quickstart default), `external_token`, or `idp` — selects exactly one auth mode | | `[auth.jwt].secret_key` | 32-byte HMAC key signing login JWTs | | `[auth.idp]` | JWKS-based IDP auth — active when `mode = "idp"`; configure for Asgardeo, Keycloak, Auth0, etc. | | `[auth.file.users]` | Local user credentials, active when `mode = "file"` (change the password hash before sharing) | -| `[server.https]` | Listener on `:9243`; `[server.https.tls] cert_file`/`key_file` point at `cert.pem`/`key.pem` | +| `[server.https]` | Listener on `:9243`; `cert_file`/`key_file` point at `cert.pem`/`key.pem` | Each key's default value is written inline in `configs/config-template.toml` and `configs/config-platform-api-template.toml` — those files are a fully-commented reference of 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 789d3e8be..8987606a9 100644 --- a/portals/developer-portal/it/configs/config-platform-api-it.toml +++ b/portals/developer-portal/it/configs/config-platform-api-it.toml @@ -21,18 +21,16 @@ [platform_api] [platform_api.logging] -log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' +level = '{{ env "APIP_CP_LOGGING_LEVEL" "info" }}' [platform_api.security] encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' [platform_api.server.https] -enabled = '{{ env "APIP_CP_SERVER_HTTPS_ENABLED" "true" }}' -port = '{{ env "APIP_CP_SERVER_HTTPS_PORT" "9243" }}' - -[platform_api.server.https.tls] -cert_file = '{{ env "APIP_CP_SERVER_HTTPS_TLS_CERT_FILE" "/app/data/certs/cert.pem" }}' -key_file = '{{ env "APIP_CP_SERVER_HTTPS_TLS_KEY_FILE" "/app/data/certs/key.pem" }}' +enabled = '{{ env "APIP_CP_SERVER_HTTPS_ENABLED" "true" }}' +port = '{{ env "APIP_CP_SERVER_HTTPS_PORT" "9243" }}' +cert_file = '{{ env "APIP_CP_SERVER_HTTPS_CERT_FILE" "/app/data/certs/cert.pem" }}' +key_file = '{{ env "APIP_CP_SERVER_HTTPS_KEY_FILE" "/app/data/certs/key.pem" }}' [platform_api.database] driver = '{{ env "APIP_CP_DATABASE_DRIVER" "sqlite3" }}' diff --git a/tests/integration-e2e/platform-api-config.toml b/tests/integration-e2e/platform-api-config.toml index 7a584c5f0..3866f262d 100644 --- a/tests/integration-e2e/platform-api-config.toml +++ b/tests/integration-e2e/platform-api-config.toml @@ -5,7 +5,7 @@ [platform_api] [platform_api.logging] -log_level = '{{ env "APIP_CP_LOG_LEVEL" "INFO" }}' +level = '{{ env "APIP_CP_LOGGING_LEVEL" "info" }}' [platform_api.security] encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' From 8acf2eabe938c721c552ec7ff52556a02baf27f0 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Tue, 21 Jul 2026 17:04:01 +0530 Subject: [PATCH 10/18] Refactor AI Workspace configuration to utilize koanf for TOML parsing --- platform-api/cmd/main.go | 4 +- platform-api/config/config.go | 8 +- platform-api/config/default_config.go | 4 +- portals/ai-workspace/bff/go.mod | 18 +- portals/ai-workspace/bff/go.sum | 22 + .../bff/internal/config/config.go | 410 ++++++++---------- .../bff/internal/config/config_test.go | 42 +- .../bff/internal/config/default_config.go | 70 +++ .../bff/internal/config/runtime_config.go | 30 +- .../bff/internal/config/settings.go | 175 ++------ .../internal/config/shipped_config_test.go | 30 +- .../ai-workspace/bff/internal/config/toml.go | 273 ------------ .../bff/internal/config/toml_test.go | 115 ----- .../server/composite_handlers_test.go | 10 +- .../bff/internal/server/routes.go | 4 +- .../bff/internal/server/server.go | 10 +- portals/ai-workspace/bff/main.go | 24 +- 17 files changed, 425 insertions(+), 824 deletions(-) create mode 100644 portals/ai-workspace/bff/internal/config/default_config.go delete mode 100644 portals/ai-workspace/bff/internal/config/toml.go delete mode 100644 portals/ai-workspace/bff/internal/config/toml_test.go diff --git a/platform-api/cmd/main.go b/platform-api/cmd/main.go index 8ced18d31..0b42a05c7 100644 --- a/platform-api/cmd/main.go +++ b/platform-api/cmd/main.go @@ -37,8 +37,8 @@ func main() { // Initialize logger logConfig := logger.Config{ - Level: cfg.Logging.LogLevel, - Format: cfg.Logging.LogFormat, + Level: cfg.Logging.Level, + Format: cfg.Logging.Format, } slogger := logger.NewLogger(logConfig) diff --git a/platform-api/config/config.go b/platform-api/config/config.go index f03c6827d..367f4e35d 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -75,8 +75,8 @@ type FileBased struct { // Logging holds logging configuration. type Logging struct { - LogLevel string `koanf:"level"` - LogFormat string `koanf:"format"` + Level string `koanf:"level"` + Format string `koanf:"format"` } // Server holds the configuration parameters for the application. @@ -399,9 +399,9 @@ func LoadConfig(configPath string) (*Server, error) { // Install the configured logger as the slog default so the warnings/info logs // emitted below (and any package-level slog.* call in this file) use the same // format as the rest of the application, instead of slog's default handler. - slog.SetDefault(logger.NewLogger(logger.Config{Level: cfg.Logging.LogLevel, Format: cfg.Logging.LogFormat})) + slog.SetDefault(logger.NewLogger(logger.Config{Level: cfg.Logging.Level, Format: cfg.Logging.Format})) - if err := validateLoggingConfig(cfg.Logging.LogLevel, cfg.Logging.LogFormat); err != nil { + if err := validateLoggingConfig(cfg.Logging.Level, cfg.Logging.Format); err != nil { return nil, err } if err := validateTimeoutsConfig(&cfg.Listeners.Timeouts); err != nil { diff --git a/platform-api/config/default_config.go b/platform-api/config/default_config.go index bdaf7813e..ac2f7911d 100644 --- a/platform-api/config/default_config.go +++ b/platform-api/config/default_config.go @@ -27,8 +27,8 @@ import ( func defaultConfig() *Server { return &Server{ Logging: Logging{ - LogLevel: "info", - LogFormat: "text", + Level: "info", + Format: "text", }, DBSchemaPath: "./internal/database/schema.sql", OpenAPISpecPath: "./resources/openapi.yaml", diff --git a/portals/ai-workspace/bff/go.mod b/portals/ai-workspace/bff/go.mod index 0b5eb197d..4cc21ea9a 100644 --- a/portals/ai-workspace/bff/go.mod +++ b/portals/ai-workspace/bff/go.mod @@ -2,7 +2,23 @@ module ai-workspace-bff go 1.26.5 -require github.com/wso2/api-platform/common v0.0.0 +require ( + github.com/knadh/koanf/parsers/toml/v2 v2.2.0 + github.com/knadh/koanf/providers/confmap v1.0.0 + github.com/knadh/koanf/providers/file v1.2.1 + github.com/knadh/koanf/v2 v2.3.2 + github.com/wso2/api-platform/common v0.0.0 +) + +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + golang.org/x/sys v0.43.0 // indirect +) replace github.com/wso2/api-platform/common => ../../../common diff --git a/portals/ai-workspace/bff/go.sum b/portals/ai-workspace/bff/go.sum index 5d19fde92..a4aad55d5 100644 --- a/portals/ai-workspace/bff/go.sum +++ b/portals/ai-workspace/bff/go.sum @@ -1,8 +1,30 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/toml/v2 v2.2.0 h1:2nV7tHYJ5OZy2BynQ4mOJ6k5bDqbbCzRERLUKBytz3A= +github.com/knadh/koanf/parsers/toml/v2 v2.2.0/go.mod h1:JpjTeK1Ge1hVX0wbof5DMCuDBriR8bWgeQP98eeOZpI= +github.com/knadh/koanf/providers/confmap v1.0.0 h1:mHKLJTE7iXEys6deO5p6olAiZdG5zwp8Aebir+/EaRE= +github.com/knadh/koanf/providers/confmap v1.0.0/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/v2 v2.3.2 h1:Ee6tuzQYFwcZXQpc2MiVeC6qHMandf5SMUJJNoFp/c4= +github.com/knadh/koanf/v2 v2.3.2/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/portals/ai-workspace/bff/internal/config/config.go b/portals/ai-workspace/bff/internal/config/config.go index a249064bc..525bcd105 100644 --- a/portals/ai-workspace/bff/internal/config/config.go +++ b/portals/ai-workspace/bff/internal/config/config.go @@ -16,11 +16,12 @@ // Package config loads BFF configuration from config.toml, resolving its // {{ env }} / {{ file }} interpolation tokens through the shared configinterpolate -// library. The file is the only source: a key takes its value from the environment or -// a mounted secret file exactly when its token says so. Browser-safe keys are surfaced -// to the SPA as APIP_AIW_* runtime config. The BFF never validates tokens, so there are -// no signing keys here — only the IDP client credentials needed to perform the OAuth2 -// code exchange. +// library and unmarshalling the result into a nested struct via koanf — the same +// loading stack the Gateway and Platform API use. The file is the only source: a key +// takes its value from the environment or a mounted secret file exactly when its token +// says so. Browser-safe keys are surfaced to the SPA as APIP_AIW_* runtime config. The +// BFF never validates tokens, so there are no signing keys here — only the IDP client +// credentials needed to perform the OAuth2 code exchange. package config import ( @@ -30,86 +31,127 @@ import ( "strconv" "strings" "time" + + "github.com/go-viper/mapstructure/v2" + "github.com/knadh/koanf/v2" ) -// Config is the fully-resolved BFF configuration. +// Config is the fully-resolved BFF configuration. Its shape mirrors the +// [ai_workspace.*] tables in config.toml, so koanf unmarshals straight into it — the +// same pattern the Platform API uses. Keys the BFF does not consume (browser-only +// values the SPA reads) are deliberately not modeled here; they flow to RuntimeConfig +// straight from the parsed config, gated by browserSafeKeys (see runtime_config.go). type Config struct { - // Listener. Addr is derived from [server.https] port as ":" + port (e.g. ":5380") — - // there is no host to configure, since the listener always binds all interfaces. - Addr string - StaticDir string // directory containing the built SPA (index.html + assets) - - // Logging - LogLevel string // "debug" | "info" | "warn" | "error" (default "info") - LogFormat string // "text" | "json" (default "text") - - // TLS for the BFF listener - TLS TLSConfig - - // Upstream Platform API (control plane) - ControlPlane ControlPlaneConfig + Server ServerConfig `koanf:"server"` + Logging LoggingConfig `koanf:"logging"` + ControlPlane ControlPlaneConfig `koanf:"control_plane"` + Session SessionConfig `koanf:"session"` + Auth AuthConfig `koanf:"auth"` - // Same-origin reverse-proxy prefix the SPA calls (stripped before forwarding) - ProxyPrefix string + // Cookie attributes are fixed implementation details of the session mechanism, not + // deployment config; Load sets them. RuntimeConfig is assembled after load. + Cookie CookieConfig `koanf:"-"` + RuntimeConfig map[string]string `koanf:"-"` +} - // Session / cookie - Session SessionConfig - Cookie CookieConfig +// ServerConfig is [ai_workspace.server]. +type ServerConfig struct { + StaticDir string `koanf:"static_dir"` // directory containing the built SPA (index.html + assets) + HTTPS HTTPSConfig `koanf:"https"` +} - // Auth - AuthMode string // "basic" | "oidc" — informs the SPA which login UX to show - OIDC OIDCConfig +// HTTPSConfig is [ai_workspace.server.https] — the single listener, following the +// platform-wide [server.https] shape. Enabled makes the BFF terminate TLS itself, +// presenting the certificate and decrypting inbound TLS; set it false only when a +// trusted upstream (ingress, service-mesh sidecar) terminates TLS and forwards plain +// HTTP, in which case the listener serves plain HTTP on the same port and no +// certificate is read or required. CertFile/KeyFile are required when Enabled — there +// is no self-signed fallback. +type HTTPSConfig struct { + Enabled bool `koanf:"enabled"` + Port int `koanf:"port"` + CertFile string `koanf:"cert_file"` + KeyFile string `koanf:"key_file"` +} - // Claims maps which token claim names carry each user/org field. Shared by both - // auth modes: OIDC tokens from the configured IDP, and the HMAC JWTs the Platform - // API's file-based login endpoint signs. Override per IDP when the defaults don't - // match (e.g. the display name lands on "sub"). - Claims ClaimMappingConfig +// Addr is the listener address, ":" + port (e.g. ":5380"). The listener always binds +// all interfaces, so there is no host to configure. +func (c *Config) Addr() string { + return ":" + strconv.Itoa(c.Server.HTTPS.Port) +} - // Runtime config surfaced to the SPA (window.__RUNTIME_CONFIG__) - RuntimeConfig map[string]string +// LoggingConfig is [ai_workspace.logging]. Level/Format are this process's own logs; +// browser_debug is browser-only and not modeled here — it reaches the SPA through +// RuntimeConfig. Level and Format are matched case-insensitively (lowercased in Load). +type LoggingConfig struct { + Level string `koanf:"level"` // debug | info | warn | error (default "info") + Format string `koanf:"format"` // text | json (default "text") } -// ControlPlaneConfig groups everything about the upstream Platform API (control -// plane) hop: where it is, and how its TLS certificate is trusted. +// ControlPlaneConfig is [ai_workspace.control_plane]: everything about the upstream +// Platform API hop — where it is, how its TLS certificate is trusted, and the +// same-origin prefix the SPA calls. type ControlPlaneConfig struct { // URL is the base URL, e.g. https://platform-api:9243. Its http/https scheme is // the single source of truth for whether the outbound hop uses TLS — there is // deliberately no separate boolean, since that could contradict the URL. - URL string - // CAFile is a PEM bundle to trust for the upstream's TLS certificate. It is - // appended to the system roots rather than replacing them, so public CAs keep - // working; leaving it empty simply uses the OS trust store on its own. Set it to - // accept a private/self-signed Platform API cert with verification still on. - // Ignored when TLSSkipVerify is true. - CAFile string - // TLSSkipVerify disables upstream certificate verification entirely. - // Last-resort escape hatch for dev/demo only; prefer CAFile. - TLSSkipVerify bool - // PortalBasePath is the Platform API's portal route prefix (e.g. - // /api/portal/v0.9), used to build paths for BFF-initiated calls to the - // Platform API — file-based login today, others (e.g. /auth/me) later. - PortalBasePath string + URL string `koanf:"url"` + // CAFile is a PEM bundle to trust for the upstream's TLS certificate, appended to + // the system roots rather than replacing them. Ignored when TLSSkipVerify is true. + CAFile string `koanf:"ca_file"` + // TLSSkipVerify disables upstream certificate verification entirely. Last-resort + // escape hatch for dev/demo only; prefer CAFile. + TLSSkipVerify bool `koanf:"tls_skip_verify"` + // PortalBasePath is the Platform API's portal route prefix (e.g. /api/portal/v0.9), + // used to build paths for BFF-initiated calls (file-based login today). + PortalBasePath string `koanf:"portal_base_path"` + // ProxyPrefix is the same-origin reverse-proxy prefix the SPA calls; it is stripped + // before forwarding upstream, so the browser only ever talks to the app origin. + ProxyPrefix string `koanf:"proxy_prefix"` } -// TLSConfig controls whether the BFF listener serves HTTPS directly or sits -// behind a component that terminates TLS on its behalf. -type TLSConfig struct { - // TerminateTLS makes the BFF serve HTTPS on its own listener: it presents the - // certificate and decrypts inbound TLS itself. Defaults to true (config key - // [server.https] enabled). Set to false only when a trusted upstream (ingress, - // service-mesh sidecar) terminates TLS and forwards plain HTTP to the BFF; no - // certificate is then read, generated, or required. - TerminateTLS bool - CertFile string - KeyFile string +// SessionConfig is [ai_workspace.session]: server-side session lifetime. +type SessionConfig struct { + Store string `koanf:"store"` // "memory" (default) | "redis" (future) + IdleTimeout time.Duration `koanf:"idle_timeout"` // sliding idle window + AbsoluteTTL time.Duration `koanf:"absolute_ttl"` // hard cap regardless of activity / token exp } -// SessionConfig controls server-side session lifetime. -type SessionConfig struct { - Store string // "memory" (default) | "redis" (future) - IdleTimeout time.Duration // sliding idle window - AbsoluteTTL time.Duration // hard cap regardless of activity / token exp +// AuthConfig is [ai_workspace.auth]: the login mode and the claim/OIDC settings. +type AuthConfig struct { + Mode string `koanf:"mode"` // "basic" | "oidc" — informs the SPA which login UX to show + OIDC OIDCConfig `koanf:"oidc"` + ClaimMappings ClaimMappingConfig `koanf:"claim_mappings"` +} + +// OIDCConfig is [ai_workspace.auth.oidc]: the confidential-client settings. The client +// secret lives only here on the BFF and is never emitted to the browser. Enabled is +// both a config key and derived — Load ORs it with (auth.mode == "oidc"). +type OIDCConfig struct { + Enabled bool `koanf:"enabled"` + Issuer string `koanf:"authority"` // discovery base; {issuer}/.well-known/openid-configuration + ClientID string `koanf:"client_id"` + ClientSecret string `koanf:"client_secret"` + RedirectURL string `koanf:"redirect_url"` // must equal the IDP-registered redirect, points at /api/auth/callback + PostLogoutRedirectURL string `koanf:"post_logout_redirect_url"` + Scopes string `koanf:"scope"` // space-separated +} + +// ClaimMappingConfig is [ai_workspace.auth.claim_mappings]: which claim names the BFF +// reads for each user/org field. It mirrors the Platform API's [auth.claim_mappings] +// key for key, and the two must agree. It is a sibling of [auth.oidc], not nested +// inside it, because it applies to BOTH auth modes — OIDC tokens from the configured +// IDP, and the HMAC JWTs the Platform API's file-based login endpoint signs with these +// same mapped claim names. The same keys drive the BFF's session mapping and the SPA's +// runtime config, so one config entry keeps both layers in sync. +type ClaimMappingConfig struct { + Username string `koanf:"username"` + Email string `koanf:"email"` + Roles string `koanf:"roles"` + Scope string `koanf:"scope"` + OrgID string `koanf:"organization"` + OrgName string `koanf:"org_name"` + OrgHandle string `koanf:"org_handle"` } // CookieConfig controls the session cookie attributes. Not user-configurable: these @@ -134,32 +176,6 @@ const cookieName = "_ai_workspace_session" // src/config.env.ts CSRF_HEADER and must be kept in sync with this value. const CSRFHeaderName = "X-Requested-By" -// OIDCConfig holds the confidential-client settings. The client secret lives -// only here on the BFF and is never emitted to the browser. -type OIDCConfig struct { - Enabled bool - Issuer string // discovery base; {issuer}/.well-known/openid-configuration - ClientID string - ClientSecret string - RedirectURL string // must equal the IDP-registered redirect, points at /api/auth/callback - PostLogoutRedirectURL string - Scopes string // space-separated -} - -// ClaimMappingConfig configures which claim names the BFF reads for each -// user/org field. Empty fields fall back to built-in defaults in the session -// package. Shared by OIDC and file-based (basic) tokens — both are read through -// the same mapping so a custom claim name only needs to be set once. -type ClaimMappingConfig struct { - Username string - Email string - Roles string - Scope string - OrgID string - OrgName string - OrgHandle string -} - // defaultOIDCScopes is the full set of scopes the BFF requests in OIDC mode so a // logged-in user's access token carries every ap:* permission the Platform API // authorizes against. The IDP must still have these scopes registered and granted @@ -212,173 +228,131 @@ const defaultOIDCScopes = "openid profile email offline_access" + const DefaultConfigFile = "/etc/ai-workspace/config.toml" // Load resolves configuration from the config.toml at path, or from the mounted -// DefaultConfigFile when path is empty. The file's {{ env }} / {{ file }} tokens are -// expanded first, so any key — the OIDC client secret in particular — can be pulled -// from an environment variable or a mounted secret file instead of being written in -// the clear. A key not present in the file falls back to the default below. +// DefaultConfigFile when path is empty. It loads defaults, overlays the file (with its +// {{ env }} / {{ file }} tokens expanded), normalizes derived fields, then validates — +// so any key, the OIDC client secret in particular, can be pulled from an environment +// variable or a mounted secret file instead of being written in the clear, and a key +// not present in the file falls back to its default. func Load(path string) (*Config, error) { if path == "" { path = DefaultConfigFile } - s, err := loadSettings(path) + k, err := loadConfigKoanf(path) if err != nil { return nil, err } - authMode := strings.ToLower(s.get("auth.mode", "basic")) - // A typo'd mode must not silently degrade to basic auth: any value other than - // "oidc" would leave OIDC.Enabled false and hand the SPA an unknown login UX. - if authMode != "basic" && authMode != "oidc" { - return nil, fmt.Errorf("invalid [auth] mode %q: must be \"basic\" or \"oidc\"", authMode) + // Defaults first, then overlay the file. WeaklyTypedInput lets a {{ env }} token's + // string value decode into the typed field (e.g. "5380" -> int, "true" -> bool); + // a value that cannot be coerced (e.g. enabled = "maybe") fails startup here rather + // than being silently dropped. + cfg := defaultConfig() + if err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{ + DecoderConfig: &mapstructure.DecoderConfig{ + TagName: "koanf", + WeaklyTypedInput: true, + Result: cfg, + DecodeHook: mapstructure.StringToTimeDurationHookFunc(), + }, + }); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) } - // Parse typed values up front so a malformed one fails startup instead of - // being silently replaced with the default. - tlsEnabled, err := s.getbool("server.https.enabled", true) - if err != nil { - return nil, err - } - controlPlaneTLSSkipVerify, err := s.getbool("control_plane.tls_skip_verify", false) - if err != nil { + cfg.normalize() + if err := cfg.validate(); err != nil { return nil, err } - idleTimeout, err := s.getdur("session.idle_timeout", 30*time.Minute) - if err != nil { - return nil, err - } - absoluteTTL, err := s.getdur("session.absolute_ttl", 8*time.Hour) - if err != nil { - return nil, err + + cfg.RuntimeConfig = buildRuntimeConfig(cfg, k) + return cfg, nil +} + +// normalize resolves the derived fields that are not a straight copy of a config key: +// case-folding (level/format/mode), trimming trailing slashes off URLs/prefixes, the +// oidc-mode-implies-enabled rule, and the fixed cookie attributes. +func (c *Config) normalize() { + c.Logging.Level = strings.ToLower(c.Logging.Level) + c.Logging.Format = strings.ToLower(c.Logging.Format) + c.Auth.Mode = strings.ToLower(c.Auth.Mode) + + c.ControlPlane.URL = strings.TrimRight(c.ControlPlane.URL, "/") + c.ControlPlane.PortalBasePath = strings.TrimRight(c.ControlPlane.PortalBasePath, "/") + c.ControlPlane.ProxyPrefix = strings.TrimRight(c.ControlPlane.ProxyPrefix, "/") + c.Auth.OIDC.Issuer = strings.TrimRight(c.Auth.OIDC.Issuer, "/") + + // oidc mode implies the client is enabled even if the explicit flag is unset, so a + // typo'd mode cannot silently degrade to basic auth. + c.Auth.OIDC.Enabled = c.Auth.OIDC.Enabled || c.Auth.Mode == "oidc" + + c.Cookie = CookieConfig{Name: cookieName, Secure: true, SameSite: "lax"} +} + +// validate fails startup on any value that would otherwise surface as a confusing +// runtime error (a bad port, an empty upstream URL, an incomplete OIDC set) and warns +// on security-relevant downgrades. +func (c *Config) validate() error { + // A typo'd mode must not silently degrade to basic auth: any value other than + // "oidc" would leave OIDC.Enabled false and hand the SPA an unknown login UX. + if c.Auth.Mode != "basic" && c.Auth.Mode != "oidc" { + return fmt.Errorf("invalid [auth] mode %q: must be \"basic\" or \"oidc\"", c.Auth.Mode) } - oidcEnabled, err := s.getbool("auth.oidc.enabled", false) - if err != nil { - return nil, err + if c.Server.HTTPS.Port < 1 || c.Server.HTTPS.Port > 65535 { + return fmt.Errorf("[server.https] port must be between 1 and 65535, got %d", c.Server.HTTPS.Port) } - // A bare port number is easier to get right than a full "host:port" address (there - // is never a host to fill in — the listener always binds all interfaces), and it - // matches the Platform API's [server.https] port convention. Parsed as an int so a - // typo'd value fails startup instead of a confusing net.Listen error. - portNum, err := s.getint("server.https.port", 5380, 1, 65535) - if err != nil { - return nil, err + // Every session duration is a lifetime, where <= 0 is never meaningful. + if c.Session.IdleTimeout <= 0 { + return fmt.Errorf("[session] idle_timeout must be positive, got %s", c.Session.IdleTimeout) } - - cfg := &Config{ - Addr: ":" + strconv.Itoa(portNum), - StaticDir: s.get("server.static_dir", "/app"), - LogLevel: strings.ToLower(s.get("logging.level", "info")), - LogFormat: strings.ToLower(s.get("logging.format", "text")), - TLS: TLSConfig{ - TerminateTLS: tlsEnabled, - // Convention matches the container's mount path. A certificate pair is - // required there whenever TerminateTLS is on. - CertFile: s.get("server.https.cert_file", "/etc/ai-workspace/tls/cert.pem"), - KeyFile: s.get("server.https.key_file", "/etc/ai-workspace/tls/key.pem"), - }, - ControlPlane: ControlPlaneConfig{ - URL: strings.TrimRight(s.get("control_plane.url", ""), "/"), - CAFile: s.get("control_plane.ca_file", ""), - TLSSkipVerify: controlPlaneTLSSkipVerify, - PortalBasePath: strings.TrimRight(s.get("control_plane.portal_base_path", "/api/portal/v0.9"), "/"), - }, - ProxyPrefix: strings.TrimRight(s.get("control_plane.proxy_prefix", "/proxy"), "/"), - Session: SessionConfig{ - Store: s.get("session.store", "memory"), - IdleTimeout: idleTimeout, - AbsoluteTTL: absoluteTTL, - }, - Cookie: CookieConfig{ - Name: cookieName, - Secure: true, - SameSite: "lax", - }, - AuthMode: authMode, - OIDC: OIDCConfig{ - Enabled: authMode == "oidc" || oidcEnabled, - Issuer: strings.TrimRight(s.get("auth.oidc.authority", ""), "/"), - ClientID: s.get("auth.oidc.client_id", ""), - // Never write the secret as a literal. Point the key at an environment - // variable with '{{ env "APIP_AIW_AUTH_OIDC_CLIENT_SECRET" }}', or — - // preferably — at a mounted file with - // '{{ file "/secrets/ai-workspace/oidc_client_secret" }}'. - ClientSecret: s.get("auth.oidc.client_secret", ""), - RedirectURL: s.get("auth.oidc.redirect_url", ""), - // Empty by default: LogoutURL() forwards this as post_logout_redirect_uri, - // which IDPs require to be an absolute, pre-registered URL. A relative - // default would produce an invalid logout request, so leave it unset - // unless an absolute URL is explicitly configured. - PostLogoutRedirectURL: s.get("auth.oidc.post_logout_redirect_url", ""), - Scopes: s.get("auth.oidc.scope", defaultOIDCScopes), - }, - // [auth.claim_mappings] deliberately mirrors the Platform API's - // [auth.claim_mappings], key for key: both describe the same claim names, and - // they must agree. It is a sibling of [auth.oidc], not nested inside it, - // because it applies to BOTH auth modes — OIDC tokens from the configured IDP, - // and the HMAC JWTs the Platform API's file-based login endpoint signs with - // these same mapped claim names (see platform-api's auth_login.go). Defaults - // below match the Platform API's own claim_mappings defaults so the two agree - // out of the box; override on both sides together when an IDP uses different - // claim names (e.g. Asgardeo's "org_id"). - // - // The same keys drive the BFF's session mapping and the SPA's runtime config, - // so one config entry keeps both layers in sync. - Claims: ClaimMappingConfig{ - Username: s.get("auth.claim_mappings.username", "username"), - Email: s.get("auth.claim_mappings.email", "email"), - Roles: s.get("auth.claim_mappings.roles", "roles"), - Scope: s.get("auth.claim_mappings.scope", "scope"), - OrgID: s.get("auth.claim_mappings.organization", "organization"), - OrgName: s.get("auth.claim_mappings.org_name", "org_name"), - OrgHandle: s.get("auth.claim_mappings.org_handle", "org_handle"), - }, + if c.Session.AbsoluteTTL <= 0 { + return fmt.Errorf("[session] absolute_ttl must be positive, got %s", c.Session.AbsoluteTTL) } - if cfg.ControlPlane.URL == "" { - return nil, fmt.Errorf("[control_plane] url is required: set it in config.toml, " + + if c.ControlPlane.URL == "" { + return fmt.Errorf("[control_plane] url is required: set it in config.toml, " + "either as a literal or via an {{ env }} / {{ file }} token") } // The scheme is the single source of truth for the outbound TLS decision, so a - // missing/typo'd scheme must fail at startup rather than surface as an opaque - // dial error on the first proxied request. - u, err := url.Parse(cfg.ControlPlane.URL) + // missing/typo'd scheme must fail at startup rather than surface as an opaque dial + // error on the first proxied request. + u, err := url.Parse(c.ControlPlane.URL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { - return nil, fmt.Errorf("[control_plane] url must be an absolute http:// or https:// URL, got %q", cfg.ControlPlane.URL) + return fmt.Errorf("[control_plane] url must be an absolute http:// or https:// URL, got %q", c.ControlPlane.URL) } // Trust knobs only apply to an https upstream; flag them on a plain-http URL so a // mistaken belief that TLS is in effect is caught early. if u.Scheme == "http" { - if cfg.ControlPlane.CAFile != "" || cfg.ControlPlane.TLSSkipVerify { - return nil, fmt.Errorf("[control_plane] ca_file / tls_skip_verify are set but [control_plane] url is http:// (no TLS on the upstream hop)") + if c.ControlPlane.CAFile != "" || c.ControlPlane.TLSSkipVerify { + return fmt.Errorf("[control_plane] ca_file / tls_skip_verify are set but [control_plane] url is http:// (no TLS on the upstream hop)") } } - // Skipping verification is a security downgrade; say so loudly and point at - // the supported alternative. - if u.Scheme == "https" && cfg.ControlPlane.TLSSkipVerify { + // Skipping verification is a security downgrade; say so loudly and point at the + // supported alternative. + if u.Scheme == "https" && c.ControlPlane.TLSSkipVerify { slog.Warn("[control_plane] tls_skip_verify = true — upstream certificate verification is DISABLED. " + "Trust the upstream certificate with [control_plane] ca_file instead.") } - if cfg.OIDC.Enabled { - if cfg.OIDC.Issuer == "" || cfg.OIDC.ClientID == "" || cfg.OIDC.ClientSecret == "" || cfg.OIDC.RedirectURL == "" { - return nil, fmt.Errorf("OIDC mode requires [auth.oidc] authority, client_id, client_secret and redirect_url") + + if c.Auth.OIDC.Enabled { + if c.Auth.OIDC.Issuer == "" || c.Auth.OIDC.ClientID == "" || c.Auth.OIDC.ClientSecret == "" || c.Auth.OIDC.RedirectURL == "" { + return fmt.Errorf("OIDC mode requires [auth.oidc] authority, client_id, client_secret and redirect_url") } } // Empty is fine (the key is optional), but a relative value would be forwarded as an // invalid post_logout_redirect_uri and only fail at logout time — catch it here. - if cfg.OIDC.PostLogoutRedirectURL != "" { - u, err := url.Parse(cfg.OIDC.PostLogoutRedirectURL) + if c.Auth.OIDC.PostLogoutRedirectURL != "" { + u, err := url.Parse(c.Auth.OIDC.PostLogoutRedirectURL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { - return nil, fmt.Errorf("[auth.oidc] post_logout_redirect_url must be an absolute http:// or https:// URL, got %q", - cfg.OIDC.PostLogoutRedirectURL) + return fmt.Errorf("[auth.oidc] post_logout_redirect_url must be an absolute http:// or https:// URL, got %q", + c.Auth.OIDC.PostLogoutRedirectURL) } } // Basic (file-based) auth is supported for quickstart deployments but is not // recommended for production; point operators at OIDC. - if !cfg.OIDC.Enabled { + if !c.Auth.OIDC.Enabled { slog.Warn("basic (file-based) auth is enabled — this is not recommended for production; " + "configure OIDC (set [auth] mode = \"oidc\" and [auth.oidc] authority, client_id, client_secret, redirect_url)") } - cfg.RuntimeConfig = buildRuntimeConfig(cfg, s) - return cfg, nil + return nil } diff --git a/portals/ai-workspace/bff/internal/config/config_test.go b/portals/ai-workspace/bff/internal/config/config_test.go index afcdc1768..f4b4b5220 100644 --- a/portals/ai-workspace/bff/internal/config/config_test.go +++ b/portals/ai-workspace/bff/internal/config/config_test.go @@ -51,8 +51,8 @@ url = "https://platform-api:9243" if cfg.ControlPlane.URL != "https://platform-api:9243" { t.Errorf("ControlPlane.URL = %q, want the config.toml value", cfg.ControlPlane.URL) } - if cfg.LogLevel != "warn" { - t.Errorf("LogLevel = %q, want %q", cfg.LogLevel, "warn") + if cfg.Logging.Level != "warn" { + t.Errorf("LogLevel = %q, want %q", cfg.Logging.Level, "warn") } } @@ -74,11 +74,11 @@ url = "https://platform-api:9243" if err != nil { t.Fatalf("Load() error = %v", err) } - if cfg.LogLevel != "debug" { - t.Errorf("LogLevel = %q, want %q (the token's variable is set)", cfg.LogLevel, "debug") + if cfg.Logging.Level != "debug" { + t.Errorf("LogLevel = %q, want %q (the token's variable is set)", cfg.Logging.Level, "debug") } - if cfg.LogFormat != "text" { - t.Errorf("LogFormat = %q, want the token default %q", cfg.LogFormat, "text") + if cfg.Logging.Format != "text" { + t.Errorf("LogFormat = %q, want the token default %q", cfg.Logging.Format, "text") } } @@ -99,9 +99,9 @@ url = "https://platform-api:9243" if err != nil { t.Fatalf("Load() error = %v", err) } - if cfg.LogLevel != "warn" { + if cfg.Logging.Level != "warn" { t.Errorf("LogLevel = %q, want the config.toml literal %q — an env var must not override a key with no token", - cfg.LogLevel, "warn") + cfg.Logging.Level, "warn") } } @@ -127,8 +127,8 @@ redirect_url = "https://localhost:5380/api/auth/callback" if err != nil { t.Fatalf("Load() error = %v", err) } - if cfg.OIDC.ClientSecret != "s3cr3t" { - t.Errorf("OIDC.ClientSecret = %q, want the value resolved from the env token", cfg.OIDC.ClientSecret) + if cfg.Auth.OIDC.ClientSecret != "s3cr3t" { + t.Errorf("OIDC.ClientSecret = %q, want the value resolved from the env token", cfg.Auth.OIDC.ClientSecret) } } @@ -159,8 +159,8 @@ redirect_url = "https://localhost:5380/api/auth/callback" t.Fatalf("Load() error = %v", err) } // The trailing newline every secret file ends with must be trimmed. - if cfg.OIDC.ClientSecret != "from-file" { - t.Errorf("OIDC.ClientSecret = %q, want %q", cfg.OIDC.ClientSecret, "from-file") + if cfg.Auth.OIDC.ClientSecret != "from-file" { + t.Errorf("OIDC.ClientSecret = %q, want %q", cfg.Auth.OIDC.ClientSecret, "from-file") } } @@ -320,8 +320,8 @@ absolute_ttl = "2h" if err != nil { t.Fatalf("Load() error = %v", err) } - if cfg.TLS.TerminateTLS { - t.Error("TLS.TerminateTLS = true, want false from the bare TOML boolean") + if cfg.Server.HTTPS.Enabled { + t.Error("Server.HTTPS.Enabled = true, want false from the bare TOML boolean") } if cfg.Session.AbsoluteTTL != 2*time.Hour { t.Errorf("Session.AbsoluteTTL = %s, want 2h", cfg.Session.AbsoluteTTL) @@ -353,10 +353,10 @@ redirect_url = "https://localhost:5380/api/auth/callback" if err != nil { t.Fatalf("Load() error = %v", err) } - if cfg.TLS.TerminateTLS { - t.Error("TLS.TerminateTLS = true, want false — [server.https] enabled must not read [auth.oidc] enabled") + if cfg.Server.HTTPS.Enabled { + t.Error("Server.HTTPS.Enabled = true, want false — [server.https] enabled must not read [auth.oidc] enabled") } - if !cfg.OIDC.Enabled { + if !cfg.Auth.OIDC.Enabled { t.Error("OIDC.Enabled = false, want true") } } @@ -382,11 +382,11 @@ roles = "roles" if err != nil { t.Fatalf("Load() error = %v", err) } - if cfg.Claims.OrgID != "org_uuid" { - t.Errorf("Claims.OrgID = %q, want %q from organization", cfg.Claims.OrgID, "org_uuid") + if cfg.Auth.ClaimMappings.OrgID != "org_uuid" { + t.Errorf("Claims.OrgID = %q, want %q from organization", cfg.Auth.ClaimMappings.OrgID, "org_uuid") } - if cfg.Claims.Roles != "roles" { - t.Errorf("Claims.Roles = %q, want %q from roles", cfg.Claims.Roles, "roles") + if cfg.Auth.ClaimMappings.Roles != "roles" { + t.Errorf("Claims.Roles = %q, want %q from roles", cfg.Auth.ClaimMappings.Roles, "roles") } if got := cfg.RuntimeConfig["APIP_AIW_AUTH_CLAIM_MAPPINGS_ORGANIZATION"]; got != "org_uuid" { t.Errorf("runtime APIP_AIW_AUTH_CLAIM_MAPPINGS_ORGANIZATION = %q, want %q", got, "org_uuid") diff --git a/portals/ai-workspace/bff/internal/config/default_config.go b/portals/ai-workspace/bff/internal/config/default_config.go new file mode 100644 index 000000000..9ba2bcaf0 --- /dev/null +++ b/portals/ai-workspace/bff/internal/config/default_config.go @@ -0,0 +1,70 @@ +/* + * 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 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import "time" + +// defaultConfig returns a Config with all built-in defaults. Load overlays the parsed +// config.toml on top of it, so any key absent from the file keeps the value here. The +// required keys (control_plane.url) have no meaningful default and are enforced by +// Config.validate instead. Cookie and RuntimeConfig are populated by Load, not here. +func defaultConfig() *Config { + return &Config{ + Server: ServerConfig{ + StaticDir: "/app", + HTTPS: HTTPSConfig{ + Enabled: true, + Port: 5380, + // Convention matches the container's mount path. A certificate pair is + // required there whenever the listener terminates TLS. + CertFile: "/etc/ai-workspace/tls/cert.pem", + KeyFile: "/etc/ai-workspace/tls/key.pem", + }, + }, + Logging: LoggingConfig{ + Level: "info", + Format: "text", + }, + ControlPlane: ControlPlaneConfig{ + PortalBasePath: "/api/portal/v0.9", + ProxyPrefix: "/proxy", + }, + Session: SessionConfig{ + Store: "memory", + IdleTimeout: 30 * time.Minute, + AbsoluteTTL: 8 * time.Hour, + }, + Auth: AuthConfig{ + Mode: "basic", + OIDC: OIDCConfig{ + Scopes: defaultOIDCScopes, + }, + // Defaults mirror the Platform API's own claim_mappings defaults so the two + // agree out of the box; override on both sides together when an IDP uses + // different claim names (e.g. Asgardeo's "org_id"). + ClaimMappings: ClaimMappingConfig{ + Username: "username", + Email: "email", + Roles: "roles", + Scope: "scope", + OrgID: "organization", + OrgName: "org_name", + OrgHandle: "org_handle", + }, + }, + } +} diff --git a/portals/ai-workspace/bff/internal/config/runtime_config.go b/portals/ai-workspace/bff/internal/config/runtime_config.go index b615fd2af..ca8808de4 100644 --- a/portals/ai-workspace/bff/internal/config/runtime_config.go +++ b/portals/ai-workspace/bff/internal/config/runtime_config.go @@ -16,7 +16,12 @@ package config -import "strings" +import ( + "fmt" + "strings" + + "github.com/knadh/koanf/v2" +) // browserSafeKeys is the allowlist of config keys the SPA may read from // window.__RUNTIME_CONFIG__. It is an allowlist, not a filter: only these keys ever @@ -27,7 +32,7 @@ import "strings" // performs the whole OIDC handshake, so the SPA needs no client identity. var browserSafeKeys = []string{ // Identity of the deployment. auth.mode is not listed: buildRuntimeConfig - // always emits it from the parsed cfg.AuthMode instead. + // always emits it from the parsed cfg.Auth.Mode instead. "domain", "default_org_region", "gateway.controlplane_host", @@ -65,18 +70,29 @@ func runtimeKey(configKey string) string { // buildRuntimeConfig collects the browser-safe values the SPA reads from // window.__RUNTIME_CONFIG__, then forces the API base URLs onto the same-origin // proxy prefix so the browser only ever talks to the BFF. -func buildRuntimeConfig(cfg *Config, s settings) map[string]string { +// +// Values are read straight from the parsed config (k, rooted at [ai_workspace]), not +// from the resolved Config struct, so only keys actually present in config.toml are +// surfaced — a code default is used internally by the BFF but never pushed to the +// browser. Most browser-safe keys (domain, gateway.*, moesif_*, dev_portal_base_url, +// ...) are browser-only and not modeled on Config at all; this is where they flow out. +func buildRuntimeConfig(cfg *Config, k *koanf.Koanf) map[string]string { out := make(map[string]string, len(browserSafeKeys)+3) for _, key := range browserSafeKeys { - if v, ok := s[key]; ok && v != "" { + if !k.Exists(key) { + continue + } + // Stringify so a value written bare (a bool/int, e.g. logging.browser_debug) + // reaches the SPA the same as a quoted one. + if v := fmt.Sprint(k.Get(key)); v != "" { out[runtimeKey(key)] = v } } // Force the SPA's API base URLs through the BFF same-origin proxy. - out[runtimeKey("platform_api_base_url")] = cfg.ProxyPrefix + "/api/v0.9" - out[runtimeKey("portal_api_base_url")] = cfg.ProxyPrefix + "/api/portal/v0.9" - out[runtimeKey("auth_mode")] = cfg.AuthMode + out[runtimeKey("platform_api_base_url")] = cfg.ControlPlane.ProxyPrefix + "/api/v0.9" + out[runtimeKey("portal_api_base_url")] = cfg.ControlPlane.ProxyPrefix + "/api/portal/v0.9" + out[runtimeKey("auth_mode")] = cfg.Auth.Mode return out } diff --git a/portals/ai-workspace/bff/internal/config/settings.go b/portals/ai-workspace/bff/internal/config/settings.go index 8c7e7129a..8b3632b9a 100644 --- a/portals/ai-workspace/bff/internal/config/settings.go +++ b/portals/ai-workspace/bff/internal/config/settings.go @@ -20,10 +20,11 @@ import ( "fmt" "log/slog" "os" - "strconv" - "strings" - "time" + tomlparser "github.com/knadh/koanf/parsers/toml/v2" + "github.com/knadh/koanf/providers/confmap" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/v2" "github.com/wso2/api-platform/common/configinterpolate" ) @@ -44,9 +45,8 @@ const EnvPrefix = "APIP_AIW_" // under (e.g. [ai_workspace], [ai_workspace.control_plane]). It mirrors the Platform // API's platformAPIConfigKey: this namespacing lets an AI Workspace config file // coexist with sibling services' sections ([platform_api], ...) in a shared -// deployment config, the same file convention as the Platform API's [platform_api] -// table. Every key below this cut (logging.level, control_plane.url, auth.oidc.*, ...) is -// resolved relative to [ai_workspace], not the file root. +// deployment config. loadConfigKoanf cuts to this table, so every key is resolved +// relative to [ai_workspace] and sibling tables are ignored. const aiWorkspaceConfigKey = "ai_workspace" // defaultFileSourceAllowlist is the AI Workspace's default set of directories a @@ -57,36 +57,34 @@ var defaultFileSourceAllowlist = []string{ "/secrets/ai-workspace", } -// settings is the fully-resolved configuration: the config.toml values under -// [ai_workspace], with every {{ env }} / {{ file }} token expanded and flattened to -// dotted paths relative to that table. A key is its path under [ai_workspace] joined -// with dots — [ai_workspace.control_plane] url becomes "control_plane.url" — and a key -// directly under [ai_workspace] keeps its bare name ("domain"). Sibling top-level -// tables belonging to other services (e.g. [platform_api]) are ignored. -type settings map[string]string - -// loadSettings reads config.toml and expands its interpolation tokens. config.toml is -// the only source of configuration: there is no implicit environment overlay, so a -// value comes from the environment exactly when the key's token asks for it, e.g. -// -// level = '{{ env "APIP_AIW_LOGGING_LEVEL" "info" }}' +// loadConfigKoanf reads config.toml, expands its {{ env }} / {{ file }} interpolation +// tokens, and returns a koanf instance rooted at the [ai_workspace] subtree — the same +// koanf-based loading stack the Gateway and Platform API use. // -// One mechanism therefore covers both ordinary settings and secrets, and every source -// a value can come from is visible in the file itself rather than implied by a naming -// rule. Interpolation fails closed: an unset {{ env }} variable with no default, or an +// config.toml is the only source of configuration: there is no implicit environment +// overlay, so a value comes from the environment exactly when the key's token asks for +// it. Interpolation fails closed — an unset {{ env }} variable with no default, or an // unreadable/disallowed {{ file }} path, aborts startup rather than silently yielding // an empty credential. // -// A missing config.toml is not an error — the built-in defaults still apply — but the -// required keys (control_plane.url) then have no value, so Load fails on them. -func loadSettings(tomlPath string) (settings, error) { - raw, err := parseTOML(tomlPath) - if err != nil { - return nil, err +// A missing config.toml is not an error: the returned instance is empty, so every key +// falls back to defaultConfig() and Load fails only on the required ones. +func loadConfigKoanf(tomlPath string) (*koanf.Koanf, error) { + k := koanf.New(".") + + // Stat first so a missing file stays a no-op (defaults apply) rather than a koanf + // load error; anything else (e.g. a permission problem) is surfaced. + if _, statErr := os.Stat(tomlPath); statErr == nil { + if err := k.Load(file.Provider(tomlPath), tomlparser.Parser()); err != nil { + return nil, fmt.Errorf("failed to parse config file %q: %w", tomlPath, err) + } + } else if !os.IsNotExist(statErr) { + return nil, fmt.Errorf("failed to read config file %q: %w", tomlPath, statErr) } - // Expand walks the whole tree, so a token works at any depth. - expanded, stats, err := configinterpolate.Expand(raw, configinterpolate.Options{ + // Expand tokens across the whole tree, so a token works at any depth. Shared with + // the Platform API via configinterpolate, operating on koanf's raw nested map. + expanded, stats, err := configinterpolate.Expand(k.Raw(), configinterpolate.Options{ FileAllowlist: configinterpolate.ResolveAllowlist(defaultFileSourceAllowlist), }) if err != nil { @@ -100,116 +98,11 @@ func loadSettings(tomlPath string) (settings, error) { slog.Int("fields", stats.Fields)) } - s := settings{} - flatten(s, "", cut(expanded, aiWorkspaceConfigKey)) - return s, nil -} - -// cut returns the subtree of tree rooted at key, or an empty tree when key is -// absent (a missing [ai_workspace] table simply leaves every key on its default, -// same as a missing config file). A key present but not a table is also treated as -// absent — flatten only ever descends into map[string]any nodes. -func cut(tree map[string]any, key string) map[string]any { - if sub, ok := tree[key].(map[string]any); ok { - return sub - } - return map[string]any{} -} - -// parseTOML decodes the config file with the stdlib subset parser (see toml.go). -// A missing file yields an empty tree rather than an error, leaving every key on -// its default — Load then fails on the required ones. -func parseTOML(path string) (map[string]any, error) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return map[string]any{}, nil - } - return nil, fmt.Errorf("failed to read config file %q: %w", path, err) - } - - raw, err := parseTOMLSubset(data) - if err != nil { - return nil, fmt.Errorf("failed to parse config file %q: %w", path, err) - } - return raw, nil -} - -// flatten collapses the decoded tree into dotted keys ([auth.oidc] client_id -> -// "auth.oidc.client_id"), stringifying scalars so a value may be written either quoted or -// bare — tls.enabled = true and tls.enabled = "true" both reach getbool as "true". -// Arrays have no config key and are rejected by the parser before reaching here. -func flatten(dst settings, prefix string, tree map[string]any) { - for k, v := range tree { - key := strings.ToLower(k) - if prefix != "" { - key = prefix + "." + key - } - switch val := v.(type) { - case map[string]any: - flatten(dst, key, val) - case string: - dst[key] = val - case bool, int64, float64: - dst[key] = fmt.Sprint(val) - } - } -} - -// get returns the value for key, or def when it is unset or empty. -func (s settings) get(key, def string) string { - if v, ok := s[key]; ok && v != "" { - return v - } - return def -} - -// getbool parses key as a boolean. A malformed value fails startup rather than -// being silently replaced by the default. -func (s settings) getbool(key string, def bool) (bool, error) { - v, ok := s[key] - if !ok || v == "" { - return def, nil - } - b, err := strconv.ParseBool(strings.TrimSpace(v)) - if err != nil { - return false, fmt.Errorf("invalid boolean for %s=%q: %w", key, v, err) - } - return b, nil -} - -// getint parses key as an integer within [min, max]. A missing or empty value -// returns def; a malformed or out-of-range value fails startup rather than being -// silently replaced by the default. -func (s settings) getint(key string, def, min, max int) (int, error) { - v, ok := s[key] - if !ok || v == "" { - return def, nil - } - n, err := strconv.Atoi(strings.TrimSpace(v)) - if err != nil { - return 0, fmt.Errorf("invalid integer for %s=%q: %w", key, v, err) - } - if n < min || n > max { - return 0, fmt.Errorf("invalid integer for %s=%q: must be between %d and %d", key, v, min, max) - } - return n, nil -} - -// getdur parses key as a Go duration. A malformed, zero, or negative value fails -// startup — every duration setting is a lifetime or timeout, where <= 0 is never -// meaningful. -func (s settings) getdur(key string, def time.Duration) (time.Duration, error) { - v, ok := s[key] - if !ok || v == "" { - return def, nil - } - d, err := time.ParseDuration(strings.TrimSpace(v)) - if err != nil { - return 0, fmt.Errorf("invalid duration for %s=%q: %w", key, v, err) - } - if d <= 0 { - return 0, fmt.Errorf("invalid duration for %s=%q: must be positive", key, v) + // Reload the expanded map into a fresh instance so no un-interpolated leaf survives, + // then cut to the [ai_workspace] subtree. + out := koanf.New(".") + if err := out.Load(confmap.Provider(expanded, "."), nil); err != nil { + return nil, fmt.Errorf("failed to reload interpolated config: %w", err) } - return d, nil + return out.Cut(aiWorkspaceConfigKey), nil } 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 e8285b6a9..caa29d322 100644 --- a/portals/ai-workspace/bff/internal/config/shipped_config_test.go +++ b/portals/ai-workspace/bff/internal/config/shipped_config_test.go @@ -38,21 +38,21 @@ func TestShippedConfig_QuickstartLoadsWithNoEnv(t *testing.T) { t.Fatalf("Load(configs/config.toml) error = %v — the shipped quickstart config must load with an empty environment", err) } - if cfg.AuthMode != "basic" { - t.Errorf("AuthMode = %q, want %q", cfg.AuthMode, "basic") + if cfg.Auth.Mode != "basic" { + t.Errorf("AuthMode = %q, want %q", cfg.Auth.Mode, "basic") } // The missing [ai_workspace.auth.oidc] table must not switch the OIDC client on, or // Load would demand an authority/client_id/client_secret the quickstart has no use for. - if cfg.OIDC.Enabled { + if cfg.Auth.OIDC.Enabled { t.Error("OIDC.Enabled = true, want false — the empty [ai_workspace.auth.oidc] defaults must leave basic mode intact") } // The defaults in the file are the container's: the port it publishes and the SPA // baked into the image. - if cfg.Addr != ":5380" { - t.Errorf("Addr = %q, want the container default %q", cfg.Addr, ":5380") + if cfg.Addr() != ":5380" { + t.Errorf("Addr = %q, want the container default %q", cfg.Addr(), ":5380") } - if cfg.StaticDir != "/app" { - t.Errorf("StaticDir = %q, want the container default %q", cfg.StaticDir, "/app") + if cfg.Server.StaticDir != "/app" { + t.Errorf("StaticDir = %q, want the container default %q", cfg.Server.StaticDir, "/app") } if cfg.ControlPlane.URL != "https://platform-api:9243" { t.Errorf("ControlPlane.URL = %q, want the compose hostname", cfg.ControlPlane.URL) @@ -89,10 +89,10 @@ func TestShippedConfig_MakeBffRunOverrides(t *testing.T) { } for _, tc := range []struct{ name, got, want string }{ - {"Addr", cfg.Addr, ":8081"}, - {"StaticDir", cfg.StaticDir, "../dist"}, + {"Addr", cfg.Addr(), ":8081"}, + {"StaticDir", cfg.Server.StaticDir, "../dist"}, {"ControlPlane.URL", cfg.ControlPlane.URL, "https://localhost:9243"}, - {"LogLevel", cfg.LogLevel, "debug"}, + {"LogLevel", cfg.Logging.Level, "debug"}, } { if tc.got != tc.want { t.Errorf("%s = %q, want %q — `make bff-run` sets this variable, so configs/config.toml must carry the matching {{ env }} token", @@ -117,11 +117,11 @@ func TestShippedConfig_OIDCFromEnvAlone(t *testing.T) { if err != nil { t.Fatalf("Load(configs/config-template.toml) error = %v — the [ai_workspace.auth.oidc] tokens must make OIDC settable from the environment", err) } - if !cfg.OIDC.Enabled { + if !cfg.Auth.OIDC.Enabled { t.Fatal("OIDC.Enabled = false, want true — [auth] mode = oidc must enable the client") } - if cfg.OIDC.ClientSecret != "s3cr3t" { - t.Errorf("OIDC.ClientSecret = %q, want the value from APIP_AIW_AUTH_OIDC_CLIENT_SECRET", cfg.OIDC.ClientSecret) + if cfg.Auth.OIDC.ClientSecret != "s3cr3t" { + t.Errorf("OIDC.ClientSecret = %q, want the value from APIP_AIW_AUTH_OIDC_CLIENT_SECRET", cfg.Auth.OIDC.ClientSecret) } // Whatever the mode, the client credentials stay server-side. for _, key := range []string{"APIP_AIW_AUTH_OIDC_CLIENT_SECRET", "APIP_AIW_AUTH_OIDC_CLIENT_ID", "APIP_AIW_AUTH_OIDC_AUTHORITY"} { @@ -141,8 +141,8 @@ func TestShippedConfig_TemplateLoads(t *testing.T) { if err != nil { t.Fatalf("Load(configs/config-template.toml) error = %v — the template must remain a loadable config", err) } - if cfg.AuthMode != "basic" { - t.Errorf("AuthMode = %q, want %q", cfg.AuthMode, "basic") + if cfg.Auth.Mode != "basic" { + t.Errorf("AuthMode = %q, want %q", cfg.Auth.Mode, "basic") } // Unlike the quickstart file, the template defaults to verifying the upstream // certificate — it is the starting point for a real deployment. diff --git a/portals/ai-workspace/bff/internal/config/toml.go b/portals/ai-workspace/bff/internal/config/toml.go deleted file mode 100644 index f27e97d3b..000000000 --- a/portals/ai-workspace/bff/internal/config/toml.go +++ /dev/null @@ -1,273 +0,0 @@ -/* - * 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 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package config - -import ( - "fmt" - "strconv" - "strings" - "unicode" - "unicode/utf8" -) - -// This file implements the TOML subset the AI Workspace config uses, on the -// standard library alone. The full grammar is deliberately not supported: the -// config format is scalars in (possibly nested) tables — see flatten — so the -// parser accepts exactly that and fails closed on everything else. Rejecting an -// unsupported construct with a line-numbered error preserves integrity: a config -// can never be silently misread, only refused. -// -// Accepted per line (after comment/whitespace handling): -// - [table] and [nested.table] headers with bare-key segments -// - key = 'literal string' (no escapes, as TOML defines) -// - key = "basic string" (TOML escape sequences) -// - key = true | false | integer | float -// -// Rejected: arrays, inline tables, arrays of tables [[x]], multi-line strings, -// dotted or quoted keys, dates/times, and duplicate keys or table redefinitions. - -// bareKeyValid reports whether s is a valid TOML bare key (A-Za-z0-9_-, non-empty). -func bareKeyValid(s string) bool { - if s == "" { - return false - } - for _, r := range s { - if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') { - return false - } - } - return true -} - -// tomlParser carries the document state: the root tree, the table the current -// header points at, and the set of explicitly declared headers and assigned keys -// (both used only for duplicate detection). -type tomlParser struct { - root map[string]any - current map[string]any - headers map[string]bool // explicitly declared [table] paths - keys map[string]bool // fully-qualified assigned keys - prefix string // dotted path of the current table ("" at root) -} - -// parseTOMLSubset parses data and returns the nested tree parseTOML used to get -// from go-toml: tables as map[string]any, scalars as string / bool / int64 / float64. -func parseTOMLSubset(data []byte) (map[string]any, error) { - p := &tomlParser{ - root: map[string]any{}, - headers: map[string]bool{}, - keys: map[string]bool{}, - } - p.current = p.root - - for n, line := range strings.Split(string(data), "\n") { - if err := p.parseLine(strings.TrimSpace(strings.TrimSuffix(line, "\r"))); err != nil { - return nil, fmt.Errorf("line %d: %w", n+1, err) - } - } - return p.root, nil -} - -func (p *tomlParser) parseLine(line string) error { - if line == "" || strings.HasPrefix(line, "#") { - return nil - } - if strings.HasPrefix(line, "[") { - return p.parseHeader(line) - } - return p.parseKeyValue(line) -} - -// parseHeader handles [table] and [a.b] lines, creating intermediate tables. -func (p *tomlParser) parseHeader(line string) error { - if strings.HasPrefix(line, "[[") { - return fmt.Errorf("arrays of tables ([[...]]) are not supported") - } - end := strings.IndexByte(line, ']') - if end < 0 { - return fmt.Errorf("unterminated table header %q", line) - } - if rest := strings.TrimSpace(line[end+1:]); rest != "" && !strings.HasPrefix(rest, "#") { - return fmt.Errorf("unexpected content after table header: %q", rest) - } - - path := strings.TrimSpace(line[1:end]) - if p.headers[path] { - return fmt.Errorf("table [%s] is defined twice", path) - } - table := p.root - for _, seg := range strings.Split(path, ".") { - seg = strings.TrimSpace(seg) - if !bareKeyValid(seg) { - return fmt.Errorf("invalid table name segment %q in [%s]", seg, path) - } - switch child := table[seg].(type) { - case nil: - next := map[string]any{} - table[seg] = next - table = next - case map[string]any: - table = child - default: - return fmt.Errorf("[%s] conflicts with key %q which already holds a value", path, seg) - } - } - p.headers[path] = true - p.current = table - p.prefix = path - return nil -} - -// parseKeyValue handles a `key = value` line inside the current table. -func (p *tomlParser) parseKeyValue(line string) error { - eq := strings.IndexByte(line, '=') - if eq < 0 { - return fmt.Errorf("expected key = value, got %q", line) - } - key := strings.TrimSpace(line[:eq]) - if !bareKeyValid(key) { - return fmt.Errorf("invalid key %q (only bare keys are supported)", key) - } - - qualified := key - if p.prefix != "" { - qualified = p.prefix + "." + key - } - if p.keys[qualified] { - return fmt.Errorf("key %q is set twice", qualified) - } - if _, isTable := p.current[key].(map[string]any); isTable { - return fmt.Errorf("key %q conflicts with table [%s]", key, qualified) - } - - value, rest, err := parseValue(strings.TrimSpace(line[eq+1:])) - if err != nil { - return fmt.Errorf("value for key %q: %w", qualified, err) - } - if rest = strings.TrimSpace(rest); rest != "" && !strings.HasPrefix(rest, "#") { - return fmt.Errorf("unexpected content after value for key %q: %q", qualified, rest) - } - - p.keys[qualified] = true - p.current[key] = value - return nil -} - -// parseValue parses one scalar at the start of s and returns it with the unparsed -// remainder (trailing comments are the caller's to validate). -func parseValue(s string) (any, string, error) { - switch { - case s == "": - return nil, "", fmt.Errorf("missing value") - case strings.HasPrefix(s, "'''"), strings.HasPrefix(s, `"""`): - return nil, "", fmt.Errorf("multi-line strings are not supported") - case s[0] == '\'': - end := strings.IndexByte(s[1:], '\'') - if end < 0 { - return nil, "", fmt.Errorf("unterminated literal string") - } - return s[1 : 1+end], s[2+end:], nil - case s[0] == '"': - return parseBasicString(s) - case s[0] == '[': - return nil, "", fmt.Errorf("arrays are not supported") - case s[0] == '{': - return nil, "", fmt.Errorf("inline tables are not supported") - } - - // Bare scalar: the token runs to the first whitespace or comment. - token := s - if i := strings.IndexAny(s, " \t#"); i >= 0 { - token, s = s[:i], s[i:] - } else { - s = "" - } - switch token { - case "true": - return true, s, nil - case "false": - return false, s, nil - } - // TOML permits underscore separators between digits (1_000). - numeric := strings.ReplaceAll(token, "_", "") - if i, err := strconv.ParseInt(numeric, 10, 64); err == nil { - return i, s, nil - } - if f, err := strconv.ParseFloat(numeric, 64); err == nil { - return f, s, nil - } - return nil, "", fmt.Errorf("unsupported value %q (expected a string, boolean, or number)", token) -} - -// parseBasicString decodes a double-quoted TOML basic string with its escape -// sequences, returning the value and the remainder after the closing quote. -func parseBasicString(s string) (string, string, error) { - var b strings.Builder - i := 1 // past the opening quote - for i < len(s) { - c := s[i] - switch c { - case '"': - return b.String(), s[i+1:], nil - case '\\': - if i+1 >= len(s) { - return "", "", fmt.Errorf("unterminated escape sequence") - } - i++ - switch e := s[i]; e { - case 'b': - b.WriteByte('\b') - case 't': - b.WriteByte('\t') - case 'n': - b.WriteByte('\n') - case 'f': - b.WriteByte('\f') - case 'r': - b.WriteByte('\r') - case '"': - b.WriteByte('"') - case '\\': - b.WriteByte('\\') - case 'u', 'U': - width := 4 - if e == 'U' { - width = 8 - } - if i+width >= len(s) { - return "", "", fmt.Errorf(`truncated \%c escape`, e) - } - code, err := strconv.ParseUint(s[i+1:i+1+width], 16, 32) - if err != nil { - return "", "", fmt.Errorf(`invalid \%c escape: %v`, e, err) - } - r := rune(code) - if !utf8.ValidRune(r) || unicode.Is(unicode.Cs, r) { - return "", "", fmt.Errorf(`\%c escape is not a valid Unicode scalar value`, e) - } - b.WriteRune(r) - i += width - default: - return "", "", fmt.Errorf(`invalid escape sequence \%c`, e) - } - default: - b.WriteByte(c) - } - i++ - } - return "", "", fmt.Errorf("unterminated basic string") -} diff --git a/portals/ai-workspace/bff/internal/config/toml_test.go b/portals/ai-workspace/bff/internal/config/toml_test.go deleted file mode 100644 index f5050cf47..000000000 --- a/portals/ai-workspace/bff/internal/config/toml_test.go +++ /dev/null @@ -1,115 +0,0 @@ -/* - * 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 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package config - -import ( - "reflect" - "strings" - "testing" -) - -// The parser accepts the full shape the shipped configs use: root keys, nested -// tables, both string quoting styles, scalars, comments, and CRLF endings. -func TestParseTOMLSubset(t *testing.T) { - doc := strings.ReplaceAll(`# top comment -domain = "app.example.com" # trailing comment -debug = true -retries = 1_000 -ratio = 0.5 - -[platform_api] -url = 'https://api.example.com' - -[oidc] -client_secret = '{{ env "APIP_AIW_OIDC_CLIENT_SECRET" }}' -greeting = "line1\nline2 \"quoted\" é" - -[oidc.claim_mappings] -organization = "org_id" -`, "\n", "\r\n") - - got, err := parseTOMLSubset([]byte(doc)) - if err != nil { - t.Fatalf("parseTOMLSubset() error = %v", err) - } - want := map[string]any{ - "domain": "app.example.com", - "debug": true, - "retries": int64(1000), - "ratio": 0.5, - "platform_api": map[string]any{ - "url": "https://api.example.com", - }, - "oidc": map[string]any{ - "client_secret": `{{ env "APIP_AIW_OIDC_CLIENT_SECRET" }}`, - "greeting": "line1\nline2 \"quoted\" é", - "claim_mappings": map[string]any{ - "organization": "org_id", - }, - }, - } - if !reflect.DeepEqual(got, want) { - t.Errorf("parseTOMLSubset() = %#v, want %#v", got, want) - } -} - -// A parent table may be declared after its child ([a.b] then [a]) — TOML allows -// it, and the template's table ordering must not become load-order sensitive. -func TestParseTOMLSubsetParentAfterChild(t *testing.T) { - got, err := parseTOMLSubset([]byte("[a.b]\nx = 1\n[a]\ny = 2\n")) - if err != nil { - t.Fatalf("parseTOMLSubset() error = %v", err) - } - want := map[string]any{"a": map[string]any{"b": map[string]any{"x": int64(1)}, "y": int64(2)}} - if !reflect.DeepEqual(got, want) { - t.Errorf("parseTOMLSubset() = %#v, want %#v", got, want) - } -} - -// Every unsupported or malformed construct must be an error, never a silent -// misread — the parser's whole safety argument is that it fails closed. -func TestParseTOMLSubsetRejects(t *testing.T) { - cases := map[string]string{ - "array value": `skip_paths = ["/health"]`, - "inline table": `oidc = { client_id = "x" }`, - "array of tables": "[[servers]]\nname = \"a\"", - "multiline basic": "s = \"\"\"\nx\n\"\"\"", - "multiline literal": "s = '''\nx\n'''", - "dotted key": `oidc.client_id = "x"`, - "quoted key": `"my key" = "x"`, - "duplicate key": "a = 1\na = 2", - "duplicate table": "[t]\n[t]", - "key vs table": "[oidc.claim_mappings]\nx = 1\n[oidc]\nclaim_mappings = 1", - "table vs key": "oidc = 1\n[oidc]", - "unterminated string": `s = "abc`, - "unterminated literal": `s = 'abc`, - "unterminated header": "[oidc", - "bad escape": `s = "a\qb"`, - "bad unicode escape": `s = "\uZZZZ"`, - "surrogate escape": `s = "\uD800"`, - "bare garbage value": "a = maybe", - "trailing garbage": `a = "x" y`, - "missing value": "a =", - "no equals": "just a line", - "datetime": "a = 2026-07-13T00:00:00Z", - } - for name, doc := range cases { - if _, err := parseTOMLSubset([]byte(doc)); err == nil { - t.Errorf("%s: parseTOMLSubset(%q) succeeded, want error", name, doc) - } - } -} 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 a7271831c..c2f11d3d3 100644 --- a/portals/ai-workspace/bff/internal/server/composite_handlers_test.go +++ b/portals/ai-workspace/bff/internal/server/composite_handlers_test.go @@ -101,14 +101,13 @@ func buildTestServer(t *testing.T, platformURL, jwt string) (*Server, *httptest. } cfg := &config.Config{ - ControlPlane: config.ControlPlaneConfig{URL: platformURL}, - ProxyPrefix: "/proxy", + ControlPlane: config.ControlPlaneConfig{URL: platformURL, ProxyPrefix: "/proxy"}, Cookie: config.CookieConfig{Name: "_ai_workspace_session"}, } s := &Server{ cfg: cfg, - proxy: proxy.ReverseProxy(mustParseURL(platformURL), cfg.ProxyPrefix, transport), + proxy: proxy.ReverseProxy(mustParseURL(platformURL), cfg.ControlPlane.ProxyPrefix, transport), refreshLocks: make(map[string]*refreshLock), } @@ -270,8 +269,7 @@ func TestHandleCreateWithSecretCompensation_NoSecretNoDelete(t *testing.T) { func TestHandleCreateWithSecretCompensation_Unauthenticated(t *testing.T) { platform, _ := fakeControlPlane(t, nil) cfg := &config.Config{ - ControlPlane: config.ControlPlaneConfig{URL: platform.URL}, - ProxyPrefix: "/proxy", + ControlPlane: config.ControlPlaneConfig{URL: platform.URL, ProxyPrefix: "/proxy"}, Cookie: config.CookieConfig{Name: "_ai_workspace_session"}, } transport, err := proxy.NewTransport(proxy.TLSClientOptions{SkipVerify: true}) @@ -280,7 +278,7 @@ func TestHandleCreateWithSecretCompensation_Unauthenticated(t *testing.T) { } s := &Server{ cfg: cfg, - proxy: proxy.ReverseProxy(mustParseURL(platform.URL), cfg.ProxyPrefix, transport), + proxy: proxy.ReverseProxy(mustParseURL(platform.URL), cfg.ControlPlane.ProxyPrefix, transport), refreshLocks: make(map[string]*refreshLock), } diff --git a/portals/ai-workspace/bff/internal/server/routes.go b/portals/ai-workspace/bff/internal/server/routes.go index a86884bf8..c9c25e14b 100644 --- a/portals/ai-workspace/bff/internal/server/routes.go +++ b/portals/ai-workspace/bff/internal/server/routes.go @@ -43,10 +43,10 @@ func (s *Server) routes() http.Handler { // Same-origin reverse proxy to the Platform API. The proxy's Rewrite hook // strips the prefix before forwarding, so we register the subtree directly. - mux.HandleFunc(s.cfg.ProxyPrefix+"/", s.handleProxy) + mux.HandleFunc(s.cfg.ControlPlane.ProxyPrefix+"/", s.handleProxy) // SPA static files + client-side routing fallback (must be last). - mux.Handle("/", spaHandler(s.cfg.StaticDir)) + mux.Handle("/", spaHandler(s.cfg.Server.StaticDir)) return chain(mux, recoverPanic, diff --git a/portals/ai-workspace/bff/internal/server/server.go b/portals/ai-workspace/bff/internal/server/server.go index 45d3e8c19..3c97d88c5 100644 --- a/portals/ai-workspace/bff/internal/server/server.go +++ b/portals/ai-workspace/bff/internal/server/server.go @@ -78,24 +78,24 @@ func New(ctx context.Context, cfg *config.Config) (*Server, error) { // Shared by both auth modes: OIDC tokens from the configured IDP, and the HMAC // JWTs the Platform API's file-based login endpoint signs with the same mapped // claim names. Building it once keeps the two readers from drifting apart. - claims := buildClaimMapping(cfg.Claims) + claims := buildClaimMapping(cfg.Auth.ClaimMappings) s := &Server{ cfg: cfg, claims: claims, fileBased: auth.NewFileBased(upstream, cfg.ControlPlane.URL, cfg.ControlPlane.PortalBasePath, cfg.Session.AbsoluteTTL, claims), - proxy: proxy.ReverseProxy(target, cfg.ProxyPrefix, transport), + proxy: proxy.ReverseProxy(target, cfg.ControlPlane.ProxyPrefix, transport), refreshLocks: make(map[string]*refreshLock), } - if cfg.OIDC.Enabled { + if cfg.Auth.OIDC.Enabled { // The session store exists only to hold OIDC refresh/id tokens for renewal. // File-based sessions are fully self-contained in the cookie JWT. s.store = session.NewMemoryStore() o, err := auth.NewOIDC( ctx, upstream, - cfg.OIDC.Issuer, cfg.OIDC.ClientID, cfg.OIDC.ClientSecret, - cfg.OIDC.RedirectURL, cfg.OIDC.PostLogoutRedirectURL, cfg.OIDC.Scopes, + cfg.Auth.OIDC.Issuer, cfg.Auth.OIDC.ClientID, cfg.Auth.OIDC.ClientSecret, + cfg.Auth.OIDC.RedirectURL, cfg.Auth.OIDC.PostLogoutRedirectURL, cfg.Auth.OIDC.Scopes, claims, cfg.Session.AbsoluteTTL, ) if err != nil { diff --git a/portals/ai-workspace/bff/main.go b/portals/ai-workspace/bff/main.go index 212fd5724..3d988abff 100644 --- a/portals/ai-workspace/bff/main.go +++ b/portals/ai-workspace/bff/main.go @@ -99,7 +99,7 @@ func main() { slog.Error("configuration error", "err", err) os.Exit(1) } - slog.SetDefault(logger.NewLogger(logger.Config{Level: cfg.LogLevel, Format: cfg.LogFormat})) + slog.SetDefault(logger.NewLogger(logger.Config{Level: cfg.Logging.Level, Format: cfg.Logging.Format})) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -112,7 +112,7 @@ func main() { defer srv.Close() httpServer := &http.Server{ - Addr: cfg.Addr, + Addr: cfg.Addr(), Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 60 * time.Second, @@ -120,7 +120,7 @@ func main() { IdleTimeout: 120 * time.Second, } - tlsConfig, err := buildTLS(cfg.TLS) + tlsConfig, err := buildTLS(cfg.Server.HTTPS) if err != nil { slog.Error("failed to set up TLS", "err", err) os.Exit(1) @@ -128,13 +128,13 @@ func main() { httpServer.TLSConfig = tlsConfig go func() { - url := portalURL(cfg.Addr, tlsConfig != nil) + url := portalURL(cfg.Addr(), tlsConfig != nil) slog.Info("AI Workspace BFF started", - "addr", cfg.Addr, + "addr", cfg.Addr(), "url", url, - "auth_mode", cfg.AuthMode, + "auth_mode", cfg.Auth.Mode, "control_plane", cfg.ControlPlane.URL, - "oidc_enabled", cfg.OIDC.Enabled, + "oidc_enabled", cfg.Auth.OIDC.Enabled, ) printStartedMarker(url) var serveErr error @@ -165,10 +165,10 @@ func main() { // 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 { +func buildTLS(c config.HTTPSConfig) (*tls.Config, error) { + if !c.Enabled { // Plain HTTP is only safe when something upstream terminates TLS. - slog.Warn("TLS: disabled ([tls] enabled = false) — serving plain HTTP. " + + slog.Warn("TLS: disabled ([server.https] 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 @@ -180,8 +180,8 @@ func buildTLS(c config.TLSConfig) (*tls.Config, error) { } 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", c.CertFile, c.KeyFile) + "set [server.https] cert_file (%q) and key_file (%q) to existing files, "+ + "or set [server.https] enabled = false to serve plain HTTP behind a TLS-terminating proxy", c.CertFile, c.KeyFile) } cert, err := tlsutil.CertFromFiles(c.CertFile, c.KeyFile) if err != nil { From a05107c06d5ae9604b44dd41e9d9842324e9d254 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Tue, 21 Jul 2026 17:40:11 +0530 Subject: [PATCH 11/18] Update authentication configuration to use RS256 asymmetric signing for JWTs --- .gitignore | 2 - platform-api/README.md | 6 +- platform-api/config/config-template.toml | 34 +++--- platform-api/config/config.go | 89 ++++++++++----- platform-api/config/config.toml | 10 +- platform-api/config/config_test.go | 108 ++++++++++++++----- platform-api/config/default_config.go | 4 +- platform-api/internal/handler/auth_login.go | 11 +- platform-api/internal/middleware/auth.go | 17 ++- platform-api/internal/server/server.go | 17 ++- portals/ai-workspace/.gitignore | 1 + portals/ai-workspace/README.md | 3 +- portals/ai-workspace/distribution/README.md | 5 +- portals/ai-workspace/docker-compose.yaml | 8 +- portals/ai-workspace/production/README.md | 5 +- portals/ai-workspace/setup.sh | 28 ++++- portals/developer-portal/.gitignore | 3 + portals/developer-portal/README.md | 4 +- portals/developer-portal/docker-compose.yaml | 5 + portals/developer-portal/scripts/setup.sh | 42 +++++--- 20 files changed, 288 insertions(+), 114 deletions(-) diff --git a/.gitignore b/.gitignore index 287e2a958..68bc16f8a 100644 --- a/.gitignore +++ b/.gitignore @@ -169,7 +169,5 @@ dev-policies/ gateway/target/ -portals/ai-workspace/docker-compose.yaml - # Devportal portals/developer-portal/target/ diff --git a/platform-api/README.md b/platform-api/README.md index 3fbae9dcf..1f73b1051 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -275,7 +275,7 @@ All settings live under `[platform_api]` / `[platform_api.*]`. The main sections | `[platform_api.security.api_key]` | `hashing_algorithms` accepted for API key verification | | `[platform_api.database]` | `driver` (`sqlite3` / `postgres` / `sqlserver`), connection fields, pool sizing | | `[platform_api.auth]` | `mode` — one of `external_token`, `file`, or `idp`; `scope_validation`; `skip_paths` | -| `[platform_api.auth.jwt]` | HMAC login token settings: `issuer`, `secret_key` (**required**), `token_ttl` | +| `[platform_api.auth.jwt]` | Asymmetric (RS256) token settings: `issuer`, `public_key` (**required** — PEM RSA public key, verifies tokens), `private_key` (**required in `file` mode** — PEM RSA private key, signs login tokens), `token_ttl` | | `[platform_api.auth.idp]` / `[platform_api.auth.claim_mappings]` | JWKS endpoint, issuer/audience, validation mode, and JWT claim-name mappings for `idp` mode | | `[platform_api.auth.file.organization]` / `[[platform_api.auth.file.users]]` | Local org + username/password/scope entries for `file` mode | | `[platform_api.server.http]` / `[platform_api.server.https]` | Listener enablement, ports, and (HTTPS) `cert_file` / `key_file` paths (certificates are always required for HTTPS — no self-signed fallback) | @@ -291,8 +291,8 @@ All settings live under `[platform_api]` / `[platform_api.*]`. The main sections `platform_api.auth.mode` selects exactly one mode; only that mode's section is read: -- **`external_token`** — verify locally-signed HMAC JWTs (`[platform_api.auth.jwt]`); tokens are minted externally (e.g. by the Developer Portal) using the shared `secret_key`. -- **`file`** — `external_token` plus local username/password login: the login endpoint authenticates against `[platform_api.auth.file]` and issues HMAC JWTs signed with the same `[platform_api.auth.jwt]` secret. Used by the AI Workspace and Developer Portal quickstarts. +- **`external_token`** — verify locally-issued, asymmetrically-signed (RS256) JWTs (`[platform_api.auth.jwt]`); tokens are minted externally (e.g. by the Developer Portal) and signed with the matching RSA private key, verified here against `public_key`. Symmetric (HMAC) and unsigned (`none`) tokens are rejected. +- **`file`** — `external_token` plus local username/password login: the login endpoint authenticates against `[platform_api.auth.file]` and issues RS256 JWTs signed with `[platform_api.auth.jwt].private_key`, verified with the matching `public_key`. Used by the AI Workspace and Developer Portal quickstarts. - **`idp`** — validate tokens against an external IDP's JWKS endpoint (Thunder, Asgardeo, Keycloak, Azure AD, Okta, etc.) via `[platform_api.auth.idp]`; `jwks_url` and `issuer` are required. `platform_api.auth.skip_paths` is a structured list (not a scalar), so it's edited directly in diff --git a/platform-api/config/config-template.toml b/platform-api/config/config-template.toml index 4195cd319..aa0a2f7f8 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -119,12 +119,14 @@ conn_max_lifetime = '{{ env "APIP_CP_DATABASE_CONN_MAX_LIFETIME" "300" }}' # s # Authentication # # auth.mode selects exactly one mode: -# "external_token" — verify locally-signed HMAC JWTs, minted externally -# (e.g. by the Developer Portal) with the shared secret. +# "external_token" — verify locally-issued, asymmetrically-signed (RS256) JWTs, +# minted externally (e.g. by the Developer Portal) and +# signed with the matching RSA private key; verified here +# against auth.jwt.public_key. # "file" — external_token + local username/password login: the # login endpoint authenticates from -# [platform_api.auth.file] and issues HMAC JWTs -# signed with the same secret. +# [platform_api.auth.file] and issues RS256 JWTs signed +# with auth.jwt.private_key. # "idp" — validate tokens against an external IDP's JWKS # ([platform_api.auth.idp]). # Only the selected mode's section is read; the others are ignored. @@ -221,16 +223,22 @@ scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:mana # password_hash = "$2a$12$" # scopes = "ap:organization:read ap:gateway:read ap:rest_api:read ap:llm_provider:read" -# JWT (local HMAC) — used by "external_token" and "file" modes: -# "external_token" only verifies externally-minted tokens with this key; -# "file" also signs the tokens its login endpoint issues. Resolved from -# APIP_CP_AUTH_JWT_SECRET_KEY; prefer a secret file in production: -# secret_key = '{{ file "/secrets/platform-api/jwt_secret" }}' +# JWT (local RS256) — used by "external_token" and "file" modes. Tokens are +# signed asymmetrically: "external_token" only verifies externally-minted tokens +# with the public key; "file" also signs the tokens its login endpoint issues +# with the private key. Symmetric (HMAC) and unsigned ("none") tokens are +# rejected. PEM keys are multi-line, so read them from files via {{ file }} +# rather than an env var (scripts/setup.sh generates the pair): +# public_key = '{{ file "/secrets/platform-api/jwt_public.pem" }}' +# private_key = '{{ file "/secrets/platform-api/jwt_private.pem" }}' [platform_api.auth.jwt] -issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' -# REQUIRED — a 32-byte key (64 hex chars or base64); never generated, no -# fallback, so an unset APIP_CP_AUTH_JWT_SECRET_KEY fails config load. -secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' +issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' +# public_key is REQUIRED in every mode (verifies token signatures); private_key +# is REQUIRED only in "file" mode (signs login tokens) and must be the matching +# half of the pair. Neither is generated — a missing/malformed key fails config +# load. Both must be PEM-encoded RSA keys. +public_key = '{{ file "/etc/platform-api/keys/jwt_public.pem" }}' +private_key = '{{ file "/etc/platform-api/keys/jwt_private.pem" }}' # Lifetime of tokens issued by the file-mode login endpoint (Go duration # syntax). Not used for "external_token" tokens — their expiry is whatever # "exp" claim the issuer set. diff --git a/platform-api/config/config.go b/platform-api/config/config.go index 367f4e35d..6eb3e4578 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -29,6 +29,7 @@ import ( "time" "github.com/go-viper/mapstructure/v2" + "github.com/golang-jwt/jwt/v5" toml "github.com/knadh/koanf/parsers/toml/v2" "github.com/knadh/koanf/providers/confmap" "github.com/knadh/koanf/providers/file" @@ -102,12 +103,15 @@ type Server struct { // modeling the choice as a single discriminator (rather than per-mode enabled // flags) makes conflicting configurations inexpressible. const ( - // AuthModeExternalToken verifies locally-signed HMAC JWTs (auth.jwt.secret_key) - // that are minted externally, e.g. by the Developer Portal using the shared secret. + // AuthModeExternalToken verifies locally-issued, asymmetrically-signed JWTs + // (RS256) minted externally, e.g. by the Developer Portal. Verification uses + // the RSA public key in auth.jwt.public_key; symmetric (HMAC) and unsigned + // ("none") tokens are rejected. AuthModeExternalToken = "external_token" // AuthModeFile is AuthModeExternalToken plus local username/password login: the - // login endpoint authenticates users from auth.file and issues HMAC JWTs signed - // with auth.jwt.secret_key. + // login endpoint authenticates users from auth.file and issues RS256 JWTs signed + // with the RSA private key in auth.jwt.private_key, verified with the matching + // auth.jwt.public_key. AuthModeFile = "file" // AuthModeIDP validates tokens against an external IDP's JWKS (auth.idp). AuthModeIDP = "idp" @@ -123,8 +127,8 @@ type Auth struct { SkipPaths []string `koanf:"skip_paths"` IDP IDP `koanf:"idp"` // JWT is shared by two modes — "external_token" mode only verifies - // externally-minted tokens with it, "file" mode both signs and verifies - // with it. + // externally-minted tokens with the public key, "file" mode both signs (with + // the private key) and verifies (with the public key) using the RSA key pair. JWT JWT `koanf:"jwt"` File FileBased `koanf:"file"` // ClaimMappings names the JWT claims that carry each identity field. It is @@ -256,14 +260,24 @@ type CORS struct { AllowedOrigins []string `koanf:"allowed_origins"` } -// JWT holds configuration for local HMAC JWT authentication. Active when -// Auth.Mode is AuthModeExternalToken (verify-only, externally-minted tokens) -// or AuthModeFile (file mode also issues these tokens). Signature validation -// is always on. +// JWT holds configuration for local asymmetric (RS256) JWT authentication. +// Active when Auth.Mode is AuthModeExternalToken (verify-only, externally-minted +// tokens) or AuthModeFile (file mode also issues these tokens). Signature +// validation is always on and strictly asymmetric — symmetric (HMAC) and +// unsigned ("none") algorithms are rejected. +// +// TODO(pqc): migrate — RS256 is quantum-vulnerable. Move to an ML-DSA (FIPS 204) +// signature once a Go JWT library exposes it. See post-quantum-cryptography.md. type JWT struct { - SecretKey string `koanf:"secret_key"` - Issuer string `koanf:"issuer"` - TokenTTL time.Duration `koanf:"token_ttl"` + // PublicKey is the PEM-encoded RSA public key used to verify token + // signatures. Required in both "external_token" and "file" modes. + PublicKey string `koanf:"public_key"` + // PrivateKey is the PEM-encoded RSA private key used to sign tokens. Required + // only in "file" mode, whose login endpoint mints tokens; unused (and not + // required) in verify-only "external_token" mode. + PrivateKey string `koanf:"private_key"` + Issuer string `koanf:"issuer"` + TokenTTL time.Duration `koanf:"token_ttl"` } // WebSocket holds WebSocket-specific configuration. @@ -532,9 +546,11 @@ func validateTimeoutsConfig(cfg *Timeouts) error { func validateAuthConfig(auth *Auth) error { switch auth.Mode { case AuthModeExternalToken: - return validateJWTConfig(&auth.JWT) + // Verify-only: a public key is sufficient (tokens are minted elsewhere). + return validateJWTConfig(&auth.JWT, false) case AuthModeFile: - if err := validateJWTConfig(&auth.JWT); err != nil { + // File mode also mints tokens, so it additionally needs a signing key. + if err := validateJWTConfig(&auth.JWT, true); err != nil { return err } // TokenTTL only matters in file mode: the login endpoint mints tokens @@ -552,17 +568,40 @@ func validateAuthConfig(auth *Auth) error { } } -// validateJWTConfig verifies the local HMAC JWT secret. The same secret signs and -// verifies the login tokens issued in file mode, so it is required in both the -// "external_token" and "file" auth modes. The secret is never generated: a -// missing or malformed key fails startup. -func validateJWTConfig(jwt *JWT) error { - if jwt.SecretKey == "" { - return fmt.Errorf("Auth.JWT.SecretKey is required when auth.mode is %q or %q "+ - "(set auth.jwt.secret_key in config via {{ env }}/{{ file }})", AuthModeExternalToken, AuthModeFile) +// validateJWTConfig verifies the local asymmetric JWT key material. The RSA +// public key verifies token signatures and is required in both the +// "external_token" and "file" auth modes. When requireSigningKey is true (file +// mode, which mints tokens at its login endpoint) the RSA private key is also +// required and must form a matching pair with the public key. Keys are never +// generated: missing or malformed material fails startup. Only asymmetric RSA +// keys are accepted, so symmetric (HMAC) verification is structurally +// impossible. +func validateJWTConfig(jwtCfg *JWT, requireSigningKey bool) error { + if jwtCfg.PublicKey == "" { + return fmt.Errorf("Auth.JWT.PublicKey is required when auth.mode is %q or %q "+ + "(set auth.jwt.public_key to a PEM-encoded RSA public key via {{ env }}/{{ file }})", + AuthModeExternalToken, AuthModeFile) + } + pub, err := jwt.ParseRSAPublicKeyFromPEM([]byte(jwtCfg.PublicKey)) + if err != nil { + return fmt.Errorf("invalid Auth.JWT.PublicKey: must be a PEM-encoded RSA public key: %w", err) + } + + if !requireSigningKey { + return nil + } + + if jwtCfg.PrivateKey == "" { + return fmt.Errorf("Auth.JWT.PrivateKey is required when auth.mode is %q "+ + "(set auth.jwt.private_key to a PEM-encoded RSA private key via {{ env }}/{{ file }})", + AuthModeFile) + } + priv, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(jwtCfg.PrivateKey)) + if err != nil { + return fmt.Errorf("invalid Auth.JWT.PrivateKey: must be a PEM-encoded RSA private key: %w", err) } - if !valid32ByteKey(jwt.SecretKey) { - return fmt.Errorf("invalid Auth.JWT.SecretKey: must be 64 hex characters or base64 decoding to 32 bytes") + if !priv.PublicKey.Equal(pub) { + return fmt.Errorf("Auth.JWT.PrivateKey and Auth.JWT.PublicKey must be a matching RSA key pair") } return nil } diff --git a/platform-api/config/config.toml b/platform-api/config/config.toml index 50f622e6f..c11fa327c 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -14,9 +14,15 @@ path = '{{ env "APIP_CP_DATABASE_PATH" "./data/api_platform.db" }}' mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' scope_validation = '{{ env "APIP_CP_AUTH_SCOPE_VALIDATION" "true" }}' +# Asymmetric (RS256) signing keys. public_key verifies every token and is +# required in all modes; private_key signs login tokens and is required only in +# "file" mode. The PEM files are generated by scripts/setup.sh and mounted into +# the container (see the developer-portal docker-compose.yaml) — {{ file }} reads +# them at startup. Symmetric (HMAC) and unsigned ("none") tokens are rejected. [platform_api.auth.jwt] -issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' -secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' +issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' +public_key = '{{ file "/etc/platform-api/keys/jwt_public.pem" }}' +private_key = '{{ file "/etc/platform-api/keys/jwt_private.pem" }}' [platform_api.auth.file.organization] id = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_ID" "default" }}' diff --git a/platform-api/config/config_test.go b/platform-api/config/config_test.go index 4793968be..7001bd60b 100644 --- a/platform-api/config/config_test.go +++ b/platform-api/config/config_test.go @@ -19,6 +19,10 @@ package config import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" "os" "path/filepath" "testing" @@ -28,22 +32,53 @@ import ( "github.com/stretchr/testify/require" ) -// Valid 32-byte keys encoded as 64 hex chars. -const ( - validInlineKey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" - validJWTKey = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" +// validInlineKey is a valid 32-byte AES key encoded as 64 hex chars, used for the +// at-rest encryption key (still a symmetric key — unrelated to JWT signing). +const validInlineKey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + +// RSA key-pair PEM fixtures for the asymmetric JWT config, generated once in init(). +// validJWTPublicKey/validJWTPrivateKey form a matching pair; otherJWTPublicKey is +// from a different pair, used to exercise the matching-pair validation. +var ( + validJWTPublicKey string + validJWTPrivateKey string + otherJWTPublicKey string ) +// genRSAKeyPEMs generates a fresh RSA key pair and returns its public key (PKIX PEM) +// and private key (PKCS1 PEM). +func genRSAKeyPEMs() (pubPEM, privPEM string) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + privPEM = string(pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + })) + pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + panic(err) + } + pubPEM = string(pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: pubDER, + })) + return pubPEM, privPEM +} + // validKeysBase is a minimal config whose required secrets resolve from the -// APIP_CP_ENCRYPTION_KEY / APIP_CP_AUTH_JWT_SECRET_KEY env vars via {{ env }} +// APIP_CP_ENCRYPTION_KEY / APIP_CP_AUTH_JWT_PUBLIC_KEY env vars via {{ env }} // interpolation. Environment variables reach config ONLY through these tokens now // (there is no direct env-key override), so tests must go through a config file. +// The default auth mode is "external_token", which needs only the verification +// public key. const validKeysBase = ` [platform_api.security] encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' [platform_api.auth.jwt] -secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' +public_key = '{{ env "APIP_CP_AUTH_JWT_PUBLIC_KEY" }}' ` // loadTOML writes toml to a temp config file and loads it through LoadConfig. @@ -60,7 +95,7 @@ func loadTOML(t *testing.T, toml string) (*Server, error) { 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_CP_AUTH_JWT_PUBLIC_KEY", validJWTPublicKey) return loadTOML(t, validKeysBase+extra) } @@ -73,12 +108,12 @@ func TestLoadConfig_ValidKeys_Succeeds(t *testing.T) { // 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_CP_AUTH_JWT_PUBLIC_KEY", validJWTPublicKey) - // Encryption key omitted entirely; the JWT secret resolves so the JWT check passes first. + // Encryption key omitted entirely; the JWT public key resolves so the JWT check passes first. _, err := loadTOML(t, ` [platform_api.auth.jwt] -secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' +public_key = '{{ env "APIP_CP_AUTH_JWT_PUBLIC_KEY" }}' `) require.Error(t, err) assert.Contains(t, err.Error(), "EncryptionKey is required") @@ -86,21 +121,21 @@ secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' // A provided encryption key must be an AES-256-sized key (64 hex / base64→32 bytes). func TestLoadConfig_InvalidEncryptionKey_Errors(t *testing.T) { - t.Setenv("APIP_CP_AUTH_JWT_SECRET_KEY", validJWTKey) + t.Setenv("APIP_CP_AUTH_JWT_PUBLIC_KEY", validJWTPublicKey) _, err := loadTOML(t, ` [platform_api.security] encryption_key = "not-a-valid-32-byte-key" [platform_api.auth.jwt] -secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' +public_key = '{{ env "APIP_CP_AUTH_JWT_PUBLIC_KEY" }}' `) require.Error(t, err) assert.Contains(t, err.Error(), "invalid EncryptionKey") } -// The JWT secret is required (default auth mode is "external_token") and never generated. -func TestLoadConfig_MissingJWTSecretKey_Errors(t *testing.T) { +// The JWT public key is required (default auth mode is "external_token") and never generated. +func TestLoadConfig_MissingJWTPublicKey_Errors(t *testing.T) { t.Setenv("APIP_CP_ENCRYPTION_KEY", validInlineKey) _, err := loadTOML(t, ` @@ -108,11 +143,11 @@ func TestLoadConfig_MissingJWTSecretKey_Errors(t *testing.T) { encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' `) require.Error(t, err) - assert.Contains(t, err.Error(), "Auth.JWT.SecretKey is required") + assert.Contains(t, err.Error(), "Auth.JWT.PublicKey is required") } -// A provided JWT secret must be an AES-256-sized key (64 hex / base64→32 bytes). -func TestLoadConfig_InvalidJWTSecretKey_Errors(t *testing.T) { +// A provided JWT public key must be a PEM-encoded RSA public key. +func TestLoadConfig_InvalidJWTPublicKey_Errors(t *testing.T) { t.Setenv("APIP_CP_ENCRYPTION_KEY", validInlineKey) _, err := loadTOML(t, ` @@ -120,10 +155,10 @@ func TestLoadConfig_InvalidJWTSecretKey_Errors(t *testing.T) { encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' [platform_api.auth.jwt] -secret_key = "not-a-valid-32-byte-key" +public_key = "not-a-valid-rsa-public-key" `) require.Error(t, err) - assert.Contains(t, err.Error(), "invalid Auth.JWT.SecretKey") + assert.Contains(t, err.Error(), "invalid Auth.JWT.PublicKey") } // --- valid32ByteKey unit coverage --- @@ -140,9 +175,14 @@ func TestValid32ByteKey(t *testing.T) { // Clear env vars that LoadConfig reads so each test starts from a known baseline and host // environment values don't leak into assertions. func init() { + // Generate the RSA key-pair fixtures used across the asymmetric JWT tests. + validJWTPublicKey, validJWTPrivateKey = genRSAKeyPEMs() + otherJWTPublicKey, _ = genRSAKeyPEMs() + for _, v := range []string{ "APIP_CP_ENCRYPTION_KEY", - "APIP_CP_AUTH_JWT_SECRET_KEY", + "APIP_CP_AUTH_JWT_PUBLIC_KEY", + "APIP_CP_AUTH_JWT_PRIVATE_KEY", } { os.Unsetenv(v) } @@ -157,22 +197,36 @@ func TestValidateAuthConfig(t *testing.T) { wantErr string }{ { - name: "external_token mode with valid secret", - auth: Auth{Mode: AuthModeExternalToken, JWT: JWT{SecretKey: validJWTKey}}, + name: "external_token mode with valid public key", + auth: Auth{Mode: AuthModeExternalToken, JWT: JWT{PublicKey: validJWTPublicKey}}, }, { - name: "external_token mode without secret", + name: "external_token mode without public key", auth: Auth{Mode: AuthModeExternalToken}, - wantErr: "Auth.JWT.SecretKey is required", + wantErr: "Auth.JWT.PublicKey is required", + }, + { + name: "file mode without private key", + auth: Auth{Mode: AuthModeFile, JWT: JWT{PublicKey: validJWTPublicKey, TokenTTL: time.Hour}}, + wantErr: "Auth.JWT.PrivateKey is required", + }, + { + name: "file mode with mismatched key pair", + auth: Auth{Mode: AuthModeFile, JWT: JWT{ + PublicKey: otherJWTPublicKey, + PrivateKey: validJWTPrivateKey, + TokenTTL: time.Hour, + }}, + wantErr: "matching RSA key pair", }, { name: "file mode without token_ttl", - auth: Auth{Mode: AuthModeFile, JWT: JWT{SecretKey: validJWTKey}}, + auth: Auth{Mode: AuthModeFile, JWT: JWT{PublicKey: validJWTPublicKey, PrivateKey: validJWTPrivateKey}}, wantErr: "Auth.JWT.TokenTTL must be a positive duration", }, { name: "file mode requires org and users", - auth: Auth{Mode: AuthModeFile, JWT: JWT{SecretKey: validJWTKey, TokenTTL: time.Hour}}, + auth: Auth{Mode: AuthModeFile, JWT: JWT{PublicKey: validJWTPublicKey, PrivateKey: validJWTPrivateKey, TokenTTL: time.Hour}}, // Default org fields are empty in a zero-value Auth — users check fires // after the org checks. wantErr: "auth.file.organization.id", @@ -181,7 +235,7 @@ func TestValidateAuthConfig(t *testing.T) { name: "file mode fully configured", auth: Auth{ Mode: AuthModeFile, - JWT: JWT{SecretKey: validJWTKey, TokenTTL: time.Hour}, + JWT: JWT{PublicKey: validJWTPublicKey, PrivateKey: validJWTPrivateKey, TokenTTL: time.Hour}, File: FileBased{ Organization: FileBasedOrg{ID: "default", DisplayName: "Default"}, Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash"}}, diff --git a/platform-api/config/default_config.go b/platform-api/config/default_config.go index ac2f7911d..fd5bd0df1 100644 --- a/platform-api/config/default_config.go +++ b/platform-api/config/default_config.go @@ -41,8 +41,8 @@ func defaultConfig() *Server { ConnMaxLifetime: 300, }, Auth: Auth{ - // Default mode verifies locally-signed HMAC JWTs; the quickstart config - // selects "file" to add username/password login on top of it. + // Default mode verifies locally-issued, asymmetrically-signed (RS256) JWTs; + // the quickstart config selects "file" to add username/password login on top. Mode: AuthModeExternalToken, ScopeValidation: true, // SkipPaths bypasses JWT/IDP auth middleware. Paths below the health/metrics diff --git a/platform-api/internal/handler/auth_login.go b/platform-api/internal/handler/auth_login.go index 71e98f17f..d6e23c40b 100644 --- a/platform-api/internal/handler/auth_login.go +++ b/platform-api/internal/handler/auth_login.go @@ -107,8 +107,15 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) error { "iat": time.Now().Unix(), } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - signed, err := token.SignedString([]byte(h.cfg.Auth.JWT.SecretKey)) + // Sign asymmetrically with RS256 using the configured RSA private key. + // Config validation (validateJWTConfig) guarantees the key parses and matches + // the verification public key, so a parse error here is an internal fault. + privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(h.cfg.Auth.JWT.PrivateKey)) + if err != nil { + return apperror.Internal.Wrap(err).WithLogMessage("failed to parse JWT signing key") + } + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + signed, err := token.SignedString(privateKey) if err != nil { return apperror.Internal.Wrap(err).WithLogMessage("failed to issue token") } diff --git a/platform-api/internal/middleware/auth.go b/platform-api/internal/middleware/auth.go index 6cec80198..d43fbdfd8 100644 --- a/platform-api/internal/middleware/auth.go +++ b/platform-api/internal/middleware/auth.go @@ -19,6 +19,7 @@ package middleware import ( "context" + "crypto/rsa" "fmt" "log/slog" "net/http" @@ -64,7 +65,10 @@ type CustomClaims struct { // AuthConfig holds the configuration for the local JWT (non-IDP) authentication path. type AuthConfig struct { - SecretKey string + // PublicKey is the RSA public key used to verify token signatures (RS256). + // Only asymmetric verification is supported; symmetric (HMAC) and unsigned + // ("none") tokens are rejected. + PublicKey *rsa.PublicKey TokenIssuer string SkipPaths []string SkipValidation bool @@ -167,11 +171,14 @@ func validateLocalJWT(r *http.Request, tokenString string, config AuthConfig) (* } } else { token, err := jwt.ParseWithClaims(tokenString, mapClaims, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + // Strictly enforce asymmetric RSA signatures. Rejecting non-RSA + // methods here blocks the "none" algorithm and the HMAC-with-public-key + // forgery where an attacker signs with the public key as an HMAC secret. + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected or forbidden signing method: %v", token.Header["alg"]) } - return []byte(config.SecretKey), nil - }) + return config.PublicKey, nil + }, jwt.WithValidMethods([]string{"RS256", "RS384", "RS512"})) if err != nil { return nil, fmt.Errorf("invalid token: %w", err) } diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index 1badfcb75..61b9129a5 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -46,6 +46,7 @@ import ( "github.com/wso2/api-platform/platform-api/internal/webhook" "github.com/wso2/api-platform/platform-api/internal/websocket" + "github.com/golang-jwt/jwt/v5" "github.com/wso2/api-platform/common/authenticators" "github.com/wso2/api-platform/common/eventhub" commonmodels "github.com/wso2/api-platform/common/models" @@ -482,10 +483,14 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, })) if cfg.Auth.Mode == config.AuthModeFile { - slogger.Info("Auth mode: file (local users, HMAC-signed JWT)") + slogger.Info("Auth mode: file (local users, RS256-signed JWT)") slogger.Warn("file-based authentication is enabled — this is not recommended for production; please configure an IDP of your choice") + publicKey, err := jwt.ParseRSAPublicKeyFromPEM([]byte(cfg.Auth.JWT.PublicKey)) + if err != nil { + return nil, fmt.Errorf("failed to parse auth.jwt.public_key: %w", err) + } chain = append(chain, middleware.LocalJWTAuthMiddleware(middleware.AuthConfig{ - SecretKey: cfg.Auth.JWT.SecretKey, + PublicKey: publicKey, TokenIssuer: cfg.Auth.JWT.Issuer, SkipPaths: cfg.Auth.SkipPaths, SkipValidation: false, @@ -564,10 +569,14 @@ func buildClaimMappings(cm config.ClaimMappings, roleScopeMap map[string][]strin // its own local-JWT middleware). func buildAuthenticator(cfg *config.Server, slogger *slog.Logger, roleScopeMap map[string][]string) (middleware.Authenticator, error) { if cfg.Auth.Mode != config.AuthModeIDP { - slogger.Info("Auth mode: jwt (HMAC signature validation enabled)") + slogger.Info("Auth mode: jwt (asymmetric RS256 signature validation enabled)") + publicKey, err := jwt.ParseRSAPublicKeyFromPEM([]byte(cfg.Auth.JWT.PublicKey)) + if err != nil { + return nil, fmt.Errorf("failed to parse auth.jwt.public_key: %w", err) + } return middleware.NewJWTAuthenticator( middleware.LocalJWTAuthMiddleware(middleware.AuthConfig{ - SecretKey: cfg.Auth.JWT.SecretKey, + PublicKey: publicKey, TokenIssuer: cfg.Auth.JWT.Issuer, SkipPaths: cfg.Auth.SkipPaths, SkipValidation: false, diff --git a/portals/ai-workspace/.gitignore b/portals/ai-workspace/.gitignore index 97628a037..25031c3f3 100644 --- a/portals/ai-workspace/.gitignore +++ b/portals/ai-workspace/.gitignore @@ -6,3 +6,4 @@ target .vscode api-platform.env resources/certificates/ +resources/keys/ \ No newline at end of file diff --git a/portals/ai-workspace/README.md b/portals/ai-workspace/README.md index b6e18dace..f04518a3f 100644 --- a/portals/ai-workspace/README.md +++ b/portals/ai-workspace/README.md @@ -475,7 +475,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 `api-platform.env` | Manage as real secrets; prefer mounting files and referencing them with `{{ file "..." }}` in the config TOML | +| **Platform API** — `APIP_CP_ENCRYPTION_KEY` | Generated into `api-platform.env` | Manage as real secrets; prefer mounting files and referencing them with `{{ file "..." }}` in the config TOML | +| **Platform API** — RS256 JWT signing keypair | Generated as PEM files in `resources/keys/` (`jwt_private.pem` / `jwt_public.pem`), read by `config.toml` via `{{ file }}` | Mount your own RSA keypair at the same paths; rotate as real signing keys | | **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 (`[ai_workspace.control_plane] ca_file`) | Point `ca_file` at your CA bundle; never use `tls_skip_verify` in production | diff --git a/portals/ai-workspace/distribution/README.md b/portals/ai-workspace/distribution/README.md index eb607ec39..952e5eb49 100644 --- a/portals/ai-workspace/distribution/README.md +++ b/portals/ai-workspace/distribution/README.md @@ -40,7 +40,8 @@ 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) | +| `api-platform.env` (git-ignored) | `APIP_CP_ENCRYPTION_KEY` (at-rest encryption), `APIP_CP_ADMIN_USERNAME`, `APIP_CP_ADMIN_PASSWORD_HASH` (bcrypt) | +| `resources/keys/jwt_private.pem` + `jwt_public.pem` (git-ignored) | RS256 keypair signing/verifying login JWTs; read by `config.toml` via `{{ file }}` | | `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). @@ -95,7 +96,7 @@ Environment overrides go in `api-platform.env` (git-ignored; loaded into both co | `[security].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` | | `[database].driver` | `sqlite3` or `postgres` | | `[auth].mode` | `file` (quickstart default), `external_token`, or `idp` — selects exactly one auth mode | -| `[auth.jwt].secret_key` | 32-byte HMAC key signing login JWTs | +| `[auth.jwt].public_key` / `private_key` | RS256 (asymmetric) PEM keys; `public_key` verifies every token, `private_key` signs login JWTs in `file` mode. Read via `{{ file }}` — HMAC and unsigned tokens are rejected | | `[auth.idp]` | JWKS-based IDP auth — active when `mode = "idp"`; configure for Asgardeo, Keycloak, Auth0, etc. | | `[auth.file.users]` | Local user credentials, active when `mode = "file"` (change the password hash before sharing) | | `[server.https]` | Listener on `:9243`; `cert_file`/`key_file` point at `cert.pem`/`key.pem` | diff --git a/portals/ai-workspace/docker-compose.yaml b/portals/ai-workspace/docker-compose.yaml index b165d834f..c2e8683ec 100644 --- a/portals/ai-workspace/docker-compose.yaml +++ b/portals/ai-workspace/docker-compose.yaml @@ -11,7 +11,7 @@ services: platform-api: - image: ghcr.io/wso2/api-platform/platform-api:0.11.0 + image: ghcr.io/wso2/api-platform/platform-api:0.13.0-SNAPSHOT container_name: platform-api restart: unless-stopped # Mounts the single source-of-truth config shipped with the Platform API @@ -26,10 +26,8 @@ services: volumes: - ../../platform-api/config/config.toml:/etc/platform-api/config.toml:ro - platform-api-data:/app/data - # Certs land under /app/data/certs to match the binary's compiled - # default cert_file/key_file paths (./data/certs/{cert,key}.pem, - # resolved against WORKDIR /app) — no cert path override needed. - ./resources/certificates:/app/data/certs:ro + - ./resources/keys:/etc/platform-api/keys:ro ports: - "9243:9243" healthcheck: @@ -40,7 +38,7 @@ services: retries: 3 ai-workspace: - image: ghcr.io/wso2/api-platform/ai-workspace:1.0.0-alpha2-SNAPSHOT + image: ghcr.io/wso2/api-platform/ai-workspace:1.0.0-beta-SNAPSHOT container_name: ai-workspace restart: unless-stopped depends_on: diff --git a/portals/ai-workspace/production/README.md b/portals/ai-workspace/production/README.md index 48d429a34..c06536638 100644 --- a/portals/ai-workspace/production/README.md +++ b/portals/ai-workspace/production/README.md @@ -238,8 +238,9 @@ There is no demo mode: startup checks are always on for **both** the `platform-a `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). +self-signed pairs, and manage `APIP_CP_ENCRYPTION_KEY` and the RS256 JWT signing keypair +(`resources/keys/jwt_private.pem` / `jwt_public.pem`, read via `{{ file }}`) as stable, real +secrets (prefer `{{ file }}` tokens over environment variables). 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 index 4d1eec6d4..0741ba5b8 100755 --- a/portals/ai-workspace/setup.sh +++ b/portals/ai-workspace/setup.sh @@ -15,6 +15,9 @@ cd "$(dirname "$0")" ENV_FILE="api-platform.env" CERTS_DIR="resources/certificates" +# RS256 JWT keypair (PEM). Mounted into the platform-api container at +# /etc/platform-api/keys and read by config.toml via {{ file }}. +KEYS_DIR="resources/keys" FORCE=false CERTS_ONLY=false @@ -105,8 +108,28 @@ ADMIN_PASSWORD="${ADMIN_PASSWORD:-$GENERATED_PASSWORD}" log "Generating secrets into $ENV_FILE ..." ENCRYPTION_KEY="$(openssl rand -hex 32)" log " - APIP_CP_ENCRYPTION_KEY generated" -JWT_SECRET_KEY="$(openssl rand -hex 32)" -log " - APIP_CP_AUTH_JWT_SECRET_KEY generated" + +log "Provisioning Platform API JWT signing keypair (RS256) ..." +# Tokens are signed asymmetrically (RS256), not with a shared HMAC secret. The +# Platform API mints login tokens with the RSA private key and verifies every +# token with the matching public key. A PEM key is multi-line and does not +# survive an env file (one KEY=VALUE per line), so — like the TLS cert above — +# the keypair is written to files and read by config.toml via {{ file }}: +# config.toml -> public_key/private_key = '{{ file "/etc/platform-api/keys/jwt_*.pem" }}' +# resources/keys is mounted into the platform-api container at +# /etc/platform-api/keys (see docker-compose.yaml), which is on the Platform +# API's {{ file }} allowlist. +mkdir -p "$KEYS_DIR" +# PKCS#8 private key + matching SPKI public key — the PEM encodings golang-jwt's +# ParseRSAPrivateKeyFromPEM / ParseRSAPublicKeyFromPEM accept. +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ + -out "$KEYS_DIR/jwt_private.pem" 2>/dev/null +openssl rsa -in "$KEYS_DIR/jwt_private.pem" -pubout \ + -out "$KEYS_DIR/jwt_public.pem" 2>/dev/null +# 644 (not 600) so the container's non-root user can read a host-owned file, +# matching the TLS key.pem above. +chmod 644 "$KEYS_DIR/jwt_private.pem" "$KEYS_DIR/jwt_public.pem" +log " - RS256 JWT keypair generated at $KEYS_DIR" log "Provisioning admin credentials ..." ADMIN_PASSWORD_HASH="$(bcrypt_hash "$ADMIN_PASSWORD")" @@ -117,7 +140,6 @@ cat > "$ENV_FILE" </dev/null; then - log " - APIP_CP_AUTH_JWT_SECRET_KEY already set in api-platform.env, leaving as-is" +log "Provisioning Platform API JWT signing keypair (RS256) ..." +# Tokens are signed asymmetrically now (RS256), not with a shared HMAC secret. +# The Platform API mints login tokens with the RSA private key and verifies every +# token with the matching public key. A PEM key is multi-line and does not survive +# an env file (one KEY=VALUE per line), so — like the TLS cert above — the keypair +# is written to files and read by config.toml via {{ file }}: +# config.toml -> public_key/private_key = '{{ file "/etc/platform-api/keys/jwt_*.pem" }}' +# resources/keys is mounted into the platform-api container at /etc/platform-api/keys +# (see docker-compose.yaml), which is on the Platform API's {{ file }} allowlist. +if [ -f "$JWT_KEY_DIR/jwt_private.pem" ] && [ -f "$JWT_KEY_DIR/jwt_public.pem" ]; then + log " - $JWT_KEY_DIR already has a JWT keypair, leaving as-is" else - printf 'APIP_CP_AUTH_JWT_SECRET_KEY=%s\n' "$(openssl rand -hex 32)" >> "$ENV_FILE" - log " - APIP_CP_AUTH_JWT_SECRET_KEY generated" + mkdir -p "$JWT_KEY_DIR" + # PKCS#8 private key + matching SPKI public key — the PEM encodings + # golang-jwt's ParseRSAPrivateKeyFromPEM / ParseRSAPublicKeyFromPEM accept. + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ + -out "$JWT_KEY_DIR/jwt_private.pem" 2>/dev/null + openssl rsa -in "$JWT_KEY_DIR/jwt_private.pem" -pubout \ + -out "$JWT_KEY_DIR/jwt_public.pem" 2>/dev/null + chmod "$CERT_FILE_MODE" "$JWT_KEY_DIR/jwt_private.pem" "$JWT_KEY_DIR/jwt_public.pem" + log " - RS256 JWT keypair generated at $JWT_KEY_DIR" fi -JWT_SECRET_KEY="$(get_env_var APIP_CP_AUTH_JWT_SECRET_KEY)" -set_env_var "APIP_DP_PLATFORMAPI_JWTSECRET" "$JWT_SECRET_KEY" log "Provisioning Platform API admin credentials ..." CREDENTIALS_PROVISIONED=false From 2f4294b4e49a375540720ef2f3583b9cbc81e51e Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Tue, 21 Jul 2026 17:51:03 +0530 Subject: [PATCH 12/18] Update authentication configuration in harness_test.go to use IDP mode with JWKS URL and issuer --- platform-api/internal/integration/harness_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform-api/internal/integration/harness_test.go b/platform-api/internal/integration/harness_test.go index da17194f8..cb5c4583d 100644 --- a/platform-api/internal/integration/harness_test.go +++ b/platform-api/internal/integration/harness_test.go @@ -50,7 +50,7 @@ func TestMain(m *testing.M) { if err != nil { panic(fmt.Sprintf("integration harness: create temp config: %v", err)) } - if _, err := fmt.Fprintf(f, "[platform_api.security]\nencryption_key = %q\n\n[platform_api.auth]\nmode = \"external_token\"\n\n[platform_api.auth.jwt]\nsecret_key = %q\n", testKey, testKey); err != nil { + if _, err := fmt.Fprintf(f, "[platform_api.security]\nencryption_key = %q\n\n[platform_api.auth]\nmode = \"idp\"\n\n[platform_api.auth.idp]\njwks_url = \"https://example.invalid/jwks\"\nissuer = \"https://example.invalid\"\n", testKey); err != nil { panic(fmt.Sprintf("integration harness: write temp config: %v", err)) } _ = f.Close() From b021650f1e15c756cc5bc1bc03ace3990861c81a Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Tue, 21 Jul 2026 18:16:28 +0530 Subject: [PATCH 13/18] Add developer portal configuration and update Docker Compose for RS256 JWT support --- distribution/all-in-one/devportal-config.toml | 48 +++++++++++++ distribution/all-in-one/docker-compose.yaml | 69 ++++++++++++++++--- platform-api/config/config-template.toml | 12 ++-- portals/developer-portal/it/Makefile | 15 ++++ .../it/configs/config-platform-api-it.toml | 7 +- .../it/docker-compose.test.postgres.yaml | 11 +-- .../it/docker-compose.test.yaml | 11 +-- tests/integration-e2e/devportal-config.toml | 7 +- .../docker-compose.sqlite.yaml | 28 +++++++- .../docker-compose.sqlserver.yaml | 28 +++++++- tests/integration-e2e/docker-compose.yaml | 38 ++++++++-- .../integration-e2e/platform-api-config.toml | 10 ++- tests/integration-e2e/steps_devportal_test.go | 9 ++- 13 files changed, 256 insertions(+), 37 deletions(-) create mode 100644 distribution/all-in-one/devportal-config.toml diff --git a/distribution/all-in-one/devportal-config.toml b/distribution/all-in-one/devportal-config.toml new file mode 100644 index 000000000..4d9f43710 --- /dev/null +++ b/distribution/all-in-one/devportal-config.toml @@ -0,0 +1,48 @@ +# Developer Portal configuration for the all-in-one (build-from-source) compose +# stack. Mounted at /app/configs/config.toml — replaces the image's shipped +# configs/config.toml, which fails closed at startup on the required security +# secrets and only wires the SQLite `file` key (this stack shares one Postgres +# server with platform-api). +# +# There is NO automatic APIP_DP_* environment-variable override (see +# src/config/configLoader.js) — an env var set in docker-compose.yaml's +# `devportal.environment` block only takes effect where a key below explicitly +# references it via a {{ env "..." }} token. + +[server] +base_url = '{{ env "APIP_DP_SERVER_BASEURL" "http://localhost:3001" }}' +port = 3000 + +[tls] +# Plain HTTP — nothing in this stack talks to devportal over TLS. +enabled = false + +[logging] +console_only = true + +[database] +type = '{{ env "APIP_DP_DATABASE_TYPE" "postgres" }}' +host = '{{ env "APIP_DP_DATABASE_HOST" "postgres" }}' +port = '{{ env "APIP_DP_DATABASE_PORT" "5432" }}' +name = '{{ env "APIP_DP_DATABASE_NAME" "devportal" }}' +username = '{{ env "APIP_DP_DATABASE_USERNAME" "postgres" }}' +password = '{{ env "APIP_DP_DATABASE_PASSWORD" "postgres" }}' + +[organization] +default_name = '{{ env "APIP_DP_ORGANIZATION_DEFAULTNAME" "default" }}' + +[platform_api] +# platform-api signs admin JWTs with RS256 — there is no shared HMAC secret to +# set here. jwt_secret left empty makes the devportal decode the token payload +# without verifying its signature, trusting the direct HTTPS connection +# (insecure=true) instead — see extractPlatformJwtClaims in +# src/utils/platformJwt.js. platform-api's cert is self-signed (platform-api-certgen). +base_url = '{{ env "APIP_DP_PLATFORMAPI_BASEURL" "https://platform-api:9243" }}' +insecure = true + +[security] +# Required — devportal fails closed at startup if either doesn't resolve to a +# 64-char hex string. Set APIP_DP_SECURITY_ENCRYPTIONKEY / APIP_DP_SECURITY_SESSIONSECRET +# in the environment before `docker compose up` (e.g. `openssl rand -hex 32` each). +encryption_key = '{{ env "APIP_DP_SECURITY_ENCRYPTIONKEY" }}' +session_secret = '{{ env "APIP_DP_SECURITY_SESSIONSECRET" }}' diff --git a/distribution/all-in-one/docker-compose.yaml b/distribution/all-in-one/docker-compose.yaml index c4a6c7b5f..a32a34066 100644 --- a/distribution/all-in-one/docker-compose.yaml +++ b/distribution/all-in-one/docker-compose.yaml @@ -41,17 +41,31 @@ services: dockerfile: Dockerfile container_name: devportal environment: - - APIP_DP_PLATFORMAPI_JWTSECRET=${APIP_CP_AUTH_JWT_SECRET_KEY:-} + - APIP_DP_SERVER_BASEURL=http://localhost:3001 + - APIP_DP_DATABASE_TYPE=postgres + - APIP_DP_DATABASE_HOST=postgres + - APIP_DP_DATABASE_PORT=5432 + - APIP_DP_DATABASE_NAME=devportal + - APIP_DP_DATABASE_USERNAME=postgres + - APIP_DP_DATABASE_PASSWORD=postgres + - APIP_DP_PLATFORMAPI_BASEURL=https://platform-api:9243 + # Required — devportal fails closed at startup if either doesn't resolve + # to a 64-char hex string. Set both before `docker compose up`, e.g.: + # export APIP_DP_SECURITY_ENCRYPTIONKEY=$(openssl rand -hex 32) + # export APIP_DP_SECURITY_SESSIONSECRET=$(openssl rand -hex 32) + - APIP_DP_SECURITY_ENCRYPTIONKEY=${APIP_DP_SECURITY_ENCRYPTIONKEY:-} + - APIP_DP_SECURITY_SESSIONSECRET=${APIP_DP_SECURITY_SESSIONSECRET:-} ports: - - "3001:3001" + - "3001:3000" volumes: - - ../../portals/developer-portal/config.json:/app/config.json:ro - - ../../portals/developer-portal/secret.json:/app/secret.json:ro + # Replaces the image's shipped configs/config.toml (SQLite-only, fails + # closed on security secrets) — see devportal-config.toml for why. + - ./devportal-config.toml:/app/configs/config.toml:ro depends_on: postgres: condition: service_healthy - platform-api-certgen: - condition: service_completed_successfully + platform-api: + condition: service_healthy # One-shot init container: generates the TLS pair the platform-api HTTPS # listener requires (the server no longer generates a self-signed fallback). @@ -68,20 +82,50 @@ services: volumes: - platform-api-certs:/certs + # One-shot init container: generates the RS256 keypair platform-api's + # auth.jwt config requires (config.toml reads it via {{ file }}). Tokens are + # signed asymmetrically — there is no shared HMAC secret anymore. + platform-api-jwtkeygen: + image: alpine/openssl + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + [ -f /keys/jwt_private.pem ] && [ -f /keys/jwt_public.pem ] && exit 0 + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ + -out /keys/jwt_private.pem 2>/dev/null + openssl rsa -in /keys/jwt_private.pem -pubout \ + -out /keys/jwt_public.pem 2>/dev/null + # jwtkeygen runs as root; platform-api runs as uid 10001, so the private + # key must be owned by that uid, not made world-readable. + chown 10001 /keys/jwt_private.pem + chmod 0600 /keys/jwt_private.pem + chmod 0644 /keys/jwt_public.pem + volumes: + - platform-api-jwt-keys:/keys + platform-api: - image: ghcr.io/wso2/api-platform/platform-api:latest + image: ghcr.io/wso2/api-platform/platform-api:latest build: context: ../../platform-api dockerfile: Dockerfile additional_contexts: common: ../../common + # Mounts the shipped default config (../../platform-api/config/config.toml) + # so the APIP_CP_* env vars below actually take effect — {{ env }} / {{ file }} + # tokens only resolve from a loaded config file, they are not read directly. + command: ["-config", "/etc/platform-api/config.toml"] ports: - "9243:9243" volumes: + - ../../platform-api/config/config.toml:/etc/platform-api/config.toml:ro - platform-api-data:/api-platform/data - platform-api-certs:/app/data/certs + # RS256 JWT signing/verification keys — on the Platform API's {{ file }} + # allowlist (/etc/platform-api). + - platform-api-jwt-keys:/etc/platform-api/keys:ro environment: - - APIP_CP_DEFAULT_DEVPORTAL_API_URL=http://devportal:3001 + - APIP_CP_DEFAULT_DEVPORTAL_API_URL=http://devportal:3000 - APIP_CP_DATABASE_DRIVER=postgres - APIP_CP_DATABASE_HOST=postgres - APIP_CP_DATABASE_PORT=5432 @@ -93,11 +137,16 @@ services: - APIP_CP_DATABASE_MAX_IDLE_CONNS=10 - APIP_CP_DATABASE_CONN_MAX_LIFETIME=300 - APIP_CP_DATABASE_EXECUTE_SCHEMA_DDL=true + # Required — set before `docker compose up`, e.g.: + # export APIP_CP_ENCRYPTION_KEY=$(openssl rand -hex 32) - APIP_CP_ENCRYPTION_KEY=${APIP_CP_ENCRYPTION_KEY:-} - - APIP_CP_AUTH_JWT_SECRET_KEY=${APIP_CP_AUTH_JWT_SECRET_KEY:-} depends_on: postgres: condition: service_healthy + platform-api-certgen: + condition: service_completed_successfully + platform-api-jwtkeygen: + condition: service_completed_successfully healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "-O", "/dev/null", "--no-check-certificate", "https://localhost:9243/health"] interval: 30s @@ -120,4 +169,6 @@ volumes: driver: local platform-api-certs: driver: local + platform-api-jwt-keys: + driver: local postgres_data: diff --git a/platform-api/config/config-template.toml b/platform-api/config/config-template.toml index aa0a2f7f8..1e4905531 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -24,10 +24,14 @@ # # QUICK START (file auth mode — no external IDP needed): # 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 `api-platform.env`. The fields below read them via -# {{ env "..." }} tokens — never paste raw key values into this file. +# 2. Generate APIP_CP_ENCRYPTION_KEY: openssl rand -hex 32, put it in +# `api-platform.env`. The field below reads it via a {{ env "..." }} token +# — never paste raw key values into this file. +# 3. Generate an RS256 JWT keypair (PEM files, not an env var — see +# auth.jwt.public_key / private_key below): +# openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ +# -out jwt_private.pem +# openssl rsa -in jwt_private.pem -pubout -out jwt_public.pem # 4. Set [platform_api.auth] mode = "file" and configure users. # 5. Run: docker compose up # diff --git a/portals/developer-portal/it/Makefile b/portals/developer-portal/it/Makefile index 171cc8932..fd7010aa0 100644 --- a/portals/developer-portal/it/Makefile +++ b/portals/developer-portal/it/Makefile @@ -36,6 +36,11 @@ CYPRESS_PLATFORM ?= $(shell [ "$$(uname -m)" = "arm64" ] && echo "linux/arm64" | # platform-api (docker-compose.test*.yaml) instead of the old certgen init # container + named volume. 644, not 600, so the container's non-root user can # read a file owned by the host user (same tradeoff as setup.sh's CERT_FILE_MODE). +# +# Also provisions the RS256 JWT signing keypair platform-api's auth.jwt config +# requires (config-platform-api-it.toml reads it via {{ file }}) — tokens are +# signed asymmetrically, there is no shared HMAC secret to pass via env anymore. +# Written into the same dir so one bind mount covers both TLS and JWT material. ensure-certs: @mkdir -p $(CERT_DIR) @if [ -f $(CERT_DIR)/cert.pem ] && [ -f $(CERT_DIR)/key.pem ]; then \ @@ -48,6 +53,16 @@ ensure-certs: -addext "subjectAltName=DNS:localhost,DNS:platform-api,IP:127.0.0.1" 2>/dev/null; \ chmod 644 $(CERT_DIR)/key.pem $(CERT_DIR)/cert.pem; \ fi + @if [ -f $(CERT_DIR)/jwt_private.pem ] && [ -f $(CERT_DIR)/jwt_public.pem ]; then \ + echo "Using existing $(CERT_DIR)/jwt_{private,public}.pem"; \ + else \ + echo "Generating platform-api RS256 JWT keypair at $(CERT_DIR) ..."; \ + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ + -out $(CERT_DIR)/jwt_private.pem 2>/dev/null; \ + openssl rsa -in $(CERT_DIR)/jwt_private.pem -pubout \ + -out $(CERT_DIR)/jwt_public.pem 2>/dev/null; \ + chmod 644 $(CERT_DIR)/jwt_private.pem $(CERT_DIR)/jwt_public.pem; \ + fi # Tag the pre-built developer-portal image as :test so docker-compose.test.yaml can # reference a stable :test tag regardless of whether the local build is :VERSION. 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 8987606a9..1e13649b0 100644 --- a/portals/developer-portal/it/configs/config-platform-api-it.toml +++ b/portals/developer-portal/it/configs/config-platform-api-it.toml @@ -40,8 +40,11 @@ path = '{{ env "APIP_CP_DATABASE_PATH" "/app/data/platform-api-it.db" }}' mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' [platform_api.auth.jwt] -issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api-it" }}' -secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' +issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api-it" }}' +# RS256 keypair generated by `make ensure-certs` (it/Makefile) and bind-mounted +# read-only at /etc/platform-api/keys — on the Platform API's {{ file }} allowlist. +public_key = '{{ file "/etc/platform-api/keys/jwt_public.pem" }}' +private_key = '{{ file "/etc/platform-api/keys/jwt_private.pem" }}' [platform_api.auth.file.organization] id = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_ID" "default" }}' diff --git a/portals/developer-portal/it/docker-compose.test.postgres.yaml b/portals/developer-portal/it/docker-compose.test.postgres.yaml index f76770015..ad14d81b7 100644 --- a/portals/developer-portal/it/docker-compose.test.postgres.yaml +++ b/portals/developer-portal/it/docker-compose.test.postgres.yaml @@ -50,14 +50,17 @@ services: command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: APIP_CP_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - APIP_CP_AUTH_JWT_SECRET_KEY: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" volumes: - ./configs/config-platform-api-it.toml:/etc/platform-api/config-platform-api.toml:ro - platform-api-data:/app/data - # TLS pair generated once, host-side, by `make ensure-certs` (mirrors - # ../scripts/setup.sh's approach for the production compose) — read-only, - # 644-mode so the container's non-root user can read a host-owned file. + # TLS pair + RS256 JWT keypair generated once, host-side, by `make + # ensure-certs` (mirrors ../scripts/setup.sh's approach for the production + # compose) — read-only, 644-mode so the container's non-root user can read + # a host-owned file. Same host dir, two mount points: TLS cert/key.pem + # under /app/data/certs, jwt_{public,private}.pem under + # /etc/platform-api/keys (the Platform API's {{ file }} allowlist). - ./.certs:/app/data/certs:ro + - ./.certs:/etc/platform-api/keys:ro healthcheck: test: ["CMD", "curl", "-fk", "https://localhost:9243/health"] interval: 5s diff --git a/portals/developer-portal/it/docker-compose.test.yaml b/portals/developer-portal/it/docker-compose.test.yaml index 00b782448..b05c20e9d 100644 --- a/portals/developer-portal/it/docker-compose.test.yaml +++ b/portals/developer-portal/it/docker-compose.test.yaml @@ -34,14 +34,17 @@ services: command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: APIP_CP_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - APIP_CP_AUTH_JWT_SECRET_KEY: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" volumes: - ./configs/config-platform-api-it.toml:/etc/platform-api/config-platform-api.toml:ro - platform-api-data:/app/data - # TLS pair generated once, host-side, by `make ensure-certs` (mirrors - # ../scripts/setup.sh's approach for the production compose) — read-only, - # 644-mode so the container's non-root user can read a host-owned file. + # TLS pair + RS256 JWT keypair generated once, host-side, by `make + # ensure-certs` (mirrors ../scripts/setup.sh's approach for the production + # compose) — read-only, 644-mode so the container's non-root user can read + # a host-owned file. Same host dir, two mount points: TLS cert/key.pem + # under /app/data/certs, jwt_{public,private}.pem under + # /etc/platform-api/keys (the Platform API's {{ file }} allowlist). - ./.certs:/app/data/certs:ro + - ./.certs:/etc/platform-api/keys:ro healthcheck: test: ["CMD", "curl", "-fk", "https://localhost:9243/health"] interval: 5s diff --git a/tests/integration-e2e/devportal-config.toml b/tests/integration-e2e/devportal-config.toml index c09acbe82..bcf3b2203 100644 --- a/tests/integration-e2e/devportal-config.toml +++ b/tests/integration-e2e/devportal-config.toml @@ -40,8 +40,11 @@ default_name = '{{ env "APIP_DP_ORGANIZATION_DEFAULTNAME" "default" }}' auto_create_subscription_plans = false [platform_api] -# The devportal validates the platform-api admin JWT locally; jwt_secret MUST -# equal the stack's APIP_CP_AUTH_JWT_SECRET_KEY so the admin token is accepted. +# platform-api signs admin JWTs with RS256 now — there is no shared HMAC secret +# to set here. jwt_secret left empty (APIP_DP_PLATFORMAPI_JWTSECRET is unset in +# docker-compose.yaml) makes the devportal decode the token payload without +# verifying its signature, trusting the direct HTTPS connection (insecure=true) +# instead — see extractPlatformJwtClaims in src/utils/platformJwt.js. base_url = '{{ env "APIP_DP_PLATFORMAPI_BASEURL" "https://platform-api:9243" }}' jwt_secret = '{{ env "APIP_DP_PLATFORMAPI_JWTSECRET" }}' insecure = true diff --git a/tests/integration-e2e/docker-compose.sqlite.yaml b/tests/integration-e2e/docker-compose.sqlite.yaml index b00ec5bf1..9b65c37f5 100644 --- a/tests/integration-e2e/docker-compose.sqlite.yaml +++ b/tests/integration-e2e/docker-compose.sqlite.yaml @@ -23,6 +23,29 @@ services: - platform-api-certs:/certs networks: [e2e] + # One-shot init container: generates the RS256 keypair platform-api's + # auth.jwt config requires (platform-api-config.toml reads it via {{ file }}). + # Tokens are signed asymmetrically — there is no shared HMAC secret anymore. + platform-api-jwtkeygen: + image: alpine/openssl + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + [ -f /keys/jwt_private.pem ] && [ -f /keys/jwt_public.pem ] && exit 0 + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ + -out /keys/jwt_private.pem 2>/dev/null + openssl rsa -in /keys/jwt_private.pem -pubout \ + -out /keys/jwt_public.pem 2>/dev/null + # jwtkeygen runs as root; platform-api runs as uid 10001, so the private + # key must be owned by that uid, not made world-readable. + chown 10001 /keys/jwt_private.pem + chmod 0600 /keys/jwt_private.pem + chmod 0644 /keys/jwt_public.pem + volumes: + - platform-api-jwt-keys:/keys + networks: [e2e] + platform-api: image: ${PLATFORM_API_IMAGE:-platform-api:it-e2e} command: ["-config", "/etc/platform-api/config.toml"] @@ -38,15 +61,17 @@ services: - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_UUID=99089a17-72e0-4dd8-a2f4-c8dfbb085295 - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_DISPLAY_NAME=Default - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_REGION=us - - APIP_CP_AUTH_JWT_SECRET_KEY=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro - platform-api-certs:/app/data/certs + - platform-api-jwt-keys:/etc/platform-api/keys:ro ports: - "${PA_HOST_PORT:-9243}:9243" depends_on: platform-api-certgen: condition: service_completed_successfully + platform-api-jwtkeygen: + condition: service_completed_successfully networks: [e2e] gateway-controller: @@ -103,3 +128,4 @@ networks: volumes: platform-api-certs: + platform-api-jwt-keys: diff --git a/tests/integration-e2e/docker-compose.sqlserver.yaml b/tests/integration-e2e/docker-compose.sqlserver.yaml index 2c83be0d9..e395c576b 100644 --- a/tests/integration-e2e/docker-compose.sqlserver.yaml +++ b/tests/integration-e2e/docker-compose.sqlserver.yaml @@ -62,6 +62,29 @@ services: - platform-api-certs:/certs networks: [e2e] + # One-shot init container: generates the RS256 keypair platform-api's + # auth.jwt config requires (platform-api-config.toml reads it via {{ file }}). + # Tokens are signed asymmetrically — there is no shared HMAC secret anymore. + platform-api-jwtkeygen: + image: alpine/openssl + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + [ -f /keys/jwt_private.pem ] && [ -f /keys/jwt_public.pem ] && exit 0 + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ + -out /keys/jwt_private.pem 2>/dev/null + openssl rsa -in /keys/jwt_private.pem -pubout \ + -out /keys/jwt_public.pem 2>/dev/null + # jwtkeygen runs as root; platform-api runs as uid 10001, so the private + # key must be owned by that uid, not made world-readable. + chown 10001 /keys/jwt_private.pem + chmod 0600 /keys/jwt_private.pem + chmod 0644 /keys/jwt_public.pem + volumes: + - platform-api-jwt-keys:/keys + networks: [e2e] + platform-api: image: ${PLATFORM_API_IMAGE:-platform-api:it-e2e} command: ["-config", "/etc/platform-api/config.toml"] @@ -81,10 +104,10 @@ services: - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_UUID=99089a17-72e0-4dd8-a2f4-c8dfbb085295 - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_DISPLAY_NAME=Default - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_REGION=us - - APIP_CP_AUTH_JWT_SECRET_KEY=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro - platform-api-certs:/app/data/certs + - platform-api-jwt-keys:/etc/platform-api/keys:ro ports: - "${PA_HOST_PORT:-9243}:9243" depends_on: @@ -92,6 +115,8 @@ services: condition: service_completed_successfully platform-api-certgen: condition: service_completed_successfully + platform-api-jwtkeygen: + condition: service_completed_successfully networks: [e2e] gateway-controller: @@ -150,3 +175,4 @@ networks: volumes: platform-api-certs: + platform-api-jwt-keys: diff --git a/tests/integration-e2e/docker-compose.yaml b/tests/integration-e2e/docker-compose.yaml index d80997242..24ba7f98d 100644 --- a/tests/integration-e2e/docker-compose.yaml +++ b/tests/integration-e2e/docker-compose.yaml @@ -63,6 +63,29 @@ services: - platform-api-certs:/certs networks: [e2e] + # One-shot init container: generates the RS256 keypair platform-api's + # auth.jwt config requires (platform-api-config.toml reads it via {{ file }}). + # Tokens are signed asymmetrically — there is no shared HMAC secret anymore. + platform-api-jwtkeygen: + image: alpine/openssl + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + [ -f /keys/jwt_private.pem ] && [ -f /keys/jwt_public.pem ] && exit 0 + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ + -out /keys/jwt_private.pem 2>/dev/null + openssl rsa -in /keys/jwt_private.pem -pubout \ + -out /keys/jwt_public.pem 2>/dev/null + # jwtkeygen runs as root; platform-api runs as uid 10001, so the private + # key must be owned by that uid, not made world-readable. + chown 10001 /keys/jwt_private.pem + chmod 0600 /keys/jwt_private.pem + chmod 0644 /keys/jwt_public.pem + volumes: + - platform-api-jwt-keys:/keys + networks: [e2e] + platform-api: image: ${PLATFORM_API_IMAGE:-platform-api:it-e2e} command: ["-config", "/etc/platform-api/config.toml"] @@ -82,7 +105,6 @@ services: - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_UUID=99089a17-72e0-4dd8-a2f4-c8dfbb085295 - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_DISPLAY_NAME=Default - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_REGION=us - - APIP_CP_AUTH_JWT_SECRET_KEY=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 # The @devportal stack injects the admin user (with dp:* scopes) here so the # issued JWT is authorized on the developer portal. Empty (the default) means # platform-api uses its built-in admin (ap:* scopes only). @@ -104,6 +126,7 @@ services: # fallback for manual `docker compose up` after a suite run has generated it. - ${PA_WEBHOOK_KEY:-./.webhook-key.it.pem}:/etc/platform-api/devportal-webhook.pem:ro - platform-api-certs:/app/data/certs + - platform-api-jwt-keys:/etc/platform-api/keys:ro ports: - "${PA_HOST_PORT:-9243}:9243" depends_on: @@ -111,6 +134,8 @@ services: condition: service_healthy platform-api-certgen: condition: service_completed_successfully + platform-api-jwtkeygen: + condition: service_completed_successfully networks: [e2e] gateway-controller: @@ -259,10 +284,14 @@ services: - APIP_DP_DEMO_ENABLED=false - APIP_DP_ORGANIZATION_AUTOCREATESUBSCRIPTIONPLANS=false - APIP_DP_LOGGING_CONSOLEONLY=true - # Validate platform-api-issued JWTs locally: this MUST equal the stack's - # APIP_CP_AUTH_JWT_SECRET_KEY so the admin token is accepted by the devportal. + # platform-api now signs admin tokens with RS256 (asymmetric), so there is + # no shared HMAC secret to hand the devportal for local verification. + # APIP_DP_PLATFORMAPI_JWTSECRET is left unset: the devportal decodes the + # token payload without verifying its signature, trusting the direct + # HTTPS connection to platform-api instead (see extractPlatformJwtClaims + # in src/utils/platformJwt.js) — the same trust boundary + # APIP_DP_PLATFORMAPI_INSECURE already relies on for this test stack. - APIP_DP_PLATFORMAPI_BASEURL=https://platform-api:9243 - - APIP_DP_PLATFORMAPI_JWTSECRET=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 - APIP_DP_PLATFORMAPI_INSECURE=true # The webhook delivery worker POSTs over raw https with the default agent, # and platform-api serves a self-signed cert here. APIP_DP_PLATFORMAPI_INSECURE @@ -286,3 +315,4 @@ networks: volumes: platform-api-certs: + platform-api-jwt-keys: diff --git a/tests/integration-e2e/platform-api-config.toml b/tests/integration-e2e/platform-api-config.toml index 3866f262d..7243d4e02 100644 --- a/tests/integration-e2e/platform-api-config.toml +++ b/tests/integration-e2e/platform-api-config.toml @@ -16,7 +16,7 @@ path = '{{ env "APIP_CP_DATABASE_PATH" "/app/data/platform.db" }}' host = '{{ env "APIP_CP_DATABASE_HOST" "" }}' port = '{{ env "APIP_CP_DATABASE_PORT" "5432" }}' name = '{{ env "APIP_CP_DATABASE_NAME" "" }}' -username = '{{ env "APIP_CP_DATABASE_USER" "" }}' +user = '{{ env "APIP_CP_DATABASE_USER" "" }}' password = '{{ env "APIP_CP_DATABASE_PASSWORD" "" }}' ssl_mode = '{{ env "APIP_CP_DATABASE_SSL_MODE" "disable" }}' @@ -24,8 +24,12 @@ ssl_mode = '{{ env "APIP_CP_DATABASE_SSL_MODE" "disable" }}' mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' [platform_api.auth.jwt] -issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' -secret_key = '{{ env "APIP_CP_AUTH_JWT_SECRET_KEY" }}' +issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' +# RS256 keypair generated by the platform-api-jwtkeygen init container and +# bind-mounted read-only at /etc/platform-api/keys — on the Platform API's +# {{ file }} allowlist. +public_key = '{{ file "/etc/platform-api/keys/jwt_public.pem" }}' +private_key = '{{ file "/etc/platform-api/keys/jwt_private.pem" }}' [platform_api.auth.file.organization] id = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_ID" "default" }}' diff --git a/tests/integration-e2e/steps_devportal_test.go b/tests/integration-e2e/steps_devportal_test.go index 0ce034926..549a96b63 100644 --- a/tests/integration-e2e/steps_devportal_test.go +++ b/tests/integration-e2e/steps_devportal_test.go @@ -50,9 +50,12 @@ import ( // - Delivery is fire-once on a ~2s poll, and the plaintext key/token the portal // returns to the user are exactly what the gateway validates. // -// The devportal accepts the platform-api admin JWT directly (it verifies it with -// the shared APIP_DP_PLATFORMAPI_JWTSECRET and takes the org from the token's -// org_handle claim), so suite.token is reused for every call here. +// The devportal accepts the platform-api admin JWT directly (platform-api signs +// it with RS256, so with no shared HMAC secret configured the devportal decodes +// the payload without verifying the signature, trusting the direct HTTPS +// connection instead — see APIP_DP_PLATFORMAPI_INSECURE in docker-compose.yaml — +// and takes the org from the token's org_handle claim), so suite.token is reused +// for every call here. // webhookReceiverURL is the platform-api webhook receiver at its container-internal // host (the devportal reaches platform-api by service name on the compose network, From caea33b322d66b26a90353c59a5ed5eeefdf9087 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Wed, 22 Jul 2026 09:18:01 +0530 Subject: [PATCH 14/18] Update BFF configuration to support local development with static directory override --- portals/ai-workspace/Makefile | 3 +-- .../bff/internal/config/shipped_config_test.go | 9 +++++---- portals/ai-workspace/bff/main.go | 9 +++++++++ portals/ai-workspace/configs/config-template.toml | 7 ++++--- portals/ai-workspace/configs/config.toml | 4 ---- tests/integration-e2e/devportal-config.toml | 2 +- 6 files changed, 20 insertions(+), 14 deletions(-) diff --git a/portals/ai-workspace/Makefile b/portals/ai-workspace/Makefile index 91f0de8c6..d0cb9e02c 100644 --- a/portals/ai-workspace/Makefile +++ b/portals/ai-workspace/Makefile @@ -63,9 +63,8 @@ bff-run: ## Run the BFF locally (proxies to CONTROL_PLANE_URL; pair with `make r APIP_AIW_SERVER_HTTPS_CERT_FILE=../resources/certificates/cert.pem \ APIP_AIW_SERVER_HTTPS_KEY_FILE=../resources/certificates/key.pem \ APIP_AIW_SERVER_HTTPS_PORT=$(BFF_DEV_PORT) \ - APIP_AIW_SERVER_STATIC_DIR=../dist \ APIP_AIW_LOGGING_LEVEL=debug \ - go run . -config ../configs/config.toml + go run . -config ../configs/config.toml -static-dir ../dist bff-test: ## Run BFF unit tests cd $(BFF_DIR) && GOWORK=off go test ./... 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 caa29d322..6188a8cee 100644 --- a/portals/ai-workspace/bff/internal/config/shipped_config_test.go +++ b/portals/ai-workspace/bff/internal/config/shipped_config_test.go @@ -73,14 +73,16 @@ func TestShippedConfig_QuickstartLoadsWithNoEnv(t *testing.T) { // `make bff-run` loads this same file and points the container-shaped defaults at the // developer's machine, purely through the {{ env }} tokens in it. A token is the only // way an environment variable reaches a key, so dropping one of these keys from -// config.toml would silently strip the override and leave the BFF on :5380 serving -// /app. This test pins the Makefile's contract with the file. +// config.toml would silently strip the override and leave the BFF on :5380. This test +// pins the Makefile's contract with the file. static_dir is NOT among these: the +// shipped config has no token for it (the container always serves /app), so +// `make bff-run` retargets it with the -static-dir CLI flag in main.go instead — +// outside config.Load, and so outside this test. func TestShippedConfig_MakeBffRunOverrides(t *testing.T) { // Exactly the variables the bff-run target sets. t.Setenv("APIP_AIW_CONTROL_PLANE_URL", "https://localhost:9243") t.Setenv("APIP_AIW_CONTROL_PLANE_TLS_SKIP_VERIFY", "true") t.Setenv("APIP_AIW_SERVER_HTTPS_PORT", "8081") - t.Setenv("APIP_AIW_SERVER_STATIC_DIR", "../dist") t.Setenv("APIP_AIW_LOGGING_LEVEL", "debug") cfg, err := Load(quickstartConfig) @@ -90,7 +92,6 @@ func TestShippedConfig_MakeBffRunOverrides(t *testing.T) { for _, tc := range []struct{ name, got, want string }{ {"Addr", cfg.Addr(), ":8081"}, - {"StaticDir", cfg.Server.StaticDir, "../dist"}, {"ControlPlane.URL", cfg.ControlPlane.URL, "https://localhost:9243"}, {"LogLevel", cfg.Logging.Level, "debug"}, } { diff --git a/portals/ai-workspace/bff/main.go b/portals/ai-workspace/bff/main.go index 3d988abff..55f0a89a3 100644 --- a/portals/ai-workspace/bff/main.go +++ b/portals/ai-workspace/bff/main.go @@ -91,6 +91,12 @@ func main() { // The container reads its mounted config.toml, so -config is only needed to run // the BFF outside one (see `make bff-run`). configFile := flag.String("config", config.DefaultConfigFile, "path to config.toml") + // The shipped config.toml has no {{ env }} token for static_dir — the container + // always serves the SPA baked in at /app, so there's nothing for an operator to + // override. `make bff-run` is the one caller that needs a different directory + // (the locally-built ../dist), so it's a dev-only CLI flag rather than a config + // key, keeping the shipped config free of a token no deployment ever sets. + staticDir := flag.String("static-dir", "", "override the directory serving the built SPA (local dev only, e.g. `make bff-run`)") flag.Parse() cfg, err := config.Load(*configFile) @@ -99,6 +105,9 @@ func main() { slog.Error("configuration error", "err", err) os.Exit(1) } + if *staticDir != "" { + cfg.Server.StaticDir = *staticDir + } slog.SetDefault(logger.NewLogger(logger.Config{Level: cfg.Logging.Level, Format: cfg.Logging.Format})) ctx, cancel := context.WithCancel(context.Background()) diff --git a/portals/ai-workspace/configs/config-template.toml b/portals/ai-workspace/configs/config-template.toml index f3051b19f..a2a84fbdc 100644 --- a/portals/ai-workspace/configs/config-template.toml +++ b/portals/ai-workspace/configs/config-template.toml @@ -81,10 +81,11 @@ default_org_region = '{{ env "APIP_AIW_DEFAULT_ORG_REGION" "us" }}' # All keys are optional: the value in each token's default position is the built-in # default. # --------------------------------------------------------------------------- -[ai_workspace.server] -# Directory holding the built SPA (index.html + assets). -static_dir = '{{ env "APIP_AIW_SERVER_STATIC_DIR" "/app" }}' +# Note: the directory serving the built SPA (index.html + assets) is not +# configurable here — the container always serves the copy baked in at /app. +# Local dev (`make bff-run`) points the BFF at a different build via the +# -static-dir CLI flag instead, since no deployment ever needs to override it. # The single listener follows the platform-wide [server.https] shape. The listener # always binds all interfaces (there is no host to configure), and the container diff --git a/portals/ai-workspace/configs/config.toml b/portals/ai-workspace/configs/config.toml index 651b9f2dd..cf247bbfb 100644 --- a/portals/ai-workspace/configs/config.toml +++ b/portals/ai-workspace/configs/config.toml @@ -13,10 +13,6 @@ domain = '{{ env "APIP_AIW_DOMAIN" "localhost:5380" }}' default_org_region = '{{ env "APIP_AIW_DEFAULT_ORG_REGION" "us" }}' -[ai_workspace.server] - -static_dir = '{{ env "APIP_AIW_SERVER_STATIC_DIR" "/app" }}' - [ai_workspace.server.https] enabled = '{{ env "APIP_AIW_SERVER_HTTPS_ENABLED" "true" }}' diff --git a/tests/integration-e2e/devportal-config.toml b/tests/integration-e2e/devportal-config.toml index bcf3b2203..04ebf0349 100644 --- a/tests/integration-e2e/devportal-config.toml +++ b/tests/integration-e2e/devportal-config.toml @@ -46,7 +46,7 @@ auto_create_subscription_plans = false # verifying its signature, trusting the direct HTTPS connection (insecure=true) # instead — see extractPlatformJwtClaims in src/utils/platformJwt.js. base_url = '{{ env "APIP_DP_PLATFORMAPI_BASEURL" "https://platform-api:9243" }}' -jwt_secret = '{{ env "APIP_DP_PLATFORMAPI_JWTSECRET" }}' +jwt_secret = '{{ env "APIP_DP_PLATFORMAPI_JWTSECRET" "" }}' insecure = true [security] From fffab52c068eba9ea899123e9a3911b08dcc4335 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Wed, 22 Jul 2026 09:54:05 +0530 Subject: [PATCH 15/18] Refactor authMiddleware to enhance token verification logic. --- .../src/middlewares/authMiddleware.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/portals/developer-portal/src/middlewares/authMiddleware.js b/portals/developer-portal/src/middlewares/authMiddleware.js index 68da0dcb1..2fae23236 100644 --- a/portals/developer-portal/src/middlewares/authMiddleware.js +++ b/portals/developer-portal/src/middlewares/authMiddleware.js @@ -161,10 +161,21 @@ async function verifyBearerToken(token, req) { const idp = resolveOrgIdp(); if (!idp || !idp.clientId) { // Local auth mode: verify the Platform API JWT with the shared secret. - // Fail closed if no secret is configured — never accept an unverified token. const jwtSecret = config.platformApi?.jwtSecret; - if (!jwtSecret) return { valid: false, scopes: '' }; - const claims = await verifyPlatformJwtClaims(token, jwtSecret); + if (jwtSecret) { + const claims = await verifyPlatformJwtClaims(token, jwtSecret); + if (!claims) return { valid: false, scopes: '' }; + return { valid: true, scopes: claims.scopes?.join(' ') ?? '' }; + } + // Platform API now signs its admin tokens with RS256 (asymmetric), so there + // is no shared HMAC secret to verify against. When platformApi.insecure is + // explicitly enabled, decode the payload without verifying its signature, + // trusting the direct HTTPS connection to Platform API instead (mirrors the + // session-based local-auth branch above, which already does this via + // decodePlatformJwtClaims). Fail closed otherwise — never accept an + // unverified token by default. + if (!config.platformApi?.insecure) return { valid: false, scopes: '' }; + const claims = decodePlatformJwtClaims(token); if (!claims) return { valid: false, scopes: '' }; return { valid: true, scopes: claims.scopes?.join(' ') ?? '' }; } From 9c7cb37bde76457c95bef12c60d49c3509a3e44f Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Wed, 22 Jul 2026 09:59:52 +0530 Subject: [PATCH 16/18] Update go.sum to remove redundant module entry for cloudflare/circl --- event-gateway/it/go.sum | 1 - 1 file changed, 1 deletion(-) diff --git a/event-gateway/it/go.sum b/event-gateway/it/go.sum index bf8f5e7c4..49167fa37 100644 --- a/event-gateway/it/go.sum +++ b/event-gateway/it/go.sum @@ -33,7 +33,6 @@ github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F9 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= -github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= github.com/compose-spec/compose-go/v2 v2.11.0 h1:xoq/ootgIL6TsHmbJHrkuh7+bzjhPV3NHftHRPPyVXM= From ef7808e32a57d3d088716dc0553436c56a56a0d9 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Wed, 22 Jul 2026 10:57:51 +0530 Subject: [PATCH 17/18] Update go.mod and go.sum files to add new dependencies and update existing ones for gateway-operator and ai-workspace BFF. --- kubernetes/gateway-operator/go.mod | 4 ++-- portals/ai-workspace/bff/go.mod | 4 ++-- portals/ai-workspace/bff/go.sum | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/kubernetes/gateway-operator/go.mod b/kubernetes/gateway-operator/go.mod index bb9cb9c0e..f16e15427 100644 --- a/kubernetes/gateway-operator/go.mod +++ b/kubernetes/gateway-operator/go.mod @@ -15,11 +15,13 @@ require ( gopkg.in/yaml.v3 v3.0.1 helm.sh/helm/v3 v3.18.5 k8s.io/api v0.35.2 + k8s.io/apiextensions-apiserver v0.35.2 k8s.io/apimachinery v0.35.2 k8s.io/cli-runtime v0.33.3 k8s.io/client-go v0.35.2 sigs.k8s.io/controller-runtime v0.23.1 sigs.k8s.io/gateway-api v1.5.1 + sigs.k8s.io/yaml v1.6.0 ) require ( @@ -164,7 +166,6 @@ require ( google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - k8s.io/apiextensions-apiserver v0.35.2 // indirect k8s.io/apiserver v0.35.2 // indirect k8s.io/component-base v0.35.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect @@ -177,5 +178,4 @@ require ( sigs.k8s.io/kustomize/kyaml v0.19.0 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/portals/ai-workspace/bff/go.mod b/portals/ai-workspace/bff/go.mod index 4cc21ea9a..c405405cc 100644 --- a/portals/ai-workspace/bff/go.mod +++ b/portals/ai-workspace/bff/go.mod @@ -3,6 +3,7 @@ module ai-workspace-bff go 1.26.5 require ( + github.com/go-viper/mapstructure/v2 v2.5.0 github.com/knadh/koanf/parsers/toml/v2 v2.2.0 github.com/knadh/koanf/providers/confmap v1.0.0 github.com/knadh/koanf/providers/file v1.2.1 @@ -12,12 +13,11 @@ require ( require ( github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - golang.org/x/sys v0.43.0 // indirect + golang.org/x/sys v0.47.0 // indirect ) replace github.com/wso2/api-platform/common => ../../../common diff --git a/portals/ai-workspace/bff/go.sum b/portals/ai-workspace/bff/go.sum index a4aad55d5..6aad93e97 100644 --- a/portals/ai-workspace/bff/go.sum +++ b/portals/ai-workspace/bff/go.sum @@ -2,8 +2,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/toml/v2 v2.2.0 h1:2nV7tHYJ5OZy2BynQ4mOJ6k5bDqbbCzRERLUKBytz3A= @@ -24,7 +24,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From b881d856aeda3a9b1a4b4046979c458ce8cc07c7 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Wed, 22 Jul 2026 12:33:03 +0530 Subject: [PATCH 18/18] Refactor JWT configuration to use file paths for public and private keys. --- platform-api/config/config-template.toml | 232 +++++++++--------- platform-api/config/config.go | 99 +++++--- platform-api/config/config.toml | 38 +-- platform-api/config/config_test.go | 87 ++++--- platform-api/internal/handler/auth_login.go | 11 +- platform-api/internal/server/server.go | 9 +- portals/ai-workspace/Makefile | 26 +- portals/ai-workspace/README.md | 2 +- .../bff/internal/config/config.go | 32 +-- .../bff/internal/config/config_test.go | 34 +-- .../bff/internal/config/default_config.go | 15 +- .../bff/internal/config/runtime_config.go | 2 +- .../internal/config/shipped_config_test.go | 57 +++-- portals/ai-workspace/bff/main.go | 10 +- .../ai-workspace/configs/config-template.toml | 90 ++++--- portals/ai-workspace/configs/config.toml | 20 +- portals/ai-workspace/distribution/README.md | 53 ++-- portals/ai-workspace/docker-compose.yaml | 4 - portals/ai-workspace/src/config.env.ts | 2 +- portals/ai-workspace/vite.config.ts | 2 +- .../it/configs/config-platform-api-it.toml | 7 +- .../integration-e2e/platform-api-config.toml | 8 +- 22 files changed, 447 insertions(+), 393 deletions(-) diff --git a/platform-api/config/config-template.toml b/platform-api/config/config-template.toml index 1e4905531..5b2815dab 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -16,30 +16,34 @@ # default. Ships with distributions (e.g. the AI Workspace zip), copied at # build time from here. # -# PRECEDENCE: a {{ env "..." }} / {{ file "..." }} token resolves from that -# source; a plain literal is used as-is; an omitted key falls back to its -# built-in default. Every non-secret key below is a {{ env "VAR" "default" }} -# token — override by setting VAR, no file edit needed. The env var name is -# just this template's naming convention (APIP_CP_), not enforced by the loader. +# Every key below is a plain literal — edit the value directly. For secrets +# (encryption_key, admin credentials, the webhook secret) that means filling +# in a real value before deploying; an empty literal is a placeholder, not a +# safe default. For a value that should come from the environment or a +# mounted file instead, use an interpolation token: +# +# key = '{{ env "APIP_CP_VAR" "default" }}' +# key = '{{ file "/secrets/platform-api/key" }}' +# +# Both fail closed at startup — a missing file, a file outside the allowed +# source directories (default /etc/platform-api and /secrets/platform-api; +# override with APIP_CONFIG_FILE_SOURCE_ALLOWLIST), or an unset variable with +# no default all refuse to start rather than run with an empty value. +# +# Never write a secret as a raw literal in a file committed to version +# control or hardcode one in docker-compose.yaml. # # QUICK START (file auth mode — no external IDP needed): # 1. Copy this file to config-platform-api.toml. -# 2. Generate APIP_CP_ENCRYPTION_KEY: openssl rand -hex 32, put it in -# `api-platform.env`. The field below reads it via a {{ env "..." }} token -# — never paste raw key values into this file. -# 3. Generate an RS256 JWT keypair (PEM files, not an env var — see -# auth.jwt.public_key / private_key below): +# 2. Set security.encryption_key: openssl rand -hex 32. +# 3. Generate an RS256 JWT keypair and mount it at the paths named by +# auth.jwt.public_key_file / private_key_file below: # openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ # -out jwt_private.pem # openssl rsa -in jwt_private.pem -pubout -out jwt_public.pem # 4. Set [platform_api.auth] mode = "file" and configure users. # 5. Run: docker compose up # -# Secrets resolve from env vars ({{ env "..." }}) or mounted files -# ({{ file "..." }}, preferred in production) — never hardcode secrets here -# or in docker-compose.yaml. A token with no fallback fails config load if -# its source is unset; only reference sources that exist. -# # OIDC mode: set [platform_api.auth] mode = "idp" and configure [platform_api.auth.idp]. # -------------------------------------------------------------------- @@ -50,20 +54,20 @@ # Resource paths loaded at startup. Defaults match the published container # image — override only for a custom layout. -db_schema_path = '{{ env "APIP_CP_DB_SCHEMA_PATH" "./internal/database/schema.sql" }}' -openapi_spec_path = '{{ env "APIP_CP_OPENAPI_SPEC_PATH" "./resources/openapi.yaml" }}' -llm_template_definitions_path = '{{ env "APIP_CP_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" # Byte cap when fetching a remote OpenAPI spec by URL. <= 0 uses the # built-in 5 MiB default. -openapi_spec_max_fetch_bytes = '{{ env "APIP_CP_OPENAPI_SPEC_MAX_FETCH_BYTES" "5242880" }}' +openapi_spec_max_fetch_bytes = 5242880 [platform_api.logging] # Level is matched case-insensitively; lowercase is canonical. -level = '{{ env "APIP_CP_LOGGING_LEVEL" "info" }}' # debug | info | warn | error +level = "info" # debug | info | warn | error # Log encoding. Use "json" when shipping logs to an aggregator. -format = '{{ env "APIP_CP_LOGGING_FORMAT" "text" }}' # text | json +format = "text" # text | json # --------------------------------------------------------------------------- # Security @@ -71,53 +75,53 @@ format = '{{ env "APIP_CP_LOGGING_FORMAT" "text" }}' # text | json [platform_api.security] # Single 32-byte key (64 hex chars or base64) used for ALL at-rest # encryption (secrets, subscription tokens, WebSub HMAC secrets). -# REQUIRED — no fallback, so an unset APIP_CP_ENCRYPTION_KEY fails config -# load. Generate with: openssl rand -hex 32 +# REQUIRED — an empty value fails config load. Generate with: +# openssl rand -hex 32 # Prefer a mounted secret file in production: # encryption_key = '{{ file "/secrets/platform-api/encryption_key" }}' -encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' +encryption_key = "" [platform_api.security.api_key] # Accepted API key hashing algorithms (comma-separated). sha256 is the # default; add "sha512" to accept both. -hashing_algorithms = '{{ env "APIP_CP_API_KEY_HASHING_ALGORITHMS" "sha256" }}' +hashing_algorithms = "sha256" # --------------------------------------------------------------------------- # Database # --------------------------------------------------------------------------- [platform_api.database] -driver = '{{ env "APIP_CP_DATABASE_DRIVER" "sqlite3" }}' # "sqlite3" | "postgres" | "postgresql" | "pgx" | "sqlserver" | "mssql" +driver = "sqlite3" # "sqlite3" | "postgres" | "postgresql" | "pgx" | "sqlserver" | "mssql" # SQLite — ignored when driver = "postgres". -path = '{{ env "APIP_CP_DATABASE_PATH" "/app/data/api_platform.db" }}' +path = "/app/data/api_platform.db" # PostgreSQL/SQL Server — ignored when driver = "sqlite3". host, port, name, # and user are required for every non-sqlite3 driver, or config load fails. -host = '{{ env "APIP_CP_DATABASE_HOST" "localhost" }}' -port = '{{ env "APIP_CP_DATABASE_PORT" "5432" }}' -name = '{{ env "APIP_CP_DATABASE_NAME" "platform_api" }}' -user = '{{ env "APIP_CP_DATABASE_USER" "platform_api" }}' +host = "localhost" +port = 5432 +name = "platform_api" +user = "platform_api" # Prefer a mounted secret file in production, e.g. # password = '{{ file "/secrets/platform-api/postgres_password" }}' -# The referenced file/env var must exist or config load fails; the "" -# fallback here is safe because sqlite3 needs no password. -password = '{{ env "APIP_CP_DATABASE_PASSWORD" "" }}' -ssl_mode = '{{ env "APIP_CP_DATABASE_SSL_MODE" "disable" }}' # "disable" | "require" | "verify-ca" | "verify-full" +# The "" default here is safe because sqlite3 needs no password; set a real +# value when driver is a non-sqlite3 database. +password = "" +ssl_mode = "disable" # "disable" | "require" | "verify-ca" | "verify-full" # CA cert used to verify the server's certificate. Required when ssl_mode # is "verify-ca" or "verify-full"; ignored otherwise. -ssl_root_cert = '{{ env "APIP_CP_DATABASE_SSL_ROOT_CERT" "" }}' +ssl_root_cert = "" # Client cert/key pair for mutual TLS (PostgreSQL only — SQL Server's driver # has no client-certificate support). Both must be set together or both # left empty. -ssl_cert = '{{ env "APIP_CP_DATABASE_SSL_CERT" "" }}' -ssl_key = '{{ env "APIP_CP_DATABASE_SSL_KEY" "" }}' +ssl_cert = "" +ssl_key = "" # Connection pool — tune for production workloads. -max_open_conns = '{{ env "APIP_CP_DATABASE_MAX_OPEN_CONNS" "25" }}' # maximum open connections -max_idle_conns = '{{ env "APIP_CP_DATABASE_MAX_IDLE_CONNS" "10" }}' # maximum idle connections in the pool -conn_max_lifetime = '{{ env "APIP_CP_DATABASE_CONN_MAX_LIFETIME" "300" }}' # seconds before a connection is recycled +max_open_conns = 25 # maximum open connections +max_idle_conns = 10 # maximum idle connections in the pool +conn_max_lifetime = 300 # seconds before a connection is recycled # --------------------------------------------------------------------------- # Authentication @@ -126,28 +130,27 @@ conn_max_lifetime = '{{ env "APIP_CP_DATABASE_CONN_MAX_LIFETIME" "300" }}' # s # "external_token" — verify locally-issued, asymmetrically-signed (RS256) JWTs, # minted externally (e.g. by the Developer Portal) and # signed with the matching RSA private key; verified here -# against auth.jwt.public_key. +# against auth.jwt.public_key_file. # "file" — external_token + local username/password login: the # login endpoint authenticates from # [platform_api.auth.file] and issues RS256 JWTs signed -# with auth.jwt.private_key. +# with auth.jwt.private_key_file. # "idp" — validate tokens against an external IDP's JWKS # ([platform_api.auth.idp]). # Only the selected mode's section is read; the others are ignored. # --------------------------------------------------------------------------- [platform_api.auth] -mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' +mode = "file" # Enforce per-endpoint OAuth2 scopes on validated tokens. Set false only to # temporarily bypass authorization during development. -scope_validation = '{{ env "APIP_CP_AUTH_SCOPE_VALIDATION" "true" }}' +scope_validation = true # Paths that bypass the auth middleware entirely. The list below is the # built-in default: health/metrics probes, the file-based login endpoint, # and internal gateway routes (which authenticate with a gateway token # instead). Setting this key REPLACES the default list — keep the entries -# you still need. Structured list, so it's edited directly rather than via -# a single env var token. +# you still need. skip_paths = [ "/health", "/metrics", @@ -175,48 +178,46 @@ skip_paths = [ # into a nested claim ("realm_access.org_id") — useful for IDPs like # Keycloak that nest fields such as roles under realm_access.roles. [platform_api.auth.claim_mappings] -organization = '{{ env "APIP_CP_AUTH_CLAIM_ORGANIZATION" "organization" }}' # claim carrying the org ID -org_name = '{{ env "APIP_CP_AUTH_CLAIM_ORG_NAME" "org_name" }}' # claim carrying the org display name -org_handle = '{{ env "APIP_CP_AUTH_CLAIM_ORG_HANDLE" "org_handle" }}' # claim carrying the org URL slug -user_id = '{{ env "APIP_CP_AUTH_CLAIM_USER_ID" "sub" }}' # claim used as the user's unique ID -username = '{{ env "APIP_CP_AUTH_CLAIM_USERNAME" "username" }}' -email = '{{ env "APIP_CP_AUTH_CLAIM_EMAIL" "email" }}' -scope = '{{ env "APIP_CP_AUTH_CLAIM_SCOPE" "scope" }}' # space-separated scope string -roles = '{{ env "APIP_CP_AUTH_CLAIM_ROLES" "" }}' # e.g. "realm_access.roles" (Keycloak) or "roles" (Asgardeo) +organization = "organization" # claim carrying the org ID +org_name = "org_name" # claim carrying the org display name +org_handle = "org_handle" # claim carrying the org URL slug +user_id = "sub" # claim used as the user's unique ID +username = "username" +email = "email" +scope = "scope" # space-separated scope string +roles = "" # e.g. "realm_access.roles" (Keycloak) or "roles" (Asgardeo) # IDP (JWKS-based) — used when mode = "idp" (Asgardeo, Keycloak, Auth0, etc.). # jwks_url and issuer are required in that mode. [platform_api.auth.idp] -name = '{{ env "APIP_CP_AUTH_IDP_NAME" "asgardeo" }}' # friendly name for logging -jwks_url = '{{ env "APIP_CP_AUTH_IDP_JWKS_URL" "https://accounts.example.com/oauth2/jwks" }}' +name = "asgardeo" # friendly name for logging +jwks_url = "https://accounts.example.com/oauth2/jwks" issuer = ["https://accounts.example.com"] # list of accepted issuers audience = [] # accepted "aud" values; empty = skip audience check # Authorization mode: "scope" (default) checks the scope claim; # "role" checks the roles claim configured below at claim_mappings.roles. -validation_mode = '{{ env "APIP_CP_AUTH_IDP_VALIDATION_MODE" "scope" }}' -role_mappings = '{{ env "APIP_CP_AUTH_IDP_ROLE_MAPPINGS" "" }}' # path to a YAML file mapping IDP roles to platform scopes +validation_mode = "scope" +role_mappings = "" # path to a YAML file mapping IDP roles to platform scopes # File auth — local username/password login, used when mode = "file". Ideal # for initial / air-gapped setup; not recommended for production — prefer # an IDP. [platform_api.auth.file.organization] -id = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_ID" "default" }}' # Required: organization handle (URL-safe slug) -display_name = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_DISPLAY_NAME" "Default" }}' -region = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_REGION" "us" }}' +id = "default" # Required: organization handle (URL-safe slug) +display_name = "Default" +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 = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_UUID" "99089a17-72e0-4dd8-a2f4-c8dfbb085295" }}' +# tokens. Pin it to keep the organization stable across fresh databases. +uuid = "99089a17-72e0-4dd8-a2f4-c8dfbb085295" # Add one [[platform_api.auth.file.users]] block per user. # Generate a bcrypt hash with: htpasswd -bnBC 12 "" | tr -d ':\n' [[platform_api.auth.file.users]] -# The quickstart resolves these from api-platform.env (generated by setup.sh). -# Neither has a fallback — an unset var fails config load rather than +# REQUIRED — an empty username/password_hash fails config load rather than # starting with a blank or guessable credential. -username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' -password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' +username = "" +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" @@ -231,22 +232,23 @@ scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:mana # signed asymmetrically: "external_token" only verifies externally-minted tokens # with the public key; "file" also signs the tokens its login endpoint issues # with the private key. Symmetric (HMAC) and unsigned ("none") tokens are -# rejected. PEM keys are multi-line, so read them from files via {{ file }} -# rather than an env var (scripts/setup.sh generates the pair): -# public_key = '{{ file "/secrets/platform-api/jwt_public.pem" }}' -# private_key = '{{ file "/secrets/platform-api/jwt_private.pem" }}' +# rejected. Keys are mounted PEM files, referenced here by path only — the +# server reads and parses them itself at startup/use, so the PEM content is +# never inlined into config (scripts/setup.sh generates the pair). Mount your +# keys at these paths, or point elsewhere by editing the path directly. [platform_api.auth.jwt] -issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' -# public_key is REQUIRED in every mode (verifies token signatures); private_key -# is REQUIRED only in "file" mode (signs login tokens) and must be the matching -# half of the pair. Neither is generated — a missing/malformed key fails config -# load. Both must be PEM-encoded RSA keys. -public_key = '{{ file "/etc/platform-api/keys/jwt_public.pem" }}' -private_key = '{{ file "/etc/platform-api/keys/jwt_private.pem" }}' +issuer = "platform-api" +# public_key_file is REQUIRED in every mode (verifies token signatures); +# private_key_file is REQUIRED only in "file" mode (signs login tokens) and +# must be the matching half of the pair. Neither is generated — a missing +# path, unreadable file, or malformed key fails config load. Both files must +# contain a PEM-encoded RSA key. +public_key_file = "/etc/platform-api/keys/jwt_public.pem" +private_key_file = "/etc/platform-api/keys/jwt_private.pem" # Lifetime of tokens issued by the file-mode login endpoint (Go duration # syntax). Not used for "external_token" tokens — their expiry is whatever # "exp" claim the issuer set. -token_ttl = '{{ env "APIP_CP_AUTH_JWT_TOKEN_TTL" "1h" }}' +token_ttl = "1h" # --------------------------------------------------------------------------- # Server listeners (HTTP + HTTPS) @@ -264,14 +266,14 @@ token_ttl = '{{ env "APIP_CP_AUTH_JWT_TOKEN_TTL" "1h" }}' # server.https.enabled = true. Certificates are always required — there is # no self-signed fallback. setup.sh generates a pair for the quickstart. [platform_api.server.http] -enabled = '{{ env "APIP_CP_SERVER_HTTP_ENABLED" "false" }}' -port = '{{ env "APIP_CP_SERVER_HTTP_PORT" "9080" }}' +enabled = false +port = 9080 [platform_api.server.https] -enabled = '{{ env "APIP_CP_SERVER_HTTPS_ENABLED" "true" }}' -port = '{{ env "APIP_CP_SERVER_HTTPS_PORT" "9243" }}' -cert_file = '{{ env "APIP_CP_SERVER_HTTPS_CERT_FILE" "/app/data/certs/cert.pem" }}' # default: ./data/certs/cert.pem -key_file = '{{ env "APIP_CP_SERVER_HTTPS_KEY_FILE" "/app/data/certs/key.pem" }}' # default: ./data/certs/key.pem +enabled = true +port = 9243 +cert_file = "/app/data/certs/cert.pem" # default: ./data/certs/cert.pem +key_file = "/app/data/certs/key.pem" # default: ./data/certs/key.pem # --------------------------------------------------------------------------- # Listener timeouts @@ -287,10 +289,10 @@ key_file = '{{ env "APIP_CP_SERVER_HTTPS_KEY_FILE" "/app/data/certs/key.pem" }} # # WebSocket routes are unaffected — the deadlines are cleared on upgrade. [platform_api.server.timeouts] -read_header = '{{ env "APIP_CP_SERVER_TIMEOUTS_READ_HEADER" "10s" }}' -read = '{{ env "APIP_CP_SERVER_TIMEOUTS_READ" "60s" }}' -write = '{{ env "APIP_CP_SERVER_TIMEOUTS_WRITE" "120s" }}' -idle = '{{ env "APIP_CP_SERVER_TIMEOUTS_IDLE" "120s" }}' +read_header = "10s" +read = "60s" +write = "120s" +idle = "120s" # --------------------------------------------------------------------------- # CORS @@ -299,38 +301,38 @@ idle = '{{ env "APIP_CP_SERVER_TIMEOUTS_IDLE" "120s" }}' # Developer Portal and AI Workspace origins. Must never contain "*"; leave # empty to disable cross-origin access (the default). [platform_api.server.cors] -allowed_origins = '{{ env "APIP_CP_CORS_ALLOWED_ORIGINS" "" }}' # comma-separated, e.g. "https://workspace.example.com,https://devportal.example.com" +allowed_origins = "" # comma-separated, e.g. "https://workspace.example.com,https://devportal.example.com" # --------------------------------------------------------------------------- # WebSocket # --------------------------------------------------------------------------- [platform_api.server.websocket] -max_connections = '{{ env "APIP_CP_WEBSOCKET_MAX_CONNECTIONS" "1000" }}' # global WebSocket connection limit -connection_timeout = '{{ env "APIP_CP_WEBSOCKET_CONNECTION_TIMEOUT" "30" }}' # seconds before an idle connection is closed -rate_limit_per_min = '{{ env "APIP_CP_WEBSOCKET_RATE_LIMIT_PER_MIN" "1000" }}' # maximum messages per minute per connection -metrics_log_enabled = '{{ env "APIP_CP_WEBSOCKET_METRICS_LOG_ENABLED" "true" }}' # emit WebSocket metrics to the log -metrics_log_interval = '{{ env "APIP_CP_WEBSOCKET_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 +metrics_log_enabled = true # emit WebSocket metrics to the log +metrics_log_interval = 10 # seconds between metrics log lines # --------------------------------------------------------------------------- # Gateway # --------------------------------------------------------------------------- [platform_api.gateway] # Reject gateway registrations whose runtime version does not match the expected range. -enable_version_verification = '{{ env "APIP_CP_GATEWAY_ENABLE_VERSION_VERIFICATION" "false" }}' +enable_version_verification = false # Reject gateways that report an unexpected functionality type. -enable_functionality_type_verification = '{{ env "APIP_CP_GATEWAY_ENABLE_FUNCTIONALITY_TYPE_VERIFICATION" "false" }}' +enable_functionality_type_verification = false # --------------------------------------------------------------------------- # Deployments # --------------------------------------------------------------------------- [platform_api.deployments] -max_per_api_gateway = '{{ env "APIP_CP_DEPLOYMENTS_MAX_PER_API_GATEWAY" "20" }}' # maximum API deployments per gateway -transitional_status_enabled = '{{ env "APIP_CP_DEPLOYMENTS_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 = '{{ env "APIP_CP_DEPLOYMENTS_TIMEOUT_ENABLED" "true" }}' -timeout_interval = '{{ env "APIP_CP_DEPLOYMENTS_TIMEOUT_INTERVAL" "20" }}' # seconds between timeout check sweeps -timeout_duration = '{{ env "APIP_CP_DEPLOYMENTS_TIMEOUT_DURATION" "60" }}' # seconds before a stuck deployment is timed out +timeout_enabled = true +timeout_interval = 20 # seconds between timeout check sweeps +timeout_duration = 60 # seconds before a stuck deployment is timed out # --------------------------------------------------------------------------- @@ -338,9 +340,9 @@ timeout_duration = '{{ env "APIP_CP_DEPLOYMENTS_TIMEOUT_DURATION" "60" }}' # s # --------------------------------------------------------------------------- # Values are durations, e.g. "3s", "10m", "1h". All must be positive. [platform_api.event_hub] -poll_interval = '{{ env "APIP_CP_EVENT_HUB_POLL_INTERVAL" "3s" }}' # how often each replica polls for new events -cleanup_interval = '{{ env "APIP_CP_EVENT_HUB_CLEANUP_INTERVAL" "10m" }}' # how often delivered events are purged -retention_period = '{{ env "APIP_CP_EVENT_HUB_RETENTION_PERIOD" "1h" }}' # how long delivered events are retained +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 @@ -348,15 +350,13 @@ retention_period = '{{ env "APIP_CP_EVENT_HUB_RETENTION_PERIOD" "1h" }}' # ho # The Developer Portal delivers signed events (API key / subscription # changes) to this endpoint. [platform_api.webhook] -enabled = '{{ env "APIP_CP_WEBHOOK_ENABLED" "false" }}' +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. The "" -# fallback below is only ever read while enabled = false. -secret = '{{ env "APIP_CP_WEBHOOK_SECRET" "" }}' +# enabled. The "" default below is only ever read while enabled = false. +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 = '{{ env "APIP_CP_WEBHOOK_PRIVATE_KEY_PATH" "" }}' -signature_tolerance = '{{ env "APIP_CP_WEBHOOK_SIGNATURE_TOLERANCE" "5m" }}' # max age of a signed request (replay protection) -max_body_size = '{{ env "APIP_CP_WEBHOOK_MAX_BODY_SIZE" "1048576" }}' # request body cap in bytes (1 MiB) -signature_header = '{{ env "APIP_CP_WEBHOOK_SIGNATURE_HEADER" "X-Devportal-Signature" }}' # header carrying the "t=...,v1=..." signature +private_key_path = "" +signature_tolerance = "5m" # max age of a signed request (replay protection) +max_body_size = 1048576 # request body cap in bytes (1 MiB) +signature_header = "X-Devportal-Signature" # header carrying the "t=...,v1=..." signature diff --git a/platform-api/config/config.go b/platform-api/config/config.go index 6eb3e4578..128535a60 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -18,11 +18,13 @@ package config import ( + "crypto/rsa" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "log/slog" + "os" "reflect" "strings" "sync" @@ -105,13 +107,13 @@ type Server struct { const ( // AuthModeExternalToken verifies locally-issued, asymmetrically-signed JWTs // (RS256) minted externally, e.g. by the Developer Portal. Verification uses - // the RSA public key in auth.jwt.public_key; symmetric (HMAC) and unsigned + // the RSA public key in auth.jwt.public_key_file; symmetric (HMAC) and unsigned // ("none") tokens are rejected. AuthModeExternalToken = "external_token" // AuthModeFile is AuthModeExternalToken plus local username/password login: the // login endpoint authenticates users from auth.file and issues RS256 JWTs signed - // with the RSA private key in auth.jwt.private_key, verified with the matching - // auth.jwt.public_key. + // with the RSA private key in auth.jwt.private_key_file, verified with the matching + // auth.jwt.public_key_file. AuthModeFile = "file" // AuthModeIDP validates tokens against an external IDP's JWKS (auth.idp). AuthModeIDP = "idp" @@ -269,15 +271,49 @@ type CORS struct { // TODO(pqc): migrate — RS256 is quantum-vulnerable. Move to an ML-DSA (FIPS 204) // signature once a Go JWT library exposes it. See post-quantum-cryptography.md. type JWT struct { - // PublicKey is the PEM-encoded RSA public key used to verify token - // signatures. Required in both "external_token" and "file" modes. - PublicKey string `koanf:"public_key"` - // PrivateKey is the PEM-encoded RSA private key used to sign tokens. Required - // only in "file" mode, whose login endpoint mints tokens; unused (and not - // required) in verify-only "external_token" mode. - PrivateKey string `koanf:"private_key"` - Issuer string `koanf:"issuer"` - TokenTTL time.Duration `koanf:"token_ttl"` + // PublicKeyFile is the path to a mounted PEM-encoded RSA public key file, + // used to verify token signatures. Required in both "external_token" and + // "file" modes. The key is read from disk at the point of use rather than + // being interpolated into config at load time, so the PEM content is never + // held in the config struct. + PublicKeyFile string `koanf:"public_key_file"` + // PrivateKeyFile is the path to a mounted PEM-encoded RSA private key file, + // used to sign tokens. Required only in "file" mode, whose login endpoint + // mints tokens; unused (and not required) in verify-only "external_token" + // mode. Read from disk at the point of use, never cached as content. + PrivateKeyFile string `koanf:"private_key_file"` + Issuer string `koanf:"issuer"` + TokenTTL time.Duration `koanf:"token_ttl"` +} + +// LoadPublicKey reads and parses the PEM-encoded RSA public key from +// PublicKeyFile. The file is read fresh on every call rather than cached, +// so PublicKeyFile is a mounted-file path, never inlined PEM content. +func (j *JWT) LoadPublicKey() (*rsa.PublicKey, error) { + raw, err := os.ReadFile(j.PublicKeyFile) + if err != nil { + return nil, fmt.Errorf("failed to read auth.jwt.public_key_file %q: %w", j.PublicKeyFile, err) + } + pub, err := jwt.ParseRSAPublicKeyFromPEM(raw) + if err != nil { + return nil, fmt.Errorf("auth.jwt.public_key_file %q must be a PEM-encoded RSA public key: %w", j.PublicKeyFile, err) + } + return pub, nil +} + +// LoadPrivateKey reads and parses the PEM-encoded RSA private key from +// PrivateKeyFile. The file is read fresh on every call rather than cached, +// so PrivateKeyFile is a mounted-file path, never inlined PEM content. +func (j *JWT) LoadPrivateKey() (*rsa.PrivateKey, error) { + raw, err := os.ReadFile(j.PrivateKeyFile) + if err != nil { + return nil, fmt.Errorf("failed to read auth.jwt.private_key_file %q: %w", j.PrivateKeyFile, err) + } + priv, err := jwt.ParseRSAPrivateKeyFromPEM(raw) + if err != nil { + return nil, fmt.Errorf("auth.jwt.private_key_file %q must be a PEM-encoded RSA private key: %w", j.PrivateKeyFile, err) + } + return priv, nil } // WebSocket holds WebSocket-specific configuration. @@ -568,40 +604,41 @@ func validateAuthConfig(auth *Auth) error { } } -// validateJWTConfig verifies the local asymmetric JWT key material. The RSA -// public key verifies token signatures and is required in both the -// "external_token" and "file" auth modes. When requireSigningKey is true (file -// mode, which mints tokens at its login endpoint) the RSA private key is also -// required and must form a matching pair with the public key. Keys are never -// generated: missing or malformed material fails startup. Only asymmetric RSA -// keys are accepted, so symmetric (HMAC) verification is structurally -// impossible. +// validateJWTConfig verifies the local asymmetric JWT key material is present +// and readable. The RSA public key verifies token signatures and is required +// in both the "external_token" and "file" auth modes. When requireSigningKey +// is true (file mode, which mints tokens at its login endpoint) the RSA +// private key is also required and must form a matching pair with the public +// key. Keys are mounted files, read fresh here rather than cached: a missing +// path, an unreadable file, or malformed material all fail startup. Only +// asymmetric RSA keys are accepted, so symmetric (HMAC) verification is +// structurally impossible. func validateJWTConfig(jwtCfg *JWT, requireSigningKey bool) error { - if jwtCfg.PublicKey == "" { - return fmt.Errorf("Auth.JWT.PublicKey is required when auth.mode is %q or %q "+ - "(set auth.jwt.public_key to a PEM-encoded RSA public key via {{ env }}/{{ file }})", + if jwtCfg.PublicKeyFile == "" { + return fmt.Errorf("Auth.JWT.PublicKeyFile is required when auth.mode is %q or %q "+ + "(set auth.jwt.public_key_file to the path of a mounted PEM-encoded RSA public key)", AuthModeExternalToken, AuthModeFile) } - pub, err := jwt.ParseRSAPublicKeyFromPEM([]byte(jwtCfg.PublicKey)) + pub, err := jwtCfg.LoadPublicKey() if err != nil { - return fmt.Errorf("invalid Auth.JWT.PublicKey: must be a PEM-encoded RSA public key: %w", err) + return fmt.Errorf("invalid Auth.JWT.PublicKeyFile: %w", err) } if !requireSigningKey { return nil } - if jwtCfg.PrivateKey == "" { - return fmt.Errorf("Auth.JWT.PrivateKey is required when auth.mode is %q "+ - "(set auth.jwt.private_key to a PEM-encoded RSA private key via {{ env }}/{{ file }})", + if jwtCfg.PrivateKeyFile == "" { + return fmt.Errorf("Auth.JWT.PrivateKeyFile is required when auth.mode is %q "+ + "(set auth.jwt.private_key_file to the path of a mounted PEM-encoded RSA private key)", AuthModeFile) } - priv, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(jwtCfg.PrivateKey)) + priv, err := jwtCfg.LoadPrivateKey() if err != nil { - return fmt.Errorf("invalid Auth.JWT.PrivateKey: must be a PEM-encoded RSA private key: %w", err) + return fmt.Errorf("invalid Auth.JWT.PrivateKeyFile: %w", err) } if !priv.PublicKey.Equal(pub) { - return fmt.Errorf("Auth.JWT.PrivateKey and Auth.JWT.PublicKey must be a matching RSA key pair") + return fmt.Errorf("Auth.JWT.PrivateKeyFile and Auth.JWT.PublicKeyFile must be a matching RSA key pair") } return nil } diff --git a/platform-api/config/config.toml b/platform-api/config/config.toml index c11fa327c..165c0c660 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -1,3 +1,12 @@ +# -------------------------------------------------------------------- +# 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 +# -------------------------------------------------------------------- + [platform_api] [platform_api.logging] @@ -7,29 +16,24 @@ level = '{{ env "APIP_CP_LOGGING_LEVEL" "info" }}' encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' [platform_api.database] -driver = '{{ env "APIP_CP_DATABASE_DRIVER" "sqlite3" }}' -path = '{{ env "APIP_CP_DATABASE_PATH" "./data/api_platform.db" }}' +driver = "sqlite3" +path = "./data/api_platform.db" [platform_api.auth] -mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' -scope_validation = '{{ env "APIP_CP_AUTH_SCOPE_VALIDATION" "true" }}' - -# Asymmetric (RS256) signing keys. public_key verifies every token and is -# required in all modes; private_key signs login tokens and is required only in -# "file" mode. The PEM files are generated by scripts/setup.sh and mounted into -# the container (see the developer-portal docker-compose.yaml) — {{ file }} reads -# them at startup. Symmetric (HMAC) and unsigned ("none") tokens are rejected. +mode = "file" +scope_validation = "true" + [platform_api.auth.jwt] -issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' -public_key = '{{ file "/etc/platform-api/keys/jwt_public.pem" }}' -private_key = '{{ file "/etc/platform-api/keys/jwt_private.pem" }}' +issuer = "platform-api" +public_key_file = "/etc/platform-api/keys/jwt_public.pem" +private_key_file = "/etc/platform-api/keys/jwt_private.pem" [platform_api.auth.file.organization] -id = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_ID" "default" }}' -display_name = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_DISPLAY_NAME" "Default" }}' -region = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_REGION" "us" }}' +id = "default" +display_name = "Default" +region = "us" [[platform_api.auth.file.users]] username = '{{ env "APIP_CP_ADMIN_USERNAME" "admin" }}' password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" "$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ." }}' -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: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:git:read ap:api_key:read ap:secret:manage dp:org_read dp:org_create dp:org_update dp:org_manage dp:org_delete dp:org_content_read dp:org_content_write dp:org_content_manage dp:org_content_delete dp:api_read dp:api_create dp:api_update dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:mcp_read dp:mcp_create dp:mcp_update dp:mcp_manage dp:mcp_delete dp:mcp_content_read dp:mcp_content_create dp:mcp_content_update dp:mcp_content_manage dp:mcp_content_delete dp:api_key_create dp:api_key_read dp:api_key_update dp:api_key_manage dp:api_key_revoke dp:mcp_key_create dp:mcp_key_read dp:mcp_key_update dp:mcp_key_manage dp:mcp_key_revoke dp:api_workflow_create dp:api_workflow_read dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:app_create dp:app_read dp:app_update dp:app_manage dp:app_delete dp:app_key_create dp:app_key_manage dp:app_key_revoke dp:app_key_mapping_read dp:app_key_mapping_write dp:app_key_mapping_manage dp:subscription_create dp:subscription_read dp:subscription_update dp:subscription_manage dp:subscription_delete dp:sub_plan_create dp:sub_plan_read dp:sub_plan_update dp:sub_plan_manage dp:sub_plan_delete dp:km_create dp:km_read dp:km_update dp:km_manage dp:km_delete dp:view_create dp:view_read dp:view_update dp:view_manage dp:view_delete dp:label_create dp:label_read dp:label_update dp:label_manage dp:label_delete dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dp:event_read dp:delivery_manage" }}' +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:git:read ap:api_key:read ap:secret:manage dp:org_read dp:org_create dp:org_update dp:org_manage dp:org_delete dp:org_content_read dp:org_content_write dp:org_content_manage dp:org_content_delete dp:api_read dp:api_create dp:api_update dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:mcp_read dp:mcp_create dp:mcp_update dp:mcp_manage dp:mcp_delete dp:mcp_content_read dp:mcp_content_create dp:mcp_content_update dp:mcp_content_manage dp:mcp_content_delete dp:api_key_create dp:api_key_read dp:api_key_update dp:api_key_manage dp:api_key_revoke dp:mcp_key_create dp:mcp_key_read dp:mcp_key_update dp:mcp_key_manage dp:mcp_key_revoke dp:api_workflow_create dp:api_workflow_read dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:app_create dp:app_read dp:app_update dp:app_manage dp:app_delete dp:app_key_create dp:app_key_manage dp:app_key_revoke dp:app_key_mapping_read dp:app_key_mapping_write dp:app_key_mapping_manage dp:subscription_create dp:subscription_read dp:subscription_update dp:subscription_manage dp:subscription_delete dp:sub_plan_create dp:sub_plan_read dp:sub_plan_update dp:sub_plan_manage dp:sub_plan_delete dp:km_create dp:km_read dp:km_update dp:km_manage dp:km_delete dp:view_create dp:view_read dp:view_update dp:view_manage dp:view_delete dp:label_create dp:label_read dp:label_update dp:label_manage dp:label_delete dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dp:event_read dp:delivery_manage" diff --git a/platform-api/config/config_test.go b/platform-api/config/config_test.go index 7001bd60b..4556718e1 100644 --- a/platform-api/config/config_test.go +++ b/platform-api/config/config_test.go @@ -36,15 +36,27 @@ import ( // at-rest encryption key (still a symmetric key — unrelated to JWT signing). const validInlineKey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" -// RSA key-pair PEM fixtures for the asymmetric JWT config, generated once in init(). -// validJWTPublicKey/validJWTPrivateKey form a matching pair; otherJWTPublicKey is -// from a different pair, used to exercise the matching-pair validation. +// RSA key-pair PEM fixtures for the asymmetric JWT config, generated once in init() +// and written to files, since JWT config now holds mounted-file paths rather than +// inline PEM content. validJWTPublicKeyFile/validJWTPrivateKeyFile form a matching +// pair; otherJWTPublicKeyFile is from a different pair, used to exercise the +// matching-pair validation. var ( - validJWTPublicKey string - validJWTPrivateKey string - otherJWTPublicKey string + validJWTPublicKeyFile string + validJWTPrivateKeyFile string + otherJWTPublicKeyFile string ) +// writePEMFile writes content to a fresh file under a package-lifetime temp +// directory and returns its path. +func writePEMFile(dir, name, content string) string { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + panic(err) + } + return path +} + // genRSAKeyPEMs generates a fresh RSA key pair and returns its public key (PKIX PEM) // and private key (PKCS1 PEM). func genRSAKeyPEMs() (pubPEM, privPEM string) { @@ -68,7 +80,7 @@ func genRSAKeyPEMs() (pubPEM, privPEM string) { } // validKeysBase is a minimal config whose required secrets resolve from the -// APIP_CP_ENCRYPTION_KEY / APIP_CP_AUTH_JWT_PUBLIC_KEY env vars via {{ env }} +// APIP_CP_ENCRYPTION_KEY / APIP_CP_AUTH_JWT_PUBLIC_KEY_FILE env vars via {{ env }} // interpolation. Environment variables reach config ONLY through these tokens now // (there is no direct env-key override), so tests must go through a config file. // The default auth mode is "external_token", which needs only the verification @@ -78,7 +90,7 @@ const validKeysBase = ` encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' [platform_api.auth.jwt] -public_key = '{{ env "APIP_CP_AUTH_JWT_PUBLIC_KEY" }}' +public_key_file = '{{ env "APIP_CP_AUTH_JWT_PUBLIC_KEY_FILE" }}' ` // loadTOML writes toml to a temp config file and loads it through LoadConfig. @@ -95,7 +107,7 @@ func loadTOML(t *testing.T, toml string) (*Server, error) { func loadWithKeys(t *testing.T, extra string) (*Server, error) { t.Helper() t.Setenv("APIP_CP_ENCRYPTION_KEY", validInlineKey) - t.Setenv("APIP_CP_AUTH_JWT_PUBLIC_KEY", validJWTPublicKey) + t.Setenv("APIP_CP_AUTH_JWT_PUBLIC_KEY_FILE", validJWTPublicKeyFile) return loadTOML(t, validKeysBase+extra) } @@ -108,12 +120,12 @@ func TestLoadConfig_ValidKeys_Succeeds(t *testing.T) { // 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_PUBLIC_KEY", validJWTPublicKey) + t.Setenv("APIP_CP_AUTH_JWT_PUBLIC_KEY_FILE", validJWTPublicKeyFile) // Encryption key omitted entirely; the JWT public key resolves so the JWT check passes first. _, err := loadTOML(t, ` [platform_api.auth.jwt] -public_key = '{{ env "APIP_CP_AUTH_JWT_PUBLIC_KEY" }}' +public_key_file = '{{ env "APIP_CP_AUTH_JWT_PUBLIC_KEY_FILE" }}' `) require.Error(t, err) assert.Contains(t, err.Error(), "EncryptionKey is required") @@ -121,14 +133,14 @@ public_key = '{{ env "APIP_CP_AUTH_JWT_PUBLIC_KEY" }}' // A provided encryption key must be an AES-256-sized key (64 hex / base64→32 bytes). func TestLoadConfig_InvalidEncryptionKey_Errors(t *testing.T) { - t.Setenv("APIP_CP_AUTH_JWT_PUBLIC_KEY", validJWTPublicKey) + t.Setenv("APIP_CP_AUTH_JWT_PUBLIC_KEY_FILE", validJWTPublicKeyFile) _, err := loadTOML(t, ` [platform_api.security] encryption_key = "not-a-valid-32-byte-key" [platform_api.auth.jwt] -public_key = '{{ env "APIP_CP_AUTH_JWT_PUBLIC_KEY" }}' +public_key_file = '{{ env "APIP_CP_AUTH_JWT_PUBLIC_KEY_FILE" }}' `) require.Error(t, err) assert.Contains(t, err.Error(), "invalid EncryptionKey") @@ -143,22 +155,23 @@ func TestLoadConfig_MissingJWTPublicKey_Errors(t *testing.T) { encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' `) require.Error(t, err) - assert.Contains(t, err.Error(), "Auth.JWT.PublicKey is required") + assert.Contains(t, err.Error(), "Auth.JWT.PublicKeyFile is required") } -// A provided JWT public key must be a PEM-encoded RSA public key. +// A provided JWT public key file must contain a PEM-encoded RSA public key. func TestLoadConfig_InvalidJWTPublicKey_Errors(t *testing.T) { t.Setenv("APIP_CP_ENCRYPTION_KEY", validInlineKey) + invalidKeyFile := writePEMFile(t.TempDir(), "invalid_public.pem", "not-a-valid-rsa-public-key") _, err := loadTOML(t, ` [platform_api.security] encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' [platform_api.auth.jwt] -public_key = "not-a-valid-rsa-public-key" +public_key_file = "`+invalidKeyFile+`" `) require.Error(t, err) - assert.Contains(t, err.Error(), "invalid Auth.JWT.PublicKey") + assert.Contains(t, err.Error(), "invalid Auth.JWT.PublicKeyFile") } // --- valid32ByteKey unit coverage --- @@ -175,14 +188,24 @@ func TestValid32ByteKey(t *testing.T) { // Clear env vars that LoadConfig reads so each test starts from a known baseline and host // environment values don't leak into assertions. func init() { - // Generate the RSA key-pair fixtures used across the asymmetric JWT tests. - validJWTPublicKey, validJWTPrivateKey = genRSAKeyPEMs() - otherJWTPublicKey, _ = genRSAKeyPEMs() + // Generate the RSA key-pair fixtures used across the asymmetric JWT tests, and + // write them to files since JWT config now holds mounted-file paths, not + // inline PEM content. + validJWTPublicKey, validJWTPrivateKey := genRSAKeyPEMs() + otherJWTPublicKey, _ := genRSAKeyPEMs() + + keysDir, err := os.MkdirTemp("", "jwt-test-keys") + if err != nil { + panic(err) + } + validJWTPublicKeyFile = writePEMFile(keysDir, "valid_public.pem", validJWTPublicKey) + validJWTPrivateKeyFile = writePEMFile(keysDir, "valid_private.pem", validJWTPrivateKey) + otherJWTPublicKeyFile = writePEMFile(keysDir, "other_public.pem", otherJWTPublicKey) for _, v := range []string{ "APIP_CP_ENCRYPTION_KEY", - "APIP_CP_AUTH_JWT_PUBLIC_KEY", - "APIP_CP_AUTH_JWT_PRIVATE_KEY", + "APIP_CP_AUTH_JWT_PUBLIC_KEY_FILE", + "APIP_CP_AUTH_JWT_PRIVATE_KEY_FILE", } { os.Unsetenv(v) } @@ -198,35 +221,35 @@ func TestValidateAuthConfig(t *testing.T) { }{ { name: "external_token mode with valid public key", - auth: Auth{Mode: AuthModeExternalToken, JWT: JWT{PublicKey: validJWTPublicKey}}, + auth: Auth{Mode: AuthModeExternalToken, JWT: JWT{PublicKeyFile: validJWTPublicKeyFile}}, }, { name: "external_token mode without public key", auth: Auth{Mode: AuthModeExternalToken}, - wantErr: "Auth.JWT.PublicKey is required", + wantErr: "Auth.JWT.PublicKeyFile is required", }, { name: "file mode without private key", - auth: Auth{Mode: AuthModeFile, JWT: JWT{PublicKey: validJWTPublicKey, TokenTTL: time.Hour}}, - wantErr: "Auth.JWT.PrivateKey is required", + auth: Auth{Mode: AuthModeFile, JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, TokenTTL: time.Hour}}, + wantErr: "Auth.JWT.PrivateKeyFile is required", }, { name: "file mode with mismatched key pair", auth: Auth{Mode: AuthModeFile, JWT: JWT{ - PublicKey: otherJWTPublicKey, - PrivateKey: validJWTPrivateKey, - TokenTTL: time.Hour, + PublicKeyFile: otherJWTPublicKeyFile, + PrivateKeyFile: validJWTPrivateKeyFile, + TokenTTL: time.Hour, }}, wantErr: "matching RSA key pair", }, { name: "file mode without token_ttl", - auth: Auth{Mode: AuthModeFile, JWT: JWT{PublicKey: validJWTPublicKey, PrivateKey: validJWTPrivateKey}}, + auth: Auth{Mode: AuthModeFile, JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, PrivateKeyFile: validJWTPrivateKeyFile}}, wantErr: "Auth.JWT.TokenTTL must be a positive duration", }, { name: "file mode requires org and users", - auth: Auth{Mode: AuthModeFile, JWT: JWT{PublicKey: validJWTPublicKey, PrivateKey: validJWTPrivateKey, TokenTTL: time.Hour}}, + auth: Auth{Mode: AuthModeFile, JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, PrivateKeyFile: validJWTPrivateKeyFile, TokenTTL: time.Hour}}, // Default org fields are empty in a zero-value Auth — users check fires // after the org checks. wantErr: "auth.file.organization.id", @@ -235,7 +258,7 @@ func TestValidateAuthConfig(t *testing.T) { name: "file mode fully configured", auth: Auth{ Mode: AuthModeFile, - JWT: JWT{PublicKey: validJWTPublicKey, PrivateKey: validJWTPrivateKey, TokenTTL: time.Hour}, + JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, PrivateKeyFile: validJWTPrivateKeyFile, TokenTTL: time.Hour}, File: FileBased{ Organization: FileBasedOrg{ID: "default", DisplayName: "Default"}, Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash"}}, diff --git a/platform-api/internal/handler/auth_login.go b/platform-api/internal/handler/auth_login.go index d6e23c40b..970d07e0e 100644 --- a/platform-api/internal/handler/auth_login.go +++ b/platform-api/internal/handler/auth_login.go @@ -107,12 +107,13 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) error { "iat": time.Now().Unix(), } - // Sign asymmetrically with RS256 using the configured RSA private key. - // Config validation (validateJWTConfig) guarantees the key parses and matches - // the verification public key, so a parse error here is an internal fault. - privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(h.cfg.Auth.JWT.PrivateKey)) + // Sign asymmetrically with RS256 using the configured RSA private key, + // read fresh from its mounted file. Config validation (validateJWTConfig) + // guarantees the key file is readable and matches the verification public + // key, so a load error here is an internal fault. + privateKey, err := h.cfg.Auth.JWT.LoadPrivateKey() if err != nil { - return apperror.Internal.Wrap(err).WithLogMessage("failed to parse JWT signing key") + return apperror.Internal.Wrap(err).WithLogMessage("failed to load JWT signing key") } token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) signed, err := token.SignedString(privateKey) diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index 61b9129a5..4aba8437c 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -46,7 +46,6 @@ import ( "github.com/wso2/api-platform/platform-api/internal/webhook" "github.com/wso2/api-platform/platform-api/internal/websocket" - "github.com/golang-jwt/jwt/v5" "github.com/wso2/api-platform/common/authenticators" "github.com/wso2/api-platform/common/eventhub" commonmodels "github.com/wso2/api-platform/common/models" @@ -485,9 +484,9 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger) (*Server, if cfg.Auth.Mode == config.AuthModeFile { slogger.Info("Auth mode: file (local users, RS256-signed JWT)") slogger.Warn("file-based authentication is enabled — this is not recommended for production; please configure an IDP of your choice") - publicKey, err := jwt.ParseRSAPublicKeyFromPEM([]byte(cfg.Auth.JWT.PublicKey)) + publicKey, err := cfg.Auth.JWT.LoadPublicKey() if err != nil { - return nil, fmt.Errorf("failed to parse auth.jwt.public_key: %w", err) + return nil, fmt.Errorf("failed to load auth.jwt.public_key_file: %w", err) } chain = append(chain, middleware.LocalJWTAuthMiddleware(middleware.AuthConfig{ PublicKey: publicKey, @@ -570,9 +569,9 @@ func buildClaimMappings(cm config.ClaimMappings, roleScopeMap map[string][]strin func buildAuthenticator(cfg *config.Server, slogger *slog.Logger, roleScopeMap map[string][]string) (middleware.Authenticator, error) { if cfg.Auth.Mode != config.AuthModeIDP { slogger.Info("Auth mode: jwt (asymmetric RS256 signature validation enabled)") - publicKey, err := jwt.ParseRSAPublicKeyFromPEM([]byte(cfg.Auth.JWT.PublicKey)) + publicKey, err := cfg.Auth.JWT.LoadPublicKey() if err != nil { - return nil, fmt.Errorf("failed to parse auth.jwt.public_key: %w", err) + return nil, fmt.Errorf("failed to load auth.jwt.public_key_file: %w", err) } return middleware.NewJWTAuthenticator( middleware.LocalJWTAuthMiddleware(middleware.AuthConfig{ diff --git a/portals/ai-workspace/Makefile b/portals/ai-workspace/Makefile index d0cb9e02c..a35a28e55 100644 --- a/portals/ai-workspace/Makefile +++ b/portals/ai-workspace/Makefile @@ -197,7 +197,6 @@ endif dist: clean-dist ## Build standalone AI Workspace + Platform API distribution zip @echo "Building distribution $(DIST_NAME)..." @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) @echo "Fetching platform-api db-scripts from tag $(PLATFORM_API_TAG)..." @@ -213,19 +212,24 @@ ifeq ($(PLATFORM_API_FROM_TAG),true) 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 + > $(DIST_DIR)/.platform-api-config-template.toml @git -C ../.. show "$(PLATFORM_API_TAG):platform-api/config/config.toml" \ - > $(DIST_DIR)/configs/config-platform-api.toml + > $(DIST_DIR)/.platform-api-config.toml else @cp ../../platform-api/internal/database/schema.*.sql $(DIST_DIR)/resources/platform-api/db-scripts/ - @cp ../../platform-api/config/config-template.toml $(DIST_DIR)/configs/config-platform-api-template.toml - # The source docker-compose.yaml mounts ../../platform-api/config/config.toml - # directly (the monorepo's single source-of-truth Platform API config, no - # per-portal copy) — the standalone zip has no platform-api/ sibling, so bake - # a real copy into its own configs/ dir; the sed below repoints the compose - # file at it. - @cp ../../platform-api/config/config.toml $(DIST_DIR)/configs/config-platform-api.toml + @cp ../../platform-api/config/config-template.toml $(DIST_DIR)/.platform-api-config-template.toml + @cp ../../platform-api/config/config.toml $(DIST_DIR)/.platform-api-config.toml endif + # Platform API's [platform_api.*] tables and AI Workspace's [ai_workspace.*] tables + # are deliberately namespaced so they can coexist in one file without collision (see + # configs/config-template.toml) — combine them into the single configs/config.toml + # that the sed below mounts into BOTH containers, so there is one file to edit for + # the whole standalone deployment instead of two kept in sync by hand. + @{ cat $(DIST_DIR)/.platform-api-config.toml; echo; echo; cat configs/config.toml; } \ + > $(DIST_DIR)/configs/config.toml + @{ cat $(DIST_DIR)/.platform-api-config-template.toml; echo; echo; cat configs/config-template.toml; } \ + > $(DIST_DIR)/configs/config-template.toml + @rm -f $(DIST_DIR)/.platform-api-config.toml $(DIST_DIR)/.platform-api-config-template.toml @printf '%s\n' \ '# Generated secrets — never commit these.' \ '*.env' \ @@ -233,7 +237,7 @@ endif > $(DIST_DIR)/.gitignore @cp setup.sh $(DIST_DIR)/scripts/setup.sh @chmod +x $(DIST_DIR)/scripts/setup.sh - @sed 's#\.\./\.\./platform-api/config/config\.toml#./configs/config-platform-api.toml#' \ + @sed 's#\.\./\.\./platform-api/config/config\.toml#./configs/config.toml#' \ docker-compose.yaml > $(DIST_DIR)/docker-compose.yaml @cp distribution/README.md $(DIST_DIR)/README.md @sed -i.bak -E \ diff --git a/portals/ai-workspace/README.md b/portals/ai-workspace/README.md index f04518a3f..8d08ea04f 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 | [../../platform-api/config/config-template.toml](../../platform-api/config/config-template.toml) (copied into the distribution zip as `configs/config-platform-api-template.toml`) | +| Platform API configuration reference | [../../platform-api/config/config-template.toml](../../platform-api/config/config-template.toml) (merged into `configs/config-template.toml` / `configs/config.toml` in the distribution zip — see `make dist`) | --- diff --git a/portals/ai-workspace/bff/internal/config/config.go b/portals/ai-workspace/bff/internal/config/config.go index 525bcd105..f120d6085 100644 --- a/portals/ai-workspace/bff/internal/config/config.go +++ b/portals/ai-workspace/bff/internal/config/config.go @@ -54,20 +54,20 @@ type Config struct { RuntimeConfig map[string]string `koanf:"-"` } -// ServerConfig is [ai_workspace.server]. +// ServerConfig is [ai_workspace.server] — the single listener, following the +// platform-wide [server.https] shape. Domain is the one browser-safe key here (served +// to the SPA as APIP_AIW_SERVER_DOMAIN); everything else stays server-side. type ServerConfig struct { - StaticDir string `koanf:"static_dir"` // directory containing the built SPA (index.html + assets) - HTTPS HTTPSConfig `koanf:"https"` -} - -// HTTPSConfig is [ai_workspace.server.https] — the single listener, following the -// platform-wide [server.https] shape. Enabled makes the BFF terminate TLS itself, -// presenting the certificate and decrypting inbound TLS; set it false only when a -// trusted upstream (ingress, service-mesh sidecar) terminates TLS and forwards plain -// HTTP, in which case the listener serves plain HTTP on the same port and no -// certificate is read or required. CertFile/KeyFile are required when Enabled — there -// is no self-signed fallback. -type HTTPSConfig struct { + StaticDir string `koanf:"static_dir"` // directory containing the built SPA (index.html + assets) + // Domain is shown in the browser address bar (host:port, or just host for port + // 80/443). Browser-safe — see browserSafeKeys in runtime_config.go. + Domain string `koanf:"domain"` + // Enabled makes the BFF terminate TLS itself, presenting the certificate and + // decrypting inbound TLS; set it false only when a trusted upstream (ingress, + // service-mesh sidecar) terminates TLS and forwards plain HTTP, in which case the + // listener serves plain HTTP on the same port and no certificate is read or + // required. CertFile/KeyFile are required when Enabled — there is no self-signed + // fallback. Enabled bool `koanf:"enabled"` Port int `koanf:"port"` CertFile string `koanf:"cert_file"` @@ -77,7 +77,7 @@ type HTTPSConfig struct { // Addr is the listener address, ":" + port (e.g. ":5380"). The listener always binds // all interfaces, so there is no host to configure. func (c *Config) Addr() string { - return ":" + strconv.Itoa(c.Server.HTTPS.Port) + return ":" + strconv.Itoa(c.Server.Port) } // LoggingConfig is [ai_workspace.logging]. Level/Format are this process's own logs; @@ -296,8 +296,8 @@ func (c *Config) validate() error { if c.Auth.Mode != "basic" && c.Auth.Mode != "oidc" { return fmt.Errorf("invalid [auth] mode %q: must be \"basic\" or \"oidc\"", c.Auth.Mode) } - if c.Server.HTTPS.Port < 1 || c.Server.HTTPS.Port > 65535 { - return fmt.Errorf("[server.https] port must be between 1 and 65535, got %d", c.Server.HTTPS.Port) + if c.Server.Port < 1 || c.Server.Port > 65535 { + return fmt.Errorf("[server] port must be between 1 and 65535, got %d", c.Server.Port) } // Every session duration is a lifetime, where <= 0 is never meaningful. if c.Session.IdleTimeout <= 0 { diff --git a/portals/ai-workspace/bff/internal/config/config_test.go b/portals/ai-workspace/bff/internal/config/config_test.go index f4b4b5220..7fc80be5b 100644 --- a/portals/ai-workspace/bff/internal/config/config_test.go +++ b/portals/ai-workspace/bff/internal/config/config_test.go @@ -209,7 +209,7 @@ client_secret = '{{ env "CUSTOM_SECRET_VAR" }}' // The upstream URL is mandatory — the BFF has nothing to proxy to without it. func TestLoad_MissingControlPlaneURL_Errors(t *testing.T) { - cfgPath := writeConfig(t, "[ai_workspace]\ndomain = \"localhost:5380\"") + cfgPath := writeConfig(t, "[ai_workspace.server]\ndomain = \"localhost:5380\"") _, err := Load(cfgPath) if err == nil { @@ -224,7 +224,7 @@ func TestLoad_MissingControlPlaneURL_Errors(t *testing.T) { // and OIDC client credentials must never appear in it. func TestLoad_RuntimeConfigExcludesServerSideKeys(t *testing.T) { cfgPath := writeConfig(t, ` -[ai_workspace] +[ai_workspace.server] domain = "localhost:5380" [ai_workspace.auth] @@ -245,8 +245,8 @@ redirect_url = "https://localhost:5380/api/auth/callback" t.Fatalf("Load() error = %v", err) } - if got := cfg.RuntimeConfig["APIP_AIW_DOMAIN"]; got != "localhost:5380" { - t.Errorf("APIP_AIW_DOMAIN = %q, want the browser-safe domain to be surfaced", got) + if got := cfg.RuntimeConfig["APIP_AIW_SERVER_DOMAIN"]; got != "localhost:5380" { + t.Errorf("APIP_AIW_SERVER_DOMAIN = %q, want the browser-safe domain to be surfaced", got) } for _, v := range cfg.RuntimeConfig { if strings.Contains(v, "s3cr3t") || strings.Contains(v, "platform-api:9243") { @@ -285,20 +285,20 @@ url = "https://platform-api:9243" // under that same name, exactly as if it had been written as a literal. func TestLoad_BrowserSafeKeyFromEnvToken(t *testing.T) { cfgPath := writeConfig(t, ` -[ai_workspace] -domain = '{{ env "APIP_AIW_DOMAIN" "localhost:5380" }}' +[ai_workspace.server] +domain = '{{ env "APIP_AIW_SERVER_DOMAIN" "localhost:5380" }}' [ai_workspace.control_plane] url = "https://platform-api:9243" `) - t.Setenv("APIP_AIW_DOMAIN", "app.example.com") + t.Setenv("APIP_AIW_SERVER_DOMAIN", "app.example.com") cfg, err := Load(cfgPath) if err != nil { t.Fatalf("Load() error = %v", err) } - if got := cfg.RuntimeConfig["APIP_AIW_DOMAIN"]; got != "app.example.com" { - t.Errorf("APIP_AIW_DOMAIN = %q, want the token-resolved value to reach the browser", got) + if got := cfg.RuntimeConfig["APIP_AIW_SERVER_DOMAIN"]; got != "app.example.com" { + t.Errorf("APIP_AIW_SERVER_DOMAIN = %q, want the token-resolved value to reach the browser", got) } } @@ -309,7 +309,7 @@ func TestLoad_BareTOMLScalars(t *testing.T) { [ai_workspace.control_plane] url = "https://platform-api:9243" -[ai_workspace.server.https] +[ai_workspace.server] enabled = false [ai_workspace.session] @@ -320,8 +320,8 @@ absolute_ttl = "2h" if err != nil { t.Fatalf("Load() error = %v", err) } - if cfg.Server.HTTPS.Enabled { - t.Error("Server.HTTPS.Enabled = true, want false from the bare TOML boolean") + if cfg.Server.Enabled { + t.Error("Server.Enabled = true, want false from the bare TOML boolean") } if cfg.Session.AbsoluteTTL != 2*time.Hour { t.Errorf("Session.AbsoluteTTL = %s, want 2h", cfg.Session.AbsoluteTTL) @@ -329,7 +329,7 @@ absolute_ttl = "2h" } // A key in a table must not collide with the same key in another table — they are -// distinct dotted paths, so [server.https] enabled and [auth.oidc] enabled are independent. +// distinct dotted paths, so [server] enabled and [auth.oidc] enabled are independent. func TestLoad_SameKeyInDifferentTables(t *testing.T) { cfgPath := writeConfig(t, ` [ai_workspace.auth] @@ -338,7 +338,7 @@ mode = "oidc" [ai_workspace.control_plane] url = "https://platform-api:9243" -[ai_workspace.server.https] +[ai_workspace.server] enabled = false [ai_workspace.auth.oidc] @@ -353,8 +353,8 @@ redirect_url = "https://localhost:5380/api/auth/callback" if err != nil { t.Fatalf("Load() error = %v", err) } - if cfg.Server.HTTPS.Enabled { - t.Error("Server.HTTPS.Enabled = true, want false — [server.https] enabled must not read [auth.oidc] enabled") + if cfg.Server.Enabled { + t.Error("Server.Enabled = true, want false — [server] enabled must not read [auth.oidc] enabled") } if !cfg.Auth.OIDC.Enabled { t.Error("OIDC.Enabled = false, want true") @@ -406,7 +406,7 @@ func TestLoad_InvalidBool_Errors(t *testing.T) { [ai_workspace.control_plane] url = "https://platform-api:9243" -[ai_workspace.server.https] +[ai_workspace.server] enabled = "maybe" `) diff --git a/portals/ai-workspace/bff/internal/config/default_config.go b/portals/ai-workspace/bff/internal/config/default_config.go index 9ba2bcaf0..5f2fccc94 100644 --- a/portals/ai-workspace/bff/internal/config/default_config.go +++ b/portals/ai-workspace/bff/internal/config/default_config.go @@ -26,14 +26,13 @@ func defaultConfig() *Config { return &Config{ Server: ServerConfig{ StaticDir: "/app", - HTTPS: HTTPSConfig{ - Enabled: true, - Port: 5380, - // Convention matches the container's mount path. A certificate pair is - // required there whenever the listener terminates TLS. - CertFile: "/etc/ai-workspace/tls/cert.pem", - KeyFile: "/etc/ai-workspace/tls/key.pem", - }, + Domain: "localhost:5380", + Enabled: true, + Port: 5380, + // Convention matches the container's mount path. A certificate pair is + // required there whenever the listener terminates TLS. + CertFile: "/etc/ai-workspace/tls/cert.pem", + KeyFile: "/etc/ai-workspace/tls/key.pem", }, Logging: LoggingConfig{ Level: "info", diff --git a/portals/ai-workspace/bff/internal/config/runtime_config.go b/portals/ai-workspace/bff/internal/config/runtime_config.go index ca8808de4..da8d5971a 100644 --- a/portals/ai-workspace/bff/internal/config/runtime_config.go +++ b/portals/ai-workspace/bff/internal/config/runtime_config.go @@ -33,7 +33,7 @@ import ( var browserSafeKeys = []string{ // Identity of the deployment. auth.mode is not listed: buildRuntimeConfig // always emits it from the parsed cfg.Auth.Mode instead. - "domain", + "server.domain", "default_org_region", "gateway.controlplane_host", "gateway.platform_gateway_versions", 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 6188a8cee..3ad6871b3 100644 --- a/portals/ai-workspace/bff/internal/config/shipped_config_test.go +++ b/portals/ai-workspace/bff/internal/config/shipped_config_test.go @@ -17,7 +17,9 @@ package config import ( + "os" "path/filepath" + "strings" "testing" ) @@ -105,24 +107,38 @@ func TestShippedConfig_MakeBffRunOverrides(t *testing.T) { // configs/config.toml is deliberately minimal and ships without an // [ai_workspace.auth.oidc] table — the quickstart only ever runs in basic-auth mode. // configs/config-template.toml is the file a real deployment copies from, and its -// [ai_workspace.auth.oidc] tokens are what let OIDC be turned on from the environment -// alone, without further edits to the file. -func TestShippedConfig_OIDCFromEnvAlone(t *testing.T) { - t.Setenv("APIP_AIW_AUTH_MODE", "oidc") - t.Setenv("APIP_AIW_AUTH_OIDC_AUTHORITY", "https://idp.example.com") - t.Setenv("APIP_AIW_AUTH_OIDC_CLIENT_ID", "client-id") - t.Setenv("APIP_AIW_AUTH_OIDC_CLIENT_SECRET", "s3cr3t") - t.Setenv("APIP_AIW_AUTH_OIDC_REDIRECT_URL", "https://localhost:5380/api/auth/callback") +// [ai_workspace.auth.oidc] holds plain-literal placeholder values (mode = "basic", +// an empty client_secret) rather than {{ env }} tokens, so OIDC is not settable from +// the environment alone — a real deployment copies the template to config.toml and +// edits [ai_workspace.auth] mode plus [ai_workspace.auth.oidc] directly. This +// exercises that edited shape still loads correctly and keeps OIDC credentials off +// the browser-safe runtime config. +func TestShippedConfig_TemplateEditedForOIDC(t *testing.T) { + templateBytes, err := os.ReadFile(templateConfig) + if err != nil { + t.Fatalf("read %s: %v", templateConfig, err) + } + edited := strings.NewReplacer( + `mode = "basic"`, `mode = "oidc"`, + `authority = "https://accounts.example.com"`, `authority = "https://idp.example.com"`, + `client_id = "your-client-id"`, `client_id = "client-id"`, + `client_secret = ""`, `client_secret = "s3cr3t"`, + ).Replace(string(templateBytes)) - cfg, err := Load(templateConfig) + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(edited), 0o600); err != nil { + t.Fatalf("write edited template: %v", err) + } + + cfg, err := Load(path) if err != nil { - t.Fatalf("Load(configs/config-template.toml) error = %v — the [ai_workspace.auth.oidc] tokens must make OIDC settable from the environment", err) + t.Fatalf("Load(edited template) error = %v", err) } if !cfg.Auth.OIDC.Enabled { - t.Fatal("OIDC.Enabled = false, want true — [auth] mode = oidc must enable the client") + t.Fatal("OIDC.Enabled = false, want true — mode = oidc must enable the client") } if cfg.Auth.OIDC.ClientSecret != "s3cr3t" { - t.Errorf("OIDC.ClientSecret = %q, want the value from APIP_AIW_AUTH_OIDC_CLIENT_SECRET", cfg.Auth.OIDC.ClientSecret) + t.Errorf("OIDC.ClientSecret = %q, want %q", cfg.Auth.OIDC.ClientSecret, "s3cr3t") } // Whatever the mode, the client credentials stay server-side. for _, key := range []string{"APIP_AIW_AUTH_OIDC_CLIENT_SECRET", "APIP_AIW_AUTH_OIDC_CLIENT_ID", "APIP_AIW_AUTH_OIDC_AUTHORITY"} { @@ -132,12 +148,9 @@ func TestShippedConfig_OIDCFromEnvAlone(t *testing.T) { } } -// config-template.toml is copied to config.toml by users, so it must stay loadable. -// Its client_secret token deliberately has no default — an unset variable fails startup -// rather than running OIDC with an empty credential — so the variable is set here. +// config-template.toml is copied to config.toml by users, so it must stay loadable +// as shipped — OIDC off, basic auth, an unset (empty) client_secret. func TestShippedConfig_TemplateLoads(t *testing.T) { - t.Setenv("APIP_AIW_AUTH_OIDC_CLIENT_SECRET", "s3cr3t") - cfg, err := Load(templateConfig) if err != nil { t.Fatalf("Load(configs/config-template.toml) error = %v — the template must remain a loadable config", err) @@ -151,13 +164,3 @@ func TestShippedConfig_TemplateLoads(t *testing.T) { t.Error("ControlPlane.TLSSkipVerify = true, want false — the template must default to a verified upstream") } } - -// The template's client_secret has no default on purpose: leaving the variable unset -// must abort startup, not resolve to an empty credential. -func TestShippedConfig_TemplateFailsClosedOnMissingSecret(t *testing.T) { - t.Setenv("APIP_AIW_AUTH_OIDC_CLIENT_SECRET", "") - - if _, err := Load(templateConfig); err == nil { - t.Fatal("Load(configs/config-template.toml) succeeded with APIP_AIW_AUTH_OIDC_CLIENT_SECRET unset, want an error — the token has no default and must fail closed") - } -} diff --git a/portals/ai-workspace/bff/main.go b/portals/ai-workspace/bff/main.go index 55f0a89a3..4140355b7 100644 --- a/portals/ai-workspace/bff/main.go +++ b/portals/ai-workspace/bff/main.go @@ -129,7 +129,7 @@ func main() { IdleTimeout: 120 * time.Second, } - tlsConfig, err := buildTLS(cfg.Server.HTTPS) + tlsConfig, err := buildTLS(cfg.Server) if err != nil { slog.Error("failed to set up TLS", "err", err) os.Exit(1) @@ -174,10 +174,10 @@ func main() { // 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.HTTPSConfig) (*tls.Config, error) { +func buildTLS(c config.ServerConfig) (*tls.Config, error) { if !c.Enabled { // Plain HTTP is only safe when something upstream terminates TLS. - slog.Warn("TLS: disabled ([server.https] enabled = false) — serving plain HTTP. " + + slog.Warn("TLS: disabled ([server] 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 @@ -189,8 +189,8 @@ func buildTLS(c config.HTTPSConfig) (*tls.Config, error) { } if !fileExists(c.CertFile) { return nil, fmt.Errorf("TLS is enabled but no certificate is mounted: "+ - "set [server.https] cert_file (%q) and key_file (%q) to existing files, "+ - "or set [server.https] enabled = false to serve plain HTTP behind a TLS-terminating proxy", c.CertFile, c.KeyFile) + "set [server] cert_file (%q) and key_file (%q) to existing files, "+ + "or set [server] enabled = false to serve plain HTTP behind a TLS-terminating proxy", c.CertFile, c.KeyFile) } cert, err := tlsutil.CertFromFiles(c.CertFile, c.KeyFile) if err != nil { diff --git a/portals/ai-workspace/configs/config-template.toml b/portals/ai-workspace/configs/config-template.toml index a2a84fbdc..433caffc0 100644 --- a/portals/ai-workspace/configs/config-template.toml +++ b/portals/ai-workspace/configs/config-template.toml @@ -49,7 +49,7 @@ # QUICK START (basic-auth mode): # 1. Copy this file to config.toml. # 2. Set [ai_workspace.auth] mode = "basic" (uses the users defined in -# config-platform-api.toml). +# [platform_api.auth.file.users] in the same config.toml). # 3. Run: docker compose up # # OIDC mode: set [ai_workspace.auth] mode = "oidc" and fill in [ai_workspace.auth.oidc]. @@ -64,19 +64,17 @@ # window.__RUNTIME_CONFIG__. Never put a credential among them. # ==================================================================== -# Domain shown in the browser address bar (host:port, or just host for port 80/443). -domain = '{{ env "APIP_AIW_DOMAIN" "localhost:5380" }}' - # Default region assigned to new organizations on first login. -default_org_region = '{{ env "APIP_AIW_DEFAULT_ORG_REGION" "us" }}' +default_org_region = "us" # --------------------------------------------------------------------------- # Server # # Everything in this table configures the AI Workspace server itself. These values -# stay server-side and are never sent to the browser (the [ai_workspace.auth.oidc] -# claim names being the one exception — the SPA needs them to display user/org identity). +# stay server-side and are never sent to the browser, with two exceptions: domain +# (the SPA needs it to display the deployment's own address) and the +# [ai_workspace.auth.oidc] claim names (the SPA needs them to display user/org identity). # # All keys are optional: the value in each token's default position is the built-in # default. @@ -90,20 +88,19 @@ default_org_region = '{{ env "APIP_AIW_DEFAULT_ORG_REGION" "us" }}' # The single listener follows the platform-wide [server.https] shape. The listener # always binds all interfaces (there is no host to configure), and the container # publishes this port. -[ai_workspace.server.https] +[ai_workspace.server] -# Terminate TLS on this listener. Set false only when a trusted upstream (ingress, -# service-mesh sidecar) terminates TLS for you — the listener then serves plain HTTP -# on the same port and no certificate is read or required. -enabled = '{{ env "APIP_AIW_SERVER_HTTPS_ENABLED" "true" }}' +# Domain shown in the browser address bar (host:port, or just host for port 80/443). +# Browser-safe — served to the SPA as APIP_AIW_SERVER_DOMAIN. +domain = "localhost:5380" # Port the server listens on. -port = '{{ env "APIP_AIW_SERVER_HTTPS_PORT" "5380" }}' +port = 5380 # Paths to a mounted TLS certificate and key. REQUIRED when enabled = true; setup.sh # generates a pair for the quickstart. -cert_file = '{{ env "APIP_AIW_SERVER_HTTPS_CERT_FILE" "/etc/ai-workspace/tls/cert.pem" }}' -key_file = '{{ env "APIP_AIW_SERVER_HTTPS_KEY_FILE" "/etc/ai-workspace/tls/key.pem" }}' +cert_file = "/etc/ai-workspace/tls/cert.pem" +key_file = "/etc/ai-workspace/tls/key.pem" # --------------------------------------------------------------------------- @@ -113,9 +110,9 @@ key_file = '{{ env "APIP_AIW_SERVER_HTTPS_KEY_FILE" "/etc/ai-workspace/tls/key. # --------------------------------------------------------------------------- [ai_workspace.logging] -level = '{{ env "APIP_AIW_LOGGING_LEVEL" "info" }}' # debug | info | warn | error -format = '{{ env "APIP_AIW_LOGGING_FORMAT" "text" }}' # text | json -browser_debug = '{{ env "APIP_AIW_LOGGING_BROWSER_DEBUG" "false" }}' +level = "info" # debug | info | warn | error +format = "text" # text | json +browser_debug = "false" # --------------------------------------------------------------------------- @@ -125,24 +122,24 @@ browser_debug = '{{ env "APIP_AIW_LOGGING_BROWSER_DEBUG" "false" }}' # Base URL to reach the Platform API. REQUIRED. Use the compose hostname in docker # compose, or localhost when running locally. The scheme decides if the hop uses TLS. -url = '{{ env "APIP_AIW_CONTROL_PLANE_URL" "https://platform-api:9243" }}' +url = "https://platform-api:9243" # Same-origin prefix the SPA calls; stripped before forwarding upstream, so the # browser only ever talks to the app origin and never sees the platform-api cert. -proxy_prefix = '{{ env "APIP_AIW_CONTROL_PLANE_PROXY_PREFIX" "/proxy" }}' +proxy_prefix = "/proxy" # Platform API's portal route prefix. BFF-initiated calls (e.g. file-based login) are # built as this base path plus the route's own suffix, e.g. base + "/auth/login". -portal_base_path = '{{ env "APIP_AIW_CONTROL_PLANE_PORTAL_BASE_PATH" "/api/portal/v0.9" }}' +portal_base_path = "/api/portal/v0.9" # --- Trust for the upstream's TLS certificate --- # Accept the Platform API's self-signed certificate without verification (local dev # only) — prefer ca_file instead. -tls_skip_verify = '{{ env "APIP_AIW_CONTROL_PLANE_TLS_SKIP_VERIFY" "false" }}' +tls_skip_verify = "false" # PEM bundle to trust for the upstream's TLS certificate, appended to system roots. -ca_file = '{{ env "APIP_AIW_CONTROL_PLANE_CA_FILE" "" }}' +ca_file = "" # --------------------------------------------------------------------------- @@ -154,12 +151,12 @@ ca_file = '{{ env "APIP_AIW_CONTROL_PLANE_CA_FILE" "" }}' # host:port that deployed gateways use to reach the Platform API, shown in setup # instructions (keys.env, helm values). Must be externally reachable, not a proxy path. -controlplane_host = '{{ env "APIP_AIW_GATEWAY_CONTROLPLANE_HOST" "localhost:9243" }}' +controlplane_host = "localhost:9243" # Gateway versions offered in the create-gateway version selector (JSON array string). # Each entry: version (helm chart minor), latestVersion (image/chart tag, used in the # helm --version flag), channel ("STS" | "LTS"). The first entry is the default. -platform_gateway_versions = '{{ env "APIP_AIW_GATEWAY_PLATFORM_GATEWAY_VERSIONS" "[{\"version\":\"1.2\",\"latestVersion\":\"v1.2.0-alpha2\",\"channel\":\"STS\"}]" }}' +platform_gateway_versions = "[{\"version\":\"1.2\",\"latestVersion\":\"v1.2.0-alpha2\",\"channel\":\"STS\"}]" @@ -169,13 +166,13 @@ platform_gateway_versions = '{{ env "APIP_AIW_GATEWAY_PLATFORM_GATEWAY_VERSIONS" [ai_workspace.session] # Session store. Only "memory" is supported today ("redis" is future). -store = '{{ env "APIP_AIW_SESSION_STORE" "memory" }}' +store = "memory" # Sliding idle window (Go duration, e.g. "30m"). Reserved — not enforced yet. -idle_timeout = '{{ env "APIP_AIW_SESSION_IDLE_TIMEOUT" "30m" }}' +idle_timeout = "30m" # Hard cap on session lifetime regardless of activity/refresh (Go duration). -absolute_ttl = '{{ env "APIP_AIW_SESSION_ABSOLUTE_TTL" "8h" }}' +absolute_ttl = "8h" # --------------------------------------------------------------------------- @@ -185,25 +182,25 @@ absolute_ttl = '{{ env "APIP_AIW_SESSION_ABSOLUTE_TTL" "8h" }}' # Authentication mode for the UI: "basic" (local users) or "oidc" (external IDP). # Choosing "oidc" enables the [ai_workspace.auth.oidc] table below. -mode = '{{ env "APIP_AIW_AUTH_MODE" "basic" }}' +mode = "basic" # --------------------------------------------------------------------------- # JWT claim name mappings — which token claim carries each user/org field. Applies to # BOTH auth modes (a sibling of [ai_workspace.auth.oidc], not nested in it): in basic # mode the Platform API signs JWTs using these same claim names, so the two services -# must agree. Mirrors [platform_api.auth.claim_mappings] in config-platform-api.toml +# must agree. Mirrors [platform_api.auth.claim_mappings] in the same config.toml # key for key — override only if your IDP uses different claim names. # --------------------------------------------------------------------------- [ai_workspace.auth.claim_mappings] -organization = '{{ env "APIP_AIW_AUTH_CLAIM_MAPPINGS_ORGANIZATION" "organization" }}' # claim carrying the org ID -org_name = '{{ env "APIP_AIW_AUTH_CLAIM_MAPPINGS_ORG_NAME" "org_name" }}' # org display name -org_handle = '{{ env "APIP_AIW_AUTH_CLAIM_MAPPINGS_ORG_HANDLE" "org_handle" }}' # org URL slug -username = '{{ env "APIP_AIW_AUTH_CLAIM_MAPPINGS_USERNAME" "username" }}' -email = '{{ env "APIP_AIW_AUTH_CLAIM_MAPPINGS_EMAIL" "email" }}' -scope = '{{ env "APIP_AIW_AUTH_CLAIM_MAPPINGS_SCOPE" "scope" }}' # space-separated scope string -roles = '{{ env "APIP_AIW_AUTH_CLAIM_MAPPINGS_ROLES" "roles" }}' +organization = "organization" # claim carrying the org ID +org_name = "org_name" # org display name +org_handle = "org_handle" # org URL slug +username = "username" +email = "email" +scope = "scope" # space-separated scope string +roles = "roles" # --------------------------------------------------------------------------- @@ -216,28 +213,29 @@ roles = '{{ env "APIP_AIW_AUTH_CLAIM_MAPPINGS_ROLES" "roles" }}' # Force the OIDC client on independently of mode. Rarely needed — mode = "oidc" # above already enables it. -enabled = '{{ env "APIP_AIW_AUTH_OIDC_ENABLED" "false" }}' +enabled = "false" # Issuer URL — endpoints are auto-discovered from /.well-known/openid-configuration. -authority = '{{ env "APIP_AIW_AUTH_OIDC_AUTHORITY" "https://accounts.example.com" }}' +authority = "https://accounts.example.com" # Client ID registered in your IDP. -client_id = '{{ env "APIP_AIW_AUTH_OIDC_CLIENT_ID" "your-client-id" }}' +client_id = "your-client-id" # Confidential-client secret. REQUIRED in OIDC mode with no default — an unset # variable fails startup. Prefer the {{ file }} form in production. -client_secret = '{{ env "APIP_AIW_AUTH_OIDC_CLIENT_SECRET" }}' +client_secret = "" # Server-side callback the IDP redirects to after login (not an SPA route). Must # match the redirect URI registered on the IDP application. -redirect_url = '{{ env "APIP_AIW_AUTH_OIDC_REDIRECT_URL" "https://localhost:5380/api/auth/callback" }}' +redirect_url = "https://localhost:5380/api/auth/callback" # Absolute URL the IDP redirects to after logout (must be pre-registered there). -post_logout_redirect_url = '{{ env "APIP_AIW_AUTH_OIDC_POST_LOGOUT_REDIRECT_URL" "https://localhost:5380/login" }}' +post_logout_redirect_url = "https://localhost:5380/login" -# Scopes requested at login (space-separated). Leave unset to request the built-in -# full ap:* set (recommended); if overriding, keep offline_access or refresh breaks. -scope = '{{ env "APIP_AIW_AUTH_OIDC_SCOPE" "" }}' +# Scopes requested at login (space-separated). Defaults to the full ap:* set the +# Platform API authorizes against (recommended) — trim only what you need to +# restrict, and always keep offline_access or token refresh breaks. +scope = "openid profile email offline_access ap:organization:read ap:organization:manage ap:organization:subscription:read ap:project:read ap:project:create ap:project:update ap:project:delete ap:project:manage ap:application:read ap:application:create ap:application:update ap:application:delete ap:application:manage ap:application:api_key:read ap:application:api_key:create ap:application:api_key:delete ap:application:api_key:manage ap:application:association:read ap:application:association:create ap:application:association:delete ap:application:association:manage ap:application:association:api_key:read ap:gateway:read ap:gateway:create ap:gateway:update ap:gateway:delete ap:gateway:manage ap:gateway:token:read ap:gateway:token:create ap:gateway:token:delete ap:gateway:token:manage ap:gateway_custom_policy:read ap:gateway_custom_policy:create ap:gateway_custom_policy:delete ap:gateway_custom_policy:manage ap:gateway:artifact:read ap:gateway:manifest:read ap:rest_api:read ap:rest_api:create ap:rest_api:update ap:rest_api:delete ap:rest_api:manage ap:rest_api:import ap:rest_api:gateway:read ap:rest_api:gateway:create ap:rest_api:gateway:manage ap:rest_api:deployment:read ap:rest_api:deployment:create ap:rest_api:deployment:delete ap:rest_api:deployment:manage ap:rest_api:deployment:undeploy ap:rest_api:deployment:restore ap:rest_api:api_key:read ap:rest_api:api_key:create ap:rest_api:api_key:update ap:rest_api:api_key:delete ap:rest_api:api_key:manage ap:rest_api:publication:read ap:rest_api:publication:create ap:rest_api:publication:delete ap:devportal:read ap:devportal:create ap:devportal:update ap:devportal:delete ap:devportal:manage ap:subscription:read ap:subscription:create ap:subscription:update ap:subscription:delete ap:subscription:manage ap:subscription_plan:read ap:subscription_plan:create ap:subscription_plan:update ap:subscription_plan:delete ap:subscription_plan:manage ap:llm_template:read ap:llm_template:create ap:llm_template:update ap:llm_template:delete ap:llm_template:manage ap:llm_provider:read ap:llm_provider:create ap:llm_provider:update ap:llm_provider:delete ap:llm_provider:manage ap:llm_provider:api_key:read ap:llm_provider:api_key:create ap:llm_provider:api_key:delete ap:llm_provider:api_key:manage ap:llm_provider:deployment:read ap:llm_provider:deployment:create ap:llm_provider:deployment:delete ap:llm_provider:deployment:manage ap:llm_provider:deployment:undeploy ap:llm_provider:deployment:restore ap:llm_proxy:read ap:llm_proxy:create ap:llm_proxy:update ap:llm_proxy:delete ap:llm_proxy:manage ap:llm_proxy:api_key:read ap:llm_proxy:api_key:create ap:llm_proxy:api_key:delete ap:llm_proxy:api_key:manage ap:llm_proxy:deployment:read ap:llm_proxy:deployment:create ap:llm_proxy:deployment:delete ap:llm_proxy:deployment:manage ap:llm_proxy:deployment:undeploy ap:llm_proxy:deployment:restore ap:mcp_proxy:read ap:mcp_proxy:create ap:mcp_proxy:update ap:mcp_proxy:delete ap:mcp_proxy:manage ap:mcp_proxy:deployment:read ap:mcp_proxy:deployment:create ap:mcp_proxy:deployment:delete ap:mcp_proxy:deployment:manage ap:mcp_proxy:deployment:undeploy ap:mcp_proxy:deployment:restore ap:websub_api:read ap:websub_api:create ap:websub_api:update ap:websub_api:delete ap:websub_api:manage ap:websub_api:api_key:read ap:websub_api:api_key:create ap:websub_api:api_key:delete ap:websub_api:api_key:manage ap:websub_api:api_key:update ap:websub_api:deployment:read ap:websub_api:deployment:create ap:websub_api:deployment:delete ap:websub_api:deployment:manage ap:websub_api:deployment:undeploy ap:websub_api:deployment:restore ap:websub_api:publication:read ap:websub_api:publication:create ap:websub_api:publication:delete ap:webbroker_api:read ap:webbroker_api:create ap:webbroker_api:update ap:webbroker_api:delete ap:webbroker_api:manage ap:webbroker_api:api_key:read ap:webbroker_api:api_key:create ap:webbroker_api:api_key:delete ap:webbroker_api:api_key:manage ap:webbroker_api:api_key:update ap:webbroker_api:deployment:read ap:webbroker_api:deployment:create ap:webbroker_api:deployment:delete ap:webbroker_api:deployment:manage ap:webbroker_api:deployment:undeploy ap:webbroker_api:deployment:restore ap:webbroker_api:publication:read ap:webbroker_api:publication:create ap:webbroker_api:publication:delete ap:secret:read ap:secret:create ap:secret:update ap:secret:delete ap:secret:manage ap:git:read" # ==================================================================== diff --git a/portals/ai-workspace/configs/config.toml b/portals/ai-workspace/configs/config.toml index cf247bbfb..28d4aed9f 100644 --- a/portals/ai-workspace/configs/config.toml +++ b/portals/ai-workspace/configs/config.toml @@ -1,22 +1,12 @@ -# -------------------------------------------------------------------- -# 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] -domain = '{{ env "APIP_AIW_DOMAIN" "localhost:5380" }}' -default_org_region = '{{ env "APIP_AIW_DEFAULT_ORG_REGION" "us" }}' +default_org_region = "us" -[ai_workspace.server.https] +[ai_workspace.server] -enabled = '{{ env "APIP_AIW_SERVER_HTTPS_ENABLED" "true" }}' -port = '{{ env "APIP_AIW_SERVER_HTTPS_PORT" "5380" }}' +domain = '{{ env "APIP_AIW_SERVER_DOMAIN" "localhost:5380" }}' +port = 5380 cert_file = "/etc/ai-workspace/tls/cert.pem" key_file = "/etc/ai-workspace/tls/key.pem" @@ -36,7 +26,7 @@ ca_file = "/etc/ai-workspace/tls/cert.pem" [ai_workspace.gateway] controlplane_host = '{{ env "APIP_AIW_GATEWAY_CONTROLPLANE_HOST" "host.docker.internal:9243" }}' -platform_gateway_versions = '{{ env "APIP_AIW_GATEWAY_PLATFORM_GATEWAY_VERSIONS" "[{\"version\":\"1.2\",\"latestVersion\":\"v1.2.0-alpha2\",\"channel\":\"STS\"}]" }}' +platform_gateway_versions = "[{\"version\":\"1.2\",\"latestVersion\":\"v1.2.0-alpha2\",\"channel\":\"STS\"}]" [ai_workspace.auth] diff --git a/portals/ai-workspace/distribution/README.md b/portals/ai-workspace/distribution/README.md index 952e5eb49..2cc99c460 100644 --- a/portals/ai-workspace/distribution/README.md +++ b/portals/ai-workspace/distribution/README.md @@ -11,10 +11,10 @@ wso2apip-ai-workspace-/ ├── scripts/ │ └── setup.sh # One-time TLS + secrets provisioning ├── configs/ -│ ├── config.toml # AI Workspace active configuration -│ ├── config-template.toml # AI Workspace full configuration reference -│ ├── config-platform-api.toml # Platform API active configuration -│ └── config-platform-api-template.toml # Platform API full configuration reference +│ ├── config.toml # Active configuration for BOTH services — +│ │ # [platform_api.*] and [ai_workspace.*] +│ │ # tables side by side in one file +│ └── config-template.toml # Full configuration reference for both └── resources/ ├── roles.yaml # Platform API role definitions └── platform-api/ @@ -68,42 +68,41 @@ 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. +Both services read their settings from the single `configs/config.toml` — Platform API's `[platform_api.*]` tables and AI Workspace's `[ai_workspace.*]` tables live side by side in the same file (each service reads only its own top-level table and ignores the other's), and `docker-compose.yaml` mounts that one file into both containers. Edit it and restart the affected service — no rebuild required. -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_LOGGING_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. +Each config key writes its value as a `'{{ env "..." }}'` token, so it can be set from the environment without editing the file — the token names the variable, by convention the key's path uppercased and prefixed `APIP_AIW_` (AI Workspace) or `APIP_CP_` (Platform API), e.g. `APIP_AIW_LOGGING_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. 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_AUTH_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`) +### AI Workspace (`[ai_workspace.*]`) | Setting | Description | |---------|-------------| -| `domain` | Host and port shown in the browser address bar | +| `[ai_workspace] default_org_region` | Default region assigned to new organizations on first login | +| `[ai_workspace.server] domain` | Host and port shown in the browser address bar | | `[ai_workspace.auth] mode` | `basic` (file-based quickstart) or `oidc` (external IDP) | | `[ai_workspace.control_plane].url` | Base URL of the upstream Platform API hop | | `[ai_workspace.control_plane].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` | | `[ai_workspace.control_plane].tls_skip_verify` | Skip upstream cert verification — local dev only | | `[ai_workspace.gateway].controlplane_host` | Address gateways use to reach the Platform API | | `[ai_workspace.gateway].platform_gateway_versions` | Gateway versions shown in the create-gateway selector | -| `[ai_workspace.server.https].cert_file` / `key_file` | Listener certificate pair — required when `[ai_workspace.server.https].enabled` is `true`. Fixed to the mounted path, same as `ca_file` above | +| `[ai_workspace.server].cert_file` / `key_file` | Listener certificate pair. Fixed to the mounted path, same as `ca_file` above | | `[ai_workspace.auth.oidc].*` | Used only when `[ai_workspace.auth] mode = "oidc"` — see [OIDC](#oidc-production) below | -### Platform API (`configs/config-platform-api.toml`) +### Platform API (`[platform_api.*]`) | Setting | Description | |---------|-------------| -| `[logging].level` | Log level (`debug`, `info`, `warn`, `error`; matched case-insensitively) | -| `[security].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` | -| `[database].driver` | `sqlite3` or `postgres` | -| `[auth].mode` | `file` (quickstart default), `external_token`, or `idp` — selects exactly one auth mode | -| `[auth.jwt].public_key` / `private_key` | RS256 (asymmetric) PEM keys; `public_key` verifies every token, `private_key` signs login JWTs in `file` mode. Read via `{{ file }}` — HMAC and unsigned tokens are rejected | -| `[auth.idp]` | JWKS-based IDP auth — active when `mode = "idp"`; configure for Asgardeo, Keycloak, Auth0, etc. | -| `[auth.file.users]` | Local user credentials, active when `mode = "file"` (change the password hash before sharing) | -| `[server.https]` | Listener on `:9243`; `cert_file`/`key_file` point at `cert.pem`/`key.pem` | - -Each key's default value is written inline in `configs/config-template.toml` and -`configs/config-platform-api-template.toml` — those files are a fully-commented reference of -every available setting and its default, so defaults are not restated here. +| `[platform_api.logging].level` | Log level (`debug`, `info`, `warn`, `error`; matched case-insensitively) | +| `[platform_api.security].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` | +| `[platform_api.database].driver` | `sqlite3` or `postgres` | +| `[platform_api.auth].mode` | `file` (quickstart default), `external_token`, or `idp` — selects exactly one auth mode | +| `[platform_api.auth.jwt].public_key_file` / `private_key_file` | RS256 (asymmetric) PEM keys; `public_key_file` verifies every token, `private_key_file` signs login JWTs in `file` mode. Read via `{{ file }}` — HMAC and unsigned tokens are rejected | +| `[platform_api.auth.idp]` | JWKS-based IDP auth — active when `mode = "idp"`; configure for Asgardeo, Keycloak, Auth0, etc. | +| `[platform_api.auth.file.users]` | Local user credentials, active when `mode = "file"` (change the password hash before sharing) | +| `[platform_api.server.https]` | Listener on `:9243`; `cert_file`/`key_file` point at `cert.pem`/`key.pem` | + +Each key's default value is written inline in `configs/config-template.toml` — a fully-commented reference of every available setting and its default for both services, so defaults are not restated here. ## Authentication Modes @@ -115,7 +114,7 @@ The admin user is generated by `setup.sh` (see [Quick Start](#quick-start)). To htpasswd -bnBC 10 "" NEW_PASSWORD | tr -d ':\n' ``` -Replace the `password_hash` value in `configs/config-platform-api.toml` before starting. +Replace the `password_hash` value under `[platform_api.auth.file.users]` in `configs/config.toml` before starting. ### OIDC (production) @@ -134,9 +133,9 @@ To delegate login to an external OIDC-compliant provider (Asgardeo, Keycloak, Au Leaving `APIP_AIW_AUTH_OIDC_SCOPE` unset requests the full `ap:*` scope set. -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 `[auth] mode = "idp"` and fill in `jwks_url` and `issuer` for your IDP. `auth.mode` selects exactly one mode, so switching to `"idp"` stops the file-based login endpoint from being used. Align `[auth.claim_mappings]` with `[ai_workspace.auth.claim_mappings]` in `configs/config.toml` — both services must read the same claims out of the same token. +3. **Platform API** (`[platform_api.*]` tables in `configs/config.toml`): the `[platform_api.auth.idp]` fields have no env-var tokens in the quickstart file, so edit the TOML directly — set `[platform_api.auth] mode = "idp"` and fill in `jwks_url` and `issuer` for your IDP. `mode` selects exactly one auth mode, so switching to `"idp"` stops the file-based login endpoint from being used. Align `[platform_api.auth.claim_mappings]` with `[ai_workspace.auth.claim_mappings]` — both services must read the same claims out of the same token. -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. +See `configs/config-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. ## Custom TLS Certificates @@ -148,10 +147,10 @@ docker compose up -d ## Database -The Platform API uses **SQLite** by default (data persisted in a Docker volume). To switch to PostgreSQL, update `configs/config-platform-api.toml`: +The Platform API uses **SQLite** by default (data persisted in a Docker volume). To switch to PostgreSQL, update `[platform_api.database]` in `configs/config.toml`: ```toml -[database] +[platform_api.database] driver = "postgres" host = "your-db-host" port = 5432 diff --git a/portals/ai-workspace/docker-compose.yaml b/portals/ai-workspace/docker-compose.yaml index c2e8683ec..fd3f13001 100644 --- a/portals/ai-workspace/docker-compose.yaml +++ b/portals/ai-workspace/docker-compose.yaml @@ -14,10 +14,6 @@ services: image: ghcr.io/wso2/api-platform/platform-api:0.13.0-SNAPSHOT container_name: platform-api restart: unless-stopped - # Mounts the single source-of-truth config shipped with the Platform API - # itself (../../platform-api/config/config.toml) rather than keeping a - # separate copy under this portal's configs/ — every portal's compose - # file mounts the same file. command: ["-config", "/etc/platform-api/config.toml"] env_file: - path: api-platform.env diff --git a/portals/ai-workspace/src/config.env.ts b/portals/ai-workspace/src/config.env.ts index 8abf4d588..bbc523a7a 100644 --- a/portals/ai-workspace/src/config.env.ts +++ b/portals/ai-workspace/src/config.env.ts @@ -37,7 +37,7 @@ import { getEnvOrDefault } from './utils/getEnvOrDefault'; export const DEBUG = getEnvOrDefault('APIP_AIW_LOGGING_BROWSER_DEBUG', false); // Domain and environment settings -export const DOMAIN = getEnvOrDefault('APIP_AIW_DOMAIN', 'localhost:5380'); +export const DOMAIN = getEnvOrDefault('APIP_AIW_SERVER_DOMAIN', 'localhost:5380'); // Default region used when auto-registering an organization on first login. diff --git a/portals/ai-workspace/vite.config.ts b/portals/ai-workspace/vite.config.ts index cd29d3303..e402d5777 100644 --- a/portals/ai-workspace/vite.config.ts +++ b/portals/ai-workspace/vite.config.ts @@ -66,7 +66,7 @@ const readyLogPlugin: PluginOption = { // only these — a blanket 'APIP_AIW_' prefix would also inline secrets that share the // namespace (e.g. APIP_AIW_AUTH_OIDC_CLIENT_SECRET) into the bundle if set at build time. const browserSafeEnvVars = [ - 'APIP_AIW_DOMAIN', + 'APIP_AIW_SERVER_DOMAIN', 'APIP_AIW_AUTH_MODE', 'APIP_AIW_DEFAULT_ORG_REGION', 'APIP_AIW_GATEWAY_CONTROLPLANE_HOST', 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 1e13649b0..86764acd6 100644 --- a/portals/developer-portal/it/configs/config-platform-api-it.toml +++ b/portals/developer-portal/it/configs/config-platform-api-it.toml @@ -42,9 +42,10 @@ mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' [platform_api.auth.jwt] issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api-it" }}' # RS256 keypair generated by `make ensure-certs` (it/Makefile) and bind-mounted -# read-only at /etc/platform-api/keys — on the Platform API's {{ file }} allowlist. -public_key = '{{ file "/etc/platform-api/keys/jwt_public.pem" }}' -private_key = '{{ file "/etc/platform-api/keys/jwt_private.pem" }}' +# read-only at /etc/platform-api/keys. The server reads these paths directly +# rather than inlining the PEM content into config. +public_key_file = '{{ env "APIP_CP_AUTH_JWT_PUBLIC_KEY_FILE" "/etc/platform-api/keys/jwt_public.pem" }}' +private_key_file = '{{ env "APIP_CP_AUTH_JWT_PRIVATE_KEY_FILE" "/etc/platform-api/keys/jwt_private.pem" }}' [platform_api.auth.file.organization] id = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_ID" "default" }}' diff --git a/tests/integration-e2e/platform-api-config.toml b/tests/integration-e2e/platform-api-config.toml index 7243d4e02..0e8802761 100644 --- a/tests/integration-e2e/platform-api-config.toml +++ b/tests/integration-e2e/platform-api-config.toml @@ -26,10 +26,10 @@ mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' [platform_api.auth.jwt] issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' # RS256 keypair generated by the platform-api-jwtkeygen init container and -# bind-mounted read-only at /etc/platform-api/keys — on the Platform API's -# {{ file }} allowlist. -public_key = '{{ file "/etc/platform-api/keys/jwt_public.pem" }}' -private_key = '{{ file "/etc/platform-api/keys/jwt_private.pem" }}' +# bind-mounted read-only at /etc/platform-api/keys. The server reads these +# paths directly rather than inlining the PEM content into config. +public_key_file = '{{ env "APIP_CP_AUTH_JWT_PUBLIC_KEY_FILE" "/etc/platform-api/keys/jwt_public.pem" }}' +private_key_file = '{{ env "APIP_CP_AUTH_JWT_PRIVATE_KEY_FILE" "/etc/platform-api/keys/jwt_private.pem" }}' [platform_api.auth.file.organization] id = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_ID" "default" }}'