diff --git a/k8s/deployment/templates/dns-endpoint.yaml.tpl b/k8s/deployment/templates/dns-endpoint.yaml.tpl index e68e1903..b1dd2340 100644 --- a/k8s/deployment/templates/dns-endpoint.yaml.tpl +++ b/k8s/deployment/templates/dns-endpoint.yaml.tpl @@ -17,6 +17,6 @@ spec: endpoints: - dnsName: {{ .scope.domain }} recordTTL: 60 - recordType: A + recordType: {{ .dns_record_type }} targets: - - "{{ .gateway_ip }}" + - "{{ .gateway_target }}" diff --git a/k8s/scope/build_context b/k8s/scope/build_context index e60aa4ae..720e8b79 100755 --- a/k8s/scope/build_context +++ b/k8s/scope/build_context @@ -36,6 +36,7 @@ USE_ACCOUNT_SLUG=$(echo "$CONTEXT" | jq -r --arg default "$USE_ACCOUNT_SLUG" ' REGION=$(echo "$CONTEXT" | jq -r '.providers["cloud-providers"].account.region // "us-east-1"') SCOPE_VISIBILITY=$(echo "$CONTEXT" | jq -r '.scope.capabilities.visibility') +JWT_AUTHORIZATION_ENABLED=$(echo "$CONTEXT" | jq -r '.scope.capabilities.jwt_authorization_enabled // false') if [ "$SCOPE_VISIBILITY" = "public" ]; then DOMAIN=$(echo "$CONTEXT" | jq -r --arg default "$DOMAIN" ' @@ -58,6 +59,7 @@ export APPLICATION_ID=\(capture("application=(?\\d+)").id) SCOPE_ID=$(echo "$CONTEXT" | jq -r '.scope.id') export SCOPE_VISIBILITY +export JWT_AUTHORIZATION_ENABLED export SCOPE_DOMAIN if [ "$SCOPE_VISIBILITY" = "public" ]; then diff --git a/k8s/scope/networking/dns/external_dns/manage_route b/k8s/scope/networking/dns/external_dns/manage_route index 7c8cfdf4..baae7591 100644 --- a/k8s/scope/networking/dns/external_dns/manage_route +++ b/k8s/scope/networking/dns/external_dns/manage_route @@ -4,49 +4,79 @@ set -euo pipefail if [ "$ACTION" = "CREATE" ]; then echo "Building DNSEndpoint manifest for ExternalDNS..." - - echo "Getting IP for gateway: $GATEWAY_NAME" + echo "Getting address for gateway: $GATEWAY_NAME" + + # Try to get IP address first (for cloud providers that support it) GATEWAY_IP=$(kubectl get gateway "$GATEWAY_NAME" -n gateways \ -o jsonpath='{.status.addresses[?(@.type=="IPAddress")].value}' 2>/dev/null) + # If no IP from Gateway resource, try Service IP if [ -z "$GATEWAY_IP" ]; then - echo "Warning: Could not get gateway IP for $GATEWAY_NAME" - + echo "No IP in Gateway resource, checking Service..." GATEWAY_IP=$(kubectl get service "$GATEWAY_NAME" -n gateways \ -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>/dev/null) fi - + + # If still no IP, try to get hostname (for EKS/ELB scenarios) + GATEWAY_HOSTNAME="" + RECORD_TYPE="A" + if [ -z "$GATEWAY_IP" ]; then - echo "Warning: Could not determine gateway IP address yet, DNSEndpoint will be created later" - exit 0 + echo "No IP found, checking for hostname..." + GATEWAY_HOSTNAME=$(kubectl get gateway "$GATEWAY_NAME" -n gateways \ + -o jsonpath='{.status.addresses[?(@.type=="Hostname")].value}' 2>/dev/null) + + # If no hostname from Gateway resource, try Service hostname + if [ -z "$GATEWAY_HOSTNAME" ]; then + GATEWAY_HOSTNAME=$(kubectl get service "$GATEWAY_NAME" -n gateways \ + -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' 2>/dev/null) + fi + + if [ -n "$GATEWAY_HOSTNAME" ]; then + RECORD_TYPE="CNAME" + echo "Gateway hostname: $GATEWAY_HOSTNAME" + fi + else + echo "Gateway IP: $GATEWAY_IP" fi - - echo "Gateway IP: $GATEWAY_IP" - - DNS_ENDPOINT_TEMPLATE="${DNS_ENDPOINT_TEMPLATE:-$SERVICE_PATH/deployment/templates/dns-endpoint.yaml.tpl}" - - if [ -f "$DNS_ENDPOINT_TEMPLATE" ]; then - DNS_ENDPOINT_FILE="$OUTPUT_DIR/dns-endpoint-$SCOPE_ID.yaml" - CONTEXT_PATH="$OUTPUT_DIR/context-$SCOPE_ID-dns.json" - - echo "$CONTEXT" | jq --arg gateway_ip "$GATEWAY_IP" '. + {gateway_ip: $gateway_ip}' > "$CONTEXT_PATH" - - echo "Building DNSEndpoint Template: $DNS_ENDPOINT_TEMPLATE to $DNS_ENDPOINT_FILE" - - gomplate -c .="$CONTEXT_PATH" \ - --file "$DNS_ENDPOINT_TEMPLATE" \ - --out "$DNS_ENDPOINT_FILE" - - echo "DNSEndpoint manifest created at: $DNS_ENDPOINT_FILE" - - rm "$CONTEXT_PATH" - + + # Check if we have either IP or hostname + if [ -z "$GATEWAY_IP" ] && [ -z "$GATEWAY_HOSTNAME" ]; then + echo "Warning: Could not determine gateway address yet, DNSEndpoint will be created later" + echo "Skipping DNSEndpoint creation" else - echo "Error: DNSEndpoint template not found at $DNS_ENDPOINT_TEMPLATE" - exit 1 + # Determine the target value (IP or hostname) + GATEWAY_TARGET="${GATEWAY_IP:-$GATEWAY_HOSTNAME}" + + DNS_ENDPOINT_TEMPLATE="${DNS_ENDPOINT_TEMPLATE:-$SERVICE_PATH/deployment/templates/dns-endpoint.yaml.tpl}" + + if [ -f "$DNS_ENDPOINT_TEMPLATE" ]; then + DNS_ENDPOINT_FILE="$OUTPUT_DIR/dns-endpoint-$SCOPE_ID.yaml" + CONTEXT_PATH="$OUTPUT_DIR/context-$SCOPE_ID-dns.json" + + echo "$CONTEXT" | jq --arg gateway_target "$GATEWAY_TARGET" \ + --arg record_type "$RECORD_TYPE" \ + '. + {gateway_target: $gateway_target, dns_record_type: $record_type}' > "$CONTEXT_PATH" + + echo "Building DNSEndpoint Template: $DNS_ENDPOINT_TEMPLATE to $DNS_ENDPOINT_FILE" + + gomplate -c .="$CONTEXT_PATH" \ + --file "$DNS_ENDPOINT_TEMPLATE" \ + --out "$DNS_ENDPOINT_FILE" + + echo "DNSEndpoint manifest created at: $DNS_ENDPOINT_FILE" + + rm "$CONTEXT_PATH" + + else + echo "Error: DNSEndpoint template not found at $DNS_ENDPOINT_TEMPLATE" + exit 1 + fi fi + echo "DNSEndpoint management completed" + elif [ "$ACTION" = "DELETE" ]; then echo "Deleting DNSEndpoint for external_dns..." @@ -56,4 +86,6 @@ elif [ "$ACTION" = "DELETE" ]; then kubectl delete dnsendpoint "$DNS_ENDPOINT_NAME" -n "$K8S_NAMESPACE" || echo "DNSEndpoint may already be deleted" echo "DNSEndpoint deletion completed" -fi \ No newline at end of file +fi + +echo "External DNS route management completed" \ No newline at end of file diff --git a/k8s/scope/security/README.md b/k8s/scope/security/README.md new file mode 100644 index 00000000..7b51b82b --- /dev/null +++ b/k8s/scope/security/README.md @@ -0,0 +1,134 @@ +# JWT Authentication for Scopes + +This directory manages JWT-based authentication for scopes with "jwt_authorization_enabled" using Istio security resources. + +## Overview + +The JWT authentication flow uses two Istio Custom Resource Definitions (CRDs): + +1. **RequestAuthentication**: Validates JWT tokens from incoming requests +2. **AuthorizationPolicy**: Enforces access control based on JWT claims + +## Important: Istio Gateway Dependency + +**This authentication mechanism ONLY works with Istio gateways.** The resources use Istio-specific CRDs (`security.istio.io/v1`) that are processed by Istio's data plane (Envoy proxy). If your gateway is not Istio-based (e.g., Nginx, Kong, Traefik), you will need to implement authentication using that gateway's native mechanisms. + +## Resources Provisioned + +### 1. RequestAuthentication + +Created once per cluster in the gateway namespace. This resource configures JWT validation. + +**File**: [templates/request-authentication.yaml.tmpl](templates/request-authentication.yaml.tmpl) + +**Example**: +```yaml +apiVersion: security.istio.io/v1 +kind: RequestAuthentication +metadata: + name: nullplatform-scope-jwt-auth + namespace: gateways +spec: + selector: + matchLabels: + gateway.networking.k8s.io/gateway-name: my-gateway + jwtRules: + - issuer: "https://api.nullplatform.com/scope" + jwksUri: "https://api.nullplatform.com/scope/.well-known/jwks.json" + fromHeaders: + - name: Authorization + prefix: "Bearer " + fromCookies: + - "np_scope_token" + outputClaimToHeaders: + - header: "X-User-ID" + claim: "sub" + - header: "X-User-Email" + claim: "email" +``` + +**Key Features**: +- Validates JWTs from `Authorization: Bearer ` header or `np_scope_token` cookie +- Extracts claims and forwards them as headers to backend services +- Non-blocking: Invalid tokens are marked but not rejected at this stage + +### 2. AuthorizationPolicy + +Created per scope domain. This resource enforces JWT requirements. + +**File**: [templates/authorization-policy.yaml.tmpl](templates/authorization-policy.yaml.tmpl) + +**Example**: +```yaml +apiVersion: security.istio.io/v1 +kind: AuthorizationPolicy +metadata: + name: require-jwt-scope-123 + namespace: gateways +spec: + action: DENY + selector: + matchLabels: + gateway.networking.k8s.io/gateway-name: my-gateway + rules: + - to: + - operation: + hosts: + - "my-app.example.com" + ports: ["443"] + notPaths: + - /health + when: + - key: request.auth.claims[aud] + notValues: ["my-app.example.com"] +``` + +**Key Features**: +- DENY action: Blocks requests that don't meet the conditions +- Requires JWT audience (`aud` claim) to match the scope domain +- Exempts health check endpoints (`/health`) +- Applies only to HTTPS traffic (port 443) + +## Flow Diagram + +``` +┌─────────────┐ +│ Client │ +└──────┬──────┘ + │ Request with JWT + │ (Header or Cookie) + ▼ +┌─────────────────────┐ +│ Istio Gateway │ +│ (Envoy Proxy) │ +└──────┬──────────────┘ + │ + ▼ +┌──────────────────────────────┐ +│ RequestAuthentication │ +│ - Validates JWT signature │ +│ - Checks issuer/expiration │ +│ - Extracts claims │ +└──────┬───────────────────────┘ + │ + ▼ +┌──────────────────────────────┐ +│ AuthorizationPolicy │ +│ - Checks aud claim matches │ +│ - DENY if invalid/missing │ +└──────┬───────────────────────┘ + │ + ▼ (if authorized) +┌─────────────────────┐ +│ Backend Service │ +│ + Headers: │ +│ X-User-ID │ +│ X-User-Email │ +└─────────────────────┘ +``` + +## References + +- [Istio RequestAuthentication Documentation](https://istio.io/latest/docs/reference/config/security/request_authentication/) +- [Istio AuthorizationPolicy Documentation](https://istio.io/latest/docs/reference/config/security/authorization-policy/) +- [JWT Best Practices](https://datatracker.ietf.org/doc/html/rfc8725) diff --git a/k8s/scope/security/manage_jwt_auth b/k8s/scope/security/manage_jwt_auth new file mode 100755 index 00000000..24326a2b --- /dev/null +++ b/k8s/scope/security/manage_jwt_auth @@ -0,0 +1,123 @@ +#!/bin/bash + +set -euo pipefail + +# This script manages JWT authentication resources for scopes with "exposer" visibility +# It creates/deletes RequestAuthentication and AuthorizationPolicy resources + +ACTION=${ACTION:-"create"} +GATEWAY_NAMESPACE=${GATEWAY_NAMESPACE:-"gateways"} +JWT_ISSUER=${JWT_ISSUER:-"https://api.nullplatform.com/scope"} +JWT_JWKS_URI=${JWT_JWKS_URI:-"https://api.nullplatform.com/scope/.well-known/jwks.json"} + +echo "JWT authorization enabled: $JWT_AUTHORIZATION_ENABLED" + +if [ "$JWT_AUTHORIZATION_ENABLED" = "true" ]; then + echo "Managing JWT authentication for scope: $SCOPE_ID" + echo "Action: $ACTION" + echo "Scope domain: $SCOPE_DOMAIN" + echo "Gateway name: $GATEWAY_NAME" + echo "Gateway namespace: $GATEWAY_NAMESPACE" + + # Ensure templates directory exists + TEMPLATE_DIR="$SERVICE_PATH/scope/security/templates" + if [ ! -d "$TEMPLATE_DIR" ]; then + echo "ERROR: Template directory not found: $TEMPLATE_DIR" + exit 1 + fi + + # Create or verify RequestAuthentication (one per cluster) + REQUEST_AUTH_NAME="nullplatform-scope-jwt-auth" + echo "Checking RequestAuthentication: $REQUEST_AUTH_NAME" + + if kubectl get requestauthentication "$REQUEST_AUTH_NAME" -n "$GATEWAY_NAMESPACE" &> /dev/null; then + echo "RequestAuthentication '$REQUEST_AUTH_NAME' already exists, skipping creation" + else + if [ "$ACTION" = "create" ] || [ "$ACTION" = "apply" ]; then + echo "Creating RequestAuthentication: $REQUEST_AUTH_NAME" + + # Build RequestAuthentication template + CONTEXT_PATH="$OUTPUT_DIR/jwt-context-$SCOPE_ID.json" + REQUEST_AUTH_PATH="$OUTPUT_DIR/request-authentication-$SCOPE_ID.yaml" + + echo "$CONTEXT" | jq \ + --arg gateway_namespace "$GATEWAY_NAMESPACE" \ + --arg request_auth_name "$REQUEST_AUTH_NAME" \ + --arg jwt_issuer "$JWT_ISSUER" \ + --arg jwt_jwks_uri "$JWT_JWKS_URI" \ + '. + { + gateway_namespace: $gateway_namespace, + request_auth_name: $request_auth_name, + jwt_issuer: $jwt_issuer, + jwt_jwks_uri: $jwt_jwks_uri + }' > "$CONTEXT_PATH" + + gomplate -c .="$CONTEXT_PATH" \ + --file "$TEMPLATE_DIR/request-authentication.yaml.tmpl" \ + --out "$REQUEST_AUTH_PATH" + + if [ $? -ne 0 ]; then + echo "ERROR: Failed to build RequestAuthentication template" + exit 1 + fi + + kubectl apply -f "$REQUEST_AUTH_PATH" + echo "RequestAuthentication created successfully" + fi + fi + + # Manage AuthorizationPolicy per scope domain + AUTHZ_POLICY_NAME="require-nullplatform-jwt-${SCOPE_SLUG}-${SCOPE_ID}" + echo "Managing AuthorizationPolicy: $AUTHZ_POLICY_NAME" + + if [ "$ACTION" = "delete" ]; then + echo "Deleting AuthorizationPolicy: $AUTHZ_POLICY_NAME" + + if kubectl get authorizationpolicy "$AUTHZ_POLICY_NAME" -n "$GATEWAY_NAMESPACE" &> /dev/null; then + kubectl delete authorizationpolicy "$AUTHZ_POLICY_NAME" -n "$GATEWAY_NAMESPACE" + echo "AuthorizationPolicy deleted successfully" + else + echo "AuthorizationPolicy '$AUTHZ_POLICY_NAME' does not exist, skipping deletion" + fi + else + # Create or update AuthorizationPolicy + echo "Creating/updating AuthorizationPolicy: $AUTHZ_POLICY_NAME" + + # Extract health check path from scope context + HEALTH_CHECK_PATH=$(echo "$CONTEXT" | jq -r '.scope.capabilities.health_check.path // "/health"') + echo "Health check path: $HEALTH_CHECK_PATH" + + CONTEXT_PATH="$OUTPUT_DIR/jwt-context-$SCOPE_ID.json" + AUTHZ_POLICY_PATH="$OUTPUT_DIR/authorization-policy-$SCOPE_ID.yaml" + + echo "$CONTEXT" | jq \ + --arg gateway_namespace "$GATEWAY_NAMESPACE" \ + --arg authz_policy_name "$AUTHZ_POLICY_NAME" \ + --arg scope_domain "$SCOPE_DOMAIN" \ + --arg jwt_issuer "$JWT_ISSUER" \ + --arg health_check_path "$HEALTH_CHECK_PATH" \ + '. + { + gateway_namespace: $gateway_namespace, + authz_policy_name: $authz_policy_name, + scope_domain: $scope_domain, + jwt_issuer: $jwt_issuer, + health_check_path: $health_check_path + }' > "$CONTEXT_PATH" + + gomplate -c .="$CONTEXT_PATH" \ + --file "$TEMPLATE_DIR/authorization-policy.yaml.tmpl" \ + --out "$AUTHZ_POLICY_PATH" + + if [ $? -ne 0 ]; then + echo "ERROR: Failed to build AuthorizationPolicy template" + exit 1 + fi + + kubectl apply -f "$AUTHZ_POLICY_PATH" + echo "AuthorizationPolicy created/updated successfully" + fi +else + echo "JWT authorization is not enabled for this scope, skipping JWT authentication setup" +fi + +echo "JWT authentication management completed" diff --git a/k8s/scope/security/templates/authorization-policy.yaml.tmpl b/k8s/scope/security/templates/authorization-policy.yaml.tmpl new file mode 100644 index 00000000..9e5f7d7c --- /dev/null +++ b/k8s/scope/security/templates/authorization-policy.yaml.tmpl @@ -0,0 +1,26 @@ +--- +apiVersion: security.istio.io/v1 +kind: AuthorizationPolicy +metadata: + name: {{ .authz_policy_name }} + namespace: {{ .gateway_namespace }} + labels: + nullplatform.com/managed-by: endpoint-exposer + nullplatform.com/scope-id: "{{ .scope.id }}" +spec: + action: DENY + selector: + matchLabels: + gateway.networking.k8s.io/gateway-name: {{ .gateway_name }} + rules: + # Deny requests to this scope's domain without valid JWT + - to: + - operation: + hosts: + - "{{ .scope_domain }}" + ports: ["443"] + notPaths: + - "{{ .health_check_path }}" + when: + - key: request.auth.claims[aud] + notValues: ["{{ .scope_domain }}"] \ No newline at end of file diff --git a/k8s/scope/security/templates/request-authentication.yaml.tmpl b/k8s/scope/security/templates/request-authentication.yaml.tmpl new file mode 100644 index 00000000..06a39405 --- /dev/null +++ b/k8s/scope/security/templates/request-authentication.yaml.tmpl @@ -0,0 +1,27 @@ +--- +apiVersion: security.istio.io/v1 +kind: RequestAuthentication +metadata: + name: {{ .request_auth_name }} + namespace: {{ .gateway_namespace }} + labels: + nullplatform.com/managed-by: endpoint-exposer +spec: + selector: + matchLabels: + gateway.networking.k8s.io/gateway-name: {{ .gateway_name }} + jwtRules: + - issuer: "{{ .jwt_issuer }}" + jwksUri: "{{ .jwt_jwks_uri }}" + fromHeaders: + - name: Authorization + prefix: "Bearer " + fromCookies: + - "np_scope_token" + outputClaimToHeaders: + - header: "X-User-ID" + claim: "sub" + - header: "X-User-Email" + claim: "email" + - header: "X-User-Scopes" + claim: "scope" diff --git a/k8s/scope/workflows/create.yaml b/k8s/scope/workflows/create.yaml index adb336c5..18c3e61d 100644 --- a/k8s/scope/workflows/create.yaml +++ b/k8s/scope/workflows/create.yaml @@ -70,3 +70,11 @@ steps: - name: wait on balancer type: script file: "$SERVICE_PATH/scope/wait_on_balancer" + - name: security + type: workflow + steps: + - name: setup jwt authentication + type: script + file: "$SERVICE_PATH/scope/security/manage_jwt_auth" + configuration: + ACTION: create diff --git a/k8s/scope/workflows/delete.yaml b/k8s/scope/workflows/delete.yaml index 541f53ad..4f7c25ba 100644 --- a/k8s/scope/workflows/delete.yaml +++ b/k8s/scope/workflows/delete.yaml @@ -13,6 +13,14 @@ steps: type: environment - name: OUTPUT_DIR type: environment + - name: security + type: workflow + steps: + - name: delete jwt authentication + type: script + file: "$SERVICE_PATH/scope/security/manage_jwt_auth" + configuration: + ACTION: delete - name: networking type: workflow steps: diff --git a/k8s/scope/workflows/update.yaml b/k8s/scope/workflows/update.yaml index b1e48442..b156f93b 100644 --- a/k8s/scope/workflows/update.yaml +++ b/k8s/scope/workflows/update.yaml @@ -5,4 +5,12 @@ steps: type: workflow steps: - name: generate domain - action: skip \ No newline at end of file + action: skip + - name: security + type: workflow + steps: + - name: setup jwt authentication + type: script + file: "$SERVICE_PATH/scope/security/manage_jwt_auth" + configuration: + ACTION: apply \ No newline at end of file