diff --git a/airborne_server/.env.example b/airborne_server/.env.example index 2d0c1597..8025e301 100644 --- a/airborne_server/.env.example +++ b/airborne_server/.env.example @@ -1,6 +1,13 @@ # Superposition configuration SUPERPOSITION_URL=http://localhost:8080 +SUPERPOSITION_RC_URL=http://localhost:8080 SUPERPOSITION_ORG_ID=get-org-id-from-superposition +ENABLE_AUTHENTICATED_SUPERPOSITION=false +SUPERPOSITION_TOKEN= +SUPERPOSITION_USER_TOKEN= +SUPERPOSITION_ORG_TOKEN= +SUPERPOSITION_RC_USER_TOKEN= +SUPERPOSITION_RC_ORG_TOKEN= # Keycloak settings KEYCLOAK_URL=http://localhost:8180 diff --git a/airborne_server/README.md b/airborne_server/README.md index 7e1875cf..e8128cca 100644 --- a/airborne_server/README.md +++ b/airborne_server/README.md @@ -296,6 +296,12 @@ The server relies on a set of environment variables for its configuration. These - `KEYCLOAK_REALM`: Keycloak realm name. - `KEYCLOAK_PUBLIC_KEY`: Public key for validating JWTs issued by Keycloak. - `SUPERPOSITION_URL`: URL of the Superposition service. +- `SUPERPOSITION_RC_URL`: URL of the Superposition service used by RC (`/release`) endpoints. Defaults to `SUPERPOSITION_URL` when unset. +- `ENABLE_AUTHENTICATED_SUPERPOSITION`: Enables cookie-based auth for Superposition SDK requests. +- `SUPERPOSITION_USER_TOKEN`: User token for Superposition auth cookie (`user=...`). +- `SUPERPOSITION_ORG_TOKEN`: Org token for Superposition auth cookie (`org_=...`). +- `SUPERPOSITION_RC_USER_TOKEN`: RC-specific user token for auth cookie; defaults to `SUPERPOSITION_USER_TOKEN` when unset. +- `SUPERPOSITION_RC_ORG_TOKEN`: RC-specific org token for auth cookie; defaults to `SUPERPOSITION_ORG_TOKEN` when unset. - `SUPERPOSITION_ORG_ID`: The organization ID within Superposition used by the server. - `AWS_BUCKET`: Name of the S3 bucket for storing package assets. - `PUBLIC_ENDPOINT`: The public-facing URL for accessing assets stored in S3. diff --git a/airborne_server/scripts/encrypt-envs.sh b/airborne_server/scripts/encrypt-envs.sh index 75dd51a2..2e7f0eb1 100755 --- a/airborne_server/scripts/encrypt-envs.sh +++ b/airborne_server/scripts/encrypt-envs.sh @@ -66,6 +66,87 @@ shell_quote() { printf "'%s'" "$(printf '%s' "$value" | sed "s/'/'\\\\''/g")" } +strip_shell_quotes() { + local value="$1" + + if [[ ${#value} -ge 2 ]]; then + if [[ "${value#\'}" != "$value" ]] && [[ "${value%\'}" != "$value" ]]; then + value="${value#\'}" + value="${value%\'}" + value="${value//\'\\\'\'/\'}" + elif [[ "${value#\"}" != "$value" ]] && [[ "${value%\"}" != "$value" ]]; then + value="${value#\"}" + value="${value%\"}" + fi + fi + + printf '%s' "$value" +} + +read_env_raw() { + local key="$1" + local value + value=$(grep "^${key}=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2- | head -1) + strip_shell_quotes "$value" +} + +is_value_empty() { + local value="$1" + case "$value" in + ""|"''"|'""') + return 0 + ;; + *) + return 1 + ;; + esac +} + +upsert_env_raw() { + local key="$1" + local raw_value="$2" + local tmp_file + + tmp_file=$(mktemp "${TMPDIR:-/tmp}/airborne-env.XXXXXX") + + if [[ -f "$ENV_FILE" ]]; then + awk -v key="$key" -v value="$raw_value" ' + BEGIN { updated = 0 } + index($0, key "=") == 1 { print key "=" value; updated = 1; next } + { print } + END { if (!updated) print key "=" value } + ' "$ENV_FILE" > "$tmp_file" + else + printf "%s=%s\n" "$key" "$raw_value" > "$tmp_file" + fi + + mv "$tmp_file" "$ENV_FILE" +} + +sync_superposition_rc_env_defaults() { + local superposition_url superposition_rc_url + local superposition_user_token superposition_rc_user_token + local superposition_org_token superposition_rc_org_token + + superposition_url=$(read_env_raw "SUPERPOSITION_URL") + superposition_rc_url=$(read_env_raw "SUPERPOSITION_RC_URL") + if is_value_empty "$superposition_rc_url"; then + upsert_env_raw "SUPERPOSITION_RC_URL" "$superposition_url" + fi + + superposition_user_token=$(read_env_raw "SUPERPOSITION_USER_TOKEN") + superposition_rc_user_token=$(read_env_raw "SUPERPOSITION_RC_USER_TOKEN") + if is_value_empty "$superposition_rc_user_token"; then + upsert_env_raw "SUPERPOSITION_RC_USER_TOKEN" "$superposition_user_token" + fi + + superposition_org_token=$(read_env_raw "SUPERPOSITION_ORG_TOKEN") + superposition_rc_org_token=$(read_env_raw "SUPERPOSITION_RC_ORG_TOKEN") + if is_value_empty "$superposition_rc_org_token"; then + upsert_env_raw "SUPERPOSITION_RC_ORG_TOKEN" "$superposition_org_token" + fi +} + # Function to encrypt a value using AES-GCM encrypt_value() { local value="$1" @@ -105,6 +186,8 @@ SECRETS=( "SUPERPOSITION_TOKEN" "SUPERPOSITION_USER_TOKEN" "SUPERPOSITION_ORG_TOKEN" + "SUPERPOSITION_RC_USER_TOKEN" + "SUPERPOSITION_RC_ORG_TOKEN" "GOOGLE_SERVICE_ACCOUNT_KEY" ) @@ -123,6 +206,9 @@ if [[ ! -f "$ENV_FILE" ]]; then cp "$ENV_EXAMPLE" "$ENV_FILE" fi +# Keep local RC Superposition vars aligned with existing Superposition vars when unset. +sync_superposition_rc_env_defaults + if [[ "$PLAINTEXT_MODE" == true ]]; then echo -e "${GREEN}✅ Plaintext .env file ready at: $ENV_FILE${NC}" echo "" diff --git a/airborne_server/scripts/init-localstack.sh b/airborne_server/scripts/init-localstack.sh index 1b7c379d..33eb4bb7 100755 --- a/airborne_server/scripts/init-localstack.sh +++ b/airborne_server/scripts/init-localstack.sh @@ -133,6 +133,56 @@ upsert_env_var() { mv "$tmp_file" "$file" } +read_env_value() { + local key="$1" + local value="" + + if [ -f ".env" ]; then + value=$(grep "^${key}=" ".env" 2>/dev/null | cut -d'=' -f2- | head -1) + value=$(strip_shell_quotes "$value") + fi + + echo "$value" +} + +is_value_empty() { + local value="$1" + case "$value" in + ""|"''"|'""') + return 0 + ;; + *) + return 1 + ;; + esac +} + +sync_superposition_rc_env_defaults() { + local superposition_url superposition_rc_url + local superposition_user_token superposition_rc_user_token + local superposition_org_token superposition_rc_org_token + + superposition_url=$(read_env_value "SUPERPOSITION_URL") + superposition_rc_url=$(read_env_value "SUPERPOSITION_RC_URL") + if is_value_empty "$superposition_rc_url"; then + upsert_env_var ".env" "SUPERPOSITION_RC_URL" "$superposition_url" + fi + + superposition_user_token=$(read_env_value "SUPERPOSITION_USER_TOKEN") + superposition_rc_user_token=$(read_env_value "SUPERPOSITION_RC_USER_TOKEN") + if is_value_empty "$superposition_rc_user_token"; then + upsert_env_var ".env" "SUPERPOSITION_RC_USER_TOKEN" "$superposition_user_token" + fi + + superposition_org_token=$(read_env_value "SUPERPOSITION_ORG_TOKEN") + superposition_rc_org_token=$(read_env_value "SUPERPOSITION_RC_ORG_TOKEN") + if is_value_empty "$superposition_rc_org_token"; then + upsert_env_var ".env" "SUPERPOSITION_RC_ORG_TOKEN" "$superposition_org_token" + fi +} + +sync_superposition_rc_env_defaults + echo "${YELLOW}☁️ AWS Endpoint:${NC} ${GREEN}${AWS_ENDPOINT_URL}${NC}" echo "${YELLOW}🪣 S3 Bucket:${NC} ${GREEN}${AWS_BUCKET}${NC}" echo "${YELLOW}🌍 Region:${NC} ${GREEN}${AWS_REGION}${NC}" @@ -162,7 +212,16 @@ aws --endpoint-url=${AWS_ENDPOINT_URL} s3 mb s3://$AWS_BUCKET >/dev/null 2>&1 || echo "${GREEN}✅ S3 bucket ready: $AWS_BUCKET${NC}" # Variables that need encryption/processing -SENSITIVE_VARS=("DB_PASSWORD" "DB_MIGRATION_PASSWORD" "KEYCLOAK_SECRET") +SENSITIVE_VARS=( + "DB_PASSWORD" + "DB_MIGRATION_PASSWORD" + "KEYCLOAK_SECRET" + "SUPERPOSITION_TOKEN" + "SUPERPOSITION_USER_TOKEN" + "SUPERPOSITION_ORG_TOKEN" + "SUPERPOSITION_RC_USER_TOKEN" + "SUPERPOSITION_RC_ORG_TOKEN" +) # Get values from .env.example or .env.generated get_value() { diff --git a/airborne_server/src/config.rs b/airborne_server/src/config.rs index b23d5f69..c2d768f0 100644 --- a/airborne_server/src/config.rs +++ b/airborne_server/src/config.rs @@ -52,10 +52,13 @@ pub struct AppConfig { // Superposition settings pub superposition_url: String, + pub superposition_rc_url: String, pub superposition_org_id: String, pub superposition_token: Option, pub superposition_user_token: Option, pub superposition_org_token: Option, + pub superposition_rc_user_token: Option, + pub superposition_rc_org_token: Option, pub enable_authenticated_superposition: bool, // Feature flags @@ -135,6 +138,17 @@ impl AppConfig { let get_optional = |name: &str| -> Option { env::var(name).ok().filter(|v| !v.is_empty()) }; + let superposition_url = get_env("SUPERPOSITION_URL", None)?; + let superposition_rc_url = + get_optional("SUPERPOSITION_RC_URL").unwrap_or_else(|| superposition_url.clone()); + let superposition_token = get_optional_secret("SUPERPOSITION_TOKEN")?; + let superposition_user_token = get_optional_secret("SUPERPOSITION_USER_TOKEN")?; + let superposition_org_token = get_optional_secret("SUPERPOSITION_ORG_TOKEN")?; + let superposition_rc_user_token = get_optional_secret("SUPERPOSITION_RC_USER_TOKEN")? + .or_else(|| superposition_user_token.clone()); + let superposition_rc_org_token = get_optional_secret("SUPERPOSITION_RC_ORG_TOKEN")? + .or_else(|| superposition_org_token.clone()); + Ok(AppConfig { // Server settings port: parse_env("PORT", 8081), @@ -168,11 +182,14 @@ impl AppConfig { keycloak_public_key: get_env("KEYCLOAK_PUBLIC_KEY", None)?, // Superposition settings - superposition_url: get_env("SUPERPOSITION_URL", None)?, + superposition_url, + superposition_rc_url, superposition_org_id: get_env("SUPERPOSITION_ORG_ID", None)?, - superposition_token: get_optional_secret("SUPERPOSITION_TOKEN")?, - superposition_user_token: get_optional_secret("SUPERPOSITION_USER_TOKEN")?, - superposition_org_token: get_optional_secret("SUPERPOSITION_ORG_TOKEN")?, + superposition_token, + superposition_user_token, + superposition_org_token, + superposition_rc_user_token, + superposition_rc_org_token, enable_authenticated_superposition: parse_env( "ENABLE_AUTHENTICATED_SUPERPOSITION", false, diff --git a/airborne_server/src/main.rs b/airborne_server/src/main.rs index e3833b0c..1d388a86 100644 --- a/airborne_server/src/main.rs +++ b/airborne_server/src/main.rs @@ -145,7 +145,8 @@ async fn main() -> std::io::Result<()> { let secret = app_config.keycloak_secret.clone(); let superposition_token = app_config.superposition_token.clone().unwrap_or_default(); - let cac_url = app_config.superposition_url.clone(); + let dashboard_superposition_url = app_config.superposition_url.clone(); + let rc_superposition_url = app_config.superposition_rc_url.clone(); let superposition_org_id_env = app_config.superposition_org_id.clone(); let env = types::Environment { @@ -202,37 +203,65 @@ async fn main() -> std::io::Result<()> { hub = Some(Sheets::new(client, gcp_auth)); } - let superposition_client = if app_config.enable_authenticated_superposition { - let superposition_user_token = app_config.superposition_user_token.clone().expect( - "SUPERPOSITION_USER_TOKEN must be set when ENABLE_AUTHENTICATED_SUPERPOSITION=true", - ); - let superposition_org_token = app_config.superposition_org_token.clone().expect( - "SUPERPOSITION_ORG_TOKEN must be set when ENABLE_AUTHENTICATED_SUPERPOSITION=true", - ); - - // Inject Auth cookie for Superposition SDK calls - let cookie_interceptor = CookieIntercept::new(format!( - "user={}; org_{}={}", - superposition_user_token, superposition_org_id_env, superposition_org_token, - )); - - superposition_sdk::Client::from_conf( - SrsConfig::builder() - .endpoint_url(cac_url.clone()) - .behavior_version_latest() - .bearer_token(superposition_token.into()) - .interceptor(cookie_interceptor) - .build(), - ) - } else { - superposition_sdk::Client::from_conf( - SrsConfig::builder() - .endpoint_url(cac_url.clone()) - .behavior_version_latest() - .bearer_token(superposition_token.into()) - .build(), - ) - }; + let create_superposition_client = + |endpoint_url: String, + user_token: Option, + org_token: Option, + user_token_env_hint: &str, + org_token_env_hint: &str| { + if app_config.enable_authenticated_superposition { + let superposition_user_token = user_token.unwrap_or_else(|| { + panic!( + "{} must be set when ENABLE_AUTHENTICATED_SUPERPOSITION=true", + user_token_env_hint + ) + }); + let superposition_org_token = org_token.unwrap_or_else(|| { + panic!( + "{} must be set when ENABLE_AUTHENTICATED_SUPERPOSITION=true", + org_token_env_hint + ) + }); + + // Inject Auth cookie for Superposition SDK calls + let cookie_interceptor = CookieIntercept::new(format!( + "user={}; org_{}={}", + superposition_user_token, superposition_org_id_env, superposition_org_token, + )); + + superposition_sdk::Client::from_conf( + SrsConfig::builder() + .endpoint_url(endpoint_url) + .behavior_version_latest() + .bearer_token(superposition_token.clone().into()) + .interceptor(cookie_interceptor) + .build(), + ) + } else { + superposition_sdk::Client::from_conf( + SrsConfig::builder() + .endpoint_url(endpoint_url) + .behavior_version_latest() + .bearer_token(superposition_token.clone().into()) + .build(), + ) + } + }; + + let superposition_client = create_superposition_client( + dashboard_superposition_url, + app_config.superposition_user_token.clone(), + app_config.superposition_org_token.clone(), + "SUPERPOSITION_USER_TOKEN", + "SUPERPOSITION_ORG_TOKEN", + ); + let rc_superposition_client = create_superposition_client( + rc_superposition_url, + app_config.superposition_rc_user_token.clone(), + app_config.superposition_rc_org_token.clone(), + "SUPERPOSITION_RC_USER_TOKEN or SUPERPOSITION_USER_TOKEN", + "SUPERPOSITION_RC_ORG_TOKEN or SUPERPOSITION_ORG_TOKEN", + ); let app_state = Arc::new(types::AppState { env: env.clone(), @@ -240,6 +269,7 @@ async fn main() -> std::io::Result<()> { s3_client: aws_s3_client, cf_client: aws_cloudfront_client, superposition_client, + rc_superposition_client, sheets_hub: hub, }); diff --git a/airborne_server/src/release.rs b/airborne_server/src/release.rs index 7a7fcc65..e2d5b507 100644 --- a/airborne_server/src/release.rs +++ b/airborne_server/src/release.rs @@ -1226,7 +1226,8 @@ async fn serve_release( query: Query, state: web::Data, ) -> airborne_types::Result>> { - serve_release_handler(path, req, query, state).await + let superposition_client = state.rc_superposition_client.clone(); + serve_release_handler(path, req, query, state, superposition_client).await } #[get("v2/{organisation}/{application}")] @@ -1236,7 +1237,8 @@ async fn serve_release_v2( query: Query, state: web::Data, ) -> airborne_types::Result>> { - serve_release_handler(path, req, query, state).await + let superposition_client = state.rc_superposition_client.clone(); + serve_release_handler(path, req, query, state, superposition_client).await } async fn serve_release_handler( @@ -1244,6 +1246,7 @@ async fn serve_release_handler( req: actix_web::HttpRequest, query: Query, state: web::Data, + superposition_client: superposition_sdk::Client, ) -> airborne_types::Result>> { let (organisation, application) = path.into_inner(); let superposition_org_id_from_env = state.env.superposition_org_id.clone(); @@ -1282,8 +1285,7 @@ async fn serve_release_handler( info!("Context for serving release: {:?}", context); let applicable_variants = context.iter().fold( - state - .superposition_client + superposition_client .applicable_variants() .workspace_id(workspace_name.clone()) .org_id(superposition_org_id_from_env.clone()) @@ -1311,8 +1313,7 @@ async fn serve_release_handler( .collect::>(); let resolved_config_builder = context.iter().fold( - state - .superposition_client + superposition_client .get_resolved_config() .workspace_id(workspace_name.clone()) .org_id(superposition_org_id_from_env.clone()) diff --git a/airborne_server/src/types.rs b/airborne_server/src/types.rs index b90b2ca2..dfb08b9d 100644 --- a/airborne_server/src/types.rs +++ b/airborne_server/src/types.rs @@ -38,6 +38,7 @@ pub struct AppState { pub s3_client: aws_sdk_s3::Client, pub cf_client: aws_sdk_cloudfront::Client, pub superposition_client: Client, + pub rc_superposition_client: Client, pub sheets_hub: Option< Sheets>, >,