Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions platform-api/config/config-template.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,11 @@
# 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. Set security.encryption_key: openssl rand -hex 32.
# 1. Copy this file to config.toml (standalone), or merge its [platform_api]
# section into the unified config.toml alongside other components.
# 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 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 \
Expand Down
14 changes: 13 additions & 1 deletion platform-api/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,16 @@ func LoadConfig(configPath string) (*Server, error) {
}
}

// Narrow to this component's own subtree BEFORE interpolating, so a shared
// multi-component config file (one that also carries [developer_portal] or
// [ai_workspace] sections) does not force platform-api to resolve another
// component's {{ env }}/{{ file }} tokens — those reference env vars and
// allowlisted paths that only exist in that other component's container, and
// resolving them here would fail closed. Cut promotes the platform_api.*
// children to the top level; an absent section yields an empty tree that
// leaves cfg at its defaults, matching the pre-merge behavior.
k = k.Cut(platformAPIConfigKey)

// Resolve {{ env }} / {{ file }} interpolation tokens after the env+file merge
// and before unmarshal, so any config field may pull its value from an
// environment variable or an allowlisted file. String leaves without a "{{"
Expand All @@ -431,7 +441,9 @@ func LoadConfig(configPath string) (*Server, error) {
return nil, err
}

if err := k.UnmarshalWithConf(platformAPIConfigKey, cfg, koanf.UnmarshalConf{
// Subtree is already promoted to the top level by Cut, so unmarshal from the
// root ("") rather than re-descending through platformAPIConfigKey.
if err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{
DecoderConfig: &mapstructure.DecoderConfig{
TagName: "koanf",
WeaklyTypedInput: true,
Expand Down
20 changes: 20 additions & 0 deletions platform-api/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,26 @@ func TestLoadConfig_ValidKeys_Succeeds(t *testing.T) {
assert.Equal(t, validInlineKey, cfg.Security.EncryptionKey)
}

// A merged multi-component config file also carries a foreign [developer_portal]
// section with its own interpolation tokens — here deliberately poisonous ones: an
// {{ env }} with no default that is left unset, and a {{ file }} path outside
// platform-api's allowlist. LoadConfig must interpolate and consume ONLY the
// [platform_api] subtree, leaving the foreign section (and its tokens) untouched.
// Guards the k.Cut(platformAPIConfigKey) scoping in LoadConfig: without it, the
// whole-tree interpolation would fail closed on these tokens.
func TestLoadConfig_IgnoresForeignComponentSection(t *testing.T) {
// APIP_DP_SECURITY_ENCRYPTION_KEY is intentionally never set, and /etc/devportal
// is not on platform-api's {{ file }} allowlist.
cfg, err := loadWithKeys(t, `
[developer_portal.security]
encryption_key = '{{ env "APIP_DP_SECURITY_ENCRYPTION_KEY" }}'
[developer_portal.auth.local]
jwt_public_key = '{{ file "/etc/devportal/keys/jwt_public.pem" }}'
`)
require.NoError(t, err)
assert.Equal(t, validInlineKey, cfg.Security.EncryptionKey)
}

// 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_FILE", validJWTPublicKeyFile)
Expand Down
89 changes: 74 additions & 15 deletions portals/ai-workspace/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,11 @@ DIST_DIR := target/$(DIST_NAME)
DIST_ZIP := target/$(DIST_NAME).zip
PLATFORM_API_VERSION ?= $(shell cat ../../platform-api/VERSION 2>/dev/null | sed 's/-SNAPSHOT//' || echo "0.0.0")
PLATFORM_API_TAG := platform-api/v$(PLATFORM_API_VERSION)
# Version used to pin the optional developer-portal image (the profile-gated
# service in docker-compose.yaml). Its config is not merged into the unified
# config.toml — the profile is opt-in and self-served (see README) — so only the
# image tag is pinned here for a reproducible release.
DEVPORTAL_VERSION ?= $(shell cat ../developer-portal/VERSION 2>/dev/null | sed 's/-SNAPSHOT//' || echo "0.0.0")
Comment thread
Piumal1999 marked this conversation as resolved.

# When PLATFORM_API_VERSION is supplied explicitly (e.g. `make dist PLATFORM_API_VERSION=0.10.0`),
# the platform-api db-scripts are fetched from the released git tag $(PLATFORM_API_TAG) so the
Expand All @@ -193,11 +198,57 @@ ifeq ($(origin PLATFORM_API_VERSION),environment)
PLATFORM_API_FROM_TAG := true
endif

# Merge an AI Workspace config and a Platform API config into one unified
# multi-component config file: $(call merge_config,<aiw-toml>,<cp-toml>,<out>).
# The components share no top-level keys ([ai_workspace.*] vs [platform_api.*],
# and [developer_portal.*] when append_section adds it below), so the merge is a
# banner-separated concatenation — each service reads only its own root table and
# ignores the other sections and their tokens.
define merge_config
@printf '%s\n' \
'# =============================================================================' \
'# UNIFIED API PLATFORM CONFIGURATION' \
'# =============================================================================' \
'# One file, multiple components. Each service reads ONLY its own root table' \
'# (e.g. [ai_workspace], [platform_api]) and ignores the other sections and' \
'# their tokens. GENERATED by `make dist` from the per-component source configs.' \
'' '' \
'# #############################################################################' \
'# # AI WORKSPACE' \
'# #############################################################################' \
'' > $(3)
@cat $(1) >> $(3)
@printf '%s\n' \
'' '' \
'# #############################################################################' \
'# # PLATFORM API' \
'# #############################################################################' \
'' >> $(3)
@cat $(2) >> $(3)
endef

# Append one more component section (banner + body) to a config file already
# written by merge_config: $(call append_section,<title>,<toml>,<out>). Used for
# the config-template only — it documents an optional component's settings at the
# bottom of the reference file without adding that section to the active config.toml.
define append_section
@printf '%s\n' \
'' '' \
'# #############################################################################' \
'# # $(1)' \
'# #############################################################################' \
'' >> $(3)
@cat $(2) >> $(3)
endef

.PHONY: dist clean-dist
dist: clean-dist ## Build standalone AI Workspace + Platform API distribution zip
@echo "Building distribution $(DIST_NAME)..."
@mkdir -p $(DIST_DIR)/configs $(DIST_DIR)/scripts $(DIST_DIR)/resources/certificates $(DIST_DIR)/resources/platform-api/db-scripts
@cp ../../platform-api/resources/roles.yaml $(DIST_DIR)/resources/roles.yaml
# Stage the Platform API source configs (active + template) into temp files so
# the merge below can fold them into the unified config.toml/config-template.toml.
# Both the tag and working-tree branches produce the same two temp files.
ifeq ($(PLATFORM_API_FROM_TAG),true)
@echo "Fetching platform-api db-scripts from tag $(PLATFORM_API_TAG)..."
@tag="$(PLATFORM_API_TAG)"; dest="$(DIST_DIR)/resources/platform-api/db-scripts"; \
Expand All @@ -212,37 +263,45 @@ 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)/.platform-api-config-template.toml
> $(DIST_DIR)/configs/.pa-config-template.toml
@git -C ../.. show "$(PLATFORM_API_TAG):platform-api/config/config.toml" \
> $(DIST_DIR)/.platform-api-config.toml
> $(DIST_DIR)/configs/.pa-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)/.platform-api-config-template.toml
@cp ../../platform-api/config/config.toml $(DIST_DIR)/.platform-api-config.toml
@cp ../../platform-api/config/config-template.toml $(DIST_DIR)/configs/.pa-config-template.toml
@cp ../../platform-api/config/config.toml $(DIST_DIR)/configs/.pa-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
# Unified active config.toml: two sections ([ai_workspace] + [platform_api]),
# the same file mounted into both services (each reads only its own table).
# The optional Developer Portal is opt-in (see README), so its section is NOT
# added here — a user enabling that profile appends [developer_portal] itself,
# copying it from the template below.
$(call merge_config,configs/config.toml,$(DIST_DIR)/configs/.pa-config.toml,$(DIST_DIR)/configs/config.toml)
# Unified config-template.toml (the full reference): the same two sections PLUS
# the optional Developer Portal template appended at the bottom, so a user
# enabling that profile has its [developer_portal] settings documented to copy.
$(call merge_config,configs/config-template.toml,$(DIST_DIR)/configs/.pa-config-template.toml,$(DIST_DIR)/configs/config-template.toml)
$(call append_section,DEVELOPER PORTAL (optional),../developer-portal/configs/config-template.toml,$(DIST_DIR)/configs/config-template.toml)
@rm -f $(DIST_DIR)/configs/.pa-config.toml $(DIST_DIR)/configs/.pa-config-template.toml
@printf '%s\n' \
'# Generated secrets — never commit these.' \
'*.env' \
'resources/certificates/*' \
> $(DIST_DIR)/.gitignore
@cp setup.sh $(DIST_DIR)/scripts/setup.sh
@chmod +x $(DIST_DIR)/scripts/setup.sh
@sed 's#\.\./\.\./platform-api/config/config\.toml#./configs/config.toml#' \
# Repoint the platform-api mount at the single merged file (the source compose
# mounts the monorepo's ../../platform-api/config/config.toml); the ai-workspace
# mount already points at ./configs/config.toml, so both now share one file.
# The trailing ':' anchors the match to the volume mapping so comments that
# mention the source path are left untouched.
@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 \
-e 's|^([[:space:]]*image:[[:space:]]*.*/ai-workspace):[^[:space:]]*|\1:$(DIST_VERSION)|' \
-e 's|^([[:space:]]*image:[[:space:]]*.*/platform-api):[^[:space:]]*|\1:$(PLATFORM_API_VERSION)|' \
-e 's|^([[:space:]]*image:[[:space:]]*.*/developer-portal):[^[:space:]]*|\1:$(DEVPORTAL_VERSION)|' \
$(DIST_DIR)/docker-compose.yaml
@rm -f $(DIST_DIR)/docker-compose.yaml.bak
@cd target && zip -rq $(DIST_NAME).zip $(DIST_NAME)
Expand Down
36 changes: 36 additions & 0 deletions portals/ai-workspace/bff/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,42 @@ url = "https://platform-api:9243"
}
}

// A merged multi-component config file also carries a foreign [platform_api] section
// with its own interpolation tokens — here deliberately poisonous ones: an {{ env }}
// with no default that is left unset, and a {{ file }} path outside the AI Workspace's
// allowlist. Load must interpolate and consume ONLY the [ai_workspace] subtree, leaving
// the foreign section (and its tokens) untouched. Guards the k.Cut(aiWorkspaceConfigKey)
// scoping in loadConfigKoanf: without cutting before interpolation, the whole-tree
// expand would fail closed on these tokens.
func TestLoad_IgnoresForeignComponentSection(t *testing.T) {
// APIP_CP_SECURITY_ENCRYPTION_KEY is intentionally never set, and /etc/platform-api
// is not on the AI Workspace's {{ file }} allowlist.
cfgPath := writeConfig(t, `
[ai_workspace.logging]
level = "warn"

[ai_workspace.control_plane]
url = "https://platform-api:9243"

[platform_api.security]
encryption_key = '{{ env "APIP_CP_SECURITY_ENCRYPTION_KEY" }}'

[platform_api.auth.jwt]
public_key = '{{ file "/etc/platform-api/keys/jwt_public.pem" }}'
`)

cfg, err := Load(cfgPath)
if err != nil {
t.Fatalf("Load() error = %v — the foreign [platform_api] tokens must not be resolved", err)
}
if cfg.Logging.Level != "warn" {
t.Errorf("LogLevel = %q, want %q", cfg.Logging.Level, "warn")
}
if cfg.ControlPlane.URL != "https://platform-api:9243" {
t.Errorf("ControlPlane.URL = %q, want the config.toml value", cfg.ControlPlane.URL)
}
}

// The environment reaches a key only through that key's {{ env }} token: the token
// supplies the variable's value, and its default applies when the variable is unset.
func TestLoad_EnvTokenSuppliesValueAndDefault(t *testing.T) {
Expand Down
26 changes: 19 additions & 7 deletions portals/ai-workspace/bff/internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ 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. loadConfigKoanf cuts to this table, so every key is resolved
// relative to [ai_workspace] and sibling tables are ignored.
// deployment config. loadConfigKoanf cuts to this table before interpolating, so every
// key is resolved relative to [ai_workspace] and sibling tables — including their
// {{ env }}/{{ file }} tokens — are ignored entirely.
const aiWorkspaceConfigKey = "ai_workspace"

// defaultFileSourceAllowlist is the AI Workspace's default set of directories a
Expand Down Expand Up @@ -82,8 +83,19 @@ func loadConfigKoanf(tomlPath string) (*koanf.Koanf, error) {
return nil, fmt.Errorf("failed to read config file %q: %w", tomlPath, statErr)
}

// 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.
// Narrow to this component's own subtree BEFORE interpolating, so a shared
// multi-component config file (one that also carries [platform_api] or
// [developer_portal] sections) does not force the AI Workspace to resolve
// another component's {{ env }}/{{ file }} tokens — those reference env vars
// and allowlisted paths that only exist in that other component's container,
// and resolving them here would fail closed. Cut promotes the ai_workspace.*
// children to the top level; an absent section yields an empty tree that
// leaves every key at its default.
k = k.Cut(aiWorkspaceConfigKey)

// Expand tokens across the (now ai_workspace-only) 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),
})
Expand All @@ -98,11 +110,11 @@ func loadConfigKoanf(tomlPath string) (*koanf.Koanf, error) {
slog.Int("fields", stats.Fields))
}

// Reload the expanded map into a fresh instance so no un-interpolated leaf survives,
// then cut to the [ai_workspace] subtree.
// Reload the expanded map into a fresh instance so no un-interpolated leaf survives.
// The subtree is already promoted to the top level by the Cut above.
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 out.Cut(aiWorkspaceConfigKey), nil
return out, nil
}
Loading
Loading