Skip to content
Open
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
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,12 @@ All decisions are cached in Redis for sub-millisecond latency; all configuration
## Key Features

### 🔐 Authentication & Authorization at the Gate
- **Dual identity provider support**: Switch between Entra ID (Azure AD) and Keycloak via a single `AuthProvider` config toggle
- Entra ID JWT bearer tokens with multi-tenant support
- Keycloak OIDC with client_credentials flow for APIM service-to-service calls
- Pre-check endpoint validates client, plan, deployment access, and quotas BEFORE backend receives the request
- No subscription keys — pure identity-driven access control
- Runtime auth config endpoint (`/api/auth-config`) — one container image works across all environments

### 🚀 Intelligent Model Routing (Auto-Router)
- Automatically routes requests to optimal deployments based on plan routing policies
Expand Down Expand Up @@ -238,6 +241,10 @@ The React SPA provides five pages:

## Authentication

The AI Policy Engine supports two identity providers, selected at runtime via the `AuthProvider` configuration value (`AzureAd` or `Keycloak`). The same container image works for both — no rebuild required.

### Entra ID (Azure AD) — Default

Authentication uses **Entra ID (Azure AD) App Registrations** with JWT bearer tokens and a **two-app model**:

| App Registration | Audience | Purpose |
Expand All @@ -253,7 +260,39 @@ The APIM policy (`policies/entra-jwt-policy.xml`) validates incoming tokens agai
| `aud` | Audience — validates the token is intended for this API |
| `azp` / `appid` | Authorized Party — identifies the calling client application (`azp` for delegated tokens, `appid` for client_credentials) |

> **Subscription keys are disabled.** All authentication uses standard `Authorization: Bearer <token>` headers validated by Entra ID.
> **Subscription keys are disabled.** All authentication uses standard `Authorization: Bearer <token>` headers validated by the configured identity provider.

### Keycloak OIDC — Alternative

To use Keycloak instead of Entra ID, set `AuthProvider=Keycloak` in the configmap/appsettings and configure the Keycloak section:

```yaml
# Kubernetes configmap
AuthProvider: "Keycloak"
Keycloak__Authority: "https://keycloak.example.com/realms/myrealm"
Keycloak__Audience: "ai-policy-engine"
Keycloak__ClientId: "ai-policy-engine-api" # Confidential client (backend)
Keycloak__FrontendClientId: "ai-policy-engine-ui" # Public client (SPA)
Keycloak__Realm: "myrealm"
Keycloak__FrontendUrl: "https://keycloak.example.com"
Keycloak__RequireHttpsMetadata: "true"
```

**Required Keycloak roles** (create as realm roles or client roles):

| Role | Purpose |
|------|---------|
| `AIPolicy.Admin` | Create/edit/delete plans, clients, pricing, routing policies |
| `AIPolicy.Apim` | APIM service-to-service calls (precheck, log ingest) |
| `AIPolicy.Export` | Access to data export endpoints |

Ensure roles are included in the `roles` claim of the access token via a Keycloak protocol mapper.

**APIM integration**: Use `policies/keycloak-jwt-policy.xml` instead of `entra-jwt-policy.xml`. Configure these APIM named values:
- `KeycloakOpenIdConfigUrl` — OIDC discovery URL
- `KeycloakTokenEndpoint` — Token endpoint for client_credentials
- `AIPolicyEngineApiBaseUrl` — Backend API URL
- `AIPolicyEngineApimClientId` / `AIPolicyEngineApimClientSecret` — APIM service account credentials

### Multi-Tenant Customer Model

Expand Down
6 changes: 6 additions & 0 deletions infra/terraform/modules/compute/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,17 @@ resource "azurerm_storage_account" "this" {
min_tls_version = "TLS1_2"
shared_access_key_enabled = false
default_to_oauth_authentication = true
public_network_access_enabled = false
tags = var.tags

identity {
type = "SystemAssigned"
}

network_rules {
default_action = "Deny"
bypass = ["AzureServices"]
}
}

# =============================================================================
Expand Down
46 changes: 46 additions & 0 deletions k8s/configmap.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: aipolicyengine-config
namespace: apim
data:

# ASPNET Core settings
ASPNETCORE_ENVIRONMENT: "Production"
ASPNETCORE_URLS: "http://+:8080"
AllowedHosts: "*"

# Entra ID JWT authentication
AzureAd__Instance: "https://login.microsoftonline.com/"
AzureAd__TenantId: "<your-tenant-id>"
AzureAd__ClientId: "<your-app-registration-client-id>"
AzureAd__Audience: "<your-audience>"

# Authentication provider toggle: "AzureAd" or "Keycloak"
AuthProvider: "AzureAd"

# Keycloak OIDC authentication (used when AuthProvider=Keycloak)
Keycloak__Authority: ""
Keycloak__Audience: ""
Keycloak__ClientId: ""
Keycloak__RequireHttpsMetadata: "true"
Keycloak__FrontendUrl: ""
Keycloak__Realm: ""
Keycloak__FrontendClientId: ""

# Usage policy defaults
UsagePolicy__BillingCycleStartDay: "1"
UsagePolicy__AggregatedLogRetentionDays: "30"
UsagePolicy__TraceRetentionDays: "30"

# Purview (DLP) — non-sensitive settings
PURVIEW_TENANT_ID: "<your-tenant-id>"
PURVIEW_APP_NAME: "AI Policy Engine API"
PURVIEW_APP_LOCATION: "" # optional URI
PURVIEW_IGNORE_EXCEPTIONS: "false"
PURVIEW_BACKGROUND_JOB_LIMIT: "100"
PURVIEW_MAX_CONCURRENT_CONSUMERS: "10"
PURVIEW_BLOCK_ENABLED: "false" # set true to enforce DLP blocking

# OpenTelemetry (optional — omit if not using a collector)
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4317"
71 changes: 71 additions & 0 deletions k8s/deployment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ai-policy-engine
namespace: apim
labels:
app: ai-policy-engine
spec:
replicas: 2
selector:
matchLabels:
app: ai-policy-engine
template:
metadata:
labels:
app: ai-policy-engine
spec:
# Optional: pin to specific nodes if needed
# nodeSelector:
# kubernetes.io/os: linux

containers:
- name: ai-policy-engine
image: your-registry/ai-policy-engine:latest # <-- replace with your registry
imagePullPolicy: Always
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
ports:
- containerPort: 8080
protocol: TCP

# Load non-sensitive config from ConfigMap
envFrom:
- configMapRef:
name: aipolicyengine-config
- secretRef:
name: aipolicyengine-secrets

# Health probes — Aspire ServiceDefaults exposes /health and /alive
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 15
periodSeconds: 20
failureThreshold: 3

readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3

resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "1000m"
memory: "512Mi"

# If your registry requires pull credentials:
# imagePullSecrets:
# - name: registry-credentials
4 changes: 4 additions & 0 deletions k8s/namespace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: apim
21 changes: 21 additions & 0 deletions k8s/secret.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
apiVersion: v1
kind: Secret
metadata:
name: aipolicyengine-secrets
namespace: apim
type: Opaque
stringData:
# Redis — StackExchange.Redis connection string format
# e.g. "myredis.redis.cache.windows.net:6380,password=<key>,ssl=True,abortConnect=False"
ConnectionStrings__redis: "<redis-connection-string>"

# Cosmos DB — account endpoint URI (used with DefaultAzureCredential/workload identity)
# or full connection string: "AccountEndpoint=https://...;AccountKey=..."
ConnectionStrings__aipolicy: "https://<account>.documents.azure.com:443/"

# Application Insights (omit if not using)
APPLICATIONINSIGHTS_CONNECTION_STRING: "InstrumentationKey=...;IngestionEndpoint=..."

# Purview DLP client app ID — triggers full Purview integration when set
# Leave empty / omit to use the no-op implementation
PURVIEW_CLIENT_APP_ID: "<purview-client-app-id>"
20 changes: 20 additions & 0 deletions k8s/service.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
apiVersion: v1
kind: Service
metadata:
name: ai-policy-engine
namespace: apim
labels:
app: ai-policy-engine
spec:
selector:
app: ai-policy-engine
ports:
- name: http
protocol: TCP
port: 80
targetPort: 8080
# ClusterIP makes the service reachable only within the cluster.
# Your existing APIM should reach this via the cluster's internal DNS:
# http://ai-policy-engine.apim.svc.cluster.local
# or via an Ingress / NodePort if APIM is external to the cluster.
type: ClusterIP
112 changes: 112 additions & 0 deletions k8s/virtualservice.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Istio Gateway + VirtualService for the Chargeback API.
# Replaces ingress.yaml when using the Istio ingress gateway.
#
# Prerequisites:
# - Istio installed in the cluster
# - An Istio IngressGateway running (default: istio-system namespace)
# - A DNS record pointing your hostname at the IngressGateway LoadBalancer IP
#
# Apply with:
# kubectl apply -f k8s/virtualservice.yaml
---
apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata:
name: aipolicyengine-gateway
namespace: apim
spec:
selector:
# Targets the default Istio ingress gateway pod.
# Change if you use a dedicated ingress gateway (e.g. istio: ingressgateway-internal).
istio: ingressgateway
servers:
- port:
number: 80
name: http
protocol: HTTP
hosts:
- ai-policy-engine.internal.example.com
# Optional: redirect all HTTP to HTTPS by uncommenting below
# tls:
# httpsRedirect: true

# Uncomment for TLS termination at the ingress gateway.
# Requires a TLS credential (kubectl create secret tls chargeback-tls -n istio-system ...).
# - port:
# number: 443
# name: https
# protocol: HTTPS
# hosts:
# - ai-policy-engine.internal.example.com
# tls:
# mode: SIMPLE
# credentialName: ai-policy-engine-tls # Secret must be in istio-system namespace
---
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: aipolicyengine-api
namespace: apim
spec:
hosts:
- ai-policy-engine.internal.example.com
gateways:
- aipolicyengine-gateway
# Also allow mesh-internal traffic (e.g. from APIM running inside the cluster)
- mesh
http:
# Health probe paths — bypass any auth/retry so k8s probes always succeed
- match:
- uri:
prefix: /health
- uri:
prefix: /alive
route:
- destination:
host: aipolicyengine-api
port:
number: 80
retries:
attempts: 0

# API routes
- match:
- uri:
prefix: /api
- uri:
prefix: /chargeback
- uri:
prefix: /openapi
- uri:
exact: /config
route:
- destination:
host: aipolicyengine-api
port:
number: 80
- match:
- uri:
prefix: /ws
headers:
upgrade:
exact: websocket
timeout: 30s
retries:
attempts: 2
perTryTimeout: 10s
retryOn: gateway-error,connect-failure,retriable-4xx
route:
- destination:
host: aipolicyengine-api
port:
number: 80

# React SPA + static assets (catch-all)
- match:
- uri:
prefix: /
route:
- destination:
host: aipolicyengine-api
port:
number: 80
Loading